diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1dfcd0f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,296 @@ +# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist +# +# Copyright 2022-2024, axodotdev +# SPDX-License-Identifier: MIT or Apache-2.0 +# +# CI that: +# +# * checks for a Git Tag that looks like a release +# * builds artifacts with dist (archives, installers, hashes) +# * uploads those artifacts to temporary workflow zip +# * on success, uploads the artifacts to a GitHub Release +# +# Note that the GitHub Release will be created with a generated +# title/body based on your changelogs. + +name: Release +permissions: + "contents": "write" + +# This task will run whenever you push a git tag that looks like a version +# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. +# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where +# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION +# must be a Cargo-style SemVer Version (must have at least major.minor.patch). +# +# If PACKAGE_NAME is specified, then the announcement will be for that +# package (erroring out if it doesn't have the given version or isn't dist-able). +# +# If PACKAGE_NAME isn't specified, then the announcement will be for all +# (dist-able) packages in the workspace with that version (this mode is +# intended for workspaces with only one dist-able package, or with all dist-able +# packages versioned/released in lockstep). +# +# If you push multiple tags at once, separate instances of this workflow will +# spin up, creating an independent announcement for each one. However, GitHub +# will hard limit this to 3 tags per commit, as it will assume more tags is a +# mistake. +# +# If there's a prerelease-style suffix to the version, then the release(s) +# will be marked as a prerelease. +on: + pull_request: + push: + tags: + - '**[0-9]+.[0-9]+.[0-9]+*' + +jobs: + # Run 'dist plan' (or host) to determine what tasks we need to do + plan: + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.plan.outputs.manifest }} + tag: ${{ !github.event.pull_request && github.ref_name || '' }} + tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} + publishing: ${{ !github.event.pull_request }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install dist + # we specify bash to get pipefail; it guards against the `curl` command + # failing. otherwise `sh` won't catch that `curl` returned non-0 + shell: bash + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.32.0/cargo-dist-installer.sh | sh" + - name: Cache dist + uses: actions/upload-artifact@v7 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/dist + # sure would be cool if github gave us proper conditionals... + # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible + # functionality based on whether this is a pull_request, and whether it's from a fork. + # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* + # but also really annoying to build CI around when it needs secrets to work right.) + - id: plan + run: | + dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json + echo "dist ran successfully" + cat plan-dist-manifest.json + echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v7 + with: + name: artifacts-plan-dist-manifest + path: plan-dist-manifest.json + + # Build and packages all the platform-specific things + build-local-artifacts: + name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) + # Let the initial task tell us to not run (currently very blunt) + needs: + - plan + 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') }} + strategy: + fail-fast: false + # Target platforms/runners are computed by dist in create-release. + # Each member of the matrix has the following arguments: + # + # - runner: the github runner + # - dist-args: cli flags to pass to dist + # - install-dist: expression to run to install dist on the runner + # + # Typically there will be: + # - 1 "global" task that builds universal installers + # - N "local" tasks that build each platform's binaries and platform-specific installers + matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} + runs-on: ${{ matrix.runner }} + container: ${{ matrix.container && matrix.container.image || null }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json + steps: + - name: enable windows longpaths + run: | + git config --global core.longpaths true + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install Rust non-interactively if not already installed + if: ${{ matrix.container }} + run: | + if ! command -v cargo > /dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + fi + - name: Install dist + run: ${{ matrix.install_dist.run }} + # Get the dist-manifest + - name: Fetch local artifacts + uses: actions/download-artifact@v8 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - name: Install dependencies + run: | + ${{ matrix.packages_install }} + - name: Build artifacts + run: | + # Actually do builds and make zips and whatnot + dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json + echo "dist ran successfully" + - id: cargo-dist + name: Post-build + # We force bash here just because github makes it really hard to get values up + # to "real" actions without writing to env-vars, and writing to env-vars has + # inconsistent syntax between shell and powershell. + shell: bash + run: | + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v7 + with: + name: artifacts-build-local-${{ join(matrix.targets, '_') }} + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + + # Build and package all the platform-agnostic(ish) things + build-global-artifacts: + needs: + - plan + - build-local-artifacts + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v8 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Get all the local artifacts for the global tasks to use (for e.g. checksums) + - name: Fetch local artifacts + uses: actions/download-artifact@v8 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: cargo-dist + shell: bash + run: | + dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json + echo "dist ran successfully" + + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v7 + with: + name: artifacts-build-global + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + # Determines if we should publish/announce + host: + needs: + - plan + - build-local-artifacts + - build-global-artifacts + # Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine) + 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') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.host.outputs.manifest }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v8 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Fetch artifacts from scratch-storage + - name: Fetch artifacts + uses: actions/download-artifact@v8 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: host + shell: bash + run: | + dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json + echo "artifacts uploaded and released successfully" + cat dist-manifest.json + echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v7 + with: + # Overwrite the previous copy + name: artifacts-dist-manifest + path: dist-manifest.json + # Create a GitHub Release while uploading all files to it + - name: "Download GitHub Artifacts" + uses: actions/download-artifact@v8 + with: + pattern: artifacts-* + path: artifacts + merge-multiple: true + - name: Cleanup + run: | + # Remove the granular manifests + rm -f artifacts/*-dist-manifest.json + - name: Create GitHub Release + env: + PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" + ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" + ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" + RELEASE_COMMIT: "${{ github.sha }}" + run: | + # Write and read notes from a file to avoid quoting breaking things + echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt + + gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + + announce: + needs: + - plan + - host + # use "always() && ..." to allow us to wait for all publish jobs while + # still allowing individual publish jobs to skip themselves (for prereleases). + # "host" however must run to completion, no skipping allowed! + if: ${{ always() && needs.host.result == 'success' }} + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive diff --git a/.gitignore b/.gitignore index e384093..e1b3ab3 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,8 @@ logs/ # Hatch .hatch/ + + +# Added by cargo + +/target diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..4938ce1 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,132 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +velazco.joseh@gmail.com. All complaints will be reviewed and investigated +promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..db05028 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,107 @@ +# Contributing + +First of all, thank you so much for taking the time to contribute to THOTH! 𓁟 + +## Guidelines + +The following is a set of guidelines for contributing to this repository. They are +guidelines, not strict rules, so use your best judgment, and feel free to propose changes +to this document in a pull request. + +## Development setup + +Thoth is a Rust project. You will need a [Rust toolchain](https://rustup.rs) (stable) and a +C compiler, since the bundled SQLite is built from source. + +```sh +git clone https://github.com/JoseVelazcoH/Thoth.git +cd Thoth +cargo build # build +cargo test # run the test suite +cargo run -- --help # run the tth binary +``` + +To dogfood your changes in a real shell, install the binary and enable the shell +integration: + +```sh +cargo install --path . # installs `tth` into ~/.cargo/bin +tth install # adds the eval line to your shell rc +exec $SHELL # reload your shell (or open a new terminal) +``` + +The codebase lives under `src/` (the library plus the `tth` binary), with integration +tests in `tests/`. + +## Issues + +Before opening an issue, please search the existing issues (open and closed) to make sure +the feature or bug you want to propose does not already exist. + +### Bugs + +A bug report must include the following: + +1. The version you are running (`tth --version`). +2. Exact steps to reproduce the bug. +3. A proposed fix or a hypothesis about the cause. + +### Features + +Feature issues are split into two kinds: **UI** and **Code**. + +#### UI + +UI issues are specific improvements to the user experience. The proposal must include at +least: + +1. The section you want to improve or add. +2. An image or mockup of the result you want to reach. + +#### Code + +Code issues are improvements to the codebase itself, whether for better component handling +or structure. The proposal must include at least: + +1. The section you want to improve or add. +2. A proposed solution. + +## Pull Requests + +To contribute, take one of the open issues in the repository. Anything tagged `type:bug`, +`good-first-issue`, or `help-wanted` would be fantastic. To claim an issue, leave a comment +asking for it and a maintainer will assign it to you. + +Open your pull request against the `develop` branch (not `main`), and link the issue it +resolves. + +### Quality bar + +Thoth follows test-driven development: add or update tests alongside your change. Before you +submit, make sure the checks below pass locally, since the same ones are enforced on review: + +```sh +cargo test +cargo clippy --tests -- -D warnings +cargo fmt --check +``` + +Production code avoids `.unwrap()` and `.expect()`: use the `ThothError` type and propagate +errors with `?`. Those calls are fine in tests. + +### Review cycle + +To speed up the review cycle, you can allow maintainers to push directly to your branch. +This is only done for small fixes. + +### Commits + +Commits must follow the [commit convention](docs/commit-convention.md). If a pull request +does not follow it, it will be rejected and you will be asked to correct the commit history. + +## AI + +We are not at odds with the use of AI. On the contrary, we push for more people to use it +to speed up the production process. That said, using AI correctly matters to us: every +change, and every issue and pull request description, must be tested and understood by a +human. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..a7b7583 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1364 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "comfy-table" +version = "7.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" +dependencies = [ + "crossterm 0.29.0", + "unicode-segmentation", + "unicode-width 0.2.0", +] + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "document-features", + "parking_lot", + "rustix 1.1.4", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm 0.28.1", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thoth" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "clap", + "comfy-table", + "crossterm 0.28.1", + "nucleo-matcher", + "predicates", + "ratatui", + "regex", + "rusqlite", + "serde", + "serde_json", + "tempfile", + "thiserror", + "toml", + "toml_edit", + "uuid", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..01f30ae --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "thoth" +version = "0.1.0" +edition = "2021" +description = "An intelligent shell history that captures commands with context and makes them searchable" +license = "MIT" +repository = "https://github.com/JoseVelazcoH/Thoth" +readme = "README.md" +keywords = ["shell", "history", "cli", "tui", "search"] +categories = ["command-line-utilities"] +rust-version = "1.74" + +[[bin]] +name = "tth" +path = "src/main.rs" + +[dependencies] +rusqlite = { version = "0.32", features = ["bundled"] } +clap = { version = "4", features = ["derive"] } +thiserror = "2" +uuid = { version = "1", features = ["v4"] } +toml = "0.8" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +comfy-table = "7" +ratatui = "0.29" +crossterm = "0.28" +nucleo-matcher = "0.3" +toml_edit = "0.22" +regex = "1" + +[dev-dependencies] +tempfile = "3" +assert_cmd = "2" +predicates = "3" + +# The profile that 'dist' will build with +[profile.dist] +inherits = "release" +lto = "thin" diff --git a/README.md b/README.md new file mode 100644 index 0000000..0a38ee3 --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +

+ + + Thoth + +

+ +

+ An intelligent shell history that captures every command with its context and makes it searchable. +

+ +

+ Release + License + Rust +

+ +> [Thoth](https://en.wikipedia.org/wiki/Thoth), the moon god of ancient Egypt and god of +> wisdom, was the scribe and counselor of the sun god Ra. Just as Thoth recorded the memory +> of the universe, this tool inherits his name to become the keeper of your workflow, making +> sure no valuable command is lost to the oblivion of the shell. + +Inspired by [Atuin](https://github.com/atuinsh/atuin) (highly recommended). Thoth shares the +goal of a smarter shell history and focuses on turning it into something you can act on: +record-and-replay **workspaces**, a themeable vim-style TUI, and a finder-style search. + +

+ +

+ +## Features + +- **Context capture** - every command is saved with its directory, project, exit code, + duration, and active tags. +- **Finder-style search** - an interactive TUI (press `Ctrl-R`) with live multi-field fuzzy + matching, a preview pane, and a `:` cmdline for precise filters + (`project:`, `tag:`, `exit:ok|fail`, `since:`, `until:`, `dur:>30`). +- **Workspaces** - record a window of commands (`tth-sw ` / `tth-ew`) and replay the + whole sequence in order, in your shell. +- **Vim-style modal TUI** - `Esc` for normal mode, `j/k` to move, `d` to delete, `e` to + edit, `?` for help. +- **Themeable** - 7 built-in themes (including the Catppuccin flavors) plus your own, with + quick switching via `tth theme `. +- **Sessions, stats, export, and tags** for organizing and reusing your history. +- **Private by default** - Thoth's own commands and anything matching your history filter + are never recorded, and everything lives in a local SQLite database. + +See [docs/features.md](docs/features.md) for a deeper guide to every feature. + +## Install + +### Prebuilt binary (recommended) + +```sh +curl --proto '=https' --tlsv1.2 -LsSf \ + https://github.com/JoseVelazcoH/Thoth/releases/latest/download/thoth-installer.sh | sh +``` + +### With Cargo + +```sh +cargo install --path . # from a clone; requires a Rust toolchain +``` + +### From source + +```sh +git clone https://github.com/JoseVelazcoH/Thoth.git +cd Thoth +cargo install --path . +``` + +### Enable the shell integration + +```sh +tth install # adds the eval line to your shell rc (bash or zsh) +exec $SHELL # reload your shell, or open a new terminal +``` + +## Quick start + +- Press **`Ctrl-R`** to open the interactive search. +- **Type** to fuzzy-filter across the command, project, directory, and tags. +- Press **`Esc`** for normal mode, then **`:`** to open the filter cmdline + (e.g. `project:thoth exit:fail`), or **`?`** for help. +- **`Enter`** runs the selected command, **`Tab`** puts it on your prompt to edit. + +Record a workspace and replay it later: + +```sh +tth-sw deploy # start recording into the "deploy" workspace +# ... run your commands ... +tth-ew # stop +``` + +Then open the TUI, switch to the **Workspaces** tab (`→`), and press `Enter` to replay it. + +## Contributing + +Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) to get started, and please +follow our [Code of Conduct](CODE_OF_CONDUCT.md). + +## License + +Released under the [MIT License](LICENSE). diff --git a/assets/demo.gif b/assets/demo.gif new file mode 100644 index 0000000..3ab042d Binary files /dev/null and b/assets/demo.gif differ diff --git a/assets/thoth_dark.svg b/assets/thoth_dark.svg new file mode 100644 index 0000000..5bb8ab2 --- /dev/null +++ b/assets/thoth_dark.svg @@ -0,0 +1,601 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/thoth_light.svg b/assets/thoth_light.svg new file mode 100644 index 0000000..28ba11d --- /dev/null +++ b/assets/thoth_light.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demo.tape b/demo.tape new file mode 100644 index 0000000..3031cf3 --- /dev/null +++ b/demo.tape @@ -0,0 +1,69 @@ +# Thoth demo recording. +# Install vhs: https://github.com/charmbracelet/vhs (needs ttyd + ffmpeg) +# Render: vhs demo.tape -> demo.gif +# +# Everything runs against a throwaway demo database (see scripts/seed-demo.sh), +# so your real history never appears. + +Output assets/demo.gif + +Require tth + +Set Shell "bash" +Set FontSize 18 +Set Width 1280 +Set Height 720 +Set Padding 24 +Set Theme "Catppuccin Mocha" +Set TypingSpeed 90ms + +# --- hidden setup: PATH, demo db/config, seed, clean prompt, then enable the hooks --- +# The hooks record every command, so set the prompt and clear BEFORE sourcing them, +# and drop the two commands that do get recorded with `tth forget` (which is never recorded). +Hide +Type 'export PATH="$HOME/.cargo/bin:$PATH"' Enter +Type "export THOTH_DB=/tmp/thoth-demo.db" Enter +Type "export THOTH_CONFIG=/tmp/thoth-demo-config.toml" Enter +Type "PS1='> '" Enter +Type "bash scripts/seed-demo.sh >/dev/null 2>&1" Enter +Sleep 2500ms +# Enable the shell hooks for the Ctrl-R widget, then disable command capture so the +# demo session does not record anything: the database stays exactly as seeded. +Type 'eval "$(tth init bash)"' Enter +Type "trap - DEBUG; PROMPT_COMMAND=''; clear" Enter +Sleep 1s +Show + +# --- Act 1: open the TUI (Ctrl-R) and filter live by typing --- +Sleep 700ms +Ctrl+R +Sleep 1400ms +Type "cargo" +Sleep 1800ms + +# --- Act 2: Esc to Normal (vim) mode, then ':' for the filter cmdline --- +Escape +Sleep 700ms +Type ":" +Sleep 500ms +Type "project:thoth exit:fail" +Sleep 900ms +Enter +Sleep 1800ms + +# --- Act 3: Tab puts the selected command onto the terminal prompt --- +Tab +Sleep 1800ms +Ctrl+U +Sleep 700ms + +# --- Act 4: open the TUI again, go to Workspaces, and replay one --- +Ctrl+R +Sleep 1200ms +Right +Sleep 1200ms +Enter +Sleep 800ms +Type "y" +Sleep 2800ms +Sleep 1000ms diff --git a/dist-workspace.toml b/dist-workspace.toml new file mode 100644 index 0000000..0330c09 --- /dev/null +++ b/dist-workspace.toml @@ -0,0 +1,19 @@ +[workspace] +members = ["cargo:."] + +# Config for 'dist' +[dist] +# The preferred dist version to use in CI (Cargo.toml SemVer syntax) +cargo-dist-version = "0.32.0" +# CI backends to support +ci = "github" +# The installers to generate for each app +installers = ["shell", "powershell"] +# Target platforms to build apps for (Rust target-triple syntax) +targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] +# Path that installers should place binaries in +install-path = "CARGO_HOME" +# Where to host releases +hosting = "github" +# Whether to install an updater program +install-updater = false diff --git a/docs/commit-convention.md b/docs/commit-convention.md index 7988b7e..1d7c10f 100644 --- a/docs/commit-convention.md +++ b/docs/commit-convention.md @@ -27,7 +27,7 @@ This project follows [Conventional Commits 1.0.0](https://www.conventionalcommit | `feat` | A new feature visible to the user or CLI consumer | | `fix` | A bug fix | | `docs` | Documentation only changes | -| `style` | Formatting, whitespace — no logic change | +| `style` | Formatting, whitespace - no logic change | | `refactor` | Code change that is neither a fix nor a feature | | `perf` | Performance improvement | | `test` | Adding or updating tests | diff --git a/docs/features.md b/docs/features.md new file mode 100644 index 0000000..c63a6cd --- /dev/null +++ b/docs/features.md @@ -0,0 +1,169 @@ +# Features + +A deeper reference for what Thoth does. Run `tth --help` or `tth --help` for the +exact flags of any command. + +- [Capturing commands](#capturing-commands) +- [Searching](#searching) +- [Workspaces](#workspaces) +- [Themes](#themes) +- [Other commands](#other-commands) +- [Configuration](#configuration) + +## Capturing commands + +Once the shell integration is enabled (`tth install`), every command you run is recorded +with its context: + +- the command text, working directory, and inferred project (the enclosing git repo) +- the exit code and how long it took +- the active tags and the active workspace, if any + +Thoth's own commands (`tth`, `tth-sw`, `tth-tag`, ...) are never recorded. You can keep +other commands out of your history with a regex filter (see [History filter](#history-filter)), +which is the recommended way to avoid storing secrets. Everything is stored in a local +SQLite database under `~/.local/share/thoth/`. + +## Searching + +### From the command line + +`tth search [QUERY]` does a full-text search with optional filters: + +```sh +tth search cargo --project thoth --exit fail --since 2h +``` + +Filters: `--project`, `--tag` (repeatable), `--exit ok|fail|any`, `--duration '>30'`, +`--since`, `--until`, `--session`, `--limit`. Project and tag matches are +case-insensitive (project also matches as a substring). + +### Interactive TUI + +Press **`Ctrl-R`** (or run `tth`) to open the finder. As you type, the list filters live +with fuzzy matching across the command, project, directory, and tags. A preview pane on the +right shows the full details of the highlighted entry. + +#### Modes (vim-style) + +The finder is modal. **Insert** is the default (type to filter); `Esc` drops you into +**Normal** for navigation and actions. + +| Key | Mode | Action | +| -------------- | ------ | --------------------------------------- | +| _type_ | Insert | Filter the list live | +| `Enter` | both | Run the selected command | +| `Tab` | Insert | Put the command on your prompt to edit | +| `Esc` | Insert | Switch to Normal mode | +| `j` / `k` | Normal | Move down / up (arrows work too) | +| `d` | Normal | Delete the selected command | +| `e` | Normal | Edit the selected command | +| `i` or `/` | Normal | Return to Insert mode | +| `:` | Normal | Open the filter cmdline | +| `?` | Normal | Open help | +| `q` | Normal | Quit | + +#### Filter cmdline + +In Normal mode, press **`:`** to open a floating cmdline and type a filter expression: + +``` +project:thoth exit:fail since:2h +``` + +| Field | Matches | +| -------------------- | ---------------------------------------- | +| `project:` / `p:` | Project name | +| `tag:` / `t:` | Tag (repeatable) | +| `exit:ok` / `fail` | Exit status | +| `since:` / `until:` | Time window (e.g. `since:2h`) | +| `dur:>30` | Duration in seconds (`>`, `<`, `=`) | + +Any free text becomes the fuzzy query. The active filters are shown as chips at the bottom. +Press `Enter` to apply, `Esc` to cancel. + +#### Tabs + +Use the `←`/`→` arrows to switch between the **History** tab and the **Workspaces** tab. + +## Workspaces + +A workspace is a named, ordered set of commands you can replay later. + +```sh +tth-sw deploy # start recording into the "deploy" workspace +# ... run your commands ... +tth-ew # stop recording +tth workspaces # list your workspaces +``` + +In the TUI, switch to the **Workspaces** tab: the left pane lists your workspaces and the +right pane shows the commands of the selected one in order. Press `Enter` to replay the +whole sequence in your shell (you confirm first). The commands run one after another, each +in its recorded directory. + +## Themes + +Thoth ships 7 built-in themes: `default`, `ember`, `frost`, and the Catppuccin flavors: +`latte`, `frappe`, `macchiato`, and `mocha`. + +```sh +tth theme list # show available themes (built-in + your own) +tth theme mocha # switch theme +``` + +Define your own theme as a file in `~/.config/thoth/themes/.toml`: + +```toml +extends = "mocha" # optional: start from a built-in +selection_bg = "#b4befe" # override any slot +accent = "blue" +``` + +Colors accept hex (`#rrggbb`), ANSI names (`red`, `brightblue`), or a 256-color index. +Slots: `selection_bg`, `selection_fg`, `accent`, `dim`, `border`, `ok`, `fail`, `project`, +`command`, `header`, `controls`, `directory`, `tags`. + +## Other commands + +| Command | What it does | +| ------------------------------------ | ------------------------------------------------------------------- | +| `tth sessions` | List work sessions (commands grouped by terminal session) | +| `tth stats` | Insights: top commands/projects, success rate, busiest hour, error-prone tools | +| `tth export` | Export matching commands as a runnable bash script | +| `tth forget` | Delete recent commands from history | +| `tth tag` / `tth-tag` / `tth-untag` | Manage active tags | +| `tth prompt` | Instructions to show active tags in your prompt | +| `tth doctor` | Diagnostics: hooks installed, database, theme, prompt visibility | +| `tth config` | Show or edit the configuration (`tth config set `) | + +## Configuration + +Configuration lives in `~/.config/thoth/config.toml` (created by `tth install`). All +settings are optional. + +```toml +[session] +gap_minutes = 30 # inactivity gap that starts a new session + +[tui] +orientation = "bottom" # "bottom" or "top" +columns = ["timestamp", "duration", "exit", "project", "command"] + +[search] +default_limit = 50 +# columns = [...] # columns for `tth search` +# filter = ["^ls$"] # hide matching commands from search results + +[history] +# filter = ["--password", "export .*TOKEN"] # never record matching commands + +[theme] +name = "default" +``` + +### History filter + +`[history] filter` is a list of regular expressions; any command matching one is **never +recorded**. By default it ignores Thoth's own commands; add your own patterns to keep +secrets out of your history. diff --git a/pyproject.toml b/prototype/python/pyproject.toml similarity index 100% rename from pyproject.toml rename to prototype/python/pyproject.toml diff --git a/tests/__init__.py b/prototype/python/tests/__init__.py similarity index 100% rename from tests/__init__.py rename to prototype/python/tests/__init__.py diff --git a/tests/conftest.py b/prototype/python/tests/conftest.py similarity index 100% rename from tests/conftest.py rename to prototype/python/tests/conftest.py diff --git a/tests/test_cli.py b/prototype/python/tests/test_cli.py similarity index 100% rename from tests/test_cli.py rename to prototype/python/tests/test_cli.py diff --git a/tests/test_db.py b/prototype/python/tests/test_db.py similarity index 100% rename from tests/test_db.py rename to prototype/python/tests/test_db.py diff --git a/tests/test_logging.py b/prototype/python/tests/test_logging.py similarity index 100% rename from tests/test_logging.py rename to prototype/python/tests/test_logging.py diff --git a/tests/test_paths.py b/prototype/python/tests/test_paths.py similarity index 100% rename from tests/test_paths.py rename to prototype/python/tests/test_paths.py diff --git a/tests/test_project.py b/prototype/python/tests/test_project.py similarity index 100% rename from tests/test_project.py rename to prototype/python/tests/test_project.py diff --git a/tests/test_recorder.py b/prototype/python/tests/test_recorder.py similarity index 100% rename from tests/test_recorder.py rename to prototype/python/tests/test_recorder.py diff --git a/tests/test_session.py b/prototype/python/tests/test_session.py similarity index 100% rename from tests/test_session.py rename to prototype/python/tests/test_session.py diff --git a/tth/__init__.py b/prototype/python/tth/__init__.py similarity index 100% rename from tth/__init__.py rename to prototype/python/tth/__init__.py diff --git a/tth/cli.py b/prototype/python/tth/cli.py similarity index 100% rename from tth/cli.py rename to prototype/python/tth/cli.py diff --git a/tth/database.py b/prototype/python/tth/database.py similarity index 100% rename from tth/database.py rename to prototype/python/tth/database.py diff --git a/tth/logging_config.py b/prototype/python/tth/logging_config.py similarity index 100% rename from tth/logging_config.py rename to prototype/python/tth/logging_config.py diff --git a/tth/project.py b/prototype/python/tth/project.py similarity index 100% rename from tth/project.py rename to prototype/python/tth/project.py diff --git a/tth/recorder.py b/prototype/python/tth/recorder.py similarity index 100% rename from tth/recorder.py rename to prototype/python/tth/recorder.py diff --git a/tth/schema.py b/prototype/python/tth/schema.py similarity index 100% rename from tth/schema.py rename to prototype/python/tth/schema.py diff --git a/tth/session.py b/prototype/python/tth/session.py similarity index 100% rename from tth/session.py rename to prototype/python/tth/session.py diff --git a/scripts/seed-demo.sh b/scripts/seed-demo.sh new file mode 100755 index 0000000..2aa5c5e --- /dev/null +++ b/scripts/seed-demo.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Seeds a throwaway Thoth database and config for the demo recording. +# Nothing here touches your real history: it writes only to $THOTH_DB / $THOTH_CONFIG +# and to a scratch directory under /tmp. +# +# Usage: +# THOTH_DB=/tmp/thoth-demo.db THOTH_CONFIG=/tmp/thoth-demo-config.toml bash scripts/seed-demo.sh +set -euo pipefail + +DB="${THOTH_DB:-/tmp/thoth-demo.db}" +CFG="${THOTH_CONFIG:-/tmp/thoth-demo-config.toml}" +PROJDIR="${THOTH_DEMO_PROJECTS:-/tmp/thoth-demo-projects}" +export THOTH_DB="$DB" +export THOTH_CONFIG="$CFG" + +rm -f "$DB" + +# Thoth infers the project name from the enclosing git repo, so create the demo +# projects as real (empty) git repos. Their basenames become the project names. +rm -rf "$PROJDIR" +for p in thoth webapp infra; do + mkdir -p "$PROJDIR/$p" + git -C "$PROJDIR/$p" init -q +done +THOTH="$PROJDIR/thoth" +WEB="$PROJDIR/webapp" +INFRA="$PROJDIR/infra" + +# Demo config: a nice theme and a column layout that shows tags. +cat > "$CFG" < +rec() { + tth record --cmd "$1" --dir "$2" --exit "$3" --duration "$4" \ + --tags "$5" --workspace "$6" --timestamp "$(( now - $7 ))" >/dev/null +} + +# --- project: thoth --- +rec "cargo build" "$THOTH" 0 1820 '["rust"]' "" 5400 +rec "cargo clippy --all-targets" "$THOTH" 0 2100 '["rust"]' "" 5100 +rec "cargo fmt" "$THOTH" 0 120 '[]' "" 4800 +rec "cargo test" "$THOTH" 1 4300 '["rust","ci"]' "" 4500 +rec "git status" "$THOTH" 0 30 '[]' "" 4200 +rec "cargo test --release" "$THOTH" 1 9800 '["rust","ci"]' "" 3900 +rec "cargo build --release" "$THOTH" 0 33000 '["rust"]' "" 3600 +rec "git commit -m 'feat: add fuzzy search'" "$THOTH" 0 80 '[]' "" 3300 + +# --- project: webapp --- +rec "npm install" "$WEB" 0 9100 '["node"]' "" 3000 +rec "npm run build" "$WEB" 1 5200 '["node"]' "" 2700 +rec "docker compose up -d" "$WEB" 0 4200 '["docker"]' "" 2400 +rec "npm run dev" "$WEB" 0 210 '["node"]' "" 2100 + +# --- project: infra --- +rec "terraform plan" "$INFRA" 0 6400 '["infra"]' "" 1800 +rec "kubectl get pods" "$INFRA" 0 540 '["k8s"]' "" 1500 + +# --- a recorded workspace "release" (safe commands that run on replay) --- +rec "echo 'Running release checks...'" "$THOTH" 0 12 '[]' "release" 900 +rec "cargo --version" "$THOTH" 0 40 '[]' "release" 880 +rec "echo 'All checks passed.'" "$THOTH" 0 9 '[]' "release" 860 + +echo "Seeded demo database at $DB (config $CFG)." diff --git a/scripts/test_hooks.sh b/scripts/test_hooks.sh new file mode 100755 index 0000000..15ddfe7 --- /dev/null +++ b/scripts/test_hooks.sh @@ -0,0 +1,644 @@ +#!/usr/bin/env bash +set -euo pipefail + +PASS=0 +FAIL=0 +SKIP=0 + +_pass() { echo "PASS: $1"; PASS=$(( PASS + 1 )); } +_fail() { echo "FAIL: $1"; FAIL=$(( FAIL + 1 )); } +_skip() { echo "SKIP: $1"; SKIP=$(( SKIP + 1 )); } + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TTH_BIN="${REPO_ROOT}/target/debug/tth" + +if [[ ! -x "$TTH_BIN" ]]; then + _skip "tth binary not built; run: cargo build first" + echo "---"; echo "PASS=$PASS FAIL=$FAIL SKIP=$SKIP"; exit 0 +fi + +_run_bash_test() { + local tmpdir + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' RETURN + + local stub_log="$tmpdir/stub.log" + local stub_bin="$tmpdir/tth" + + cat > "$stub_bin" <<'STUB' +#!/usr/bin/env bash +echo "$@" >> "$STUB_LOG" +STUB + chmod +x "$stub_bin" + + STUB_LOG="$stub_log" \ + PATH="$tmpdir:$REPO_ROOT/target/debug:$PATH" \ + TTH_SESSION_ID="" \ + bash --norc --noprofile -c " + export STUB_LOG='$stub_log' + source '${REPO_ROOT}/shells/thoth.bash' 2>/dev/null || true + sleep 0 + " || true + + if [[ -f "$stub_log" ]]; then + if grep -q 'new-session-id' "$stub_log" 2>/dev/null; then + _pass "bash: hook sources and calls new-session-id" + else + _pass "bash: hook sourced (new-session-id may be skipped in non-interactive)" + fi + else + _pass "bash: hook sourced without error (record call requires interactive shell)" + fi +} + +_run_bash_record_test() { + local tmpdir + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' RETURN + + local stub_log="$tmpdir/stub.log" + local stub_bin="$tmpdir/tth" + + cat > "$stub_bin" <<'STUB' +#!/usr/bin/env bash +echo "$@" >> "$STUB_LOG" +STUB + chmod +x "$stub_bin" + + STUB_LOG="$stub_log" \ + PATH="$tmpdir:$REPO_ROOT/target/debug:$PATH" \ + TTH_SESSION_ID="test-sid" \ + bash --norc --noprofile -c " + export STUB_LOG='$stub_log' + source '${REPO_ROOT}/shells/thoth.bash' 2>/dev/null || true + _THOTH_CMD='echo hello' + _THOTH_START=\$EPOCHREALTIME + _thoth_precmd + _i=0 + while (( _i < 100 )); do + [[ -s '$stub_log' ]] && break + sleep 0.05 + _i=\$(( _i + 1 )) + done + " || true + + local all_ok=1 + for flag in --cmd --dir --exit --duration --timestamp --tags --terminal-id; do + if ! grep -q -- "$flag" "$stub_log" 2>/dev/null; then + _fail "bash: record invocation missing flag $flag" + all_ok=0 + fi + done + if [[ $all_ok -eq 1 ]]; then + _pass "bash: _thoth_precmd passes all 7 flags to tth record" + fi +} + +_run_zsh_test() { + if ! command -v zsh >/dev/null 2>&1; then + _skip "zsh not available" + return + fi + + local tmpdir + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' RETURN + + local stub_log="$tmpdir/stub.log" + local stub_bin="$tmpdir/tth" + + cat > "$stub_bin" <<'STUB' +#!/usr/bin/env bash +echo "$@" >> "$STUB_LOG" +STUB + chmod +x "$stub_bin" + + STUB_LOG="$stub_log" \ + PATH="$tmpdir:$REPO_ROOT/target/debug:$PATH" \ + TTH_SESSION_ID="" \ + zsh --no-rcs -c " + export STUB_LOG='$stub_log' + source '${REPO_ROOT}/shells/thoth.zsh' 2>/dev/null || true + true + " || true + + if [[ -f "$stub_log" ]] && grep -q 'new-session-id' "$stub_log" 2>/dev/null; then + _pass "zsh: hook sources and calls new-session-id" + else + _pass "zsh: hook sourced without error" + fi +} + +_run_bash_version_check() { + if (( BASH_VERSINFO[0] < 5 )); then + _skip "bash < 5; hook returns early by design" + return + fi + _pass "bash >= 5 available" +} + +_run_bash_syntax_check() { + if bash -n "${REPO_ROOT}/shells/thoth.bash" 2>/dev/null; then + _pass "thoth.bash: syntax valid" + else + _fail "thoth.bash: syntax error" + fi +} + +_run_zsh_syntax_check() { + if ! command -v zsh >/dev/null 2>&1; then + _skip "zsh not available for syntax check" + return + fi + if zsh -n "${REPO_ROOT}/shells/thoth.zsh" 2>/dev/null; then + _pass "thoth.zsh: syntax valid" + else + _fail "thoth.zsh: syntax error" + fi +} + +_check_flags_in_file() { + local label="$1" + local file="$2" + local content + content="$(cat "$file")" + for flag in --cmd --dir --exit --duration --timestamp --tags --terminal-id; do + if [[ "$content" == *"$flag"* ]]; then + _pass "$label: flag $flag present" + else + _fail "$label: flag $flag MISSING" + fi + done +} + +_run_epochseconds_check() { + local content + content="$(cat "${REPO_ROOT}/shells/thoth.bash")" + if [[ "$content" == *'$(date +%s)'* ]]; then + _fail "thoth.bash: uses \$(date +%s) subshell instead of \$EPOCHSECONDS" + else + _pass "thoth.bash: uses \$EPOCHSECONDS builtin (no date subshell)" + fi +} + +_run_flag_check() { + _check_flags_in_file "thoth.bash" "${REPO_ROOT}/shells/thoth.bash" +} + +_run_zsh_flag_check() { + if ! command -v zsh >/dev/null 2>&1; then + _skip "zsh not available for flag check" + return + fi + _check_flags_in_file "thoth.zsh" "${REPO_ROOT}/shells/thoth.zsh" +} + +_run_bash_trap_chain_single_quote_test() { + if (( BASH_VERSINFO[0] < 5 )); then + _skip "bash < 5; trap chain test requires bash >= 5" + return + fi + + local tmpdir + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' RETURN + + local trap_log="$tmpdir/trap.log" + local err_log="$tmpdir/err.log" + + local after_err_log="$tmpdir/after_err.log" + + bash --norc --noprofile -c " + trap 'echo it'\\''s here >> \"$trap_log\"' DEBUG + source '${REPO_ROOT}/shells/thoth.bash' 2>/dev/null || true + echo trigger 2>'$after_err_log' + " 2>/dev/null || true + + local after_err="" + if [[ -f "$after_err_log" ]]; then + after_err="$(cat "$after_err_log")" + fi + + if [[ "$after_err" == *"EOF"* ]] || [[ "$after_err" == *"inesperado"* ]] || [[ "$after_err" == *"unexpected"* ]] || [[ "$after_err" == *"syntax error"* ]] || [[ "$after_err" == *"parse error"* ]] || [[ "$after_err" == *"debug trap"* ]]; then + _fail "bash: DEBUG trap chain breaks with single-quoted prior trap body" + else + _pass "bash: DEBUG trap chain survives prior trap body containing single quote" + fi +} + +_run_zsh_widget_defined() { + if ! command -v zsh >/dev/null 2>&1; then + _skip "zsh not available for widget definition check" + return + fi + local content + content="$(cat "${REPO_ROOT}/shells/thoth.zsh")" + if [[ "$content" == *"_tth_widget"* ]]; then + _pass "thoth.zsh: _tth_widget function defined" + else + _fail "thoth.zsh: _tth_widget function NOT found" + fi + if [[ "$content" == *"bindkey '^R'"* ]]; then + _pass "thoth.zsh: bindkey '^R' present" + else + _fail "thoth.zsh: bindkey '^R' NOT found" + fi + if [[ "$content" == *"zle -N _tth_widget"* ]]; then + _pass "thoth.zsh: zle -N _tth_widget registration present" + else + _fail "thoth.zsh: zle -N _tth_widget NOT found" + fi +} + +_run_bash_widget_defined() { + local content + content="$(cat "${REPO_ROOT}/shells/thoth.bash")" + if [[ "$content" == *"_tth_widget"* ]]; then + _pass "thoth.bash: _tth_widget function defined" + else + _fail "thoth.bash: _tth_widget function NOT found" + fi + if [[ "$content" == *'bind -x'*'\C-r'* ]] || [[ "$content" == *"bind -x"*"\C-r"* ]]; then + _pass "thoth.bash: bind -x Ctrl-R present" + else + _fail "thoth.bash: bind -x Ctrl-R NOT found" + fi +} + +_run_zsh_capture_exclusion() { + if ! command -v zsh >/dev/null 2>&1; then + _skip "zsh not available for capture exclusion check" + return + fi + + local tmpdir + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' RETURN + + local stub_log="$tmpdir/stub.log" + local stub_bin="$tmpdir/tth" + + cat > "$stub_bin" <<'STUB' +#!/usr/bin/env bash +echo "$@" >> "$STUB_LOG" +STUB + chmod +x "$stub_bin" + + STUB_LOG="$stub_log" \ + PATH="$tmpdir:$REPO_ROOT/target/debug:$PATH" \ + TTH_SESSION_ID="test-sid" \ + zsh --no-rcs -c " + export STUB_LOG='$stub_log' + source '${REPO_ROOT}/shells/thoth.zsh' 2>/dev/null || true + _tth_preexec 'tth search foo' + _tth_preexec 'tth' + _TTH_START=\$EPOCHREALTIME + _tth_preexec 'echo hello' + _tth_precmd + sleep 0.1 + " 2>/dev/null || true + + if [[ -f "$stub_log" ]]; then + if grep -q 'record' "$stub_log" 2>/dev/null; then + if grep 'record' "$stub_log" | grep -q 'tth'; then + _fail "zsh: tth command was recorded (exclusion not working)" + else + _pass "zsh: capture exclusion prevents tth commands from being recorded" + fi + else + _pass "zsh: capture exclusion prevents tth commands from being recorded" + fi + else + _pass "zsh: capture exclusion works (no spurious record call)" + fi +} + +_run_bash_capture_exclusion() { + if (( BASH_VERSINFO[0] < 5 )); then + _skip "bash < 5; capture exclusion test requires bash >= 5" + return + fi + + local tmpdir + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' RETURN + + local stub_log="$tmpdir/stub.log" + local stub_bin="$tmpdir/tth" + + cat > "$stub_bin" <<'STUB' +#!/usr/bin/env bash +echo "$@" >> "$STUB_LOG" +STUB + chmod +x "$stub_bin" + + STUB_LOG="$stub_log" \ + PATH="$tmpdir:$REPO_ROOT/target/debug:$PATH" \ + TTH_SESSION_ID="test-sid" \ + bash --norc --noprofile -c " + export STUB_LOG='$stub_log' + source '${REPO_ROOT}/shells/thoth.bash' 2>/dev/null || true + BASH_COMMAND='tth search foo' + _thoth_preexec + BASH_COMMAND='tth' + _thoth_preexec + BASH_COMMAND='echo hello' + _thoth_preexec + _THOTH_START=\$EPOCHREALTIME + _THOTH_CMD='echo hello' + _thoth_precmd + _i=0 + while (( _i < 100 )); do + [[ -s '$stub_log' ]] && break + sleep 0.05 + _i=\$(( _i + 1 )) + done + " 2>/dev/null || true + + if [[ -f "$stub_log" ]]; then + if grep -q 'record' "$stub_log" 2>/dev/null; then + if grep 'record' "$stub_log" | grep -qE -- '(^| )tth( |$)'; then + _fail "bash: tth command was recorded (exclusion not working)" + else + _pass "bash: capture exclusion prevents tth commands from being recorded" + fi + else + _pass "bash: capture exclusion works (no record call seen)" + fi + else + _pass "bash: capture exclusion works (no spurious record call)" + fi +} + +_run_zsh_parse_run_prefix() { + if ! command -v zsh >/dev/null 2>&1; then + _skip "zsh not available for RUN: prefix parse check" + return + fi + local result + result="$(zsh --no-rcs -c ' + out="RUN:git status" + echo "${out#RUN:}" + ' 2>/dev/null)" + if [[ "$result" == "git status" ]]; then + _pass "zsh: RUN: prefix stripped correctly" + else + _fail "zsh: RUN: prefix strip failed; got: $result" + fi +} + +_run_zsh_parse_edit_prefix() { + if ! command -v zsh >/dev/null 2>&1; then + _skip "zsh not available for EDIT: prefix parse check" + return + fi + local result + result="$(zsh --no-rcs -c ' + out="EDIT:vim main.rs" + echo "${out#EDIT:}" + ' 2>/dev/null)" + if [[ "$result" == "vim main.rs" ]]; then + _pass "zsh: EDIT: prefix stripped correctly" + else + _fail "zsh: EDIT: prefix strip failed; got: $result" + fi +} + +_run_bash_parse_run_prefix() { + local result + result="$(bash --norc --noprofile -c ' + out="RUN:git status" + echo "${out#RUN:}" + ' 2>/dev/null)" + if [[ "$result" == "git status" ]]; then + _pass "bash: RUN: prefix stripped correctly" + else + _fail "bash: RUN: prefix strip failed; got: $result" + fi +} + +_run_bash_parse_edit_prefix() { + local result + result="$(bash --norc --noprofile -c ' + out="EDIT:vim main.rs" + echo "${out#EDIT:}" + ' 2>/dev/null)" + if [[ "$result" == "vim main.rs" ]]; then + _pass "bash: EDIT: prefix stripped correctly" + else + _fail "bash: EDIT: prefix strip failed; got: $result" + fi +} + +_run_init_zsh_syntax_check() { + if ! command -v zsh >/dev/null 2>&1; then + _skip "zsh not available for init zsh syntax check" + return + fi + local tmpdir + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' RETURN + local script_file="$tmpdir/init_zsh.zsh" + "$TTH_BIN" init zsh > "$script_file" 2>/dev/null + if zsh -n "$script_file" 2>/dev/null; then + _pass "tth init zsh: output is syntactically valid zsh" + else + _fail "tth init zsh: syntax error in output" + fi +} + +_run_init_bash_syntax_check() { + local tmpdir + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' RETURN + local script_file="$tmpdir/init_bash.bash" + "$TTH_BIN" init bash > "$script_file" 2>/dev/null + if bash -n "$script_file" 2>/dev/null; then + _pass "tth init bash: output is syntactically valid bash" + else + _fail "tth init bash: syntax error in output" + fi +} + +_run_init_zsh_defines_hooks() { + if ! command -v zsh >/dev/null 2>&1; then + _skip "zsh not available for init zsh function-definition check" + return + fi + local tmpdir + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' RETURN + local stub_bin="$tmpdir/tth" + cat > "$stub_bin" <<'STUB' +#!/usr/bin/env bash +echo "$@" +STUB + chmod +x "$stub_bin" + local result + result="$(PATH="$tmpdir:$PATH" TTH_SESSION_ID="test" zsh --no-rcs -c " + source <(\"$TTH_BIN\" init zsh) 2>/dev/null || true + typeset -f _tth_preexec >/dev/null 2>&1 && echo defined_preexec + typeset -f _tth_precmd >/dev/null 2>&1 && echo defined_precmd + typeset -f _tth_widget >/dev/null 2>&1 && echo defined_widget + " 2>/dev/null)" + local ok=1 + for marker in defined_preexec defined_precmd defined_widget; do + if [[ "$result" != *"$marker"* ]]; then + _fail "tth init zsh: $marker not defined after sourcing output" + ok=0 + fi + done + [[ $ok -eq 1 ]] && _pass "tth init zsh: hook functions defined after sourcing output" +} + +_run_tth_tag_zsh_test() { + if ! command -v zsh >/dev/null 2>&1; then + _skip "zsh not available for tth-tag test" + return + fi + local tmpdir + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' RETURN + + local stub_bin="$tmpdir/tth" + cat > "$stub_bin" <<'STUB' +#!/usr/bin/env bash +if [[ "$1" == "tag" ]]; then + shift + echo "export TTH_ACTIVE_TAGS='[\"$1\"]'" + echo "export TTH_PROMPT_TAGS='[$1]'" +elif [[ "$1" == "new-session-id" ]]; then + echo "test-session" +fi +STUB + chmod +x "$stub_bin" + + local result + result="$(PATH="$tmpdir:$PATH" TTH_SESSION_ID="test" zsh --no-rcs -c " + source '${REPO_ROOT}/shells/thoth.zsh' 2>/dev/null || true + typeset -f tth-tag >/dev/null 2>&1 && echo defined_tth_tag + typeset -f tth-untag >/dev/null 2>&1 && echo defined_tth_untag + tth-tag foo 2>/dev/null + echo \"TAGS=\$TTH_ACTIVE_TAGS\" + echo \"PROMPT=\$TTH_PROMPT_TAGS\" + " 2>/dev/null)" + + if [[ "$result" == *"defined_tth_tag"* ]]; then + _pass "zsh: tth-tag function defined after sourcing thoth.zsh" + else + _fail "zsh: tth-tag function NOT defined after sourcing thoth.zsh" + fi + if [[ "$result" == *"defined_tth_untag"* ]]; then + _pass "zsh: tth-untag function defined after sourcing thoth.zsh" + else + _fail "zsh: tth-untag function NOT defined after sourcing thoth.zsh" + fi + if [[ "$result" == *'TAGS=["foo"]'* ]]; then + _pass "zsh: tth-tag foo sets TTH_ACTIVE_TAGS" + else + _fail "zsh: tth-tag foo did not set TTH_ACTIVE_TAGS; got: $result" + fi + if [[ "$result" == *"PROMPT=[foo]"* ]]; then + _pass "zsh: tth-tag foo sets TTH_PROMPT_TAGS=[foo]" + else + _fail "zsh: tth-tag foo did not set TTH_PROMPT_TAGS; got: $result" + fi +} + +_run_tth_tag_bash_test() { + if (( BASH_VERSINFO[0] < 5 )); then + _skip "bash < 5; tth-tag bash test requires bash >= 5" + return + fi + local tmpdir + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' RETURN + + local stub_bin="$tmpdir/tth" + cat > "$stub_bin" <<'STUB' +#!/usr/bin/env bash +if [[ "$1" == "tag" ]]; then + shift + echo "export TTH_ACTIVE_TAGS='[\"$1\"]'" + echo "export TTH_PROMPT_TAGS='[$1]'" +elif [[ "$1" == "new-session-id" ]]; then + echo "test-session" +fi +STUB + chmod +x "$stub_bin" + + local result + result="$(PATH="$tmpdir:$PATH" TTH_SESSION_ID="test" bash --norc --noprofile -c " + source '${REPO_ROOT}/shells/thoth.bash' 2>/dev/null || true + declare -f tth-tag >/dev/null 2>&1 && echo defined_tth_tag + declare -f tth-untag >/dev/null 2>&1 && echo defined_tth_untag + tth-tag bar 2>/dev/null + echo \"TAGS=\$TTH_ACTIVE_TAGS\" + echo \"PROMPT=\$TTH_PROMPT_TAGS\" + " 2>/dev/null)" + + if [[ "$result" == *"defined_tth_tag"* ]]; then + _pass "bash: tth-tag function defined after sourcing thoth.bash" + else + _fail "bash: tth-tag function NOT defined after sourcing thoth.bash" + fi + if [[ "$result" == *"defined_tth_untag"* ]]; then + _pass "bash: tth-untag function defined after sourcing thoth.bash" + else + _fail "bash: tth-untag function NOT defined after sourcing thoth.bash" + fi + if [[ "$result" == *'TAGS=["bar"]'* ]]; then + _pass "bash: tth-tag bar sets TTH_ACTIVE_TAGS" + else + _fail "bash: tth-tag bar did not set TTH_ACTIVE_TAGS; got: $result" + fi + if [[ "$result" == *"PROMPT=[bar]"* ]]; then + _pass "bash: tth-tag bar sets TTH_PROMPT_TAGS=[bar]" + else + _fail "bash: tth-tag bar did not set TTH_PROMPT_TAGS; got: $result" + fi +} + +_run_tth_tag_capture_exclusion() { + local content + content="$(cat "${REPO_ROOT}/shells/thoth.zsh")" + if [[ "$content" == *'tth|tth\ *'* ]] || [[ "$content" == *'tth|tth '*'*'* ]]; then + _pass "thoth.zsh: tth-tag excluded from capture (tth* case pattern covers it)" + else + _fail "thoth.zsh: capture exclusion pattern may not cover tth-tag calls" + fi +} + +echo "=== Thoth shell hook smoke tests and tag tests ===" +_run_bash_version_check +_run_bash_syntax_check +_run_zsh_syntax_check +_run_epochseconds_check +_run_flag_check +_run_zsh_flag_check +_run_bash_trap_chain_single_quote_test +_run_bash_test +_run_bash_record_test +_run_zsh_test +_run_zsh_widget_defined +_run_bash_widget_defined +_run_zsh_capture_exclusion +_run_bash_capture_exclusion +_run_zsh_parse_run_prefix +_run_zsh_parse_edit_prefix +_run_bash_parse_run_prefix +_run_bash_parse_edit_prefix +_run_init_zsh_syntax_check +_run_init_bash_syntax_check +_run_init_zsh_defines_hooks +_run_tth_tag_zsh_test +_run_tth_tag_bash_test +_run_tth_tag_capture_exclusion + +echo "---" +echo "PASS=$PASS FAIL=$FAIL SKIP=$SKIP" + +if [[ $FAIL -gt 0 ]]; then + exit 1 +fi +exit 0 diff --git a/shells/thoth.bash b/shells/thoth.bash new file mode 100644 index 0000000..313a75d --- /dev/null +++ b/shells/thoth.bash @@ -0,0 +1,94 @@ +if (( BASH_VERSINFO[0] < 5 )); then return; fi +command -v tth >/dev/null 2>&1 || return + +: ${TTH_SESSION_ID:=$(tth new-session-id)} +export TTH_SESSION_ID + +_THOTH_IN_PROMPT=0 +_THOTH_PREV_DEBUG="" + +_thoth_preexec() { + [[ $_THOTH_IN_PROMPT -eq 1 ]] && return + [[ "$BASH_COMMAND" == "_thoth_precmd" ]] && return + local _cmd="$BASH_COMMAND" + case "$_cmd" in + tth|tth\ *) return ;; + esac + _THOTH_CMD="$_cmd" + _THOTH_START=$EPOCHREALTIME + if [[ -n "$_THOTH_PREV_DEBUG" ]]; then + eval "$_THOTH_PREV_DEBUG" + fi +} + +_thoth_precmd() { + local _exit=$? + _THOTH_IN_PROMPT=1 + [[ -n "${_THOTH_CMD:-}" ]] || { _THOTH_IN_PROMPT=0; return; } + local _dur=$(( (${EPOCHREALTIME/./} - ${_THOTH_START/./}) / 1000 )) + ( tth record \ + --cmd "$_THOTH_CMD" \ + --dir "$PWD" \ + --exit "$_exit" \ + --duration "$_dur" \ + --timestamp "$EPOCHSECONDS" \ + --tags "${TTH_ACTIVE_TAGS:-[]}" \ + --terminal-id "$TTH_SESSION_ID" \ + --workspace "${TTH_ACTIVE_WORKSPACE:-}" & ) + _THOTH_CMD="" + _THOTH_IN_PROMPT=0 +} + +_thoth_chain_debug() { + local _raw _body_quoted + _raw="$(trap -p DEBUG 2>/dev/null)" + trap - DEBUG + if [[ -n "$_raw" ]]; then + _body_quoted="${_raw#trap -- }" + _body_quoted="${_body_quoted% DEBUG}" + eval "_THOTH_PREV_DEBUG=${_body_quoted}" + fi + trap '_thoth_preexec' DEBUG +} +_thoth_chain_debug + +PROMPT_COMMAND="_thoth_precmd${PROMPT_COMMAND:+;$PROMPT_COMMAND}" + +_tth_widget() { + command -v tth >/dev/null 2>&1 || return + local out + out="$(tth /dev/null)" + if [[ "$out" == RUN:* ]]; then + READLINE_LINE="${out#RUN:}" + READLINE_POINT=${#READLINE_LINE} + READLINE_MARK=-1 + ( eval "$READLINE_LINE" ) + elif [[ "$out" == EDIT:* ]]; then + READLINE_LINE="${out#EDIT:}" + READLINE_POINT=${#READLINE_LINE} + elif [[ "$out" == REPLAY:* ]]; then + eval "${out#REPLAY:}" + fi +} + +bind -x '"\C-r": _tth_widget' + +tth-tag() { + eval "$(command tth tag "$1")" +} + +tth-untag() { + if [[ "$1" == "--all" ]]; then + eval "$(command tth untag --all)" + else + eval "$(command tth untag "$1")" + fi +} + +tth-sw() { + eval "$(command tth workspace start "$1")" +} + +tth-ew() { + eval "$(command tth workspace end)" +} diff --git a/shells/thoth.zsh b/shells/thoth.zsh new file mode 100644 index 0000000..ad6bd38 --- /dev/null +++ b/shells/thoth.zsh @@ -0,0 +1,80 @@ +command -v tth >/dev/null 2>&1 || return + +zmodload zsh/datetime 2>/dev/null +autoload -Uz add-zsh-hook + +: ${TTH_SESSION_ID:=$(tth new-session-id)} +export TTH_SESSION_ID + +_tth_preexec() { + local _cmd="$1" + case "$_cmd" in + tth|tth\ *) return ;; + esac + _TTH_CMD="$_cmd" + _TTH_START=$EPOCHREALTIME +} + +_tth_precmd() { + local _TTH_EXIT=$? + [[ -n $_TTH_CMD ]] || return + local _dur=$(printf '%.0f' "$(( (EPOCHREALTIME - _TTH_START) * 1000 ))") + tth record \ + --cmd "$_TTH_CMD" \ + --dir "$PWD" \ + --exit "$_TTH_EXIT" \ + --duration "$_dur" \ + --timestamp "$EPOCHSECONDS" \ + --tags "${TTH_ACTIVE_TAGS:-[]}" \ + --terminal-id "$TTH_SESSION_ID" \ + --workspace "${TTH_ACTIVE_WORKSPACE:-}" &! + _TTH_CMD="" +} + +add-zsh-hook preexec _tth_preexec +add-zsh-hook precmd _tth_precmd + +tth-tag() { + eval "$(command tth tag "$1")" +} + +tth-untag() { + if [[ "$1" == "--all" ]]; then + eval "$(command tth untag --all)" + else + eval "$(command tth untag "$1")" + fi +} + +tth-sw() { + eval "$(command tth workspace start "$1")" +} + +tth-ew() { + eval "$(command tth workspace end)" +} + +_tth_widget() { + command -v tth >/dev/null 2>&1 || return + local out + out="$(tth /dev/null)" + local rc=$? + if [[ "$out" == RUN:* ]]; then + BUFFER="${out#RUN:}" + zle reset-prompt + zle accept-line + elif [[ "$out" == EDIT:* ]]; then + BUFFER="${out#EDIT:}" + CURSOR=${#BUFFER} + zle reset-prompt + elif [[ "$out" == REPLAY:* ]]; then + BUFFER="${out#REPLAY:}" + zle reset-prompt + zle accept-line + else + zle reset-prompt + fi +} + +zle -N _tth_widget +bindkey '^R' _tth_widget diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..597b665 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,762 @@ +use clap::{Parser, Subcommand}; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Parser)] +#[command( + name = "tth", + arg_required_else_help = false, + about = "Thoth: an intelligent shell history that captures commands with context", + long_about = "Thoth records every command you run along with its working directory, \ +project, exit code, duration, and any active tags. Run bare `tth` or press Ctrl-R \ +(after `tth install`) to open an interactive fuzzy search. Use `tth search` for \ +filtered, scriptable output. Manage your history with `tth sessions`, `tth forget`, \ +`tth export`, and `tth tags`." +)] +pub struct Cli { + #[command(subcommand)] + pub cmd: Option, +} + +#[derive(Subcommand)] +pub enum Cmd { + #[command(about = "Record a command into history (invoked by the shell hooks, not by hand)")] + Record(RecordArgs), + #[command(about = "Install the shell integration into your rc file")] + Install(InstallArgs), + #[command(about = "Remove the shell integration from your rc file")] + Uninstall(UninstallArgs), + #[command(about = "Show a quick status summary")] + Status, + #[command(about = "Search history with filters and full-text")] + Search(SearchArgs), + #[command(about = "List work sessions")] + Sessions(SessionsArgs), + #[command(about = "Delete recent commands from history")] + Forget(ForgetArgs), + #[command(about = "Export matching commands as a runnable bash script")] + Export(ExportArgs), + #[command(about = "Print the shell integration script for eval (eval \"$(tth init zsh)\")")] + Init(InitArgs), + #[command(about = "Print the export to activate a tag (used by the tth-tag shell function)")] + Tag(TagArgs), + #[command( + about = "Print the export to deactivate a tag (used by the tth-untag shell function)" + )] + Untag(UntagArgs), + #[command(about = "Show active tags, or all recorded tags with --list")] + Tags(TagsArgs), + #[command(about = "Print prompt-integration instructions for your prompt framework")] + Prompt(PromptArgs), + #[command(about = "Show history insights and statistics")] + Stats(StatsArgs), + #[command(about = "Run diagnostics and suggest fixes")] + Doctor, + #[command( + hide = true, + about = "Generate a new session ID (invoked by the shell hooks, not by hand)" + )] + NewSessionId, + #[command(about = "Show and manage the effective configuration")] + Config(ConfigArgs), + #[command(about = "Start or end a workspace session (use tth-sw / tth-ew shell functions)")] + Workspace(WorkspaceArgs), + #[command(about = "List recorded workspaces with command counts")] + Workspaces, + #[command(about = "Show, list, or switch the TUI theme")] + Theme(ThemeArgs), +} + +#[derive(clap::Args, Debug, Clone)] +pub struct ConfigArgs { + #[command(subcommand)] + pub action: Option, +} + +#[derive(clap::Subcommand, Debug, Clone)] +pub enum ConfigAction { + #[command( + about = "Print the canonical TOML of the loaded config (machine-readable, no color)" + )] + Print, + #[command(about = "Print the value for a single dotted key (e.g. tui.orientation)")] + Get(ConfigGetArgs), + #[command(about = "Set a config key to a value, preserving comments in the file")] + Set(ConfigSetArgs), +} + +#[derive(clap::Args, Debug, Clone)] +pub struct ConfigGetArgs { + #[arg(help = "Dotted key to read (e.g. session.gap_minutes)")] + pub key: String, +} + +#[derive(clap::Args, Debug, Clone)] +pub struct ConfigSetArgs { + #[arg(help = "Dotted key to set (e.g. tui.orientation)")] + pub key: String, + #[arg(help = "New value")] + pub value: String, +} + +#[derive(clap::Args, Debug, Clone)] +pub struct WorkspaceArgs { + #[command(subcommand)] + pub action: WorkspaceAction, +} + +#[derive(clap::Subcommand, Debug, Clone)] +pub enum WorkspaceAction { + #[command(about = "Activate a workspace by name (eval the output)")] + Start(WorkspaceStartArgs), + #[command(about = "Deactivate the current workspace (eval the output)")] + End, +} + +#[derive(clap::Args, Debug, Clone)] +pub struct WorkspaceStartArgs { + #[arg(help = "Workspace name to activate")] + pub name: String, +} + +#[derive(clap::Args, Debug, Clone)] +pub struct ForgetArgs { + #[arg( + long, + default_value_t = 1, + help = "Number of recent commands to forget" + )] + pub last: usize, + #[arg( + long, + help = "Preview which commands would be deleted without deleting them" + )] + pub dry_run: bool, + #[arg( + long = "terminal-id", + help = "Restrict to commands from this terminal session" + )] + pub terminal_id: Option, +} + +#[derive(clap::Args, Debug, Clone)] +pub struct RecordArgs { + #[arg(long, help = "The command string to record")] + pub cmd: String, + #[arg(long, help = "Working directory where the command ran")] + pub dir: Option, + #[arg(long = "exit", default_value_t = 0, help = "Exit code of the command")] + pub exit_code: i64, + #[arg(long, default_value_t = 0, help = "Command duration in milliseconds")] + pub duration: i64, + #[arg(long, help = "Unix timestamp when the command ran")] + pub timestamp: Option, + #[arg(long, default_value = "[]", help = "JSON array of active tag names")] + pub tags: String, + #[arg(long = "terminal-id", help = "Identifier for the terminal session")] + pub terminal_id: Option, + #[arg(long, help = "Active workspace name (set by the shell hook)")] + pub workspace: Option, +} + +#[derive(clap::Args, Debug, Clone)] +pub struct InstallArgs { + #[arg( + long, + help = "Shell to install for (bash or zsh; auto-detected if omitted)" + )] + pub shell: Option, + #[arg( + long = "rc-file", + help = "Path to the rc file to modify (default: ~/.bashrc or ~/.zshrc)" + )] + pub rc_file: Option, +} + +#[derive(clap::Args, Debug, Clone)] +pub struct SearchArgs { + #[arg(help = "Optional full-text search query")] + pub query: Option, + #[arg(short = 'p', long, help = "Filter by project (directory basename)")] + pub project: Option, + #[arg(short = 't', long, action = clap::ArgAction::Append, help = "Filter by tag (repeatable)")] + pub tag: Vec, + #[arg(long, help = "Filter by exit status: ok, fail, or any")] + pub exit: Option, + #[arg( + long, + help = "Filter by duration in seconds, e.g. >30 (over 30s) or <5 (under 5s)" + )] + pub duration: Option, + #[arg( + long, + help = "Show commands after this time, e.g. '2h ago' or '2024-01-01'" + )] + pub since: Option, + #[arg(long, help = "Show commands before this time")] + pub until: Option, + #[arg(long, help = "Filter by session ID")] + pub session: Option, + #[arg(long, help = "Maximum number of results")] + pub limit: Option, + #[arg( + long = "show-session", + default_value_t = false, + help = "Include session ID column in output" + )] + pub show_session: bool, +} + +#[derive(clap::Args, Debug, Clone)] +pub struct SessionsArgs { + #[arg(long, help = "Filter sessions by project name")] + pub project: Option, + #[arg(long, help = "Show sessions after this time")] + pub since: Option, + #[arg(long, help = "Show sessions before this time")] + pub until: Option, + #[arg( + long, + default_value_t = 20, + help = "Maximum number of sessions to show" + )] + pub limit: usize, +} + +#[derive(clap::Args, Debug, Clone)] +pub struct ExportArgs { + #[arg(long, help = "Export commands from this session ID")] + pub session: Option, + #[arg(short = 't', long, action = clap::ArgAction::Append, help = "Filter by tag (repeatable)")] + pub tag: Vec, + #[arg(short = 'p', long, help = "Filter by project name")] + pub project: Option, + #[arg(long, help = "Export commands after this time")] + pub since: Option, + #[arg(long, help = "Filter by exit status: ok, fail, or any")] + pub exit: Option, +} + +#[derive(clap::Args, Debug, Clone)] +pub struct InitArgs { + #[arg(help = "Shell to generate integration for (bash or zsh; auto-detected if omitted)")] + pub shell: Option, +} + +#[derive(clap::Args, Debug, Clone)] +pub struct TagArgs { + #[arg(help = "Tag name to activate")] + pub name: String, +} + +#[derive(clap::Args, Debug, Clone)] +pub struct UntagArgs { + #[arg(help = "Tag name to deactivate")] + pub name: Option, + #[arg(long, help = "Deactivate all tags at once")] + pub all: bool, +} + +#[derive(clap::Args, Debug, Clone)] +pub struct TagsArgs { + #[arg( + long, + help = "Show all tags recorded in the database with command counts" + )] + pub list: bool, +} + +#[derive(clap::Args, Debug, Clone)] +pub struct PromptArgs { + #[arg( + long, + help = "Prompt framework to generate for: starship, p10k, oh-my-posh, or generic" + )] + pub framework: Option, +} + +#[derive(clap::Args, Debug, Clone)] +pub struct StatsArgs { + #[arg(short = 'p', long, help = "Restrict stats to this project")] + pub project: Option, + #[arg(long, help = "Restrict stats to commands after this time")] + pub since: Option, +} + +#[derive(clap::Args, Debug, Clone)] +pub struct UninstallArgs { + #[arg( + long = "keep-data", + help = "Suppress the data-retention reminder message" + )] + pub keep_data: bool, + #[arg( + long = "rc-file", + help = "Path to the rc file to modify (default: ~/.bashrc or ~/.zshrc)" + )] + pub rc_file: Option, +} + +#[derive(clap::Args, Debug, Clone)] +pub struct ThemeArgs { + #[arg(help = "Theme name to switch to, or 'list' to list available themes")] + pub name: Option, +} + +pub fn run() -> Result<(), crate::error::ThothError> { + use clap::Parser; + let cli = Cli::parse(); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + match cli.cmd { + None => { + let cfg = crate::config::load(); + let mut conn = crate::database::get_connection(None)?; + let columns = crate::tui::render::resolve_tui_columns(&cfg.tui.columns); + crate::tui::run(&mut conn, now, cfg.tui.orientation.is_bottom(), columns)?; + } + Some(Cmd::Record(mut args)) => { + crate::logging::setup(crate::paths::resolve_error_log()); + if args.dir.is_none() { + args.dir = std::env::current_dir() + .ok() + .map(|p| p.to_string_lossy().to_string()); + } + if args.timestamp.is_none() { + args.timestamp = Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64, + ); + } + let cfg = crate::config::load(); + match crate::database::get_connection(None) { + Ok(mut conn) => crate::recorder::record( + &args, + cfg.session.gap_minutes, + &cfg.history.filter, + &mut conn, + ), + Err(e) => crate::logging::log_error(&e.to_string()), + } + } + Some(Cmd::Install(args)) => { + let shell_env = std::env::var("SHELL").ok(); + let shell = crate::hooks::detect_shell(args.shell.as_deref(), shell_env.as_deref())?; + let rc_path = if let Some(p) = args.rc_file { + p + } else { + let home = std::env::var("HOME").unwrap_or_else(|_| String::from("/tmp")); + crate::hooks::default_rc_path(&shell, std::path::Path::new(&home)) + }; + let report = crate::hooks::install(&shell, &rc_path)?; + if report.already_present { + println!("Installed (updated) hooks in {}", rc_path.display()); + } else { + println!("Installed hooks in {}", rc_path.display()); + } + println!("Reload shell: {}", report.reload_cmd); + let framework = crate::prompt::detect_framework(&crate::prompt::probe_inputs()); + let snippet = crate::prompt::prompt_snippet(&framework); + println!("\nTo show active tags in your prompt:\n{snippet}"); + let config_path = crate::config::resolve_config_path(); + if crate::config::ensure_default_config(&config_path)? { + println!("Wrote default config to {}", config_path.display()); + } + } + Some(Cmd::Uninstall(args)) => { + let home = std::env::var("HOME").unwrap_or_else(|_| String::from("/tmp")); + let rc_path = if let Some(p) = args.rc_file { + p + } else { + let shell_env = std::env::var("SHELL").ok(); + let shell = crate::hooks::detect_shell(None, shell_env.as_deref())?; + crate::hooks::default_rc_path(&shell, std::path::Path::new(&home)) + }; + let report = crate::hooks::uninstall(&rc_path)?; + if report.block_was_present { + println!("Uninstalled hooks from {}", rc_path.display()); + if !args.keep_data { + println!("Data retained. Pass --keep-data to suppress this message or remove manually."); + } + } else { + println!("No hooks found in {}", rc_path.display()); + } + } + Some(Cmd::Status) => { + let conn = crate::database::get_connection(None)?; + let shell_env = std::env::var("SHELL").ok(); + let shell = crate::hooks::detect_shell(None, shell_env.as_deref()) + .unwrap_or(crate::hooks::Shell::Bash); + let home = std::env::var("HOME").unwrap_or_else(|_| String::from("/tmp")); + let rc_path = crate::hooks::default_rc_path(&shell, std::path::Path::new(&home)); + let session_id_set = std::env::var("TTH_SESSION_ID").is_ok(); + let tth_on_path = which_tth(); + let report = crate::hooks::status(&conn, &rc_path, session_id_set, tth_on_path); + println!("Hooks installed: {}", report.hooks_installed); + println!("Schema version: {}", report.schema_version); + println!("Total commands: {}", report.total_commands); + println!( + "Last command: {}", + report + .last_timestamp + .map(|t| t.to_string()) + .unwrap_or_else(|| "none".into()) + ); + println!("Session ID set: {}", report.session_id_set); + println!("tth on PATH: {}", report.tth_on_path); + } + Some(Cmd::Search(mut args)) => { + let cfg = crate::config::load(); + let resolved_limit = args.limit.unwrap_or(cfg.search.default_limit); + let columns = crate::search::resolve_columns(&cfg.search.columns)?; + let conn = crate::database::get_connection(None)?; + + let rows = if cfg.search.filter.is_empty() { + args.limit = Some(resolved_limit); + crate::search::execute(&args, &conn, now)? + } else { + let (regexes, invalid) = crate::search::compile_filters(&cfg.search.filter); + for pat in &invalid { + eprintln!("thoth: invalid search filter pattern (skipped): {}", pat); + } + args.limit = None; + let candidates = crate::search::execute(&args, &conn, now)?; + candidates + .into_iter() + .filter(|r| !crate::search::is_filtered(&r.command, ®exes)) + .take(resolved_limit) + .collect() + }; + print!( + "{}", + crate::search::render(&rows, &columns, args.show_session) + ); + } + Some(Cmd::Sessions(args)) => { + let conn = crate::database::get_connection(None)?; + let sargs = crate::sessions::SessionsArgs { + project: args.project, + since: args.since, + until: args.until, + limit: args.limit, + }; + let rows = crate::sessions::list_sessions(&conn, &sargs, now)?; + print!("{}", crate::sessions::render(&rows)); + } + Some(Cmd::Forget(args)) => { + if args.last == 0 { + return Err(crate::error::ThothError::Forget( + "nothing to forget: --last must be >= 1".into(), + )); + } + let conn = crate::database::get_connection(None)?; + let scope = crate::forget::resolve_scope( + args.terminal_id, + std::env::var("TTH_SESSION_ID").ok(), + ); + let rows = crate::forget::select_targets(&conn, &scope, args.last)?; + if rows.is_empty() { + println!("No commands to forget."); + return Ok(()); + } + let ids: Vec = rows.iter().map(|r| r.id).collect(); + if args.dry_run { + print!("{}", crate::forget::render_preview(&rows)); + println!("Would forget {} command(s).", ids.len()); + } else { + crate::forget::delete_targets(&conn, &ids)?; + println!("Forgot {} command(s).", ids.len()); + } + } + Some(Cmd::Export(args)) => { + let conn = crate::database::get_connection(None)?; + let export_args = crate::export::ExportArgs { + session: args.session, + tag: args.tag.clone(), + project: args.project.clone(), + since: args.since, + exit: args.exit, + workspace: None, + }; + let rows = crate::export::collect(&conn, &export_args, now)?; + let meta = crate::export::ExportMeta { + project: args.project.as_deref(), + tags: &args.tag, + }; + print!("{}", crate::export::render_script(&rows, &meta, now)); + } + Some(Cmd::Init(args)) => { + let shell_env = std::env::var("SHELL").ok(); + let shell = crate::hooks::detect_shell(args.shell.as_deref(), shell_env.as_deref())?; + print!("{}", crate::hooks::render_init(&shell)); + } + Some(Cmd::Tag(args)) => { + if args.name.is_empty() { + return Err(crate::error::ThothError::Tag( + "tag name cannot be empty".into(), + )); + } + let current = std::env::var("TTH_ACTIVE_TAGS").unwrap_or_else(|_| "[]".into()); + let new_json = crate::tags::add_tag(¤t, &args.name); + print!("{}", crate::tags::export_line(&new_json)); + let segment = crate::tags::format_prompt_segment(&new_json); + let display = if segment.is_empty() { + "(none)".to_string() + } else { + segment + }; + eprintln!("Active tags: {display}"); + } + Some(Cmd::Untag(args)) => { + if args.name.is_none() && !args.all { + return Err(crate::error::ThothError::Tag( + "specify a tag name or --all".into(), + )); + } + let current = std::env::var("TTH_ACTIVE_TAGS").unwrap_or_else(|_| "[]".into()); + let new_json = if args.all { + crate::tags::clear_tags() + } else { + crate::tags::remove_tag(¤t, args.name.as_deref().unwrap_or("")) + }; + print!("{}", crate::tags::export_line(&new_json)); + let segment = crate::tags::format_prompt_segment(&new_json); + let display = if segment.is_empty() { + "(none)".to_string() + } else { + segment + }; + eprintln!("Active tags: {display}"); + } + Some(Cmd::Tags(args)) => { + if args.list { + let conn = crate::database::get_connection(None)?; + let tags = crate::tags::list_db_tags(&conn)?; + if tags.is_empty() { + println!("(no tags recorded)"); + } else { + for (tag, count) in tags { + let noun = if count == 1 { "command" } else { "commands" }; + println!("{tag} ({count} {noun})"); + } + } + } else { + let current = std::env::var("TTH_ACTIVE_TAGS").unwrap_or_else(|_| "[]".into()); + let tags = crate::tags::parse_active(¤t); + if tags.is_empty() { + println!("(none)"); + } else { + for tag in tags { + println!("{tag}"); + } + } + } + } + Some(Cmd::Prompt(args)) => { + let framework = if let Some(ref fw) = args.framework { + crate::prompt::parse_framework(fw)? + } else { + crate::prompt::detect_framework(&crate::prompt::probe_inputs()) + }; + print!("{}", crate::prompt::prompt_snippet(&framework)); + } + Some(Cmd::Stats(args)) => { + let conn = crate::database::get_connection(None)?; + let stats_args = crate::stats::StatsArgs { + project: args.project, + since: args.since, + }; + let stats = crate::stats::compute(&conn, &stats_args, now)?; + print!("{}", crate::stats::render(&stats)); + } + Some(Cmd::Doctor) => { + let shell_env = std::env::var("SHELL").ok(); + let shell = crate::hooks::detect_shell(None, shell_env.as_deref()) + .unwrap_or(crate::hooks::Shell::Bash); + let home = std::env::var("HOME").unwrap_or_else(|_| String::from("/tmp")); + let rc_path = crate::hooks::default_rc_path(&shell, std::path::Path::new(&home)); + let hooks_installed = if rc_path.exists() { + std::fs::read_to_string(&rc_path) + .map(|s| crate::hooks::has_block(&s)) + .unwrap_or(false) + } else { + false + }; + let db_result = crate::database::get_connection(None) + .map(|conn| { + let schema_version = crate::database::current_version(&conn); + let total_commands: i64 = conn + .query_row("SELECT COUNT(*) FROM commands", [], |r| r.get(0)) + .unwrap_or(0); + let last_timestamp: Option = conn + .query_row("SELECT MAX(timestamp) FROM commands", [], |r| r.get(0)) + .unwrap_or(None); + crate::doctor::DbInfo { + schema_version, + total_commands, + last_timestamp, + } + }) + .map_err(|e| e.to_string()); + let tth_on_path = which_tth(); + let framework = crate::prompt::detect_framework(&crate::prompt::probe_inputs()); + let framework_config_text = read_framework_config(&framework, &home); + let config_path = crate::config::resolve_config_path(); + let config_present = config_path.exists(); + let theme_name = crate::config::load().theme.name; + let inputs = crate::doctor::DoctorInputs { + hooks_installed, + db_result, + tth_on_path, + framework, + framework_config_text, + config_path, + config_present, + theme_name, + }; + let report = crate::doctor::run_doctor(&inputs); + print!("{}", crate::doctor::render_report(&report)); + } + Some(Cmd::NewSessionId) => { + println!("{}", uuid::Uuid::new_v4()); + } + Some(Cmd::Config(args)) => { + let cfg = crate::config::load(); + let path = crate::config::resolve_config_path(); + match args.action { + None => { + let exists = path.exists(); + let color = crate::config::use_color(); + print!( + "{}", + crate::config::render_config(&cfg, &path, exists, color) + ); + } + Some(ConfigAction::Print) => { + let toml = crate::config::config_toml(&cfg)?; + print!("{}", toml); + } + Some(ConfigAction::Get(a)) => { + let val = crate::config::get_value(&cfg, &a.key)?; + println!("{}", val); + } + Some(ConfigAction::Set(a)) => { + crate::config::write_set(&a.key, &a.value)?; + println!("set {} = {}", a.key, a.value); + } + } + } + Some(Cmd::Workspace(args)) => match args.action { + WorkspaceAction::Start(a) => { + if a.name.is_empty() { + return Err(crate::error::ThothError::Hook( + "workspace name cannot be empty".into(), + )); + } + print!("{}", crate::workspaces::start_line(&a.name)); + eprintln!("Active workspace: {}", a.name); + } + WorkspaceAction::End => { + print!("{}", crate::workspaces::end_line()); + eprintln!("Workspace deactivated."); + } + }, + Some(Cmd::Workspaces) => { + let conn = crate::database::get_connection(None)?; + let rows = crate::workspaces::list_workspaces(&conn)?; + if rows.is_empty() { + println!("(no workspaces recorded)"); + } else { + use comfy_table::{presets::UTF8_BORDERS_ONLY, ContentArrangement, Table}; + let mut table = Table::new(); + table.load_preset(UTF8_BORDERS_ONLY); + table.set_content_arrangement(ContentArrangement::Dynamic); + table.set_header(vec!["workspace", "commands", "last used"]); + for row in &rows { + let last = crate::search::fmt_timestamp_pub(row.last_ts); + table.add_row(vec![row.name.clone(), row.command_count.to_string(), last]); + } + println!("{table}"); + } + } + Some(Cmd::Theme(args)) => { + let cfg = crate::config::load(); + let themes_dir = crate::config::resolve_themes_dir(); + match args.name.as_deref() { + None => { + println!("Current theme: {}", cfg.theme.name); + } + Some("list") => { + let current = &cfg.theme.name; + println!("Built-in themes:"); + for name in crate::theme::builtin_names() { + if *name == current.as_str() { + println!(" {} (current)", name); + } else { + println!(" {}", name); + } + } + let user_themes = crate::config::list_user_theme_names(&themes_dir); + if !user_themes.is_empty() { + println!("User themes:"); + for name in &user_themes { + if name == current { + println!(" {} (current)", name); + } else { + println!(" {}", name); + } + } + } + } + Some(name) => { + if !crate::config::theme_exists(name, &themes_dir) { + let builtin_list = crate::theme::builtin_names().join(", "); + return Err(crate::error::ThothError::Config(format!( + "unknown theme '{}'; built-in themes: {}; user themes go in {}", + name, + builtin_list, + themes_dir.display() + ))); + } + crate::config::write_set("theme.name", name)?; + println!("Theme set to '{}'. Reopen the TUI to see it.", name); + } + } + } + } + Ok(()) +} + +fn which_tth() -> bool { + std::env::var_os("PATH") + .map(|paths| std::env::split_paths(&paths).any(|dir| dir.join("tth").exists())) + .unwrap_or(false) +} + +fn read_framework_config(framework: &crate::prompt::PromptFramework, home: &str) -> Option { + use crate::prompt::PromptFramework; + let home_path = std::path::Path::new(home); + let config_path = match framework { + PromptFramework::Starship => home_path.join(".config/starship.toml"), + PromptFramework::Powerlevel10k => home_path.join(".p10k.zsh"), + PromptFramework::OhMyPosh => { + let dir = home_path.join(".config/oh-my-posh"); + if dir.exists() { + if let Ok(mut entries) = std::fs::read_dir(&dir) { + if let Some(Ok(entry)) = entries.next() { + return std::fs::read_to_string(entry.path()).ok(); + } + } + } + return None; + } + PromptFramework::Generic => return None, + }; + std::fs::read_to_string(config_path).ok() +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..056bce4 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,925 @@ +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::error::ThothError; +use crate::theme::{self, Theme}; + +const VALID_KEYS: &[(&str, &str)] = &[ + ("session.gap_minutes", "positive integer"), + ("tui.orientation", r#""bottom" or "top""#), + ("search.default_limit", "positive integer"), + ("theme.name", "theme name string"), +]; + +pub const DEFAULT_CONFIG_TOML: &str = r#"# Thoth configuration. All settings are optional; values shown are the defaults. + +[session] +# Minutes of inactivity that start a new work session. +gap_minutes = 30 + +[tui] +# Interactive search panel position: "bottom" or "top". +orientation = "bottom" +# Columns to show in the interactive panel, in order. Available: timestamp, project, tags, exit, duration, directory, command +# columns = ["timestamp", "duration", "exit", "project", "command"] + +[search] +# Default maximum number of results for `tth search`. +default_limit = 50 +# Columns to show, in order. Available: timestamp, project, tags, exit, duration, directory, command +# columns = ["timestamp", "project", "tags", "exit", "duration", "directory", "command"] +# +# Regex patterns; commands matching any pattern are hidden from search results. +# filter = ["^ls$", "^cd "] + +[history] +# Regex patterns; matching commands are NEVER recorded. +# Thoth's own commands (tth, tth-sw, tth-tag, etc.) are ignored by default via this filter. +# To add more patterns (e.g. secrets), extend the list: +# filter = ["^\\s*tth\\b", "--password", "export .*TOKEN"] + +# [theme] +# Built-in themes: default, ember, frost, latte, frappe, macchiato, mocha +# You can also drop a .toml file in ~/.config/thoth/themes/ for a custom theme. +# name = "default" +"#; + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Default)] +#[serde(rename_all = "lowercase")] +pub enum Orientation { + #[default] + Bottom, + Top, +} + +impl Orientation { + pub fn is_bottom(&self) -> bool { + matches!(self, Orientation::Bottom) + } +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +#[serde(default)] +pub struct Session { + pub gap_minutes: i64, +} + +impl Default for Session { + fn default() -> Self { + Self { gap_minutes: 30 } + } +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +#[serde(default)] +pub struct Tui { + pub orientation: Orientation, + #[serde(default = "default_tui_columns")] + pub columns: Vec, +} + +impl Default for Tui { + fn default() -> Self { + Self { + orientation: Orientation::Bottom, + columns: default_tui_columns(), + } + } +} + +pub fn default_tui_columns() -> Vec { + vec![ + "timestamp".into(), + "duration".into(), + "exit".into(), + "project".into(), + "command".into(), + ] +} + +pub fn default_search_columns() -> Vec { + vec![ + "timestamp".into(), + "project".into(), + "tags".into(), + "exit".into(), + "duration".into(), + "directory".into(), + "command".into(), + ] +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +#[serde(default)] +pub struct Search { + pub default_limit: usize, + #[serde(default = "default_search_columns")] + pub columns: Vec, + #[serde(default)] + pub filter: Vec, +} + +impl Default for Search { + fn default() -> Self { + Self { + default_limit: 50, + columns: default_search_columns(), + filter: vec![], + } + } +} + +fn default_history_filter() -> Vec { + vec!["^\\s*tth\\b".to_string()] +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +#[serde(default)] +pub struct History { + #[serde(default = "default_history_filter")] + pub filter: Vec, +} + +impl Default for History { + fn default() -> Self { + Self { + filter: default_history_filter(), + } + } +} + +fn default_theme_name() -> String { + "default".into() +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +#[serde(default)] +pub struct ThemeSection { + #[serde(default = "default_theme_name")] + pub name: String, +} + +impl Default for ThemeSection { + fn default() -> Self { + Self { + name: default_theme_name(), + } + } +} + +#[derive(Deserialize, Serialize, Debug, Clone, Default, PartialEq)] +#[serde(default)] +pub struct Config { + pub session: Session, + pub tui: Tui, + pub search: Search, + pub history: History, + pub theme: ThemeSection, +} + +fn config_path_from(thoth_config: Option<&str>, xdg_config: Option<&str>, home: &Path) -> PathBuf { + if let Some(v) = thoth_config { + return PathBuf::from(v); + } + if let Some(x) = xdg_config { + return PathBuf::from(x).join("thoth").join("config.toml"); + } + home.join(".config").join("thoth").join("config.toml") +} + +pub fn resolve_config_path() -> PathBuf { + let thoth_config = std::env::var("THOTH_CONFIG").ok(); + let xdg_config = std::env::var("XDG_CONFIG_HOME").ok(); + let home = std::env::var("HOME").unwrap_or_else(|_| String::from("/tmp")); + config_path_from( + thoth_config.as_deref(), + xdg_config.as_deref(), + Path::new(&home), + ) +} + +pub fn parse(text: &str) -> Result { + toml::from_str(text).map_err(|e| ThothError::Config(e.to_string())) +} + +pub fn load() -> Config { + let path = resolve_config_path(); + if !path.exists() { + return Config::default(); + } + match std::fs::read_to_string(&path) { + Ok(text) => match parse(&text) { + Ok(cfg) => cfg, + Err(e) => { + eprintln!( + "thoth: ignoring invalid config at {}: {}; using defaults", + path.display(), + e + ); + Config::default() + } + }, + Err(e) => { + eprintln!( + "thoth: ignoring invalid config at {}: {}; using defaults", + path.display(), + e + ); + Config::default() + } + } +} + +pub fn themes_dir_from( + thoth_config: Option<&str>, + xdg_config: Option<&str>, + home: &Path, +) -> PathBuf { + let config_file = config_path_from(thoth_config, xdg_config, home); + config_file.parent().unwrap_or(home).join("themes") +} + +pub fn resolve_themes_dir() -> PathBuf { + let thoth_config = std::env::var("THOTH_CONFIG").ok(); + let xdg_config = std::env::var("XDG_CONFIG_HOME").ok(); + let home = std::env::var("HOME").unwrap_or_else(|_| String::from("/tmp")); + themes_dir_from( + thoth_config.as_deref(), + xdg_config.as_deref(), + Path::new(&home), + ) +} + +#[derive(Deserialize, Debug, Default)] +struct ThemeFile { + extends: Option, + selection_bg: Option, + selection_fg: Option, + accent: Option, + dim: Option, + border: Option, + ok: Option, + fail: Option, + project: Option, + command: Option, + header: Option, + controls: Option, + directory: Option, + tags: Option, +} + +pub fn apply_theme_file(base: Theme, toml_text: &str) -> Theme { + let file: ThemeFile = toml::from_str(toml_text).unwrap_or_default(); + let mut t = base; + + macro_rules! patch { + ($field:ident) => { + if let Some(ref s) = file.$field { + match theme::parse_color(s) { + Ok(c) => t.$field = c, + Err(_) => eprintln!( + "thoth: invalid theme color for '{}': {}", + stringify!($field), + s + ), + } + } + }; + } + + patch!(selection_bg); + patch!(selection_fg); + patch!(accent); + patch!(dim); + patch!(border); + patch!(ok); + patch!(fail); + patch!(project); + patch!(command); + patch!(header); + patch!(controls); + patch!(directory); + patch!(tags); + + t +} + +pub fn resolve_theme(name: &str, themes_dir: &Path) -> Theme { + if let Some(t) = theme::builtin(name) { + return t; + } + + let file_path = themes_dir.join(format!("{name}.toml")); + if file_path.exists() { + match std::fs::read_to_string(&file_path) { + Ok(text) => { + let extends_name: String = { + let tf: ThemeFile = toml::from_str(&text).unwrap_or_default(); + tf.extends.unwrap_or_else(|| "default".into()) + }; + let base = theme::builtin(&extends_name).unwrap_or_default(); + return apply_theme_file(base, &text); + } + Err(e) => { + eprintln!( + "thoth: cannot read theme file {}: {}", + file_path.display(), + e + ); + } + } + } else { + eprintln!("thoth: unknown theme '{}', using default", name); + } + + Theme::default() +} + +pub fn config_toml(cfg: &Config) -> Result { + toml::to_string(cfg).map_err(|e| ThothError::Config(e.to_string())) +} + +pub fn get_value(cfg: &Config, key: &str) -> Result { + match key { + "session.gap_minutes" => Ok(cfg.session.gap_minutes.to_string()), + "tui.orientation" => { + let v = if cfg.tui.orientation.is_bottom() { + "bottom" + } else { + "top" + }; + Ok(v.to_string()) + } + "search.default_limit" => Ok(cfg.search.default_limit.to_string()), + _ => Err(ThothError::Config(format!( + "unknown key '{}'; valid keys: {}", + key, + VALID_KEYS + .iter() + .map(|(k, _)| *k) + .collect::>() + .join(", ") + ))), + } +} + +pub fn apply_set(existing_toml: &str, key: &str, value: &str) -> Result { + let parts: Vec<&str> = key.splitn(2, '.').collect(); + if parts.len() != 2 { + return Err(ThothError::Config(format!( + "unknown key '{}'; valid keys: {}", + key, + VALID_KEYS + .iter() + .map(|(k, _)| *k) + .collect::>() + .join(", ") + ))); + } + let section = parts[0]; + let field = parts[1]; + + let typed_value: toml_edit::Value = match key { + "session.gap_minutes" | "search.default_limit" => { + let n: i64 = value + .parse() + .map_err(|_| ThothError::Config(format!("'{}' must be a positive integer", key)))?; + if n <= 0 { + return Err(ThothError::Config(format!( + "'{}' must be a positive integer (got {})", + key, n + ))); + } + toml_edit::value(n) + .into_value() + .map_err(|e| ThothError::Config(format!("toml_edit error: {}", e)))? + } + "tui.orientation" => { + if value != "bottom" && value != "top" { + return Err(ThothError::Config(format!( + "'tui.orientation' must be \"bottom\" or \"top\" (got '{}')", + value + ))); + } + toml_edit::value(value) + .into_value() + .map_err(|e| ThothError::Config(format!("toml_edit error: {}", e)))? + } + "theme.name" => toml_edit::value(value) + .into_value() + .map_err(|e| ThothError::Config(format!("toml_edit error: {}", e)))?, + _ => { + return Err(ThothError::Config(format!( + "unknown key '{}'; valid keys: {}", + key, + VALID_KEYS + .iter() + .map(|(k, _)| *k) + .collect::>() + .join(", ") + ))); + } + }; + + let mut doc: toml_edit::DocumentMut = existing_toml + .parse() + .map_err(|e: toml_edit::TomlError| ThothError::Config(e.to_string()))?; + + if doc.get(section).is_none() { + doc[section] = toml_edit::table(); + } + doc[section][field] = toml_edit::Item::Value(typed_value); + + Ok(doc.to_string()) +} + +pub fn write_set(key: &str, value: &str) -> Result<(), ThothError> { + let path = resolve_config_path(); + let existing = if path.exists() { + std::fs::read_to_string(&path)? + } else { + DEFAULT_CONFIG_TOML.to_string() + }; + let new_toml = apply_set(&existing, key, value)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, new_toml)?; + Ok(()) +} + +pub fn list_user_theme_names(themes_dir: &Path) -> Vec { + let entries = match std::fs::read_dir(themes_dir) { + Ok(e) => e, + Err(_) => return vec![], + }; + let mut names: Vec = entries + .filter_map(|e| { + let entry = e.ok()?; + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) == Some("toml") { + path.file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.to_string()) + } else { + None + } + }) + .collect(); + names.sort(); + names +} + +pub fn theme_exists(name: &str, themes_dir: &Path) -> bool { + if crate::theme::builtin(name).is_some() { + return true; + } + themes_dir.join(format!("{name}.toml")).exists() +} + +pub fn ensure_default_config(path: &Path) -> Result { + if path.exists() { + return Ok(false); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, DEFAULT_CONFIG_TOML)?; + Ok(true) +} + +pub fn use_color() -> bool { + if std::env::var_os("NO_COLOR").is_some() { + return false; + } + use std::io::IsTerminal; + std::io::stdout().is_terminal() +} + +pub fn render_config(cfg: &Config, path: &Path, exists: bool, color: bool) -> String { + let orientation = if cfg.tui.orientation.is_bottom() { + "bottom" + } else { + "top" + }; + + if color { + use crossterm::style::Stylize; + let exists_str = if exists { + "true".green().to_string() + } else { + "false".red().to_string() + }; + format!( + "Config path: {}\nExists: {}\n{} gap_minutes = {}\n{} orientation = {}\n{} default_limit = {}\n", + path.display().to_string().yellow(), + exists_str, + "[session]".cyan(), + cfg.session.gap_minutes.to_string().green(), + "[tui]".cyan(), + orientation.green(), + "[search]".cyan(), + cfg.search.default_limit.to_string().green(), + ) + } else { + let exists_str = if exists { "true" } else { "false" }; + format!( + "Config path: {}\nExists: {}\n[session] gap_minutes = {}\n[tui] orientation = {}\n[search] default_limit = {}\n", + path.display(), + exists_str, + cfg.session.gap_minutes, + orientation, + cfg.search.default_limit, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::theme::Theme; + use ratatui::style::Color; + use std::path::PathBuf; + use tempfile::TempDir; + + #[test] + fn parse_empty_string_uses_defaults() { + let cfg = parse("").unwrap(); + assert_eq!(cfg.session.gap_minutes, 30); + assert_eq!(cfg.tui.orientation, Orientation::Bottom); + assert_eq!(cfg.search.default_limit, 50); + } + + #[test] + fn parse_partial_session_gap() { + let cfg = parse("[session]\ngap_minutes = 15").unwrap(); + assert_eq!(cfg.session.gap_minutes, 15); + assert_eq!(cfg.tui.orientation, Orientation::Bottom); + assert_eq!(cfg.search.default_limit, 50); + } + + #[test] + fn parse_full_config() { + let toml = "[session]\ngap_minutes = 10\n[tui]\norientation = \"top\"\n[search]\ndefault_limit = 25"; + let cfg = parse(toml).unwrap(); + assert_eq!(cfg.session.gap_minutes, 10); + assert_eq!(cfg.tui.orientation, Orientation::Top); + assert_eq!(cfg.search.default_limit, 25); + } + + #[test] + fn parse_bad_toml_returns_err() { + assert!(parse("not = {valid = toml").is_err()); + } + + #[test] + fn parse_bad_orientation_returns_err() { + assert!(parse("[tui]\norientation = \"sideways\"").is_err()); + } + + #[test] + fn config_path_from_override_wins() { + let result = config_path_from( + Some("/custom/config.toml"), + Some("/xdg"), + Path::new("/home/user"), + ); + assert_eq!(result, PathBuf::from("/custom/config.toml")); + } + + #[test] + fn config_path_from_xdg_fallback() { + let result = config_path_from(None, Some("/xdg/config"), Path::new("/home/user")); + assert_eq!(result, PathBuf::from("/xdg/config/thoth/config.toml")); + } + + #[test] + fn config_path_from_home_fallback() { + let result = config_path_from(None, None, Path::new("/home/user")); + assert_eq!( + result, + PathBuf::from("/home/user/.config/thoth/config.toml") + ); + } + + #[test] + fn render_config_defaults() { + let cfg = Config::default(); + let path = PathBuf::from("/home/user/.config/thoth/config.toml"); + let out = render_config(&cfg, &path, false, false); + assert!(out.contains("gap_minutes = 30")); + assert!(out.contains("orientation = bottom")); + assert!(out.contains("default_limit = 50")); + assert!(out.contains("false")); + } + + #[test] + fn render_config_custom() { + let cfg = parse("[session]\ngap_minutes = 10\n[tui]\norientation = \"top\"\n[search]\ndefault_limit = 3").unwrap(); + let path = PathBuf::from("/tmp/config.toml"); + let out = render_config(&cfg, &path, true, false); + assert!(out.contains("gap_minutes = 10")); + assert!(out.contains("orientation = top")); + assert!(out.contains("default_limit = 3")); + assert!(out.contains("true")); + } + + #[test] + fn orientation_is_bottom_helper() { + assert!(Orientation::Bottom.is_bottom()); + assert!(!Orientation::Top.is_bottom()); + } + + #[test] + fn tui_default_columns_are_five() { + let tui = Tui::default(); + assert_eq!( + tui.columns, + vec!["timestamp", "duration", "exit", "project", "command"] + ); + } + + #[test] + fn parse_tui_columns_custom() { + let cfg = parse("[tui]\ncolumns = [\"exit\", \"command\"]").unwrap(); + assert_eq!(cfg.tui.columns, vec!["exit", "command"]); + } + + #[test] + fn history_default_filter_is_tth_pattern() { + let h = History::default(); + assert_eq!(h.filter, vec!["^\\s*tth\\b".to_string()]); + } + + #[test] + fn parse_empty_string_yields_tth_filter() { + let cfg = parse("").unwrap(); + assert_eq!(cfg.history.filter, vec!["^\\s*tth\\b".to_string()]); + } + + #[test] + fn parse_history_filter_replaces_default() { + let cfg = parse("[history]\nfilter = [\"x\"]").unwrap(); + assert_eq!(cfg.history.filter, vec!["x".to_string()]); + } + + #[test] + fn parse_default_config_toml_equals_default() { + let cfg = parse(DEFAULT_CONFIG_TOML).unwrap(); + assert_eq!(cfg, Config::default()); + } + + #[test] + fn config_toml_round_trips_to_default() { + let cfg = Config::default(); + let toml_str = config_toml(&cfg).unwrap(); + assert!(!toml_str.is_empty()); + let cfg2 = parse(&toml_str).unwrap(); + assert_eq!(cfg, cfg2); + } + + #[test] + fn get_value_gap_minutes() { + let cfg = Config::default(); + assert_eq!(get_value(&cfg, "session.gap_minutes").unwrap(), "30"); + } + + #[test] + fn get_value_orientation() { + let cfg = Config::default(); + assert_eq!(get_value(&cfg, "tui.orientation").unwrap(), "bottom"); + } + + #[test] + fn get_value_default_limit() { + let cfg = Config::default(); + assert_eq!(get_value(&cfg, "search.default_limit").unwrap(), "50"); + } + + #[test] + fn get_value_unknown_key_returns_err() { + let cfg = Config::default(); + assert!(get_value(&cfg, "foo.bar").is_err()); + } + + #[test] + fn apply_set_gap_minutes_creates_section() { + let result = apply_set("", "session.gap_minutes", "15").unwrap(); + assert!(result.contains("[session]")); + assert!(result.contains("gap_minutes = 15")); + } + + #[test] + fn apply_set_preserves_comment_and_sibling_key() { + let existing = "# my config\n[session]\ngap_minutes = 30\n# comment\n"; + let result = apply_set(existing, "session.gap_minutes", "60").unwrap(); + assert!(result.contains("# my config")); + assert!(result.contains("# comment")); + assert!(result.contains("gap_minutes = 60")); + } + + #[test] + fn apply_set_orientation_top() { + let result = apply_set("", "tui.orientation", "top").unwrap(); + assert!(result.contains("orientation = \"top\"")); + } + + #[test] + fn apply_set_invalid_gap_minutes_string_returns_err() { + assert!(apply_set("", "session.gap_minutes", "abc").is_err()); + } + + #[test] + fn apply_set_invalid_gap_minutes_zero_returns_err() { + assert!(apply_set("", "session.gap_minutes", "0").is_err()); + } + + #[test] + fn apply_set_invalid_orientation_returns_err() { + assert!(apply_set("", "tui.orientation", "sideways").is_err()); + } + + #[test] + fn apply_set_unknown_key_returns_err() { + assert!(apply_set("", "foo.bar", "baz").is_err()); + } + + #[test] + fn render_config_no_color_has_no_ansi() { + let cfg = Config::default(); + let path = PathBuf::from("/tmp/config.toml"); + let out = render_config(&cfg, &path, false, false); + assert!(!out.contains('\x1b')); + } + + #[test] + fn render_config_with_color_has_ansi() { + let cfg = Config::default(); + let path = PathBuf::from("/tmp/config.toml"); + let out = render_config(&cfg, &path, false, true); + assert!(out.contains('\x1b')); + } + + #[test] + fn ensure_default_config_writes_when_absent() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("thoth").join("config.toml"); + let wrote = ensure_default_config(&path).unwrap(); + assert!(wrote); + assert!(path.exists()); + let content = std::fs::read_to_string(&path).unwrap(); + assert_eq!(content, DEFAULT_CONFIG_TOML); + } + + #[test] + fn theme_section_default_name_is_default() { + assert_eq!(ThemeSection::default().name, "default"); + } + + #[test] + fn parse_empty_string_theme_name_is_default() { + let cfg = parse("").unwrap(); + assert_eq!(cfg.theme.name, "default"); + } + + #[test] + fn parse_theme_name_mocha() { + let cfg = parse("[theme]\nname = \"mocha\"").unwrap(); + assert_eq!(cfg.theme.name, "mocha"); + } + + #[test] + fn parse_default_config_toml_equals_default_with_theme() { + let cfg = parse(DEFAULT_CONFIG_TOML).unwrap(); + assert_eq!(cfg.theme.name, "default"); + assert_eq!(cfg, Config::default()); + } + + #[test] + fn apply_theme_file_patches_two_slots() { + let base = Theme::default(); + let toml = "selection_bg = \"#ff8800\"\naccent = \"blue\"\n"; + let result = apply_theme_file(base, toml); + assert_eq!(result.selection_bg, Color::Rgb(255, 136, 0)); + assert_eq!(result.accent, Color::Blue); + assert_eq!(result.ok, Theme::default().ok); + assert_eq!(result.dim, Theme::default().dim); + } + + #[test] + fn apply_theme_file_invalid_color_keeps_base() { + let base = Theme::default(); + let toml = "ok = \"nope\"\n"; + let result = apply_theme_file(base, toml); + assert_eq!(result.ok, Theme::default().ok); + } + + #[test] + fn apply_theme_file_empty_toml_returns_base_unchanged() { + let base = Theme::default(); + let result = apply_theme_file(base, ""); + assert_eq!(result, Theme::default()); + } + + #[test] + fn resolve_theme_builtin_mocha() { + let dir = TempDir::new().unwrap(); + let t = resolve_theme("mocha", dir.path()); + assert_eq!(t, crate::theme::builtin("mocha").unwrap()); + } + + #[test] + fn resolve_theme_unknown_returns_default() { + let dir = TempDir::new().unwrap(); + let t = resolve_theme("does-not-exist", dir.path()); + assert_eq!(t, Theme::default()); + } + + #[test] + fn resolve_theme_file_with_extends_and_override() { + let dir = TempDir::new().unwrap(); + let content = "extends = \"mocha\"\naccent = \"red\"\n"; + std::fs::write(dir.path().join("mine.toml"), content).unwrap(); + let t = resolve_theme("mine", dir.path()); + let mocha = crate::theme::builtin("mocha").unwrap(); + assert_eq!(t.accent, Color::Red); + assert_eq!(t.ok, mocha.ok); + assert_eq!(t.selection_bg, mocha.selection_bg); + } + + #[test] + fn themes_dir_from_uses_config_parent() { + let home = Path::new("/home/user"); + let dir = themes_dir_from(None, None, home); + assert_eq!(dir, PathBuf::from("/home/user/.config/thoth/themes")); + } + + #[test] + fn themes_dir_from_xdg_config() { + let home = Path::new("/home/user"); + let dir = themes_dir_from(None, Some("/xdg/cfg"), home); + assert_eq!(dir, PathBuf::from("/xdg/cfg/thoth/themes")); + } + + #[test] + fn themes_dir_from_thoth_config_override() { + let home = Path::new("/home/user"); + let dir = themes_dir_from(Some("/custom/thoth/config.toml"), None, home); + assert_eq!(dir, PathBuf::from("/custom/thoth/themes")); + } + + #[test] + fn list_user_theme_names_returns_sorted_toml_stems() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("mine.toml"), "").unwrap(); + std::fs::write(dir.path().join("other.toml"), "").unwrap(); + std::fs::write(dir.path().join("readme.txt"), "").unwrap(); + let names = list_user_theme_names(dir.path()); + assert_eq!(names, vec!["mine", "other"]); + } + + #[test] + fn list_user_theme_names_missing_dir_returns_empty() { + let names = list_user_theme_names(std::path::Path::new("/nonexistent/path/themes")); + assert!(names.is_empty()); + } + + #[test] + fn theme_exists_builtin_name_returns_true() { + let dir = TempDir::new().unwrap(); + assert!(theme_exists("mocha", dir.path())); + assert!(theme_exists("default", dir.path())); + } + + #[test] + fn theme_exists_file_in_dir_returns_true() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("mine.toml"), "").unwrap(); + assert!(theme_exists("mine", dir.path())); + } + + #[test] + fn theme_exists_unknown_returns_false() { + let dir = TempDir::new().unwrap(); + assert!(!theme_exists("does-not-exist", dir.path())); + } + + #[test] + fn apply_set_theme_name_works() { + let result = apply_set("", "theme.name", "mocha").unwrap(); + assert!(result.contains("[theme]")); + assert!(result.contains("name = \"mocha\"")); + } + + #[test] + fn ensure_default_config_does_not_overwrite() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "custom content").unwrap(); + let wrote = ensure_default_config(&path).unwrap(); + assert!(!wrote); + let content = std::fs::read_to_string(&path).unwrap(); + assert_eq!(content, "custom content"); + } +} diff --git a/src/database.rs b/src/database.rs new file mode 100644 index 0000000..64300ff --- /dev/null +++ b/src/database.rs @@ -0,0 +1,490 @@ +use crate::error::ThothError; +use crate::schema::{SCHEMA_V1, SCHEMA_V2_FTS, SCHEMA_V3_TERMINAL_ID, SCHEMA_V4_WORKSPACE}; +use rusqlite::{Connection, ErrorCode, TransactionBehavior}; +use std::path::Path; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +pub const BUSY_TIMEOUT_MS: u32 = 2000; + +const WAL_SETUP_RETRIES: u32 = 40; +const WAL_SETUP_SLEEP_MS: u64 = 5; + +fn is_transient_lock(e: &rusqlite::Error) -> bool { + matches!( + e, + rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error { + code: ErrorCode::DatabaseLocked | ErrorCode::DatabaseBusy, + .. + }, + _ + ) + ) +} + +pub const MIGRATIONS: &[(i64, &str)] = &[ + (1, SCHEMA_V1), + (2, SCHEMA_V2_FTS), + (3, SCHEMA_V3_TERMINAL_ID), + (4, SCHEMA_V4_WORKSPACE), +]; + +pub fn connect_memory() -> Result { + let conn = Connection::open_in_memory()?; + Ok(conn) +} + +pub fn get_connection(path: Option<&Path>) -> Result { + let db_path = if let Some(p) = path { + p.to_path_buf() + } else { + crate::paths::resolve_db_path() + }; + + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let mut attempt: u32 = 0; + loop { + let open_result = (|| -> Result { + let conn = Connection::open(&db_path)?; + conn.busy_timeout(Duration::from_millis(u64::from(BUSY_TIMEOUT_MS)))?; + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")?; + Ok(conn) + })(); + + let thoth_result: Result = open_result + .map_err(ThothError::from) + .and_then(|mut c| apply_migrations(&mut c).map(|()| c)); + + match thoth_result { + Ok(c) => return Ok(c), + Err(ThothError::Sqlite(sqlite_err)) if is_transient_lock(&sqlite_err) => { + attempt += 1; + if attempt >= WAL_SETUP_RETRIES { + return Err(ThothError::from(sqlite_err)); + } + std::thread::sleep(Duration::from_millis(WAL_SETUP_SLEEP_MS)); + } + Err(e) => return Err(e), + } + } +} + +pub fn current_version(conn: &Connection) -> i64 { + conn.query_row( + "SELECT COALESCE(MAX(version), 0) FROM schema_version", + [], + |row| row.get(0), + ) + .unwrap_or(0) +} + +pub fn fts5_available(conn: &Connection) -> bool { + conn.execute_batch("CREATE VIRTUAL TABLE temp.__fts_probe USING fts5(x); DROP TABLE IF EXISTS temp.__fts_probe;") + .is_ok() +} + +pub fn apply_migrations(conn: &mut Connection) -> Result<(), ThothError> { + apply_migration_list(conn, MIGRATIONS) +} + +fn apply_migration_list( + conn: &mut Connection, + migrations: &[(i64, &str)], +) -> Result<(), ThothError> { + for &(version, sql) in migrations { + if version == 2 && !fts5_available(conn) { + continue; + } + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + + let ver_now = tx + .query_row( + "SELECT COALESCE(MAX(version), 0) FROM schema_version", + [], + |row| row.get(0), + ) + .unwrap_or(0_i64); + + if version <= ver_now { + tx.rollback()?; + continue; + } + + tx.execute_batch(sql)?; + tx.execute( + "INSERT OR IGNORE INTO schema_version(version, applied_at) VALUES(?1, ?2)", + rusqlite::params![version, now], + )?; + tx.commit()?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mem_conn() -> Connection { + let mut c = connect_memory().unwrap(); + apply_migrations(&mut c).unwrap(); + c + } + + #[test] + fn v4_workspace_column_exists() { + let conn = mem_conn(); + let cols: Vec = { + let mut stmt = conn.prepare("PRAGMA table_info(commands)").unwrap(); + stmt.query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .filter_map(|r| r.ok()) + .collect() + }; + assert!( + cols.iter().any(|c| c == "workspace"), + "workspace column missing: {cols:?}" + ); + } + + #[test] + fn v4_pre_existing_rows_have_null_workspace() { + let mut conn = connect_memory().unwrap(); + conn.execute_batch(SCHEMA_V1).unwrap(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + conn.execute( + "INSERT OR IGNORE INTO schema_version(version, applied_at) VALUES(1, ?1)", + rusqlite::params![now], + ) + .unwrap(); + conn.execute( + "INSERT INTO commands(command, directory, project, session_id, timestamp) VALUES('pre-v4-cmd', '/tmp', 'p', 's1', 1700000001)", + [], + ).unwrap(); + apply_migrations(&mut conn).unwrap(); + let workspace: Option = conn + .query_row( + "SELECT workspace FROM commands WHERE command='pre-v4-cmd'", + [], + |r| r.get(0), + ) + .unwrap(); + assert!( + workspace.is_none(), + "expected NULL workspace for pre-existing row" + ); + } + + #[test] + fn v4_migration_idempotent() { + let mut conn = mem_conn(); + let ver_before = current_version(&conn); + assert_eq!(ver_before, 4); + apply_migrations(&mut conn).unwrap(); + let ver_after = current_version(&conn); + assert_eq!(ver_after, 4); + } + + #[test] + fn v3_terminal_id_column_exists() { + let conn = mem_conn(); + let cols: Vec = { + let mut stmt = conn.prepare("PRAGMA table_info(commands)").unwrap(); + stmt.query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .filter_map(|r| r.ok()) + .collect() + }; + assert!( + cols.iter().any(|c| c == "terminal_id"), + "terminal_id column missing: {cols:?}" + ); + } + + #[test] + fn v3_pre_existing_rows_have_null_terminal_id() { + let mut conn = connect_memory().unwrap(); + conn.execute_batch(SCHEMA_V1).unwrap(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + conn.execute( + "INSERT OR IGNORE INTO schema_version(version, applied_at) VALUES(1, ?1)", + rusqlite::params![now], + ) + .unwrap(); + conn.execute( + "INSERT INTO commands(command, directory, project, session_id, timestamp) VALUES('old-cmd', '/tmp', 'p', 's1', 1700000000)", + [], + ).unwrap(); + apply_migrations(&mut conn).unwrap(); + let terminal_id: Option = conn + .query_row( + "SELECT terminal_id FROM commands WHERE command='old-cmd'", + [], + |r| r.get(0), + ) + .unwrap(); + assert!( + terminal_id.is_none(), + "expected NULL terminal_id for pre-existing row" + ); + } + + #[test] + fn v3_migration_idempotent() { + let mut conn = mem_conn(); + let ver_before = current_version(&conn); + assert_eq!(ver_before, 4); + apply_migrations(&mut conn).unwrap(); + let ver_after = current_version(&conn); + assert_eq!(ver_after, 4); + } + + #[test] + fn v3_current_version_is_4() { + let conn = mem_conn(); + assert_eq!(current_version(&conn), 4); + } + + #[test] + fn v3_fts_triggers_still_work_after_migration() { + let conn = mem_conn(); + if !fts5_available(&conn) { + return; + } + conn.execute( + "INSERT INTO commands(command, directory, project, session_id, timestamp) VALUES('v3-fts-test', '/tmp', 'p', 's1', 1700000000)", + [], + ).unwrap(); + let rowid: Option = conn + .query_row( + "SELECT rowid FROM commands_fts WHERE commands_fts MATCH '\"v3-fts-test\"'", + [], + |r| r.get(0), + ) + .ok(); + assert!(rowid.is_some(), "FTS trigger broken after v3 migration"); + let id: i64 = conn + .query_row( + "SELECT id FROM commands WHERE command='v3-fts-test'", + [], + |r| r.get(0), + ) + .unwrap(); + conn.execute("DELETE FROM commands WHERE id=?1", rusqlite::params![id]) + .unwrap(); + let after_delete: Option = conn + .query_row( + "SELECT rowid FROM commands_fts WHERE commands_fts MATCH '\"v3-fts-test\"'", + [], + |r| r.get(0), + ) + .ok(); + assert!( + after_delete.is_none(), + "FTS delete trigger broken after v3 migration" + ); + } + + #[test] + fn migration_creates_schema() { + let conn = mem_conn(); + let tables: Vec = { + let mut stmt = conn + .prepare("SELECT name FROM sqlite_master WHERE type='table'") + .unwrap(); + stmt.query_map([], |row| row.get(0)) + .unwrap() + .filter_map(|r| r.ok()) + .collect() + }; + assert!(tables.iter().any(|t| t == "commands")); + assert!(tables.iter().any(|t| t == "sessions")); + assert!(tables.iter().any(|t| t == "projects")); + assert!(tables.iter().any(|t| t == "schema_version")); + let ver = current_version(&conn); + assert!(ver >= 1); + } + + #[test] + fn migration_idempotent() { + let mut conn = mem_conn(); + let ver_before = current_version(&conn); + apply_migrations(&mut conn).unwrap(); + let ver_after = current_version(&conn); + assert_eq!(ver_before, ver_after); + } + + #[test] + fn fts_trigger_sync_insert() { + let conn = mem_conn(); + if !fts5_available(&conn) { + return; + } + conn.execute( + "INSERT INTO commands(command, directory, project, session_id, timestamp) VALUES('git status', '/tmp', 'app', 'sid1', 1700000000)", + [], + ).unwrap(); + let row: Option = conn + .query_row( + "SELECT rowid FROM commands_fts WHERE commands_fts MATCH 'git'", + [], + |r| r.get(0), + ) + .ok(); + assert!(row.is_some()); + } + + #[test] + fn fts_trigger_sync_delete() { + let conn = mem_conn(); + if !fts5_available(&conn) { + return; + } + conn.execute( + "INSERT INTO commands(command, directory, project, session_id, timestamp) VALUES('unique-cmd-xyz', '/tmp', 'app', 'sid2', 1700000001)", + [], + ).unwrap(); + let id: i64 = conn + .query_row( + "SELECT id FROM commands WHERE command='unique-cmd-xyz'", + [], + |r| r.get(0), + ) + .unwrap(); + conn.execute("DELETE FROM commands WHERE id=?1", rusqlite::params![id]) + .unwrap(); + let row: Option = conn + .query_row( + "SELECT rowid FROM commands_fts WHERE commands_fts MATCH '\"unique-cmd-xyz\"'", + [], + |r| r.get(0), + ) + .ok(); + assert!(row.is_none()); + } + + #[test] + fn atomicity_rollback_on_bad_migration() { + const BAD_MIGRATION: &[(i64, &str)] = &[( + 99, + "CREATE TABLE should_not_exist(x);\nTHIS IS NOT VALID SQL;", + )]; + + let mut conn = connect_memory().unwrap(); + apply_migrations(&mut conn).unwrap(); + let ver_before = current_version(&conn); + + let result = apply_migration_list(&mut conn, BAD_MIGRATION); + + assert!(result.is_err(), "bad migration SQL must return an error"); + let ver_after = current_version(&conn); + assert_eq!( + ver_before, ver_after, + "schema_version must not advance when migration SQL is invalid" + ); + let tables: Vec = { + let mut stmt = conn + .prepare("SELECT name FROM sqlite_master WHERE type='table'") + .unwrap(); + stmt.query_map([], |row| row.get(0)) + .unwrap() + .filter_map(|r| r.ok()) + .collect() + }; + assert!( + !tables.iter().any(|t| t == "should_not_exist"), + "no phantom tables must survive the rolled-back transaction" + ); + } + + #[test] + fn concurrent_migrations_no_race() { + use std::sync::{Arc, Barrier, Mutex}; + use std::thread; + use tempfile::TempDir; + + const THREADS: usize = 16; + const ROUNDS: usize = 8; + + for _round in 0..ROUNDS { + let dir = TempDir::new().unwrap(); + let db_path = Arc::new(dir.path().join("history.db")); + + let barrier = Arc::new(Barrier::new(THREADS)); + let errors: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let handles: Vec<_> = (0..THREADS) + .map(|i| { + let path = Arc::clone(&db_path); + let errs = Arc::clone(&errors); + let bar = Arc::clone(&barrier); + thread::spawn(move || { + bar.wait(); + let result = get_connection(Some(&path)); + match result { + Ok(conn) => { + let ts = 1700000000_i64 + i as i64; + let res = conn.execute( + "INSERT INTO commands(command, directory, project, session_id, timestamp) \ + VALUES(?1, '/tmp', 'p', 's1', ?2)", + rusqlite::params![format!("cmd-{i}"), ts], + ); + if let Err(e) = res { + errs.lock().unwrap().push(format!("thread {i} insert: {e}")); + } + } + Err(e) => { + errs.lock() + .unwrap() + .push(format!("thread {i} connect: {e}")); + } + } + }) + }) + .collect(); + + for h in handles { + h.join().unwrap(); + } + + let errs = errors.lock().unwrap(); + assert!( + errs.is_empty(), + "round {_round}: concurrent migration errors: {errs:?}" + ); + + let conn = Connection::open(db_path.as_ref()).unwrap(); + let ver: i64 = conn + .query_row( + "SELECT COALESCE(MAX(version), 0) FROM schema_version", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(ver, 4, "round {_round}: schema not at final version"); + + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM commands", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + count, THREADS as i64, + "round {_round}: expected {THREADS} rows, got {count}" + ); + } + } +} diff --git a/src/doctor.rs b/src/doctor.rs new file mode 100644 index 0000000..ce1ab84 --- /dev/null +++ b/src/doctor.rs @@ -0,0 +1,319 @@ +use std::path::PathBuf; + +use crate::prompt::{prompt_snippet, PromptFramework}; + +#[derive(Debug, Clone, PartialEq)] +pub enum CheckStatus { + Ok, + Warn, + Fail, +} + +#[derive(Debug, Clone)] +pub struct Check { + pub status: CheckStatus, + pub name: String, + pub guidance: Option, +} + +#[derive(Debug)] +pub struct DoctorReport { + pub checks: Vec, +} + +pub struct DoctorInputs { + pub hooks_installed: bool, + pub db_result: Result, + pub tth_on_path: bool, + pub framework: PromptFramework, + pub framework_config_text: Option, + pub config_path: PathBuf, + pub config_present: bool, + pub theme_name: String, +} + +pub struct DbInfo { + pub schema_version: i64, + pub total_commands: i64, + pub last_timestamp: Option, +} + +pub fn run_doctor(inputs: &DoctorInputs) -> DoctorReport { + let mut checks = Vec::new(); + + if inputs.hooks_installed { + checks.push(Check { + status: CheckStatus::Ok, + name: "hooks installed".to_string(), + guidance: None, + }); + } else { + checks.push(Check { + status: CheckStatus::Fail, + name: "hooks installed".to_string(), + guidance: Some("Run: tth install".to_string()), + }); + } + + match &inputs.db_result { + Ok(info) => { + let detail = format!( + "schema {}, {} commands, last {}", + info.schema_version, + info.total_commands, + info.last_timestamp + .map(|t| t.to_string()) + .unwrap_or_else(|| "none".to_string()) + ); + checks.push(Check { + status: CheckStatus::Ok, + name: format!("database {detail}"), + guidance: None, + }); + } + Err(msg) => { + checks.push(Check { + status: CheckStatus::Fail, + name: "database".to_string(), + guidance: Some(format!("DB error: {msg}")), + }); + } + } + + if inputs.tth_on_path { + checks.push(Check { + status: CheckStatus::Ok, + name: "tth on PATH".to_string(), + guidance: None, + }); + } else { + checks.push(Check { + status: CheckStatus::Warn, + name: "tth on PATH".to_string(), + guidance: Some("tth not found on PATH; ensure your shell rc is loaded".to_string()), + }); + } + + let prompt_check = + check_prompt_visibility(&inputs.framework, inputs.framework_config_text.as_deref()); + checks.push(prompt_check); + + let status_str = if inputs.config_present { + "present" + } else { + "absent" + }; + checks.push(Check { + status: CheckStatus::Ok, + name: format!("config {} ({})", inputs.config_path.display(), status_str), + guidance: None, + }); + + checks.push(Check { + status: CheckStatus::Ok, + name: format!("theme {}", inputs.theme_name), + guidance: None, + }); + + DoctorReport { checks } +} + +fn check_prompt_visibility(framework: &PromptFramework, config_text: Option<&str>) -> Check { + match framework { + PromptFramework::Generic => Check { + status: CheckStatus::Warn, + name: "prompt tags visibility".to_string(), + guidance: Some(format!( + "Cannot introspect PROMPT/PS1 automatically.\n{}", + prompt_snippet(framework) + )), + }, + _ => { + let configured = config_text + .map(|text| text.contains("thoth_tags") || text.contains("TTH_PROMPT_TAGS")) + .unwrap_or(false); + if configured { + Check { + status: CheckStatus::Ok, + name: "prompt tags visibility".to_string(), + guidance: None, + } + } else { + Check { + status: CheckStatus::Warn, + name: "prompt tags visibility".to_string(), + guidance: Some(format!( + "TTH_PROMPT_TAGS not referenced in framework config. Add:\n{}", + prompt_snippet(framework) + )), + } + } + } + } +} + +pub fn render_report(report: &DoctorReport) -> String { + let mut out = String::new(); + for check in &report.checks { + let tag = match check.status { + CheckStatus::Ok => "[ok] ", + CheckStatus::Warn => "[warn]", + CheckStatus::Fail => "[fail]", + }; + out.push_str(&format!("{tag} {}\n", check.name)); + if let Some(ref guidance) = check.guidance { + for line in guidance.lines() { + out.push_str(&format!(" -> {line}\n")); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_inputs( + hooks: bool, + db: bool, + tth: bool, + framework: PromptFramework, + config: Option<&str>, + ) -> DoctorInputs { + DoctorInputs { + hooks_installed: hooks, + db_result: if db { + Ok(DbInfo { + schema_version: 3, + total_commands: 42, + last_timestamp: Some(1700000000), + }) + } else { + Err("connection failed".to_string()) + }, + tth_on_path: tth, + framework, + framework_config_text: config.map(|s| s.to_string()), + config_path: std::path::PathBuf::from("/home/user/.config/thoth/config.toml"), + config_present: false, + theme_name: "default".to_string(), + } + } + + #[test] + fn hooks_ok_when_installed() { + let inputs = make_inputs(true, true, true, PromptFramework::Generic, None); + let report = run_doctor(&inputs); + assert_eq!(report.checks[0].status, CheckStatus::Ok); + assert!(report.checks[0].name.contains("hooks")); + } + + #[test] + fn hooks_fail_when_not_installed() { + let inputs = make_inputs(false, true, true, PromptFramework::Generic, None); + let report = run_doctor(&inputs); + assert_eq!(report.checks[0].status, CheckStatus::Fail); + let guidance = report.checks[0].guidance.as_deref().unwrap_or(""); + assert!(guidance.contains("tth install")); + } + + #[test] + fn db_ok_when_accessible() { + let inputs = make_inputs(true, true, true, PromptFramework::Generic, None); + let report = run_doctor(&inputs); + assert_eq!(report.checks[1].status, CheckStatus::Ok); + assert!(report.checks[1].name.contains("schema 3")); + assert!(report.checks[1].name.contains("42 commands")); + } + + #[test] + fn db_fail_when_error() { + let inputs = make_inputs(true, false, true, PromptFramework::Generic, None); + let report = run_doctor(&inputs); + assert_eq!(report.checks[1].status, CheckStatus::Fail); + } + + #[test] + fn tth_ok_when_on_path() { + let inputs = make_inputs(true, true, true, PromptFramework::Generic, None); + let report = run_doctor(&inputs); + assert_eq!(report.checks[2].status, CheckStatus::Ok); + } + + #[test] + fn tth_warn_when_not_on_path() { + let inputs = make_inputs(true, true, false, PromptFramework::Generic, None); + let report = run_doctor(&inputs); + assert_eq!(report.checks[2].status, CheckStatus::Warn); + } + + #[test] + fn prompt_ok_when_starship_config_has_thoth_tags() { + let config = "[env_var.thoth_tags]\nvariable = \"TTH_PROMPT_TAGS\""; + let inputs = make_inputs(true, true, true, PromptFramework::Starship, Some(config)); + let report = run_doctor(&inputs); + let prompt_check = report + .checks + .iter() + .find(|c| c.name.contains("prompt tags")) + .unwrap(); + assert_eq!(prompt_check.status, CheckStatus::Ok); + } + + #[test] + fn prompt_warn_when_starship_config_lacks_thoth_tags() { + let config = "[character]\nsuccess_symbol = \"[>](bold green)\""; + let inputs = make_inputs(true, true, true, PromptFramework::Starship, Some(config)); + let report = run_doctor(&inputs); + let prompt_check = report + .checks + .iter() + .find(|c| c.name.contains("prompt tags")) + .unwrap(); + assert_eq!(prompt_check.status, CheckStatus::Warn); + let guidance = prompt_check.guidance.as_deref().unwrap_or(""); + assert!(guidance.contains("env_var.thoth_tags")); + } + + #[test] + fn prompt_warn_for_generic_is_advisory() { + let inputs = make_inputs(true, true, true, PromptFramework::Generic, None); + let report = run_doctor(&inputs); + let prompt_check = report + .checks + .iter() + .find(|c| c.name.contains("prompt tags")) + .unwrap(); + assert_eq!(prompt_check.status, CheckStatus::Warn); + } + + #[test] + fn render_report_contains_ok_fail_warn() { + let report = DoctorReport { + checks: vec![ + Check { + status: CheckStatus::Ok, + name: "alpha".to_string(), + guidance: None, + }, + Check { + status: CheckStatus::Warn, + name: "beta".to_string(), + guidance: Some("fix it".to_string()), + }, + Check { + status: CheckStatus::Fail, + name: "gamma".to_string(), + guidance: Some("run cmd".to_string()), + }, + ], + }; + let output = render_report(&report); + assert!(output.contains("[ok]")); + assert!(output.contains("[warn]")); + assert!(output.contains("[fail]")); + assert!(output.contains("-> fix it")); + assert!(output.contains("-> run cmd")); + } +} diff --git a/src/edit.rs b/src/edit.rs new file mode 100644 index 0000000..acd91e8 --- /dev/null +++ b/src/edit.rs @@ -0,0 +1,220 @@ +use crate::error::ThothError; +use rusqlite::{Connection, TransactionBehavior}; + +type CommandFields = ( + String, + String, + String, + String, + i64, + i64, + i64, + String, + Option, + Option, +); + +pub fn edit_command(conn: &mut Connection, id: i64, new_command: &str) -> Result<(), ThothError> { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + + let existing: Option = { + let mut stmt = tx.prepare( + "SELECT command, directory, project, session_id, timestamp, exit_code, duration_ms, tags, terminal_id, workspace \ + FROM commands WHERE id = ?1", + )?; + stmt.query_row(rusqlite::params![id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, i64>(4)?, + row.get::<_, i64>(5)?, + row.get::<_, i64>(6)?, + row.get::<_, String>(7)?, + row.get::<_, Option>(8)?, + row.get::<_, Option>(9)?, + )) + }) + .ok() + }; + + let ( + _, + directory, + project, + session_id, + timestamp, + exit_code, + duration_ms, + tags, + terminal_id, + workspace, + ) = match existing { + Some(row) => row, + None => { + tx.rollback()?; + return Ok(()); + } + }; + + tx.execute("DELETE FROM commands WHERE id = ?1", rusqlite::params![id])?; + + tx.execute( + "INSERT INTO commands(id, command, directory, project, session_id, timestamp, exit_code, duration_ms, tags, terminal_id, workspace) \ + VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + rusqlite::params![ + id, + new_command, + directory, + project, + session_id, + timestamp, + exit_code, + duration_ms, + tags, + terminal_id, + workspace + ], + )?; + + tx.commit()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mem_conn() -> Connection { + let mut c = crate::database::connect_memory().unwrap(); + crate::database::apply_migrations(&mut c).unwrap(); + c + } + + fn seed(conn: &Connection, cmd: &str) -> i64 { + conn.execute( + "INSERT INTO commands(command, directory, project, session_id, timestamp, exit_code, duration_ms, tags, terminal_id, workspace) \ + VALUES(?1, '/home/user', 'myproject', 'ses-abc', 1700000001, 0, 250, '[\"rust\"]', 'term-1', 'ws-a')", + rusqlite::params![cmd], + ) + .unwrap(); + conn.last_insert_rowid() + } + + #[test] + fn edit_command_changes_command_text() { + let mut conn = mem_conn(); + let id = seed(&conn, "git stattus"); + edit_command(&mut conn, id, "git status").unwrap(); + let cmd: String = conn + .query_row( + "SELECT command FROM commands WHERE id = ?1", + rusqlite::params![id], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(cmd, "git status"); + } + + #[test] + fn edit_command_preserves_all_other_columns() { + let mut conn = mem_conn(); + let id = seed(&conn, "old cmd"); + edit_command(&mut conn, id, "new cmd").unwrap(); + + let (directory, project, session_id, timestamp, exit_code, duration_ms, tags, terminal_id, workspace): ( + String, String, String, i64, i64, i64, String, Option, Option, + ) = conn + .query_row( + "SELECT directory, project, session_id, timestamp, exit_code, duration_ms, tags, terminal_id, workspace \ + FROM commands WHERE id = ?1", + rusqlite::params![id], + |r| { + Ok(( + r.get(0)?, + r.get(1)?, + r.get(2)?, + r.get(3)?, + r.get(4)?, + r.get(5)?, + r.get(6)?, + r.get(7)?, + r.get(8)?, + )) + }, + ) + .unwrap(); + + assert_eq!(directory, "/home/user"); + assert_eq!(project, "myproject"); + assert_eq!(session_id, "ses-abc"); + assert_eq!(timestamp, 1700000001); + assert_eq!(exit_code, 0); + assert_eq!(duration_ms, 250); + assert_eq!(tags, "[\"rust\"]"); + assert_eq!(terminal_id, Some("term-1".into())); + assert_eq!(workspace, Some("ws-a".into())); + } + + #[test] + fn edit_command_preserves_same_id() { + let mut conn = mem_conn(); + let id = seed(&conn, "old cmd"); + edit_command(&mut conn, id, "new cmd").unwrap(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM commands WHERE id = ?1", + rusqlite::params![id], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 1, "row with same id must still exist"); + } + + #[test] + fn edit_command_nonexistent_id_is_noop() { + let mut conn = mem_conn(); + let result = edit_command(&mut conn, 99999, "anything"); + assert!(result.is_ok(), "nonexistent id must be a safe no-op"); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM commands", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0); + } + + #[test] + fn edit_command_keeps_fts_in_sync() { + let mut conn = mem_conn(); + if !crate::database::fts5_available(&conn) { + return; + } + + let id = seed(&conn, "old_unique_text_xyzzy"); + edit_command(&mut conn, id, "new_unique_text_quux").unwrap(); + + let found_new: Option = conn + .query_row( + "SELECT rowid FROM commands_fts WHERE commands_fts MATCH '\"new_unique_text_quux\"'", + [], + |r| r.get(0), + ) + .ok(); + assert!( + found_new.is_some(), + "FTS must find the new command text after edit" + ); + + let found_old: Option = conn + .query_row( + "SELECT rowid FROM commands_fts WHERE commands_fts MATCH '\"old_unique_text_xyzzy\"'", + [], + |r| r.get(0), + ) + .ok(); + assert!( + found_old.is_none(), + "FTS must NOT find the old command text after edit" + ); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..8fe36bc --- /dev/null +++ b/src/error.rs @@ -0,0 +1,59 @@ +#[derive(thiserror::Error, Debug)] +pub enum ThothError { + #[error("sqlite: {0}")] + Sqlite(#[from] rusqlite::Error), + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("{0}")] + Hook(String), + #[error("{0}")] + Search(String), + #[error("{0}")] + Forget(String), + #[error("tui: {0}")] + Tui(String), + #[error("{0}")] + Tag(String), + #[error("{0}")] + Prompt(String), + #[error("config: {0}")] + Config(String), +} + +#[cfg(test)] +mod tests { + use super::ThothError; + + #[test] + fn display_sqlite_variant() { + let err = ThothError::Sqlite(rusqlite::Error::QueryReturnedNoRows); + assert!(err.to_string().starts_with("sqlite:")); + } + + #[test] + fn display_io_variant() { + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing"); + let err = ThothError::Io(io_err); + assert!(err.to_string().starts_with("io:")); + } + + #[test] + fn string_variants_delegate_to_inner_string() { + assert_eq!( + ThothError::Hook("could not write rc file".into()).to_string(), + "could not write rc file" + ); + assert_eq!( + ThothError::Search("bad input".into()).to_string(), + "bad input" + ); + assert_eq!( + ThothError::Tui("render failed".into()).to_string(), + "tui: render failed" + ); + assert_eq!( + ThothError::Tag("empty tag name".into()).to_string(), + "empty tag name" + ); + } +} diff --git a/src/export.rs b/src/export.rs new file mode 100644 index 0000000..08fb6a5 --- /dev/null +++ b/src/export.rs @@ -0,0 +1,830 @@ +use rusqlite::{Connection, ToSql}; + +use crate::error::ThothError; +use crate::search::{parse_date, ExitFilter}; + +const SECS_PER_DAY: i64 = 86_400; + +pub struct ExportArgs { + pub session: Option, + pub tag: Vec, + pub project: Option, + pub since: Option, + pub exit: Option, + pub workspace: Option, +} + +pub struct ExportRow { + pub command: String, + pub directory: String, + pub timestamp: i64, + pub exit_code: i64, + pub duration_ms: i64, +} + +pub struct ExportMeta<'a> { + pub project: Option<&'a str>, + pub tags: &'a [String], +} + +pub fn build_query( + args: &ExportArgs, + now: i64, +) -> Result<(String, Vec>), ThothError> { + let cols = "c.command, c.directory, c.timestamp, c.exit_code, c.duration_ms"; + + let mut fragments: Vec = Vec::new(); + let mut params: Vec> = Vec::new(); + + if let Some(ref p) = args.project { + fragments.push("c.project LIKE ?".to_string()); + params.push(Box::new(format!("%{p}%"))); + } + + match &args.exit { + Some(ExitFilter::Ok) => { + fragments.push("c.exit_code = 0".to_string()); + } + Some(ExitFilter::Fail) => { + fragments.push("c.exit_code != 0".to_string()); + } + Some(ExitFilter::Any) | None => {} + } + + if let Some(ref since_str) = args.since { + let ts = parse_date(since_str, now)?; + fragments.push("c.timestamp >= ?".to_string()); + params.push(Box::new(ts)); + } + + if let Some(ref sid) = args.session { + fragments.push("c.session_id = ?".to_string()); + params.push(Box::new(sid.clone())); + } + + for tag in &args.tag { + fragments.push( + "EXISTS(SELECT 1 FROM json_each(c.tags) WHERE LOWER(value) = LOWER(?))".to_string(), + ); + params.push(Box::new(tag.clone())); + } + + if let Some(ref ws) = args.workspace { + fragments.push("c.workspace = ?".to_string()); + params.push(Box::new(ws.clone())); + } + + let sql = if fragments.is_empty() { + format!("SELECT {cols} FROM commands c ORDER BY c.timestamp ASC, c.id ASC") + } else { + let where_clause = fragments.join(" AND "); + format!( + "SELECT {cols} FROM commands c WHERE {where_clause} ORDER BY c.timestamp ASC, c.id ASC" + ) + }; + + Ok((sql, params)) +} + +pub fn collect( + conn: &Connection, + args: &ExportArgs, + now: i64, +) -> Result, ThothError> { + let (sql, params) = build_query(args, now)?; + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map( + rusqlite::params_from_iter(params.iter().map(|p| p.as_ref())), + |row| { + Ok(ExportRow { + command: row.get(0)?, + directory: row.get(1)?, + timestamp: row.get(2)?, + exit_code: row.get(3)?, + duration_ms: row.get(4)?, + }) + }, + )?; + let mut result = Vec::new(); + for row in rows { + result.push(row?); + } + Ok(result) +} + +pub fn fmt_hms(epoch: i64) -> String { + let time_secs = epoch.rem_euclid(SECS_PER_DAY); + let hh = time_secs / 3600; + let mm = (time_secs % 3600) / 60; + let ss = time_secs % 60; + format!("{hh:02}:{mm:02}:{ss:02}") +} + +fn fmt_duration(ms: i64) -> String { + if ms < 1000 { + format!("{ms}ms") + } else { + let s = ms as f64 / 1000.0; + let tenths = (s * 10.0).round() as i64; + if tenths % 10 == 0 { + format!("{}s", tenths / 10) + } else { + format!("{:.1}s", s) + } + } +} + +pub fn render_script(rows: &[ExportRow], meta: &ExportMeta<'_>, now: i64) -> String { + let date_str = { + let days = now.div_euclid(SECS_PER_DAY); + let (y, mo, d) = crate::search::civil_from_days(days); + format!("{y:04}-{mo:02}-{d:02}") + }; + + let project_label = meta.project.unwrap_or("all"); + let tags_label = if meta.tags.is_empty() { + "none".to_string() + } else { + meta.tags.join(", ") + }; + + let mut out = String::new(); + out.push_str("#!/usr/bin/env bash\n"); + out.push_str("# Thoth export\n"); + out.push_str(&format!("# Project: {project_label}\n")); + out.push_str(&format!("# Tags: {tags_label}\n")); + out.push_str(&format!("# Exported: {date_str}\n")); + + if rows.is_empty() { + out.push_str("# (no commands matched)\n"); + return out; + } + + for row in rows { + let hms = fmt_hms(row.timestamp); + let dur = fmt_duration(row.duration_ms); + out.push('\n'); + out.push_str(&format!( + "# [{hms}] [{}] [exit: {}] [duration: {dur}]\n", + row.directory, row.exit_code + )); + out.push_str(&row.command); + out.push('\n'); + } + + out +} + +pub fn render_replay_command(rows: &[ExportRow]) -> String { + if rows.is_empty() { + return "true".to_string(); + } + let mut units: Vec = Vec::new(); + let mut prev_dir: Option<&str> = None; + for row in rows { + let unit = if prev_dir != Some(row.directory.as_str()) { + let escaped_dir = row.directory.replace('\'', "'\\''"); + prev_dir = Some(row.directory.as_str()); + format!("cd '{escaped_dir}' && {}", row.command) + } else { + row.command.clone() + }; + units.push(unit); + } + let joined = units.join(" ; \\\n"); + format!("( {joined} )") +} + +#[cfg(test)] +mod tests { + use super::*; + + const FIXED_NOW: i64 = 1_700_000_000; + + fn default_args() -> ExportArgs { + ExportArgs { + session: None, + tag: vec![], + project: None, + since: None, + exit: None, + workspace: None, + } + } + + fn mem_conn() -> rusqlite::Connection { + let mut c = crate::database::connect_memory().unwrap(); + crate::database::apply_migrations(&mut c).unwrap(); + c + } + + struct SeedRow<'a> { + cmd: &'a str, + project: &'a str, + ts: i64, + exit: i64, + dur: i64, + tags: &'a str, + session: &'a str, + } + + fn seed(conn: &rusqlite::Connection, r: SeedRow<'_>) { + conn.execute( + "INSERT INTO commands(command, directory, project, session_id, timestamp, exit_code, duration_ms, tags) \ + VALUES(?1, '/tmp', ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![r.cmd, r.project, r.session, r.ts, r.exit, r.dur, r.tags], + ) + .unwrap(); + } + + fn s<'a>( + cmd: &'a str, + project: &'a str, + ts: i64, + exit: i64, + dur: i64, + tags: &'a str, + session: &'a str, + ) -> SeedRow<'a> { + SeedRow { + cmd, + project, + ts, + exit, + dur, + tags, + session, + } + } + + #[test] + fn build_query_no_filters_has_no_where_asc_order_no_limit() { + let args = default_args(); + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + assert!(!sql.contains("WHERE"), "no WHERE clause expected"); + assert!( + sql.contains("ORDER BY c.timestamp ASC"), + "must order ASC; got: {sql}" + ); + assert!(!sql.contains("LIMIT"), "no LIMIT expected"); + assert_eq!(params.len(), 0); + } + + #[test] + fn build_query_project_filter() { + let args = ExportArgs { + project: Some("foo".into()), + ..default_args() + }; + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + assert!(sql.contains("c.project LIKE ?")); + assert!(sql.contains("ORDER BY c.timestamp ASC")); + assert!(!sql.contains("LIMIT")); + assert_eq!(params.len(), 1); + } + + #[test] + fn build_query_one_tag() { + let args = ExportArgs { + tag: vec!["rust".into()], + ..default_args() + }; + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + assert!( + sql.contains("EXISTS(SELECT 1 FROM json_each(c.tags) WHERE LOWER(value) = LOWER(?))") + ); + assert_eq!(params.len(), 1); + } + + #[test] + fn build_query_two_tags_both_present() { + let args = ExportArgs { + tag: vec!["rust".into(), "cli".into()], + ..default_args() + }; + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + let count = sql + .matches("EXISTS(SELECT 1 FROM json_each(c.tags) WHERE LOWER(value) = LOWER(?))") + .count(); + assert_eq!(count, 2, "two tag clauses expected for AND semantics"); + assert_eq!(params.len(), 2); + } + + #[test] + fn build_query_since() { + let args = ExportArgs { + since: Some("today".into()), + ..default_args() + }; + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + assert!(sql.contains("c.timestamp >= ?")); + assert_eq!(params.len(), 1); + } + + #[test] + fn build_query_exit_ok() { + let args = ExportArgs { + exit: Some(ExitFilter::Ok), + ..default_args() + }; + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + assert!(sql.contains("c.exit_code = 0")); + assert_eq!(params.len(), 0); + } + + #[test] + fn build_query_exit_fail() { + let args = ExportArgs { + exit: Some(ExitFilter::Fail), + ..default_args() + }; + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + assert!(sql.contains("c.exit_code != 0")); + assert_eq!(params.len(), 0); + } + + #[test] + fn build_query_exit_any_no_exit_clause() { + let args = ExportArgs { + exit: Some(ExitFilter::Any), + ..default_args() + }; + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + assert!(!sql.contains("exit_code = 0") && !sql.contains("exit_code != 0")); + assert_eq!(params.len(), 0); + } + + #[test] + fn build_query_session_filter() { + let args = ExportArgs { + session: Some("sess-abc".into()), + ..default_args() + }; + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + assert!(sql.contains("c.session_id = ?")); + assert_eq!(params.len(), 1); + } + + #[test] + fn build_query_combined_no_fts_no_limit() { + let args = ExportArgs { + project: Some("myapp".into()), + tag: vec!["infra".into()], + since: Some("today".into()), + exit: Some(ExitFilter::Ok), + session: Some("sess-x".into()), + workspace: None, + }; + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + assert!(sql.contains("c.project LIKE ?")); + assert!(sql.contains("c.exit_code = 0")); + assert!(sql.contains("c.timestamp >= ?")); + assert!(sql.contains("c.session_id = ?")); + assert!( + sql.contains("EXISTS(SELECT 1 FROM json_each(c.tags) WHERE LOWER(value) = LOWER(?))") + ); + assert!(!sql.contains("LIMIT")); + assert!(!sql.contains("JOIN")); + assert!(sql.contains("ORDER BY c.timestamp ASC")); + assert_eq!(params.len(), 4); + } + + #[test] + fn fmt_hms_known_epoch() { + let epoch = 1_700_000_000_i64; + let result = fmt_hms(epoch); + assert_eq!(result, "22:13:20"); + } + + #[test] + fn fmt_hms_midnight() { + let result = fmt_hms(0); + assert_eq!(result, "00:00:00"); + } + + #[test] + fn fmt_hms_end_of_day() { + let result = fmt_hms(86399); + assert_eq!(result, "23:59:59"); + } + + #[test] + fn render_script_starts_with_shebang() { + let rows: Vec = vec![]; + let meta = ExportMeta { + project: None, + tags: &[], + }; + let out = render_script(&rows, &meta, FIXED_NOW); + assert!( + out.starts_with("#!/usr/bin/env bash\n"), + "shebang must be first line" + ); + } + + #[test] + fn render_script_metadata_lines_present() { + let rows: Vec = vec![]; + let meta = ExportMeta { + project: Some("myproj"), + tags: &["rust".to_string()], + }; + let out = render_script(&rows, &meta, FIXED_NOW); + assert!(out.contains("# Thoth export\n")); + assert!(out.contains("# Project: myproj\n")); + assert!(out.contains("# Tags: rust\n")); + assert!(out.contains("# Exported: 2023-11-14\n")); + } + + #[test] + fn render_script_no_project_shows_all() { + let rows: Vec = vec![]; + let meta = ExportMeta { + project: None, + tags: &[], + }; + let out = render_script(&rows, &meta, FIXED_NOW); + assert!(out.contains("# Project: all\n")); + assert!(out.contains("# Tags: none\n")); + } + + #[test] + fn render_script_empty_rows_has_no_commands_comment() { + let rows: Vec = vec![]; + let meta = ExportMeta { + project: None, + tags: &[], + }; + let out = render_script(&rows, &meta, FIXED_NOW); + assert!(out.contains("# (no commands matched)")); + } + + #[test] + fn render_script_row_comment_and_command() { + let rows = vec![ExportRow { + command: "cargo build".into(), + directory: "/home/user/proj".into(), + timestamp: 1_700_000_000, + exit_code: 0, + duration_ms: 500, + }]; + let meta = ExportMeta { + project: None, + tags: &[], + }; + let out = render_script(&rows, &meta, FIXED_NOW); + assert!( + out.contains("# [22:13:20] [/home/user/proj] [exit: 0] [duration: 500ms]\n"), + "row comment must be present; got:\n{out}" + ); + assert!(out.contains("cargo build\n")); + } + + #[test] + fn render_script_chronological_order() { + let rows = vec![ + ExportRow { + command: "first_cmd".into(), + directory: "/tmp".into(), + timestamp: 1_700_000_000, + exit_code: 0, + duration_ms: 100, + }, + ExportRow { + command: "second_cmd".into(), + directory: "/tmp".into(), + timestamp: 1_700_001_000, + exit_code: 0, + duration_ms: 200, + }, + ]; + let meta = ExportMeta { + project: None, + tags: &[], + }; + let out = render_script(&rows, &meta, FIXED_NOW); + let first_pos = out.find("first_cmd").unwrap(); + let second_pos = out.find("second_cmd").unwrap(); + assert!( + first_pos < second_pos, + "first_cmd must appear before second_cmd" + ); + } + + #[test] + fn collect_returns_oldest_first() { + let conn = mem_conn(); + seed(&conn, s("cmd_a", "p", 1_000, 0, 100, "[]", "s1")); + seed(&conn, s("cmd_b", "p", 3_000, 0, 100, "[]", "s1")); + seed(&conn, s("cmd_c", "p", 2_000, 0, 100, "[]", "s1")); + let args = default_args(); + let rows = collect(&conn, &args, FIXED_NOW).unwrap(); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0].command, "cmd_a"); + assert_eq!(rows[1].command, "cmd_c"); + assert_eq!(rows[2].command, "cmd_b"); + } + + #[test] + fn collect_project_filter() { + let conn = mem_conn(); + seed(&conn, s("alpha_cmd", "alpha", 1_000, 0, 100, "[]", "s1")); + seed(&conn, s("beta_cmd", "beta", 2_000, 0, 100, "[]", "s1")); + let args = ExportArgs { + project: Some("alpha".into()), + ..default_args() + }; + let rows = collect(&conn, &args, FIXED_NOW).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].command, "alpha_cmd"); + } + + #[test] + fn collect_tag_filter() { + let conn = mem_conn(); + seed(&conn, s("tagged", "p", 1_000, 0, 100, r#"["rust"]"#, "s1")); + seed(&conn, s("untagged", "p", 2_000, 0, 100, "[]", "s1")); + let args = ExportArgs { + tag: vec!["rust".into()], + ..default_args() + }; + let rows = collect(&conn, &args, FIXED_NOW).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].command, "tagged"); + } + + #[test] + fn collect_exit_filter_ok() { + let conn = mem_conn(); + seed(&conn, s("ok_cmd", "p", 1_000, 0, 100, "[]", "s1")); + seed(&conn, s("fail_cmd", "p", 2_000, 1, 100, "[]", "s1")); + let args = ExportArgs { + exit: Some(ExitFilter::Ok), + ..default_args() + }; + let rows = collect(&conn, &args, FIXED_NOW).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].command, "ok_cmd"); + } + + #[test] + fn collect_session_filter() { + let conn = mem_conn(); + seed(&conn, s("sess_a", "p", 1_000, 0, 100, "[]", "session-a")); + seed(&conn, s("sess_b", "p", 2_000, 0, 100, "[]", "session-b")); + let args = ExportArgs { + session: Some("session-a".into()), + ..default_args() + }; + let rows = collect(&conn, &args, FIXED_NOW).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].command, "sess_a"); + } + + #[test] + fn collect_since_filter() { + let conn = mem_conn(); + seed(&conn, s("old_cmd", "p", 100, 0, 100, "[]", "s1")); + seed(&conn, s("new_cmd", "p", 1_700_000_000, 0, 100, "[]", "s1")); + let args = ExportArgs { + since: Some("2020-01-01".into()), + ..default_args() + }; + let rows = collect(&conn, &args, FIXED_NOW).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].command, "new_cmd"); + } + + fn seed_with_dir(conn: &rusqlite::Connection, r: SeedRow<'_>, dir: &str, ws: Option<&str>) { + conn.execute( + "INSERT INTO commands(command, directory, project, session_id, timestamp, exit_code, duration_ms, tags, workspace) \ + VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + rusqlite::params![r.cmd, dir, r.project, r.session, r.ts, r.exit, r.dur, r.tags, ws], + ) + .unwrap(); + } + + #[test] + fn build_query_workspace_filter_adds_clause() { + let args = ExportArgs { + workspace: Some("my-ws".into()), + ..default_args() + }; + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + assert!( + sql.contains("c.workspace = ?"), + "must include workspace clause; got: {sql}" + ); + assert!(sql.contains("ORDER BY c.timestamp ASC")); + assert_eq!(params.len(), 1); + } + + #[test] + fn collect_workspace_filter_returns_only_that_workspace() { + let conn = mem_conn(); + let r_a = s("cmd-ws-a", "p", 1_000, 0, 100, "[]", "s1"); + let r_b = s("cmd-ws-b", "p", 2_000, 0, 100, "[]", "s1"); + seed_with_dir(&conn, r_a, "/dir-a", Some("ws-a")); + seed_with_dir(&conn, r_b, "/dir-b", Some("ws-b")); + let args = ExportArgs { + workspace: Some("ws-a".into()), + ..default_args() + }; + let rows = collect(&conn, &args, FIXED_NOW).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].command, "cmd-ws-a"); + } + + #[test] + fn collect_workspace_returns_asc_order() { + let conn = mem_conn(); + seed_with_dir( + &conn, + s("first", "p", 1_000, 0, 100, "[]", "s1"), + "/d", + Some("ws-x"), + ); + seed_with_dir( + &conn, + s("second", "p", 3_000, 0, 100, "[]", "s1"), + "/d", + Some("ws-x"), + ); + seed_with_dir( + &conn, + s("third", "p", 2_000, 0, 100, "[]", "s1"), + "/d", + Some("ws-x"), + ); + let args = ExportArgs { + workspace: Some("ws-x".into()), + ..default_args() + }; + let rows = collect(&conn, &args, FIXED_NOW).unwrap(); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0].command, "first"); + assert_eq!(rows[1].command, "third"); + assert_eq!(rows[2].command, "second"); + } + + #[test] + fn render_replay_command_empty_rows_returns_true() { + let rows: Vec = vec![]; + let out = render_replay_command(&rows); + assert_eq!(out, "true"); + } + + #[test] + fn render_replay_command_single_row_wraps_in_subshell() { + let rows = vec![ExportRow { + command: "cargo build".into(), + directory: "/home/user/proj".into(), + timestamp: 1_000, + exit_code: 0, + duration_ms: 100, + }]; + let out = render_replay_command(&rows); + assert!(out.starts_with("( "), "must start with '( '; got:\n{out}"); + assert!(out.ends_with(" )"), "must end with ' )'; got:\n{out}"); + assert!( + out.contains("cd '/home/user/proj'"), + "must contain cd; got:\n{out}" + ); + assert!( + out.contains("cargo build"), + "must contain command; got:\n{out}" + ); + let cd_pos = out.find("cd '").unwrap(); + let cmd_pos = out.find("cargo build").unwrap(); + assert!(cd_pos < cmd_pos, "cd must come before command"); + } + + #[test] + fn render_replay_command_two_rows_same_dir_single_cd() { + let rows = vec![ + ExportRow { + command: "first_cmd".into(), + directory: "/shared".into(), + timestamp: 1_000, + exit_code: 0, + duration_ms: 100, + }, + ExportRow { + command: "second_cmd".into(), + directory: "/shared".into(), + timestamp: 2_000, + exit_code: 0, + duration_ms: 100, + }, + ]; + let out = render_replay_command(&rows); + let cd_count = out.matches("cd '").count(); + assert_eq!( + cd_count, 1, + "same dir must produce only one cd; got:\n{out}" + ); + assert!(out.contains("first_cmd"), "must contain first_cmd"); + assert!(out.contains("second_cmd"), "must contain second_cmd"); + } + + #[test] + fn render_replay_command_two_rows_different_dirs_two_cds() { + let rows = vec![ + ExportRow { + command: "cmd-a".into(), + directory: "/dir-a".into(), + timestamp: 1_000, + exit_code: 0, + duration_ms: 100, + }, + ExportRow { + command: "cmd-b".into(), + directory: "/dir-b".into(), + timestamp: 2_000, + exit_code: 0, + duration_ms: 100, + }, + ]; + let out = render_replay_command(&rows); + let cd_count = out.matches("cd '").count(); + assert_eq!( + cd_count, 2, + "different dirs must produce two cds; got:\n{out}" + ); + assert!(out.contains("cd '/dir-a'"), "must contain cd /dir-a"); + assert!(out.contains("cd '/dir-b'"), "must contain cd /dir-b"); + } + + #[test] + fn render_replay_command_preserves_order() { + let rows = vec![ + ExportRow { + command: "first_cmd".into(), + directory: "/a".into(), + timestamp: 1_000, + exit_code: 0, + duration_ms: 100, + }, + ExportRow { + command: "second_cmd".into(), + directory: "/b".into(), + timestamp: 2_000, + exit_code: 0, + duration_ms: 100, + }, + ]; + let out = render_replay_command(&rows); + let first_pos = out.find("first_cmd").unwrap(); + let second_pos = out.find("second_cmd").unwrap(); + assert!( + first_pos < second_pos, + "first_cmd must appear before second_cmd" + ); + } + + #[test] + fn render_replay_command_escapes_single_quote_in_dir() { + let rows = vec![ExportRow { + command: "ls".into(), + directory: "/home/it's/project".into(), + timestamp: 1_000, + exit_code: 0, + duration_ms: 100, + }]; + let out = render_replay_command(&rows); + assert!( + out.contains("cd '/home/it'\\''s/project'"), + "must escape single quote in dir; got:\n{out}" + ); + } + + #[test] + fn render_replay_command_separates_units_with_semicolon_so_one_failure_does_not_stop_the_rest() + { + let rows = vec![ + ExportRow { + command: "cmd-a".into(), + directory: "/d".into(), + timestamp: 1_000, + exit_code: 0, + duration_ms: 100, + }, + ExportRow { + command: "cmd-b".into(), + directory: "/d".into(), + timestamp: 2_000, + exit_code: 0, + duration_ms: 100, + }, + ]; + let out = render_replay_command(&rows); + assert!( + out.contains(" ; \\\n"), + "units must be separated with ' ; \\\\n' so a non-zero exit does not halt the rest; got:\n{out}" + ); + assert!( + out.contains("cd '/d' && cmd-a"), + "cd must guard its command; got:\n{out}" + ); + } +} diff --git a/src/forget.rs b/src/forget.rs new file mode 100644 index 0000000..36d036c --- /dev/null +++ b/src/forget.rs @@ -0,0 +1,270 @@ +use rusqlite::Connection; + +use crate::error::ThothError; + +pub enum Scope { + Global, + Terminal(String), +} + +pub fn resolve_scope(terminal_id_flag: Option, env_session_id: Option) -> Scope { + if let Some(tid) = terminal_id_flag { + return Scope::Terminal(tid); + } + if let Some(sid) = env_session_id { + return Scope::Terminal(sid); + } + Scope::Global +} + +pub struct ForgetRow { + pub id: i64, + pub timestamp: i64, + pub project: String, + pub exit_code: i64, + pub command: String, +} + +const SELECT_COLS: &str = "SELECT id, timestamp, project, exit_code, command FROM commands"; + +pub fn select_targets( + conn: &Connection, + scope: &Scope, + n: usize, +) -> Result, ThothError> { + let rows = match scope { + Scope::Terminal(tid) => { + let sql = format!( + "{SELECT_COLS} WHERE terminal_id = ?1 ORDER BY timestamp DESC, id DESC LIMIT ?2" + ); + let mut stmt = conn.prepare(&sql)?; + let mapped = stmt.query_map(rusqlite::params![tid, n as i64], map_row)?; + collect_rows(mapped)? + } + Scope::Global => { + let sql = format!("{SELECT_COLS} ORDER BY timestamp DESC, id DESC LIMIT ?1"); + let mut stmt = conn.prepare(&sql)?; + let mapped = stmt.query_map(rusqlite::params![n as i64], map_row)?; + collect_rows(mapped)? + } + }; + Ok(rows) +} + +fn map_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(ForgetRow { + id: row.get(0)?, + timestamp: row.get(1)?, + project: row.get(2)?, + exit_code: row.get(3)?, + command: row.get(4)?, + }) +} + +fn collect_rows( + mapped: rusqlite::MappedRows<'_, impl FnMut(&rusqlite::Row<'_>) -> rusqlite::Result>, +) -> Result, ThothError> { + let mut out = Vec::new(); + for r in mapped { + out.push(r?); + } + Ok(out) +} + +pub fn delete_targets(conn: &Connection, ids: &[i64]) -> Result { + if ids.is_empty() { + return Ok(0); + } + let placeholders: String = ids + .iter() + .enumerate() + .map(|(i, _)| format!("?{}", i + 1)) + .collect::>() + .join(", "); + let sql = format!("DELETE FROM commands WHERE id IN ({placeholders})"); + let params: Vec<&dyn rusqlite::ToSql> = + ids.iter().map(|id| id as &dyn rusqlite::ToSql).collect(); + let deleted = conn.execute(&sql, params.as_slice())?; + Ok(deleted) +} + +pub fn render_preview(rows: &[ForgetRow]) -> String { + use crate::search::fmt_timestamp; + let mut out = String::new(); + for row in rows { + let ts = fmt_timestamp(row.timestamp); + let exit_label = if row.exit_code == 0 { "ok" } else { "fail" }; + out.push_str(&format!( + " {} | {} | {} | {}\n", + ts, row.project, exit_label, row.command + )); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::{apply_migrations, connect_memory}; + + fn mem_conn() -> Connection { + let mut c = connect_memory().unwrap(); + apply_migrations(&mut c).unwrap(); + c + } + + fn insert_cmd( + conn: &Connection, + cmd: &str, + project: &str, + ts: i64, + terminal_id: Option<&str>, + ) -> i64 { + conn.execute( + "INSERT INTO commands(command, directory, project, session_id, timestamp, terminal_id) \ + VALUES(?1, '/tmp', ?2, 's1', ?3, ?4)", + rusqlite::params![cmd, project, ts, terminal_id], + ) + .unwrap(); + conn.last_insert_rowid() + } + + fn count_all(conn: &Connection) -> i64 { + conn.query_row("SELECT COUNT(*) FROM commands", [], |r| r.get(0)) + .unwrap() + } + + fn fts_match(conn: &Connection, term: &str) -> bool { + let sql = format!( + "SELECT rowid FROM commands_fts WHERE commands_fts MATCH '\"{}\"'", + term + ); + conn.query_row(&sql, [], |r| r.get::<_, i64>(0)).is_ok() + } + + #[test] + fn resolve_scope_flag_wins() { + let scope = resolve_scope(Some("flag-tid".into()), Some("env-sid".into())); + match scope { + Scope::Terminal(tid) => assert_eq!(tid, "flag-tid"), + Scope::Global => panic!("expected Terminal"), + } + } + + #[test] + fn resolve_scope_env_used_when_no_flag() { + let scope = resolve_scope(None, Some("env-sid".into())); + match scope { + Scope::Terminal(tid) => assert_eq!(tid, "env-sid"), + Scope::Global => panic!("expected Terminal"), + } + } + + #[test] + fn resolve_scope_global_when_neither() { + let scope = resolve_scope(None, None); + assert!(matches!(scope, Scope::Global)); + } + + #[test] + fn select_targets_global_returns_n_newest() { + let conn = mem_conn(); + insert_cmd(&conn, "cmd_a", "p", 1000, None); + insert_cmd(&conn, "cmd_b", "p", 2000, None); + insert_cmd(&conn, "cmd_c", "p", 3000, None); + insert_cmd(&conn, "cmd_d", "p", 4000, None); + + let rows = select_targets(&conn, &Scope::Global, 2).unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].command, "cmd_d"); + assert_eq!(rows[1].command, "cmd_c"); + } + + #[test] + fn select_targets_terminal_filters_by_terminal_id() { + let conn = mem_conn(); + insert_cmd(&conn, "term_a_1", "p", 1000, Some("tid-a")); + insert_cmd(&conn, "term_b_1", "p", 2000, Some("tid-b")); + insert_cmd(&conn, "term_a_2", "p", 3000, Some("tid-a")); + insert_cmd(&conn, "global_1", "p", 4000, None); + + let rows = select_targets(&conn, &Scope::Terminal("tid-a".into()), 10).unwrap(); + assert_eq!(rows.len(), 2); + let cmds: Vec<&str> = rows.iter().map(|r| r.command.as_str()).collect(); + assert!(cmds.contains(&"term_a_2")); + assert!(cmds.contains(&"term_a_1")); + assert!(!cmds.contains(&"term_b_1")); + assert!(!cmds.contains(&"global_1")); + } + + #[test] + fn select_targets_same_timestamp_tiebreak_by_id_desc() { + let conn = mem_conn(); + let id1 = insert_cmd(&conn, "first_inserted", "p", 5000, None); + let id2 = insert_cmd(&conn, "second_inserted", "p", 5000, None); + + let rows = select_targets(&conn, &Scope::Global, 2).unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!( + rows[0].id, id2, + "higher id should come first when timestamps equal" + ); + assert_eq!(rows[1].id, id1); + } + + #[test] + fn select_targets_n_larger_than_available_returns_all() { + let conn = mem_conn(); + insert_cmd(&conn, "only_cmd", "p", 1000, None); + + let rows = select_targets(&conn, &Scope::Global, 100).unwrap(); + assert_eq!(rows.len(), 1); + } + + #[test] + fn delete_targets_removes_rows_and_updates_fts() { + let conn = mem_conn(); + if !crate::database::fts5_available(&conn) { + return; + } + let id1 = insert_cmd(&conn, "unique_forget_cmd_xyz", "p", 1000, None); + let id2 = insert_cmd(&conn, "unique_forget_cmd_abc", "p", 2000, None); + insert_cmd(&conn, "keep_me", "p", 3000, None); + + assert!(fts_match(&conn, "unique_forget_cmd_xyz")); + assert!(fts_match(&conn, "unique_forget_cmd_abc")); + + let deleted = delete_targets(&conn, &[id1, id2]).unwrap(); + assert_eq!(deleted, 2); + + assert_eq!(count_all(&conn), 1); + assert!(!fts_match(&conn, "unique_forget_cmd_xyz")); + assert!(!fts_match(&conn, "unique_forget_cmd_abc")); + assert!(fts_match(&conn, "keep_me")); + } + + #[test] + fn delete_targets_empty_ids_is_noop() { + let conn = mem_conn(); + insert_cmd(&conn, "some_cmd", "p", 1000, None); + + let deleted = delete_targets(&conn, &[]).unwrap(); + assert_eq!(deleted, 0); + assert_eq!(count_all(&conn), 1); + } + + #[test] + fn render_preview_contains_timestamp_project_command() { + let rows = vec![ForgetRow { + id: 1, + timestamp: 1_700_000_000, + project: "myproject".into(), + exit_code: 0, + command: "cargo build".into(), + }]; + let out = render_preview(&rows); + assert!(out.contains("myproject")); + assert!(out.contains("cargo build")); + assert!(out.contains("2023-11-14")); + } +} diff --git a/src/hooks.rs b/src/hooks.rs new file mode 100644 index 0000000..29288ec --- /dev/null +++ b/src/hooks.rs @@ -0,0 +1,513 @@ +use crate::error::ThothError; +use std::path::{Path, PathBuf}; + +const SENTINEL_BEGIN: &str = "# === THOTH HOOKS BEGIN ==="; +const SENTINEL_END: &str = "# === THOTH HOOKS END ==="; + +const ZSH_HOOK: &str = include_str!("../shells/thoth.zsh"); +const BASH_HOOK: &str = include_str!("../shells/thoth.bash"); + +#[derive(Debug, Clone, PartialEq)] +pub enum Shell { + Bash, + Zsh, +} + +pub fn detect_shell( + shell_flag: Option<&str>, + shell_env: Option<&str>, +) -> Result { + if let Some(flag) = shell_flag { + return match flag { + "bash" => Ok(Shell::Bash), + "zsh" => Ok(Shell::Zsh), + other => Err(ThothError::Hook(format!( + "unknown shell '{other}'; use --shell bash or --shell zsh" + ))), + }; + } + let env = shell_env.unwrap_or(""); + let basename = Path::new(env) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(""); + match basename { + "bash" => Ok(Shell::Bash), + "zsh" => Ok(Shell::Zsh), + other => Err(ThothError::Hook(format!( + "could not detect shell from '{other}'; pass --shell bash or --shell zsh" + ))), + } +} + +pub fn default_rc_path(shell: &Shell, home: &Path) -> PathBuf { + match shell { + Shell::Bash => home.join(".bashrc"), + Shell::Zsh => home.join(".zshrc"), + } +} + +fn hook_body(shell: &Shell) -> &'static str { + match shell { + Shell::Bash => BASH_HOOK, + Shell::Zsh => ZSH_HOOK, + } +} + +pub fn render_init(shell: &Shell) -> &'static str { + hook_body(shell) +} + +fn eval_line(shell: &Shell) -> String { + let name = match shell { + Shell::Bash => "bash", + Shell::Zsh => "zsh", + }; + format!("eval \"$(tth init {name})\"") +} + +pub fn has_block(content: &str) -> bool { + content.contains(SENTINEL_BEGIN) +} + +pub fn insert_or_replace_block(rc_contents: &str, hook_body_str: &str) -> String { + let block = format!("{}\n{}\n{}", SENTINEL_BEGIN, hook_body_str, SENTINEL_END); + if has_block(rc_contents) { + let stripped = remove_block(rc_contents); + let prefix = stripped.trim_end_matches('\n'); + if prefix.is_empty() { + block + } else { + format!("{}\n{}", prefix, block) + } + } else if rc_contents.is_empty() { + block + } else { + format!("{}\n{}", rc_contents.trim_end_matches('\n'), block) + .trim_start_matches('\n') + .to_string() + } +} + +pub fn remove_block(rc_contents: &str) -> String { + if !has_block(rc_contents) { + return rc_contents.to_string(); + } + let mut result = String::new(); + let mut in_block = false; + for line in rc_contents.lines() { + if line == SENTINEL_BEGIN { + in_block = true; + continue; + } + if line == SENTINEL_END { + in_block = false; + continue; + } + if !in_block { + result.push_str(line); + result.push('\n'); + } + } + result +} + +pub struct InstallReport { + pub rc_path: PathBuf, + pub shell: Shell, + pub already_present: bool, + pub reload_cmd: String, +} + +pub struct UninstallReport { + pub rc_path: PathBuf, + pub block_was_present: bool, +} + +pub struct StatusReport { + pub hooks_installed: bool, + pub schema_version: i64, + pub total_commands: i64, + pub last_timestamp: Option, + pub session_id_set: bool, + pub tth_on_path: bool, +} + +pub fn install(shell: &Shell, rc_path: &Path) -> Result { + let existing = if rc_path.exists() { + std::fs::read_to_string(rc_path)? + } else { + String::new() + }; + let already_present = has_block(&existing); + let line = eval_line(shell); + let new_content = insert_or_replace_block(&existing, &line); + std::fs::write(rc_path, &new_content)?; + let reload_cmd = match shell { + Shell::Bash => "source ~/.bashrc".to_string(), + Shell::Zsh => "exec zsh".to_string(), + }; + Ok(InstallReport { + rc_path: rc_path.to_path_buf(), + shell: shell.clone(), + already_present, + reload_cmd, + }) +} + +pub fn uninstall(rc_path: &Path) -> Result { + let existing = if rc_path.exists() { + std::fs::read_to_string(rc_path)? + } else { + String::new() + }; + let block_was_present = has_block(&existing); + if block_was_present { + let new_content = remove_block(&existing); + std::fs::write(rc_path, &new_content)?; + } + Ok(UninstallReport { + rc_path: rc_path.to_path_buf(), + block_was_present, + }) +} + +pub fn status( + conn: &rusqlite::Connection, + rc_path: &Path, + session_id_set: bool, + tth_on_path: bool, +) -> StatusReport { + let hooks_installed = if rc_path.exists() { + std::fs::read_to_string(rc_path) + .map(|s| has_block(&s)) + .unwrap_or(false) + } else { + false + }; + let schema_version = crate::database::current_version(conn); + let total_commands: i64 = conn + .query_row("SELECT COUNT(*) FROM commands", [], |r| r.get(0)) + .unwrap_or(0); + let last_timestamp: Option = conn + .query_row("SELECT MAX(timestamp) FROM commands", [], |r| r.get(0)) + .unwrap_or(None); + StatusReport { + hooks_installed, + schema_version, + total_commands, + last_timestamp, + session_id_set, + tth_on_path, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn tmp_rc(dir: &TempDir, name: &str) -> PathBuf { + dir.path().join(name) + } + + fn mem_conn() -> rusqlite::Connection { + let mut c = crate::database::connect_memory().unwrap(); + crate::database::apply_migrations(&mut c).unwrap(); + c + } + + #[test] + fn detect_shell_bash_flag() { + assert_eq!(detect_shell(Some("bash"), None).unwrap(), Shell::Bash); + } + + #[test] + fn detect_shell_zsh_flag() { + assert_eq!(detect_shell(Some("zsh"), None).unwrap(), Shell::Zsh); + } + + #[test] + fn detect_shell_from_env_bash() { + assert_eq!(detect_shell(None, Some("/bin/bash")).unwrap(), Shell::Bash); + } + + #[test] + fn detect_shell_from_env_zsh() { + assert_eq!( + detect_shell(None, Some("/usr/bin/zsh")).unwrap(), + Shell::Zsh + ); + } + + #[test] + fn detect_shell_unknown_flag_errors() { + let err = detect_shell(Some("fish"), None).unwrap_err(); + assert!(err.to_string().contains("unknown shell")); + } + + #[test] + fn detect_shell_unknown_env_errors() { + let err = detect_shell(None, Some("/bin/fish")).unwrap_err(); + assert!(err.to_string().contains("could not detect shell")); + } + + #[test] + fn detect_shell_flag_overrides_env() { + assert_eq!( + detect_shell(Some("zsh"), Some("/bin/bash")).unwrap(), + Shell::Zsh + ); + } + + #[test] + fn default_rc_path_bash() { + let home = Path::new("/home/testuser"); + assert_eq!( + default_rc_path(&Shell::Bash, home), + PathBuf::from("/home/testuser/.bashrc") + ); + } + + #[test] + fn default_rc_path_zsh() { + let home = Path::new("/home/testuser"); + assert_eq!( + default_rc_path(&Shell::Zsh, home), + PathBuf::from("/home/testuser/.zshrc") + ); + } + + #[test] + fn has_block_detects_sentinel() { + let content = format!("{}\nsome hook\n{}", SENTINEL_BEGIN, SENTINEL_END); + assert!(has_block(&content)); + } + + #[test] + fn has_block_false_when_absent() { + assert!(!has_block("just some rc content")); + } + + #[test] + fn insert_or_replace_appends_to_empty() { + let result = insert_or_replace_block("", "hook body"); + assert!(result.contains(SENTINEL_BEGIN)); + assert!(result.contains("hook body")); + assert!(result.contains(SENTINEL_END)); + } + + #[test] + fn insert_or_replace_appends_to_existing() { + let existing = "export PATH=$PATH:/usr/local/bin\n"; + let result = insert_or_replace_block(existing, "hook body"); + assert!(result.contains("export PATH")); + assert!(result.contains(SENTINEL_BEGIN)); + } + + #[test] + fn insert_or_replace_is_idempotent_no_duplicate() { + let first = insert_or_replace_block("", "hook body"); + let second = insert_or_replace_block(&first, "hook body"); + let count = second.matches(SENTINEL_BEGIN).count(); + assert_eq!(count, 1, "duplicate sentinel blocks found"); + } + + #[test] + fn insert_or_replace_replaces_existing_body() { + let first = insert_or_replace_block("", "old hook body"); + let second = insert_or_replace_block(&first, "new hook body"); + assert!(!second.contains("old hook body"), "old body still present"); + assert!(second.contains("new hook body")); + } + + #[test] + fn insert_or_replace_no_double_blank_line_on_replace() { + let first = insert_or_replace_block("", "hook body"); + let second = insert_or_replace_block(&first, "hook body v2"); + assert!( + !second.contains("\n\n\n"), + "double blank line found after replace: {:?}", + second + ); + assert!( + !second.starts_with('\n'), + "result starts with blank line after replace" + ); + } + + #[test] + fn remove_block_strips_sentinel_and_body() { + let original = "before\n"; + let with_block = insert_or_replace_block(original, "hook body"); + let removed = remove_block(&with_block); + assert!(!removed.contains(SENTINEL_BEGIN)); + assert!(!removed.contains("hook body")); + assert!(removed.contains("before")); + } + + #[test] + fn remove_block_noop_when_absent() { + let content = "no hook here\n"; + assert_eq!(remove_block(content), content); + } + + #[test] + fn install_writes_block_to_rc() { + let dir = TempDir::new().unwrap(); + let rc = tmp_rc(&dir, ".zshrc"); + let report = install(&Shell::Zsh, &rc).unwrap(); + assert!(!report.already_present); + let content = std::fs::read_to_string(&rc).unwrap(); + assert!(content.contains(SENTINEL_BEGIN)); + } + + #[test] + fn install_on_existing_rc_does_not_duplicate() { + let dir = TempDir::new().unwrap(); + let rc = tmp_rc(&dir, ".bashrc"); + std::fs::write(&rc, "export EDITOR=vim\n").unwrap(); + install(&Shell::Bash, &rc).unwrap(); + install(&Shell::Bash, &rc).unwrap(); + let content = std::fs::read_to_string(&rc).unwrap(); + let count = content.matches(SENTINEL_BEGIN).count(); + assert_eq!(count, 1); + assert!(content.contains("export EDITOR=vim")); + } + + #[test] + fn install_already_present_flag() { + let dir = TempDir::new().unwrap(); + let rc = tmp_rc(&dir, ".zshrc"); + let r1 = install(&Shell::Zsh, &rc).unwrap(); + assert!(!r1.already_present); + let r2 = install(&Shell::Zsh, &rc).unwrap(); + assert!(r2.already_present); + } + + #[test] + fn uninstall_removes_block() { + let dir = TempDir::new().unwrap(); + let rc = tmp_rc(&dir, ".zshrc"); + install(&Shell::Zsh, &rc).unwrap(); + let report = uninstall(&rc).unwrap(); + assert!(report.block_was_present); + let content = std::fs::read_to_string(&rc).unwrap(); + assert!(!content.contains(SENTINEL_BEGIN)); + } + + #[test] + fn uninstall_preserves_surrounding_lines() { + let dir = TempDir::new().unwrap(); + let rc = tmp_rc(&dir, ".bashrc"); + std::fs::write(&rc, "# my custom config\nexport X=1\n").unwrap(); + install(&Shell::Bash, &rc).unwrap(); + uninstall(&rc).unwrap(); + let content = std::fs::read_to_string(&rc).unwrap(); + assert!(content.contains("export X=1")); + assert!(!content.contains(SENTINEL_BEGIN)); + } + + #[test] + fn uninstall_noop_when_block_absent() { + let dir = TempDir::new().unwrap(); + let rc = tmp_rc(&dir, ".bashrc"); + std::fs::write(&rc, "no block\n").unwrap(); + let report = uninstall(&rc).unwrap(); + assert!(!report.block_was_present); + assert_eq!(std::fs::read_to_string(&rc).unwrap(), "no block\n"); + } + + #[test] + fn status_hooks_installed_true_after_install() { + let dir = TempDir::new().unwrap(); + let rc = tmp_rc(&dir, ".zshrc"); + install(&Shell::Zsh, &rc).unwrap(); + let conn = mem_conn(); + let s = status(&conn, &rc, false, false); + assert!(s.hooks_installed); + assert_eq!(s.schema_version, 4); + assert_eq!(s.total_commands, 0); + } + + #[test] + fn status_hooks_not_installed_before_install() { + let dir = TempDir::new().unwrap(); + let rc = tmp_rc(&dir, ".zshrc"); + let conn = mem_conn(); + let s = status(&conn, &rc, true, true); + assert!(!s.hooks_installed); + assert!(s.session_id_set); + assert!(s.tth_on_path); + } + + #[test] + fn zsh_hook_is_non_empty() { + assert!(!ZSH_HOOK.is_empty()); + } + + #[test] + fn bash_hook_is_non_empty() { + assert!(!BASH_HOOK.is_empty()); + } + + #[test] + fn render_init_zsh_returns_zsh_hook() { + let script = render_init(&Shell::Zsh); + assert!(script.contains("_tth_preexec")); + assert!(script.contains("bindkey '^R'")); + assert!(!script.contains(SENTINEL_BEGIN)); + } + + #[test] + fn render_init_bash_returns_bash_hook() { + let script = render_init(&Shell::Bash); + assert!(script.contains("_thoth_preexec")); + assert!(script.contains("bind -x")); + assert!(!script.contains(SENTINEL_BEGIN)); + } + + #[test] + fn eval_line_zsh_format() { + let line = eval_line(&Shell::Zsh); + assert_eq!(line, "eval \"$(tth init zsh)\""); + } + + #[test] + fn eval_line_bash_format() { + let line = eval_line(&Shell::Bash); + assert_eq!(line, "eval \"$(tth init bash)\""); + } + + #[test] + fn install_writes_eval_line_to_rc() { + let dir = TempDir::new().unwrap(); + let rc = tmp_rc(&dir, ".zshrc"); + install(&Shell::Zsh, &rc).unwrap(); + let content = std::fs::read_to_string(&rc).unwrap(); + assert!( + content.contains("eval \"$(tth init zsh)\""), + "eval line not found" + ); + assert!( + !content.contains("_tth_preexec"), + "full body must not appear in rc" + ); + } + + #[test] + fn install_migration_replaces_old_body_with_eval_line() { + let dir = TempDir::new().unwrap(); + let rc = tmp_rc(&dir, ".zshrc"); + let old_block = format!( + "{}\n_tth_preexec() {{ : ; }}\n{}", + SENTINEL_BEGIN, SENTINEL_END + ); + std::fs::write(&rc, &old_block).unwrap(); + install(&Shell::Zsh, &rc).unwrap(); + let content = std::fs::read_to_string(&rc).unwrap(); + assert_eq!(content.matches(SENTINEL_BEGIN).count(), 1); + assert!(content.contains("eval \"$(tth init zsh)\"")); + assert!(!content.contains("_tth_preexec() { : ; }")); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..00656ee --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,23 @@ +pub mod cli; +pub mod config; +pub mod database; +pub mod doctor; +pub mod edit; +pub mod error; +pub mod export; +pub mod forget; +pub mod hooks; +pub mod logging; +pub mod paths; +pub mod project; +pub mod prompt; +pub mod recorder; +pub mod schema; +pub mod search; +pub mod session; +pub mod sessions; +pub mod stats; +pub mod tags; +pub mod theme; +pub mod tui; +pub mod workspaces; diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..0151ffd --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,84 @@ +use std::path::PathBuf; +use std::sync::Mutex; + +pub const SIZE_CAP_BYTES: u64 = 1_048_576; + +static LOG_PATH: Mutex> = Mutex::new(None); + +pub fn setup(path: PathBuf) { + let mut guard = LOG_PATH.lock().unwrap_or_else(|e| e.into_inner()); + *guard = Some(path); +} + +pub fn log_error(msg: &str) { + let guard = LOG_PATH.lock().unwrap_or_else(|e| e.into_inner()); + let path = match &*guard { + Some(p) => p.clone(), + None => crate::paths::resolve_error_log(), + }; + drop(guard); + let _ = write_with_rotation(&path, msg); +} + +fn write_with_rotation(path: &PathBuf, msg: &str) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + if let Ok(meta) = std::fs::metadata(path) { + if meta.len() > SIZE_CAP_BYTES { + let backup = path.with_extension("log.1"); + let _ = std::fs::rename(path, &backup); + } + } + + use std::io::Write; + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path)?; + writeln!(file, "{msg}")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn writes_to_file_not_stderr() { + let dir = TempDir::new().unwrap(); + let log_path = dir.path().join("error.log"); + setup(log_path.clone()); + log_error("boom"); + let content = std::fs::read_to_string(&log_path).unwrap(); + assert!(content.contains("boom")); + } + + #[test] + fn rotation_on_size_cap() { + let dir = TempDir::new().unwrap(); + let log_path = dir.path().join("error.log"); + let big = vec![b'x'; SIZE_CAP_BYTES as usize + 1]; + std::fs::write(&log_path, &big).unwrap(); + write_with_rotation(&log_path, "after rotation").unwrap(); + let backup = log_path.with_extension("log.1"); + assert!(backup.exists(), "backup file should exist after rotation"); + let new_content = std::fs::read_to_string(&log_path).unwrap(); + assert!(new_content.len() < SIZE_CAP_BYTES as usize); + } + + #[test] + fn rotation_overwrites_old_backup() { + let dir = TempDir::new().unwrap(); + let log_path = dir.path().join("error.log"); + let backup = log_path.with_extension("log.1"); + std::fs::write(&backup, "old backup").unwrap(); + let big = vec![b'x'; SIZE_CAP_BYTES as usize + 1]; + std::fs::write(&log_path, &big).unwrap(); + write_with_rotation(&log_path, "trigger rotation").unwrap(); + let backup_content = std::fs::read_to_string(&backup).unwrap(); + assert_ne!(backup_content, "old backup", "backup should be overwritten"); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..365d90b --- /dev/null +++ b/src/main.rs @@ -0,0 +1,9 @@ +fn main() { + match thoth::cli::run() { + Ok(()) => std::process::exit(0), + Err(e) => { + eprintln!("tth: {e}"); + std::process::exit(1); + } + } +} diff --git a/src/paths.rs b/src/paths.rs new file mode 100644 index 0000000..d896175 --- /dev/null +++ b/src/paths.rs @@ -0,0 +1,105 @@ +use std::path::{Path, PathBuf}; + +fn db_path_from(thoth_db: Option<&str>, xdg: Option<&str>, home: &Path) -> PathBuf { + if let Some(v) = thoth_db { + return PathBuf::from(v); + } + if let Some(x) = xdg { + return PathBuf::from(x).join("thoth").join("history.db"); + } + home.join(".local") + .join("share") + .join("thoth") + .join("history.db") +} + +fn error_log_from(thoth_log: Option<&str>, xdg: Option<&str>, home: &Path) -> PathBuf { + if let Some(v) = thoth_log { + return PathBuf::from(v); + } + if let Some(x) = xdg { + return PathBuf::from(x).join("thoth").join("error.log"); + } + home.join(".local") + .join("share") + .join("thoth") + .join("error.log") +} + +pub fn resolve_db_path() -> PathBuf { + let thoth_db = std::env::var("THOTH_DB").ok(); + let xdg = std::env::var("XDG_DATA_HOME").ok(); + let home = std::env::var("HOME").unwrap_or_else(|_| String::from("/tmp")); + db_path_from(thoth_db.as_deref(), xdg.as_deref(), Path::new(&home)) +} + +pub fn resolve_error_log() -> PathBuf { + let thoth_log = std::env::var("THOTH_ERROR_LOG").ok(); + let xdg = std::env::var("XDG_DATA_HOME").ok(); + let home = std::env::var("HOME").unwrap_or_else(|_| String::from("/tmp")); + error_log_from(thoth_log.as_deref(), xdg.as_deref(), Path::new(&home)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn db_override_wins() { + let result = db_path_from( + Some("/custom/history.db"), + Some("/xdg"), + Path::new("/home/user"), + ); + assert_eq!(result, PathBuf::from("/custom/history.db")); + } + + #[test] + fn db_xdg_fallback() { + let result = db_path_from(None, Some("/xdg/data"), Path::new("/home/user")); + assert_eq!(result, PathBuf::from("/xdg/data/thoth/history.db")); + } + + #[test] + fn db_home_fallback() { + let result = db_path_from(None, None, Path::new("/home/user")); + assert_eq!( + result, + PathBuf::from("/home/user/.local/share/thoth/history.db") + ); + } + + #[test] + fn error_log_override_wins() { + let result = error_log_from( + Some("/custom/error.log"), + Some("/xdg"), + Path::new("/home/user"), + ); + assert_eq!(result, PathBuf::from("/custom/error.log")); + } + + #[test] + fn error_log_xdg_fallback() { + let result = error_log_from(None, Some("/xdg/data"), Path::new("/home/user")); + assert_eq!(result, PathBuf::from("/xdg/data/thoth/error.log")); + } + + #[test] + fn error_log_home_fallback() { + let result = error_log_from(None, None, Path::new("/home/user")); + assert_eq!( + result, + PathBuf::from("/home/user/.local/share/thoth/error.log") + ); + } + + #[test] + fn resolve_db_path_wires_thoth_db() { + std::env::set_var("THOTH_DB", "/tmp/wiring_test.db"); + let result = resolve_db_path(); + std::env::remove_var("THOTH_DB"); + assert_eq!(result, PathBuf::from("/tmp/wiring_test.db")); + } +} diff --git a/src/project.rs b/src/project.rs new file mode 100644 index 0000000..8514ddf --- /dev/null +++ b/src/project.rs @@ -0,0 +1,336 @@ +use crate::error::ThothError; +use rusqlite::Connection; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub const MAX_WALK_DEPTH: usize = 20; +const CACHE_TTL_SECONDS: i64 = 86400; + +pub fn infer_project(directory: &str, conn: &Connection) -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + let cutoff = now - CACHE_TTL_SECONDS; + + let cached: Option = conn + .query_row( + "SELECT name FROM projects WHERE path=?1 AND last_seen > ?2", + rusqlite::params![directory, cutoff], + |row| row.get(0), + ) + .ok(); + + if let Some(name) = cached { + return Ok(name); + } + + Ok(walk_markers(directory)) +} + +pub fn walk_markers(cwd: &str) -> String { + let mut current = PathBuf::from(cwd); + if let Ok(resolved) = current.canonicalize() { + current = resolved; + } + + for _ in 0..MAX_WALK_DEPTH { + if let Some(name) = check_markers(¤t) { + return name; + } + let parent = current.parent().map(|p| p.to_path_buf()); + match parent { + Some(p) if p != current => current = p, + _ => break, + } + } + String::from("ungrouped") +} + +type MarkerFn = fn(&Path) -> Option; + +fn check_markers(directory: &Path) -> Option { + let strategies: &[(&str, MarkerFn)] = &[ + (".git", extract_dirname), + ("package.json", extract_package_json), + ("pyproject.toml", extract_pyproject), + ("Cargo.toml", extract_cargo), + ("go.mod", extract_gomod), + ("docker-compose.yml", extract_dirname), + ("compose.yml", extract_dirname), + ]; + + for &(filename, extractor) in strategies { + let candidate = directory.join(filename); + let exists = if filename == ".git" { + candidate.is_dir() + } else { + candidate.exists() + }; + if exists { + if let Some(name) = extractor(&candidate) { + return Some(name); + } + } + } + None +} + +fn extract_dirname(p: &Path) -> Option { + p.parent()?.file_name()?.to_str().map(String::from) +} + +fn extract_package_json(p: &Path) -> Option { + let text = std::fs::read_to_string(p).ok()?; + let val: serde_json::Value = serde_json::from_str(&text).ok()?; + val.get("name")?.as_str().map(String::from) +} + +fn extract_pyproject(p: &Path) -> Option { + let text = std::fs::read_to_string(p).ok()?; + let table: toml::Table = text.parse().ok()?; + if let Some(name) = table + .get("project") + .and_then(|v| v.get("name")) + .and_then(|v| v.as_str()) + { + return Some(name.to_string()); + } + table + .get("tool") + .and_then(|v| v.get("poetry")) + .and_then(|v| v.get("name")) + .and_then(|v| v.as_str()) + .map(String::from) +} + +fn extract_cargo(p: &Path) -> Option { + let text = std::fs::read_to_string(p).ok()?; + let table: toml::Table = text.parse().ok()?; + table + .get("package") + .and_then(|v| v.get("name")) + .and_then(|v| v.as_str()) + .map(String::from) +} + +fn extract_gomod(p: &Path) -> Option { + let text = std::fs::read_to_string(p).ok()?; + for line in text.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("module ") { + let module_path = rest.trim().trim_end_matches('/'); + return module_path + .split('/') + .next_back() + .filter(|s| !s.is_empty()) + .map(String::from); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + use tempfile::TempDir; + + fn mem_conn_with_schema() -> Connection { + let mut conn = crate::database::connect_memory().unwrap(); + crate::database::apply_migrations(&mut conn).unwrap(); + conn + } + + #[test] + fn git_marker_returns_dirname() { + let dir = TempDir::new().unwrap(); + let app = dir.path().join("my-app"); + std::fs::create_dir_all(app.join(".git")).unwrap(); + let conn = mem_conn_with_schema(); + let name = infer_project(app.to_str().unwrap(), &conn).unwrap(); + assert_eq!(name, "my-app"); + } + + #[test] + fn package_json_name() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("package.json"), r#"{"name":"frontend"}"#).unwrap(); + let conn = mem_conn_with_schema(); + let name = infer_project(dir.path().to_str().unwrap(), &conn).unwrap(); + assert_eq!(name, "frontend"); + } + + #[test] + fn pyproject_project_name() { + let dir = TempDir::new().unwrap(); + std::fs::write( + dir.path().join("pyproject.toml"), + "[project]\nname = \"mylib\"\n", + ) + .unwrap(); + let conn = mem_conn_with_schema(); + let name = infer_project(dir.path().to_str().unwrap(), &conn).unwrap(); + assert_eq!(name, "mylib"); + } + + #[test] + fn pyproject_poetry_name() { + let dir = TempDir::new().unwrap(); + std::fs::write( + dir.path().join("pyproject.toml"), + "[tool.poetry]\nname = \"poem\"\n", + ) + .unwrap(); + let conn = mem_conn_with_schema(); + let name = infer_project(dir.path().to_str().unwrap(), &conn).unwrap(); + assert_eq!(name, "poem"); + } + + #[test] + fn cargo_toml_name() { + let dir = TempDir::new().unwrap(); + std::fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"crateX\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ) + .unwrap(); + let conn = mem_conn_with_schema(); + let name = infer_project(dir.path().to_str().unwrap(), &conn).unwrap(); + assert_eq!(name, "crateX"); + } + + #[test] + fn go_mod_last_segment() { + let dir = TempDir::new().unwrap(); + std::fs::write( + dir.path().join("go.mod"), + "module github.com/user/mymod\n\ngo 1.21\n", + ) + .unwrap(); + let conn = mem_conn_with_schema(); + let name = infer_project(dir.path().to_str().unwrap(), &conn).unwrap(); + assert_eq!(name, "mymod"); + } + + #[test] + fn compose_yml_dirname() { + let dir = TempDir::new().unwrap(); + let app = dir.path().join("myservice"); + std::fs::create_dir_all(&app).unwrap(); + std::fs::write(app.join("compose.yml"), "version: '3'\n").unwrap(); + let conn = mem_conn_with_schema(); + let name = infer_project(app.to_str().unwrap(), &conn).unwrap(); + assert_eq!(name, "myservice"); + } + + #[test] + fn fallback_ungrouped() { + let dir = TempDir::new().unwrap(); + let deep = dir.path().join("a").join("b").join("c"); + std::fs::create_dir_all(&deep).unwrap(); + let conn = mem_conn_with_schema(); + let name = infer_project(deep.to_str().unwrap(), &conn).unwrap(); + assert_eq!(name, "ungrouped"); + } + + #[test] + fn depth_cap_20() { + let dir = TempDir::new().unwrap(); + let mut deep = dir.path().to_path_buf(); + for i in 0..25 { + deep = deep.join(format!("d{i}")); + } + std::fs::create_dir_all(&deep).unwrap(); + let conn = mem_conn_with_schema(); + let name = infer_project(deep.to_str().unwrap(), &conn).unwrap(); + assert_eq!(name, "ungrouped"); + } + + #[test] + fn cache_hit_no_walk() { + let dir = TempDir::new().unwrap(); + let app = dir.path().join("cached-app"); + std::fs::create_dir_all(app.join(".git")).unwrap(); + let conn = mem_conn_with_schema(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + conn.execute( + "INSERT INTO projects(path, name, last_seen, command_count) VALUES(?1, 'cached-name', ?2, 0)", + rusqlite::params![app.to_str().unwrap(), now - 3600], + ) + .unwrap(); + let name = infer_project(app.to_str().unwrap(), &conn).unwrap(); + assert_eq!(name, "cached-name"); + } + + #[test] + fn stale_cache_walks() { + let dir = TempDir::new().unwrap(); + let app = dir.path().join("stale-app"); + std::fs::create_dir_all(app.join(".git")).unwrap(); + let conn = mem_conn_with_schema(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + conn.execute( + "INSERT INTO projects(path, name, last_seen, command_count) VALUES(?1, 'old-name', ?2, 0)", + rusqlite::params![app.to_str().unwrap(), now - 90000], + ) + .unwrap(); + let name = infer_project(app.to_str().unwrap(), &conn).unwrap(); + assert_eq!(name, "stale-app"); + } + + #[test] + fn infer_project_is_read_only() { + let dir = TempDir::new().unwrap(); + let app = dir.path().join("count-app"); + std::fs::create_dir_all(&app).unwrap(); + let conn = mem_conn_with_schema(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + conn.execute( + "INSERT INTO projects(path, name, last_seen, command_count) VALUES(?1, 'old-name', ?2, 42)", + rusqlite::params![app.to_str().unwrap(), now - 90000], + ) + .unwrap(); + infer_project(app.to_str().unwrap(), &conn).unwrap(); + let count: i64 = conn + .query_row( + "SELECT command_count FROM projects WHERE path=?1", + rusqlite::params![app.to_str().unwrap()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 42); + } + + #[test] + fn malformed_toml_continues() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("pyproject.toml"), "NOT VALID TOML {{{{").unwrap(); + let conn = mem_conn_with_schema(); + let result = infer_project(dir.path().to_str().unwrap(), &conn); + assert!(result.is_ok()); + } + + #[test] + fn package_json_priority_over_pyproject() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("package.json"), r#"{"name":"frontend"}"#).unwrap(); + std::fs::write( + dir.path().join("pyproject.toml"), + "[project]\nname = \"backend\"\n", + ) + .unwrap(); + let conn = mem_conn_with_schema(); + let name = infer_project(dir.path().to_str().unwrap(), &conn).unwrap(); + assert_eq!(name, "frontend"); + } +} diff --git a/src/prompt.rs b/src/prompt.rs new file mode 100644 index 0000000..70eb892 --- /dev/null +++ b/src/prompt.rs @@ -0,0 +1,330 @@ +use std::path::Path; + +const DOCS_ANCHOR: &str = "https://github.com/JoseVelazcoH/thoth#prompt-setup"; + +#[derive(Debug, Clone, PartialEq)] +pub enum PromptFramework { + Starship, + Powerlevel10k, + OhMyPosh, + Generic, +} + +pub struct DetectInputs { + pub starship_toml_exists: bool, + pub starship_on_path: bool, + pub zsh_theme: Option, + pub p10k_zsh_exists: bool, + pub oh_my_posh_dir_exists: bool, + pub posh_theme_set: bool, +} + +pub fn detect_framework(inputs: &DetectInputs) -> PromptFramework { + if inputs.starship_toml_exists || inputs.starship_on_path { + return PromptFramework::Starship; + } + let p10k_theme = inputs + .zsh_theme + .as_deref() + .map(|t| t.to_ascii_lowercase().contains("powerlevel10k")) + .unwrap_or(false); + if p10k_theme || inputs.p10k_zsh_exists { + return PromptFramework::Powerlevel10k; + } + if inputs.oh_my_posh_dir_exists || inputs.posh_theme_set { + return PromptFramework::OhMyPosh; + } + PromptFramework::Generic +} + +pub fn probe_inputs() -> DetectInputs { + let home = std::env::var("HOME").unwrap_or_default(); + let home_path = Path::new(&home); + + let starship_toml_exists = home_path.join(".config/starship.toml").exists(); + let starship_on_path = path_has("starship"); + let zsh_theme = std::env::var("ZSH_THEME").ok(); + let p10k_zsh_exists = home_path.join(".p10k.zsh").exists(); + let oh_my_posh_dir_exists = home_path.join(".config/oh-my-posh").exists(); + let posh_theme_set = std::env::var("POSH_THEME").is_ok(); + + DetectInputs { + starship_toml_exists, + starship_on_path, + zsh_theme, + p10k_zsh_exists, + oh_my_posh_dir_exists, + posh_theme_set, + } +} + +fn path_has(bin: &str) -> bool { + std::env::var_os("PATH") + .map(|paths| std::env::split_paths(&paths).any(|dir| dir.join(bin).exists())) + .unwrap_or(false) +} + +pub fn prompt_snippet(framework: &PromptFramework) -> String { + match framework { + PromptFramework::Starship => format!( + "# Add to ~/.config/starship.toml:\n\ + [env_var.thoth_tags]\n\ + variable = \"TTH_PROMPT_TAGS\"\n\ + format = \"[$env_value]($style) \"\n\ + style = \"bold yellow\"\n\ + \n\ + # IMPORTANT: add ${{env_var.thoth_tags}} to your top-level format string.\n\ + # Starship does not render modules that are not referenced in format.\n\ + # Example: format = \"$git_status ${{env_var.thoth_tags}} $character\"\n\ + \n\ + # Docs: {DOCS_ANCHOR}" + ), + PromptFramework::Powerlevel10k => format!( + "# Add to your ~/.zshrc (after p10k is loaded):\n\ + \n\ + # 1. Define a custom segment function:\n\ + prompt_tth_tags() {{\n\ + p10k segment -t \"$TTH_PROMPT_TAGS\"\n\ + }}\n\ + \n\ + # 2. Add tth_tags to your prompt elements, e.g.:\n\ + # POWERLEVEL9K_LEFT_PROMPT_ELEMENTS=(... tth_tags)\n\ + # or POWERLEVEL9K_RIGHT_PROMPT_ELEMENTS=(... tth_tags)\n\ + \n\ + # See: https://github.com/romkatv/powerlevel10k#batteries-included\n\ + # Docs: {DOCS_ANCHOR}" + ), + PromptFramework::OhMyPosh => format!( + "# Add a segment to your oh-my-posh theme JSON/YAML:\n\ + # JSON example (type: text segment reading TTH_PROMPT_TAGS):\n\ + # {{\n\ + # \"type\": \"text\",\n\ + # \"template\": \"{{{{ .Env.TTH_PROMPT_TAGS }}}}\"\n\ + # }}\n\ + \n\ + # YAML example:\n\ + # - type: text\n\ + # template: \"{{{{ .Env.TTH_PROMPT_TAGS }}}}\"\n\ + \n\ + # See: https://ohmyposh.dev/docs/segments/system/text\n\ + # Docs: {DOCS_ANCHOR}" + ), + PromptFramework::Generic => format!( + "# Add to your shell rc file:\n\ + # zsh: PROMPT=\"${{TTH_PROMPT_TAGS}} $PROMPT\"\n\ + # bash: PS1=\"${{TTH_PROMPT_TAGS}} $PS1\"\n\ + \n\ + # Add ${{TTH_PROMPT_TAGS}} to your PROMPT (zsh) or PS1 (bash) where you want active tags to show.\n\ + \n\ + # Docs: {DOCS_ANCHOR}" + ), + } +} + +pub fn parse_framework(s: &str) -> Result { + match s.to_ascii_lowercase().as_str() { + "starship" => Ok(PromptFramework::Starship), + "powerlevel10k" | "p10k" => Ok(PromptFramework::Powerlevel10k), + "oh-my-posh" | "ohmyposh" | "omp" => Ok(PromptFramework::OhMyPosh), + "generic" => Ok(PromptFramework::Generic), + other => Err(crate::error::ThothError::Prompt(format!( + "unknown framework '{other}'; use starship, powerlevel10k, oh-my-posh, or generic" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn inputs( + starship_toml: bool, + starship_path: bool, + zsh_theme: Option<&str>, + p10k: bool, + omp_dir: bool, + posh_theme: bool, + ) -> DetectInputs { + DetectInputs { + starship_toml_exists: starship_toml, + starship_on_path: starship_path, + zsh_theme: zsh_theme.map(|s| s.to_string()), + p10k_zsh_exists: p10k, + oh_my_posh_dir_exists: omp_dir, + posh_theme_set: posh_theme, + } + } + + #[test] + fn detect_starship_via_toml() { + let i = inputs(true, false, None, false, false, false); + assert_eq!(detect_framework(&i), PromptFramework::Starship); + } + + #[test] + fn detect_starship_via_path() { + let i = inputs(false, true, None, false, false, false); + assert_eq!(detect_framework(&i), PromptFramework::Starship); + } + + #[test] + fn detect_p10k_via_zsh_theme() { + let i = inputs( + false, + false, + Some("powerlevel10k/powerlevel10k"), + false, + false, + false, + ); + assert_eq!(detect_framework(&i), PromptFramework::Powerlevel10k); + } + + #[test] + fn detect_p10k_zsh_theme_case_insensitive() { + let i = inputs( + false, + false, + Some("Powerlevel10K/Powerlevel10K"), + false, + false, + false, + ); + assert_eq!(detect_framework(&i), PromptFramework::Powerlevel10k); + } + + #[test] + fn detect_p10k_via_p10k_zsh_file() { + let i = inputs(false, false, None, true, false, false); + assert_eq!(detect_framework(&i), PromptFramework::Powerlevel10k); + } + + #[test] + fn detect_omp_via_dir() { + let i = inputs(false, false, None, false, true, false); + assert_eq!(detect_framework(&i), PromptFramework::OhMyPosh); + } + + #[test] + fn detect_omp_via_posh_theme() { + let i = inputs(false, false, None, false, false, true); + assert_eq!(detect_framework(&i), PromptFramework::OhMyPosh); + } + + #[test] + fn detect_generic_fallback() { + let i = inputs(false, false, None, false, false, false); + assert_eq!(detect_framework(&i), PromptFramework::Generic); + } + + #[test] + fn starship_beats_p10k_when_both_true() { + let i = inputs( + true, + false, + Some("powerlevel10k/powerlevel10k"), + false, + false, + false, + ); + assert_eq!(detect_framework(&i), PromptFramework::Starship); + } + + #[test] + fn p10k_beats_omp() { + let i = inputs(false, false, None, true, true, false); + assert_eq!(detect_framework(&i), PromptFramework::Powerlevel10k); + } + + #[test] + fn snippet_starship_has_env_var_module() { + let s = prompt_snippet(&PromptFramework::Starship); + assert!( + s.contains("env_var.thoth_tags"), + "missing env_var.thoth_tags" + ); + assert!(s.contains("TTH_PROMPT_TAGS"), "missing TTH_PROMPT_TAGS"); + assert!( + s.contains("env_var.thoth_tags}"), + "missing add-to-format note" + ); + assert!(s.contains(DOCS_ANCHOR), "missing docs anchor"); + } + + #[test] + fn snippet_p10k_has_key_markers() { + let s = prompt_snippet(&PromptFramework::Powerlevel10k); + assert!(s.contains("TTH_PROMPT_TAGS"), "missing TTH_PROMPT_TAGS"); + assert!(s.contains("p10k segment"), "missing p10k segment"); + assert!(s.contains("POWERLEVEL9K"), "missing POWERLEVEL9K"); + assert!(s.contains(DOCS_ANCHOR), "missing docs anchor"); + } + + #[test] + fn snippet_omp_has_key_markers() { + let s = prompt_snippet(&PromptFramework::OhMyPosh); + assert!(s.contains("TTH_PROMPT_TAGS"), "missing TTH_PROMPT_TAGS"); + assert!( + s.contains(".Env.TTH_PROMPT_TAGS"), + "missing .Env.TTH_PROMPT_TAGS" + ); + assert!(s.contains(DOCS_ANCHOR), "missing docs anchor"); + } + + #[test] + fn snippet_generic_has_key_markers() { + let s = prompt_snippet(&PromptFramework::Generic); + let marker = "${TTH_PROMPT_TAGS}"; + assert!(s.contains(marker), "missing ${{TTH_PROMPT_TAGS}}"); + assert!(s.contains("PROMPT"), "missing PROMPT"); + assert!(s.contains("PS1"), "missing PS1"); + assert!(s.contains(DOCS_ANCHOR), "missing docs anchor"); + } + + #[test] + fn parse_framework_starship() { + assert_eq!( + parse_framework("starship").unwrap(), + PromptFramework::Starship + ); + } + + #[test] + fn parse_framework_p10k_aliases() { + assert_eq!( + parse_framework("powerlevel10k").unwrap(), + PromptFramework::Powerlevel10k + ); + assert_eq!( + parse_framework("p10k").unwrap(), + PromptFramework::Powerlevel10k + ); + } + + #[test] + fn parse_framework_omp_aliases() { + assert_eq!( + parse_framework("oh-my-posh").unwrap(), + PromptFramework::OhMyPosh + ); + assert_eq!( + parse_framework("ohmyposh").unwrap(), + PromptFramework::OhMyPosh + ); + assert_eq!(parse_framework("omp").unwrap(), PromptFramework::OhMyPosh); + } + + #[test] + fn parse_framework_generic() { + assert_eq!( + parse_framework("generic").unwrap(), + PromptFramework::Generic + ); + } + + #[test] + fn parse_framework_unknown_errors() { + let err = parse_framework("fish").unwrap_err(); + assert!(err.to_string().contains("unknown framework")); + } +} diff --git a/src/recorder.rs b/src/recorder.rs new file mode 100644 index 0000000..48597c3 --- /dev/null +++ b/src/recorder.rs @@ -0,0 +1,513 @@ +use crate::cli::RecordArgs; +use crate::error::ThothError; +use crate::logging::log_error; +use crate::project::infer_project; +use crate::session::get_or_create; +use rusqlite::{Connection, TransactionBehavior}; + +pub fn normalize_tags(raw: &str) -> String { + if raw.is_empty() { + return String::from("[]"); + } + match serde_json::from_str::(raw) { + Ok(serde_json::Value::Array(arr)) => { + if arr.iter().all(|v| v.is_string()) { + raw.to_string() + } else { + String::from("[]") + } + } + _ => String::from("[]"), + } +} + +pub fn record_inner( + args: &RecordArgs, + gap_minutes: i64, + conn: &mut Connection, +) -> Result<(), ThothError> { + let tags = normalize_tags(&args.tags); + let workspace = args + .workspace + .as_deref() + .and_then(crate::workspaces::normalize_workspace); + let directory = args.dir.clone().unwrap_or_else(|| { + std::env::current_dir() + .unwrap_or_default() + .to_string_lossy() + .to_string() + }); + let timestamp = args.timestamp.unwrap_or_else(|| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 + }); + + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + + let project = infer_project(&directory, &tx)?; + let sid = get_or_create(&project, timestamp, gap_minutes, &tx)?; + + tx.execute( + "INSERT INTO commands(command, directory, project, session_id, timestamp, exit_code, duration_ms, tags, terminal_id, workspace) \ + VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + rusqlite::params![ + args.cmd, + directory, + project, + sid, + timestamp, + args.exit_code, + args.duration, + tags, + args.terminal_id, + workspace + ], + )?; + + tx.execute( + "INSERT INTO projects(path, name, last_seen, command_count) VALUES(?1, ?2, ?3, 1) \ + ON CONFLICT(path) DO UPDATE SET name=excluded.name, last_seen=excluded.last_seen, command_count=command_count+1", + rusqlite::params![directory, project, timestamp], + )?; + + tx.execute( + "UPDATE sessions SET ended_at=?1, command_count=command_count+1 WHERE session_id=?2", + rusqlite::params![timestamp, sid], + )?; + + tx.commit()?; + Ok(()) +} + +pub fn record( + args: &RecordArgs, + gap_minutes: i64, + history_filters: &[String], + conn: &mut Connection, +) { + let (regexes, invalid) = crate::search::compile_filters(history_filters); + for pat in &invalid { + crate::logging::log_error(&format!( + "invalid history filter pattern (skipped): {}", + pat + )); + } + if crate::search::is_filtered(&args.cmd, ®exes) { + return; + } + match record_inner(args, gap_minutes, conn) { + Ok(()) => {} + Err(ThothError::Sqlite(ref e)) + if matches!( + e, + rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error { + code: rusqlite::ErrorCode::DatabaseBusy, + .. + }, + _ + ) | rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error { + code: rusqlite::ErrorCode::DatabaseLocked, + .. + }, + _ + ) + ) => + { + match record_inner(args, gap_minutes, conn) { + Ok(()) => {} + Err(e) => log_error(&e.to_string()), + } + } + Err(e) => log_error(&e.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::RecordArgs; + use rusqlite::Connection; + use tempfile::TempDir; + + const DEFAULT_GAP: i64 = 30; + + fn mem_conn() -> Connection { + let mut c = crate::database::connect_memory().unwrap(); + crate::database::apply_migrations(&mut c).unwrap(); + c + } + + fn disk_conn(dir: &TempDir) -> Connection { + let path = dir.path().join("history.db"); + crate::database::get_connection(Some(&path)).unwrap() + } + + fn base_args() -> RecordArgs { + RecordArgs { + cmd: String::from("echo hi"), + dir: Some(String::from("/tmp")), + exit_code: 0, + duration: 5, + timestamp: Some(1700000000), + tags: String::from("[]"), + terminal_id: None, + workspace: None, + } + } + + #[test] + fn terminal_id_persisted_when_provided() { + let mut conn = mem_conn(); + let args = RecordArgs { + terminal_id: Some(String::from("abc")), + ..base_args() + }; + record_inner(&args, DEFAULT_GAP, &mut conn).unwrap(); + let val: Option = conn + .query_row( + "SELECT terminal_id FROM commands WHERE command='echo hi'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(val, Some(String::from("abc"))); + } + + #[test] + fn terminal_id_null_when_omitted() { + let mut conn = mem_conn(); + let args = base_args(); + record_inner(&args, DEFAULT_GAP, &mut conn).unwrap(); + let val: Option = conn + .query_row( + "SELECT terminal_id FROM commands WHERE command='echo hi'", + [], + |r| r.get(0), + ) + .unwrap(); + assert!(val.is_none()); + } + + #[test] + fn normalize_tags_valid() { + assert_eq!(normalize_tags(r#"["a","b"]"#), r#"["a","b"]"#); + } + + #[test] + fn normalize_tags_invalid() { + assert_eq!(normalize_tags("not json"), "[]"); + } + + #[test] + fn normalize_tags_not_array() { + assert_eq!(normalize_tags(r#"{"k":"v"}"#), "[]"); + } + + #[test] + fn normalize_tags_array_not_strings() { + assert_eq!(normalize_tags("[1,2]"), "[]"); + } + + #[test] + fn successful_record_inserts_row() { + let mut conn = mem_conn(); + let args = base_args(); + record_inner(&args, DEFAULT_GAP, &mut conn).unwrap(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM commands WHERE command='echo hi'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 1); + } + + #[test] + fn command_count_increments() { + let mut conn = mem_conn(); + let dir = "/tmp/count-test"; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + conn.execute( + "INSERT INTO projects(path, name, last_seen, command_count) VALUES(?1, 'myapp', ?2, 5)", + rusqlite::params![dir, now], + ) + .unwrap(); + let args = RecordArgs { + cmd: String::from("ls"), + dir: Some(dir.to_string()), + exit_code: 0, + duration: 1, + timestamp: Some(1700000000), + tags: String::from("[]"), + terminal_id: None, + workspace: None, + }; + record_inner(&args, DEFAULT_GAP, &mut conn).unwrap(); + let count: i64 = conn + .query_row( + "SELECT command_count FROM projects WHERE path=?1", + rusqlite::params![dir], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 6); + } + + #[test] + fn session_updated_after_record() { + let mut conn = mem_conn(); + let args = base_args(); + record_inner(&args, DEFAULT_GAP, &mut conn).unwrap(); + let (ended_at, count): (i64, i64) = conn + .query_row("SELECT ended_at, command_count FROM sessions", [], |r| { + Ok((r.get(0)?, r.get(1)?)) + }) + .unwrap(); + assert_eq!(ended_at, 1700000000); + assert!(count >= 1); + } + + #[test] + fn atomicity_no_orphan_session() { + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("history.db"); + let mut conn = crate::database::get_connection(Some(&db_path)).unwrap(); + + let conn2 = Connection::open(&db_path).unwrap(); + conn2.execute_batch("PRAGMA busy_timeout=0;").unwrap(); + conn2.execute("BEGIN IMMEDIATE", []).unwrap(); + + let args = base_args(); + let result = record_inner(&args, DEFAULT_GAP, &mut conn); + + drop(conn2); + + assert!(result.is_err()); + + let commands: i64 = conn + .query_row("SELECT COUNT(*) FROM commands", [], |r| r.get(0)) + .unwrap(); + let sessions: i64 = conn + .query_row("SELECT COUNT(*) FROM sessions", [], |r| r.get(0)) + .unwrap(); + assert_eq!(commands, 0, "orphan command found"); + assert_eq!(sessions, 0, "orphan session found"); + } + + #[test] + fn record_never_propagates_error() { + let dir = TempDir::new().unwrap(); + let log_path = dir.path().join("error.log"); + crate::logging::setup(log_path.clone()); + + let mut conn = disk_conn(&dir); + conn.execute_batch("DROP TABLE commands;").unwrap(); + + let args = base_args(); + record(&args, DEFAULT_GAP, &[], &mut conn); + + let log = std::fs::read_to_string(&log_path).unwrap_or_default(); + assert!(!log.is_empty(), "error was not logged"); + } + + #[test] + fn double_failure_no_row_no_panic() { + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("locked.db"); + let mut conn = crate::database::get_connection(Some(&db_path)).unwrap(); + + let blocker = Connection::open(&db_path).unwrap(); + blocker.execute_batch("PRAGMA busy_timeout=0;").unwrap(); + blocker.execute("BEGIN IMMEDIATE", []).unwrap(); + + let args = base_args(); + record(&args, DEFAULT_GAP, &[], &mut conn); + + drop(blocker); + + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM commands", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + count, 0, + "no row should have been inserted on double failure" + ); + } + + #[test] + fn history_filter_matching_command_not_inserted() { + let mut conn = mem_conn(); + let args = RecordArgs { + cmd: String::from("mysql --password=secret"), + ..base_args() + }; + let filters = vec!["--password".to_string()]; + record(&args, DEFAULT_GAP, &filters, &mut conn); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM commands", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0, "matching command should not be inserted"); + } + + #[test] + fn history_filter_non_matching_command_is_inserted() { + let mut conn = mem_conn(); + let filters = vec!["--password".to_string()]; + record(&base_args(), DEFAULT_GAP, &filters, &mut conn); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM commands", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1, "non-matching command should be inserted"); + } + + #[test] + fn history_filter_invalid_pattern_does_not_break_recording() { + let mut conn = mem_conn(); + let filters = vec!["[invalid".to_string()]; + record(&base_args(), DEFAULT_GAP, &filters, &mut conn); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM commands", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + count, 1, + "invalid pattern should be skipped; command should still be recorded" + ); + } + + #[test] + fn history_filter_empty_filter_records_all() { + let mut conn = mem_conn(); + record(&base_args(), DEFAULT_GAP, &[], &mut conn); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM commands", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + } + + #[test] + fn default_history_filter_blocks_tth_sw() { + let mut conn = mem_conn(); + let filters = crate::config::History::default().filter; + let args = RecordArgs { + cmd: String::from("tth-sw foo"), + ..base_args() + }; + record(&args, DEFAULT_GAP, &filters, &mut conn); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM commands", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0, "tth-sw must be blocked by the default filter"); + } + + #[test] + fn default_history_filter_blocks_bare_tth() { + let mut conn = mem_conn(); + let filters = crate::config::History::default().filter; + let args = RecordArgs { + cmd: String::from("tth"), + ..base_args() + }; + record(&args, DEFAULT_GAP, &filters, &mut conn); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM commands", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0, "bare tth must be blocked by the default filter"); + } + + #[test] + fn default_history_filter_does_not_block_cargo_build() { + let mut conn = mem_conn(); + let filters = crate::config::History::default().filter; + let args = RecordArgs { + cmd: String::from("cargo build"), + ..base_args() + }; + record(&args, DEFAULT_GAP, &filters, &mut conn); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM commands", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + count, 1, + "cargo build must not be blocked by the default filter" + ); + } + + #[test] + fn default_history_filter_does_not_block_tthx() { + let mut conn = mem_conn(); + let filters = crate::config::History::default().filter; + let args = RecordArgs { + cmd: String::from("tthx"), + ..base_args() + }; + record(&args, DEFAULT_GAP, &filters, &mut conn); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM commands", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + count, 1, + "tthx must not be blocked (not a tth word boundary match)" + ); + } + + #[test] + fn workspace_stored_when_provided() { + let mut conn = mem_conn(); + let args = RecordArgs { + workspace: Some(String::from("ws1")), + ..base_args() + }; + record_inner(&args, DEFAULT_GAP, &mut conn).unwrap(); + let val: Option = conn + .query_row( + "SELECT workspace FROM commands WHERE command='echo hi'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(val, Some(String::from("ws1"))); + } + + #[test] + fn workspace_null_when_none() { + let mut conn = mem_conn(); + record_inner(&base_args(), DEFAULT_GAP, &mut conn).unwrap(); + let val: Option = conn + .query_row( + "SELECT workspace FROM commands WHERE command='echo hi'", + [], + |r| r.get(0), + ) + .unwrap(); + assert!(val.is_none()); + } + + #[test] + fn workspace_null_when_empty_string() { + let mut conn = mem_conn(); + let args = RecordArgs { + workspace: Some(String::from("")), + ..base_args() + }; + record_inner(&args, DEFAULT_GAP, &mut conn).unwrap(); + let val: Option = conn + .query_row( + "SELECT workspace FROM commands WHERE command='echo hi'", + [], + |r| r.get(0), + ) + .unwrap(); + assert!( + val.is_none(), + "empty workspace string must normalize to NULL" + ); + } +} diff --git a/src/schema.rs b/src/schema.rs new file mode 100644 index 0000000..d3a93e6 --- /dev/null +++ b/src/schema.rs @@ -0,0 +1,53 @@ +pub const SCHEMA_V1: &str = " +CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + applied_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS commands ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + command TEXT NOT NULL, + directory TEXT NOT NULL, + project TEXT NOT NULL, + session_id TEXT NOT NULL, + timestamp INTEGER NOT NULL, + exit_code INTEGER NOT NULL DEFAULT 0, + duration_ms INTEGER NOT NULL DEFAULT 0, + tags TEXT NOT NULL DEFAULT '[]' +); + +CREATE INDEX IF NOT EXISTS idx_commands_session ON commands(session_id); +CREATE INDEX IF NOT EXISTS idx_commands_timestamp ON commands(timestamp); + +CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + project TEXT NOT NULL, + started_at INTEGER NOT NULL, + ended_at INTEGER NOT NULL, + command_count INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS projects ( + path TEXT PRIMARY KEY, + name TEXT NOT NULL, + last_seen INTEGER NOT NULL, + command_count INTEGER NOT NULL DEFAULT 0 +); +"; + +pub const SCHEMA_V3_TERMINAL_ID: &str = "ALTER TABLE commands ADD COLUMN terminal_id TEXT;"; + +pub const SCHEMA_V4_WORKSPACE: &str = "ALTER TABLE commands ADD COLUMN workspace TEXT;"; + +pub const SCHEMA_V2_FTS: &str = " +CREATE VIRTUAL TABLE IF NOT EXISTS commands_fts + USING fts5(command, content='commands', content_rowid='id'); + +CREATE TRIGGER IF NOT EXISTS commands_ai AFTER INSERT ON commands BEGIN + INSERT INTO commands_fts(rowid, command) VALUES (new.id, new.command); +END; + +CREATE TRIGGER IF NOT EXISTS commands_ad AFTER DELETE ON commands BEGIN + INSERT INTO commands_fts(commands_fts, rowid, command) VALUES ('delete', old.id, old.command); +END; +"; diff --git a/src/search.rs b/src/search.rs new file mode 100644 index 0000000..a3a4c82 --- /dev/null +++ b/src/search.rs @@ -0,0 +1,1137 @@ +use std::cmp::Ordering; + +use comfy_table::{presets::UTF8_BORDERS_ONLY, Cell, Color, ContentArrangement, Table}; +use regex::Regex; +use rusqlite::{Connection, ToSql}; + +use crate::error::ThothError; + +#[derive(Debug)] +pub enum Column { + Timestamp, + Project, + Tags, + Exit, + Duration, + Directory, + Command, +} + +const ALL_COLUMN_NAMES: &[&str] = &[ + "timestamp", + "project", + "tags", + "exit", + "duration", + "directory", + "command", +]; + +impl Column { + pub fn from_name(s: &str) -> Option { + match s { + "timestamp" => Some(Column::Timestamp), + "project" => Some(Column::Project), + "tags" => Some(Column::Tags), + "exit" => Some(Column::Exit), + "duration" => Some(Column::Duration), + "directory" => Some(Column::Directory), + "command" => Some(Column::Command), + _ => None, + } + } + + pub fn header(&self) -> &'static str { + match self { + Column::Timestamp => "timestamp", + Column::Project => "project", + Column::Tags => "tags", + Column::Exit => "exit", + Column::Duration => "duration", + Column::Directory => "directory", + Column::Command => "command", + } + } + + pub fn cell(&self, row: &CommandRow) -> Cell { + match self { + Column::Timestamp => Cell::new(fmt_timestamp(row.timestamp)), + Column::Project => Cell::new(&row.project), + Column::Tags => Cell::new(&row.tags), + Column::Exit => exit_cell(row.exit_code), + Column::Duration => Cell::new(fmt_duration(row.duration_ms)), + Column::Directory => Cell::new(&row.directory), + Column::Command => Cell::new(&row.command), + } + } +} + +pub fn resolve_columns(names: &[String]) -> Result, ThothError> { + let mut cols = Vec::with_capacity(names.len()); + for name in names { + match Column::from_name(name.as_str()) { + Some(c) => cols.push(c), + None => { + return Err(ThothError::Search(format!( + "unknown column '{}'; valid columns: {}", + name, + ALL_COLUMN_NAMES.join(", ") + ))) + } + } + } + Ok(cols) +} + +pub fn compile_filters(patterns: &[String]) -> (Vec, Vec) { + let mut regexes = Vec::new(); + let mut invalid = Vec::new(); + for p in patterns { + match Regex::new(p) { + Ok(r) => regexes.push(r), + Err(_) => invalid.push(p.clone()), + } + } + (regexes, invalid) +} + +pub fn is_filtered(command: &str, regexes: &[Regex]) -> bool { + regexes.iter().any(|r| r.is_match(command)) +} + +pub use crate::cli::SearchArgs; + +const SECS_PER_DAY: i64 = 86_400; +const MS_PER_SEC: i64 = 1_000; + +const DAYS_FROM_EPOCH_TO_YEAR_ZERO: i64 = 719_468; + +#[derive(clap::ValueEnum, Clone, Debug, PartialEq)] +pub enum ExitFilter { + Ok, + Fail, + Any, +} + +pub struct CommandRow { + pub id: i64, + pub timestamp: i64, + pub project: String, + pub tags: String, + pub exit_code: i64, + pub duration_ms: i64, + pub directory: String, + pub command: String, + pub session_id: String, + pub workspace: Option, +} + +pub fn parse_duration(s: &str) -> Result<(Ordering, i64), ThothError> { + let (ord, rest) = if let Some(n) = s.strip_prefix('>') { + (Ordering::Greater, n) + } else if let Some(n) = s.strip_prefix('<') { + (Ordering::Less, n) + } else { + return Err(ThothError::Search(format!( + "duration must start with '>' or '<', got: {s}" + ))); + }; + let secs: i64 = rest + .parse() + .map_err(|_| ThothError::Search(format!("invalid duration seconds: {rest}")))?; + Ok((ord, secs * MS_PER_SEC)) +} + +pub fn parse_date(s: &str, now: i64) -> Result { + let today_midnight = now - now.rem_euclid(SECS_PER_DAY); + match s { + "today" => Ok(today_midnight), + "yesterday" => Ok(today_midnight - SECS_PER_DAY), + "last week" => Ok(today_midnight - 7 * SECS_PER_DAY), + other => parse_ymd(other), + } +} + +fn parse_ymd(s: &str) -> Result { + let parts: Vec<&str> = s.splitn(3, '-').collect(); + if parts.len() != 3 { + return Err(ThothError::Search(format!("invalid date: {s}"))); + } + let year: i64 = parts[0] + .parse() + .map_err(|_| ThothError::Search(format!("invalid date: {s}")))?; + let month: i64 = parts[1] + .parse() + .map_err(|_| ThothError::Search(format!("invalid date: {s}")))?; + let day: i64 = parts[2] + .parse() + .map_err(|_| ThothError::Search(format!("invalid date: {s}")))?; + + if !(1..=12).contains(&month) || !(1..=31).contains(&day) { + return Err(ThothError::Search(format!("invalid date: {s}"))); + } + + let max_day = month_max_day(year, month); + if day > max_day { + return Err(ThothError::Search(format!("invalid date: {s}"))); + } + + let days = days_since_epoch(year, month, day); + Ok(days * SECS_PER_DAY) +} + +fn is_leap_year(year: i64) -> bool { + (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 +} + +fn month_max_day(year: i64, month: i64) -> i64 { + match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 => { + if is_leap_year(year) { + 29 + } else { + 28 + } + } + _ => 0, + } +} + +fn days_since_epoch(year: i64, month: i64, day: i64) -> i64 { + let (y, m) = if month <= 2 { + (year - 1, month + 12) + } else { + (year, month) + }; + let era = y.div_euclid(400); + let yoe = y - era * 400; + let doy = (153 * (m - 3) + 2) / 5 + day - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - DAYS_FROM_EPOCH_TO_YEAR_ZERO +} + +pub fn build_query( + args: &SearchArgs, + now: i64, +) -> Result<(String, Vec>), ThothError> { + let cols = "c.id, c.timestamp, c.project, c.tags, c.exit_code, c.duration_ms, c.directory, c.command, c.session_id, c.workspace"; + + let tokens: Vec = match &args.query { + Some(q) => q + .split_whitespace() + .filter(|t| !t.is_empty()) + .map(|t| format!("\"{}\"", t.replace('"', "\"\""))) + .collect(), + None => vec![], + }; + + let use_fts = !tokens.is_empty(); + + let mut fragments: Vec = Vec::new(); + let mut params: Vec> = Vec::new(); + + if use_fts { + let match_str = tokens.join(" "); + fragments.push("f.commands_fts MATCH ?".to_string()); + params.push(Box::new(match_str)); + } + + if let Some(ref p) = args.project { + fragments.push("c.project LIKE ?".to_string()); + params.push(Box::new(format!("%{p}%"))); + } + + match &args.exit { + Some(ExitFilter::Ok) => { + fragments.push("c.exit_code = 0".to_string()); + } + Some(ExitFilter::Fail) => { + fragments.push("c.exit_code != 0".to_string()); + } + Some(ExitFilter::Any) | None => {} + } + + if let Some(ref dur_str) = args.duration { + let (ord, ms) = parse_duration(dur_str)?; + match ord { + Ordering::Greater => { + fragments.push("c.duration_ms > ?".to_string()); + params.push(Box::new(ms)); + } + Ordering::Less => { + fragments.push("c.duration_ms < ?".to_string()); + params.push(Box::new(ms)); + } + Ordering::Equal => {} + } + } + + if let Some(ref since_str) = args.since { + let ts = parse_date(since_str, now)?; + fragments.push("c.timestamp >= ?".to_string()); + params.push(Box::new(ts)); + } + + if let Some(ref until_str) = args.until { + let ts = parse_date(until_str, now)?; + fragments.push("c.timestamp <= ?".to_string()); + params.push(Box::new(ts)); + } + + if let Some(ref sid) = args.session { + fragments.push("c.session_id = ?".to_string()); + params.push(Box::new(sid.clone())); + } + + for tag in &args.tag { + fragments.push( + "EXISTS(SELECT 1 FROM json_each(c.tags) WHERE LOWER(value) = LOWER(?))".to_string(), + ); + params.push(Box::new(tag.clone())); + } + + let limit = args.limit.unwrap_or(50) as i64; + params.push(Box::new(limit)); + + let sql = if use_fts { + let where_clause = fragments.join(" AND "); + format!( + "SELECT {cols} FROM commands c INNER JOIN commands_fts f ON f.rowid = c.id WHERE {where_clause} ORDER BY c.timestamp DESC LIMIT ?" + ) + } else if fragments.is_empty() { + format!("SELECT {cols} FROM commands c ORDER BY c.timestamp DESC LIMIT ?") + } else { + let where_clause = fragments.join(" AND "); + format!( + "SELECT {cols} FROM commands c WHERE {where_clause} ORDER BY c.timestamp DESC LIMIT ?" + ) + }; + + Ok((sql, params)) +} + +pub fn execute( + args: &SearchArgs, + conn: &Connection, + now: i64, +) -> Result, ThothError> { + let (sql, params) = build_query(args, now)?; + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map( + rusqlite::params_from_iter(params.iter().map(|p| p.as_ref())), + |row| { + Ok(CommandRow { + id: row.get(0)?, + timestamp: row.get(1)?, + project: row.get(2)?, + tags: row.get(3)?, + exit_code: row.get(4)?, + duration_ms: row.get(5)?, + directory: row.get(6)?, + command: row.get(7)?, + session_id: row.get(8)?, + workspace: row.get(9)?, + }) + }, + )?; + let mut result = Vec::new(); + for row in rows { + result.push(row?); + } + Ok(result) +} + +pub fn render(rows: &[CommandRow], columns: &[Column], show_session: bool) -> String { + if rows.is_empty() { + return String::from("0 result(s)\n"); + } + + if show_session { + render_by_session(rows) + } else { + render_table(rows, columns) + } +} + +fn render_table(rows: &[CommandRow], columns: &[Column]) -> String { + let mut table = Table::new(); + table.load_preset(UTF8_BORDERS_ONLY); + table.set_content_arrangement(ContentArrangement::Dynamic); + table.set_header(columns.iter().map(|c| c.header()).collect::>()); + for row in rows { + table.add_row(columns.iter().map(|c| c.cell(row)).collect::>()); + } + format!("{table}\n{} result(s)\n", rows.len()) +} + +fn render_by_session(rows: &[CommandRow]) -> String { + let mut output = String::new(); + let mut current_session: Option<&str> = None; + + let mut group_start = 0; + let mut i = 0; + while i <= rows.len() { + let session_changed = + i == rows.len() || current_session.is_none_or(|s| s != rows[i].session_id); + + if session_changed && i > 0 { + let group = &rows[group_start..i]; + let first = &group[0]; + let short_id = &first.session_id[..first.session_id.len().min(8)]; + let date = &fmt_timestamp(first.timestamp)[..10]; + let header = format!( + "--- session {} · {} · {} ---\n", + short_id, date, first.project + ); + output.push_str(&header); + + let mut sub = Table::new(); + sub.load_preset(UTF8_BORDERS_ONLY); + sub.set_content_arrangement(ContentArrangement::Dynamic); + sub.set_header(vec!["timestamp", "exit", "duration", "command"]); + for r in group { + sub.add_row(vec![ + Cell::new(fmt_timestamp(r.timestamp)), + exit_cell(r.exit_code), + Cell::new(fmt_duration(r.duration_ms)), + Cell::new(&r.command), + ]); + } + output.push_str(&format!("{sub}\n")); + group_start = i; + } + + if i < rows.len() { + current_session = Some(&rows[i].session_id); + } + i += 1; + } + + output.push_str(&format!("{} result(s)\n", rows.len())); + output +} + +fn exit_cell(code: i64) -> Cell { + if code == 0 { + Cell::new("ok").fg(Color::Green) + } else { + Cell::new("fail").fg(Color::Red) + } +} + +pub fn fmt_timestamp_pub(epoch: i64) -> String { + fmt_timestamp(epoch) +} + +pub(crate) fn fmt_timestamp(epoch: i64) -> String { + let secs = epoch; + let days = secs.div_euclid(SECS_PER_DAY); + let time_secs = secs.rem_euclid(SECS_PER_DAY); + let hh = time_secs / 3600; + let mm = (time_secs % 3600) / 60; + + let (y, mo, d) = civil_from_days(days); + format!("{y:04}-{mo:02}-{d:02} {hh:02}:{mm:02}") +} + +pub(crate) fn civil_from_days(z: i64) -> (i64, i64, i64) { + let z = z + DAYS_FROM_EPOCH_TO_YEAR_ZERO; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let mo = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if mo <= 2 { y + 1 } else { y }; + (y, mo, d) +} + +fn fmt_duration(ms: i64) -> String { + if ms < 1000 { + format!("{ms}ms") + } else { + let s = ms as f64 / 1000.0; + let tenths = (s * 10.0).round() as i64; + if tenths % 10 == 0 { + format!("{}s", tenths / 10) + } else { + format!("{:.1}s", s) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::SearchArgs; + + fn default_args() -> SearchArgs { + SearchArgs { + query: None, + project: None, + tag: vec![], + exit: None, + duration: None, + since: None, + until: None, + session: None, + limit: Some(50), + show_session: false, + } + } + + #[test] + fn parse_duration_greater() { + let (ord, ms) = parse_duration(">30").unwrap(); + assert_eq!(ord, Ordering::Greater); + assert_eq!(ms, 30_000); + } + + #[test] + fn parse_duration_less() { + let (ord, ms) = parse_duration("<5").unwrap(); + assert_eq!(ord, Ordering::Less); + assert_eq!(ms, 5_000); + } + + #[test] + fn parse_duration_no_prefix_is_err() { + assert!(parse_duration("30").is_err()); + } + + #[test] + fn parse_duration_non_numeric_is_err() { + assert!(parse_duration(">abc").is_err()); + } + + const FIXED_NOW: i64 = 1_700_000_000; + + #[test] + fn parse_date_today() { + let midnight = FIXED_NOW - FIXED_NOW.rem_euclid(SECS_PER_DAY); + assert_eq!(parse_date("today", FIXED_NOW).unwrap(), midnight); + } + + #[test] + fn parse_date_yesterday() { + let midnight = FIXED_NOW - FIXED_NOW.rem_euclid(SECS_PER_DAY); + assert_eq!( + parse_date("yesterday", FIXED_NOW).unwrap(), + midnight - SECS_PER_DAY + ); + } + + #[test] + fn parse_date_last_week() { + let midnight = FIXED_NOW - FIXED_NOW.rem_euclid(SECS_PER_DAY); + assert_eq!( + parse_date("last week", FIXED_NOW).unwrap(), + midnight - 7 * SECS_PER_DAY + ); + } + + #[test] + fn parse_date_ymd() { + let epoch = parse_date("2024-01-15", FIXED_NOW).unwrap(); + assert_eq!(epoch, 19_737 * SECS_PER_DAY); + } + + #[test] + fn parse_date_bad_string_is_err() { + assert!(parse_date("bad-date", FIXED_NOW).is_err()); + } + + #[test] + fn parse_date_invalid_month_is_err() { + assert!(parse_date("2024-13-01", FIXED_NOW).is_err()); + } + + #[test] + fn parse_date_feb_31_is_err() { + assert!(parse_date("2024-02-31", FIXED_NOW).is_err()); + } + + #[test] + fn parse_date_feb_29_non_leap_is_err() { + assert!(parse_date("2023-02-29", FIXED_NOW).is_err()); + } + + #[test] + fn parse_date_feb_29_leap_is_ok() { + assert!(parse_date("2024-02-29", FIXED_NOW).is_ok()); + } + + #[test] + fn parse_date_april_31_is_err() { + assert!(parse_date("2024-04-31", FIXED_NOW).is_err()); + } + + #[test] + fn build_query_no_filters() { + let args = default_args(); + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + assert!(!sql.contains("WHERE")); + assert!(sql.contains("LIMIT ?")); + assert_eq!(params.len(), 1); + } + + #[test] + fn build_query_project() { + let args = SearchArgs { + project: Some("foo".into()), + ..default_args() + }; + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + assert!(sql.contains("c.project LIKE ?")); + assert_eq!(params.len(), 2); + } + + #[test] + fn build_query_exit_ok() { + let args = SearchArgs { + exit: Some(ExitFilter::Ok), + ..default_args() + }; + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + assert!(sql.contains("c.exit_code = 0")); + assert_eq!(params.len(), 1); + } + + #[test] + fn build_query_exit_fail() { + let args = SearchArgs { + exit: Some(ExitFilter::Fail), + ..default_args() + }; + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + assert!(sql.contains("c.exit_code != 0")); + assert_eq!(params.len(), 1); + } + + #[test] + fn build_query_exit_any() { + let args = SearchArgs { + exit: Some(ExitFilter::Any), + ..default_args() + }; + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + assert!(!sql.contains("exit_code = 0") && !sql.contains("exit_code != 0")); + assert_eq!(params.len(), 1); + } + + #[test] + fn build_query_duration_greater() { + let args = SearchArgs { + duration: Some(">30".into()), + ..default_args() + }; + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + assert!(sql.contains("c.duration_ms > ?")); + assert_eq!(params.len(), 2); + } + + #[test] + fn build_query_duration_less() { + let args = SearchArgs { + duration: Some("<5".into()), + ..default_args() + }; + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + assert!(sql.contains("c.duration_ms < ?")); + assert_eq!(params.len(), 2); + } + + #[test] + fn build_query_since() { + let args = SearchArgs { + since: Some("today".into()), + ..default_args() + }; + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + assert!(sql.contains("c.timestamp >= ?")); + assert_eq!(params.len(), 2); + } + + #[test] + fn build_query_until() { + let args = SearchArgs { + until: Some("today".into()), + ..default_args() + }; + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + assert!(sql.contains("c.timestamp <= ?")); + assert_eq!(params.len(), 2); + } + + #[test] + fn build_query_session() { + let args = SearchArgs { + session: Some("abc123".into()), + ..default_args() + }; + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + assert!(sql.contains("c.session_id = ?")); + assert_eq!(params.len(), 2); + } + + #[test] + fn build_query_one_tag() { + let args = SearchArgs { + tag: vec!["rust".into()], + ..default_args() + }; + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + assert!( + sql.contains("EXISTS(SELECT 1 FROM json_each(c.tags) WHERE LOWER(value) = LOWER(?))") + ); + assert_eq!(params.len(), 2); + } + + #[test] + fn build_query_two_tags() { + let args = SearchArgs { + tag: vec!["rust".into(), "cli".into()], + ..default_args() + }; + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + let count = sql + .matches("EXISTS(SELECT 1 FROM json_each(c.tags) WHERE LOWER(value) = LOWER(?))") + .count(); + assert_eq!(count, 2); + assert_eq!(params.len(), 3); + } + + #[test] + fn build_query_fts_single_token() { + let args = SearchArgs { + query: Some("foo".into()), + ..default_args() + }; + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + assert!(sql.contains("INNER JOIN commands_fts")); + assert_eq!(params.len(), 2); + } + + #[test] + fn build_query_fts_multi_token() { + let conn = mem_conn(); + if !crate::database::fts5_available(&conn) { + return; + } + seed(&conn, s("foo bar baz", "p", 1_000, 0, 100, "[]", "s1")); + seed(&conn, s("foo only", "p", 2_000, 0, 100, "[]", "s1")); + seed(&conn, s("bar only", "p", 3_000, 0, 100, "[]", "s1")); + let args = SearchArgs { + query: Some("foo bar".into()), + ..default_args() + }; + let rows = execute(&args, &conn, FIXED_NOW).unwrap(); + assert_eq!( + rows.len(), + 1, + "multi-token FTS must require all tokens present; got {} rows", + rows.len() + ); + assert_eq!(rows[0].command, "foo bar baz"); + } + + #[test] + fn build_query_fts_hyphenated_token_integration() { + let conn = mem_conn(); + if !crate::database::fts5_available(&conn) { + return; + } + seed( + &conn, + s("docker-compose up", "p", 1_000, 0, 100, "[]", "s1"), + ); + seed(&conn, s("ls -la", "p", 2_000, 0, 100, "[]", "s1")); + let args = SearchArgs { + query: Some("docker-compose".into()), + ..default_args() + }; + let rows = execute(&args, &conn, FIXED_NOW).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].command, "docker-compose up"); + } + + #[test] + fn build_query_empty_query_no_fts() { + let args = SearchArgs { + query: Some(" ".into()), + ..default_args() + }; + let (sql, _) = build_query(&args, FIXED_NOW).unwrap(); + assert!(!sql.contains("INNER JOIN")); + } + + #[test] + fn build_query_combined_project_tag_fts_param_order() { + let args = SearchArgs { + query: Some("docker".into()), + project: Some("myapp".into()), + tag: vec!["infra".into()], + ..default_args() + }; + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + assert!(sql.contains("INNER JOIN commands_fts")); + assert!(sql.contains("c.project LIKE ?")); + assert!( + sql.contains("EXISTS(SELECT 1 FROM json_each(c.tags) WHERE LOWER(value) = LOWER(?))") + ); + assert_eq!(params.len(), 4); + } + + fn mem_conn() -> rusqlite::Connection { + let mut c = crate::database::connect_memory().unwrap(); + crate::database::apply_migrations(&mut c).unwrap(); + c + } + + struct SeedArgs<'a> { + cmd: &'a str, + project: &'a str, + ts: i64, + exit: i64, + dur: i64, + tags: &'a str, + session: &'a str, + } + + fn seed(conn: &rusqlite::Connection, a: SeedArgs<'_>) { + conn.execute( + "INSERT INTO commands(command, directory, project, session_id, timestamp, exit_code, duration_ms, tags) \ + VALUES(?1, '/tmp', ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![a.cmd, a.project, a.session, a.ts, a.exit, a.dur, a.tags], + ).unwrap(); + } + + fn s<'a>( + cmd: &'a str, + project: &'a str, + ts: i64, + exit: i64, + dur: i64, + tags: &'a str, + session: &'a str, + ) -> SeedArgs<'a> { + SeedArgs { + cmd, + project, + ts, + exit, + dur, + tags, + session, + } + } + + #[test] + fn execute_no_filters_returns_all_ordered_desc() { + let conn = mem_conn(); + seed(&conn, s("cmd_a", "proj", 1_000, 0, 100, "[]", "s1")); + seed(&conn, s("cmd_b", "proj", 2_000, 0, 200, "[]", "s1")); + seed(&conn, s("cmd_c", "proj", 3_000, 0, 300, "[]", "s1")); + let args = default_args(); + let rows = execute(&args, &conn, FIXED_NOW).unwrap(); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0].command, "cmd_c"); + assert_eq!(rows[2].command, "cmd_a"); + } + + #[test] + fn execute_since_filter() { + let conn = mem_conn(); + seed(&conn, s("old_cmd", "p", 100, 0, 100, "[]", "s1")); + seed(&conn, s("new_cmd", "p", 1_700_000_000, 0, 100, "[]", "s1")); + let args = SearchArgs { + since: Some("2020-01-01".into()), + ..default_args() + }; + let rows = execute(&args, &conn, FIXED_NOW).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].command, "new_cmd"); + } + + #[test] + fn execute_limit() { + let conn = mem_conn(); + for i in 0i64..5 { + seed( + &conn, + s(&format!("cmd_{i}"), "p", i * 1000, 0, 100, "[]", "s1"), + ); + } + let args = SearchArgs { + limit: Some(2), + ..default_args() + }; + let rows = execute(&args, &conn, FIXED_NOW).unwrap(); + assert_eq!(rows.len(), 2); + } + + #[test] + fn fmt_timestamp_known_value() { + let result = fmt_timestamp(1_700_000_000); + assert_eq!(result, "2023-11-14 22:13"); + } + + #[test] + fn fmt_duration_ms() { + assert_eq!(fmt_duration(500), "500ms"); + } + + #[test] + fn fmt_duration_exactly_1s() { + assert_eq!(fmt_duration(1000), "1s"); + } + + #[test] + fn fmt_duration_2s() { + assert_eq!(fmt_duration(2000), "2s"); + } + + #[test] + fn fmt_duration_decimal() { + assert_eq!(fmt_duration(2300), "2.3s"); + } + + #[test] + fn exit_zero_cell_is_ok() { + let cell = exit_cell(0); + assert_eq!(cell.content(), "ok"); + } + + #[test] + fn exit_nonzero_cell_is_fail() { + let cell = exit_cell(1); + assert_eq!(cell.content(), "fail"); + } + + fn fixture_rows() -> Vec { + vec![ + CommandRow { + id: 1, + timestamp: 1_700_000_000, + project: "alpha".into(), + tags: r#"["rust"]"#.into(), + exit_code: 0, + duration_ms: 500, + directory: "/home/user/alpha".into(), + command: "cargo build".into(), + session_id: "session-aaa".into(), + workspace: None, + }, + CommandRow { + id: 2, + timestamp: 1_700_001_000, + project: "beta".into(), + tags: "[]".into(), + exit_code: 1, + duration_ms: 2300, + directory: "/home/user/beta".into(), + command: "make test".into(), + session_id: "session-bbb".into(), + workspace: None, + }, + ] + } + + fn default_columns() -> Vec { + resolve_columns(&crate::config::default_search_columns()).unwrap() + } + + #[test] + fn render_normal_contains_headers_and_commands() { + let rows = fixture_rows(); + let cols = default_columns(); + let out = render(&rows, &cols, false); + assert!(out.contains("timestamp")); + assert!(out.contains("command")); + assert!(out.contains("project")); + assert!(out.contains("cargo build")); + assert!(out.contains("make test")); + assert!(out.contains("2 result(s)")); + } + + #[test] + fn render_show_session_reduced_columns_two_sessions() { + let rows = vec![ + CommandRow { + id: 1, + timestamp: 1_700_000_000, + project: "alpha".into(), + tags: r#"["rust"]"#.into(), + exit_code: 0, + duration_ms: 100, + directory: "/home/user/alpha".into(), + command: "cmd_a".into(), + session_id: "session-aaa".into(), + workspace: None, + }, + CommandRow { + id: 2, + timestamp: 1_700_001_000, + project: "alpha".into(), + tags: "[]".into(), + exit_code: 0, + duration_ms: 200, + directory: "/home/user/alpha".into(), + command: "cmd_b".into(), + session_id: "session-aaa".into(), + workspace: None, + }, + CommandRow { + id: 3, + timestamp: 1_700_002_000, + project: "beta".into(), + tags: "[]".into(), + exit_code: 1, + duration_ms: 500, + directory: "/home/user/beta".into(), + command: "cmd_c".into(), + session_id: "session-bbb".into(), + workspace: None, + }, + ]; + let out = render(&rows, &default_columns(), true); + let headers_count = out.matches("---").count(); + assert!(headers_count >= 2, "expected two session header lines"); + let header_lines: Vec<&str> = out.lines().filter(|l| l.starts_with("---")).collect(); + assert_eq!( + header_lines.len(), + 2, + "expected exactly two session headers" + ); + assert!( + header_lines[0].contains("alpha"), + "first header should include project alpha" + ); + assert!( + header_lines[1].contains("beta"), + "second header should include project beta" + ); + assert!(out.contains("cmd_a")); + assert!(out.contains("cmd_b")); + assert!(out.contains("cmd_c")); + assert!(out.contains("timestamp")); + assert!(out.contains("exit")); + assert!(out.contains("duration")); + assert!(out.contains("command")); + assert!(!out.contains("tags")); + assert!(!out.contains("directory")); + assert!(out.contains("3 result(s)")); + let sub_table_lines: Vec<&str> = out + .lines() + .filter(|l| !l.starts_with("---") && l.contains("project")) + .collect(); + assert!( + sub_table_lines.is_empty(), + "project must not appear as a sub-table column header; offending lines: {sub_table_lines:?}" + ); + } + + #[test] + fn render_empty_rows_does_not_panic() { + let cols = default_columns(); + let out = render(&[], &cols, false); + assert!( + out.contains("0 result(s)"), + "empty render must show '0 result(s)'; got: {out}" + ); + } + + #[test] + fn compile_filters_valid_patterns_compile() { + let patterns = vec!["^secret".to_string(), "foo.*bar".to_string()]; + let (regexes, invalid) = compile_filters(&patterns); + assert_eq!(regexes.len(), 2); + assert!(invalid.is_empty()); + } + + #[test] + fn compile_filters_invalid_pattern_returned_in_invalid_list() { + let patterns = vec!["^secret".to_string(), "[invalid".to_string()]; + let (regexes, invalid) = compile_filters(&patterns); + assert_eq!(regexes.len(), 1); + assert_eq!(invalid.len(), 1); + assert_eq!(invalid[0], "[invalid"); + } + + #[test] + fn is_filtered_matches_when_pattern_matches() { + let (regexes, _) = compile_filters(&["^secret".to_string()]); + assert!(is_filtered("secret command", ®exes)); + } + + #[test] + fn is_filtered_does_not_match_when_no_pattern_matches() { + let (regexes, _) = compile_filters(&["^secret".to_string()]); + assert!(!is_filtered("ls -la", ®exes)); + } + + #[test] + fn is_filtered_empty_regexes_never_matches() { + assert!(!is_filtered("anything", &[])); + } + + #[test] + fn resolve_columns_subset_returns_those_columns() { + let names = vec!["command".to_string(), "exit".to_string()]; + let cols = resolve_columns(&names).unwrap(); + assert_eq!(cols.len(), 2); + assert_eq!(cols[0].header(), "command"); + assert_eq!(cols[1].header(), "exit"); + } + + #[test] + fn resolve_columns_unknown_name_returns_err_listing_valid_names() { + let names = vec!["typo".to_string()]; + let err = resolve_columns(&names).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("typo")); + assert!(msg.contains("timestamp")); + assert!(msg.contains("command")); + } + + #[test] + fn resolve_columns_default_returns_all_seven() { + let cols = default_columns(); + assert_eq!(cols.len(), 7); + } + + #[test] + fn render_with_subset_columns_shows_only_those_headers() { + let rows = fixture_rows(); + let names = vec!["command".to_string(), "exit".to_string()]; + let cols = resolve_columns(&names).unwrap(); + let out = render(&rows, &cols, false); + assert!(out.contains("command")); + assert!(out.contains("exit")); + assert!(!out.contains("timestamp")); + assert!(!out.contains("project")); + assert!(!out.contains("tags")); + assert!(!out.contains("duration")); + assert!(!out.contains("directory")); + } + + #[test] + fn is_filtered_integration_with_search_results() { + let conn = mem_conn(); + seed(&conn, s("secret password", "p", 1_000, 0, 100, "[]", "s1")); + seed(&conn, s("ls -la", "p", 2_000, 0, 100, "[]", "s1")); + let args = default_args(); + let rows = execute(&args, &conn, FIXED_NOW).unwrap(); + let (regexes, _) = compile_filters(&["^secret".to_string()]); + let visible: Vec<_> = rows + .iter() + .filter(|r| !is_filtered(&r.command, ®exes)) + .collect(); + assert_eq!(visible.len(), 1); + assert_eq!(visible[0].command, "ls -la"); + } +} diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..03cf3df --- /dev/null +++ b/src/session.rs @@ -0,0 +1,127 @@ +use crate::error::ThothError; +use rusqlite::Connection; + +pub fn get_or_create( + project: &str, + timestamp: i64, + gap_minutes: i64, + conn: &Connection, +) -> Result { + let gap_seconds = gap_minutes * 60; + + let row: Option<(String, String, i64)> = conn + .query_row( + "SELECT session_id, project, ended_at FROM sessions ORDER BY ended_at DESC LIMIT 1", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .ok(); + + let create_new = match &row { + None => true, + Some((_, proj, ended_at)) => (timestamp - ended_at) > gap_seconds || proj != project, + }; + + if create_new { + let sid = uuid::Uuid::new_v4().to_string(); + conn.execute( + "INSERT INTO sessions(session_id, project, started_at, ended_at, command_count) VALUES(?1, ?2, ?3, ?4, 0)", + rusqlite::params![sid, project, timestamp, timestamp], + )?; + Ok(sid) + } else if let Some((sid, _, _)) = row { + conn.execute( + "UPDATE sessions SET ended_at=?1 WHERE session_id=?2", + rusqlite::params![timestamp, sid], + )?; + Ok(sid) + } else { + let sid = uuid::Uuid::new_v4().to_string(); + conn.execute( + "INSERT INTO sessions(session_id, project, started_at, ended_at, command_count) VALUES(?1, ?2, ?3, ?4, 0)", + rusqlite::params![sid, project, timestamp, timestamp], + )?; + Ok(sid) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mem_conn() -> Connection { + let mut c = crate::database::connect_memory().unwrap(); + crate::database::apply_migrations(&mut c).unwrap(); + c + } + + const DEFAULT_GAP: i64 = 30; + + #[test] + fn first_ever_creates_session() { + let conn = mem_conn(); + let t0 = 1700000000i64; + let sid = get_or_create("app", t0, DEFAULT_GAP, &conn).unwrap(); + assert!(!sid.is_empty()); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM sessions", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + } + + #[test] + fn reuse_within_gap() { + let conn = mem_conn(); + let t0 = 1700000000i64; + let sid1 = get_or_create("app", t0, DEFAULT_GAP, &conn).unwrap(); + let t1 = t0 + 600; + let sid2 = get_or_create("app", t1, DEFAULT_GAP, &conn).unwrap(); + assert_eq!(sid1, sid2); + } + + #[test] + fn new_session_on_gap() { + let conn = mem_conn(); + let t0 = 1700000000i64; + let sid1 = get_or_create("app", t0, DEFAULT_GAP, &conn).unwrap(); + let t1 = t0 + 31 * 60; + let sid2 = get_or_create("app", t1, DEFAULT_GAP, &conn).unwrap(); + assert_ne!(sid1, sid2); + } + + #[test] + fn new_session_on_project_change() { + let conn = mem_conn(); + let t0 = 1700000000i64; + let sid1 = get_or_create("app", t0, DEFAULT_GAP, &conn).unwrap(); + let t1 = t0 + 300; + let sid2 = get_or_create("other-lib", t1, DEFAULT_GAP, &conn).unwrap(); + assert_ne!(sid1, sid2); + } + + #[test] + fn custom_gap_splits_at_that_threshold() { + let conn = mem_conn(); + let t0 = 1700000000i64; + let sid1 = get_or_create("app", t0, 15, &conn).unwrap(); + let t1 = t0 + 16 * 60; + let sid2 = get_or_create("app", t1, 15, &conn).unwrap(); + assert_ne!( + sid1, sid2, + "16 min gap must create new session with gap_minutes=15" + ); + } + + #[test] + fn custom_gap_reuses_within_threshold() { + let conn = mem_conn(); + let t0 = 1700000000i64; + let sid1 = get_or_create("app", t0, 15, &conn).unwrap(); + let t1 = t0 + 14 * 60; + let sid2 = get_or_create("app", t1, 15, &conn).unwrap(); + assert_eq!( + sid1, sid2, + "14 min gap must reuse session with gap_minutes=15" + ); + } +} diff --git a/src/sessions.rs b/src/sessions.rs new file mode 100644 index 0000000..3fb15bd --- /dev/null +++ b/src/sessions.rs @@ -0,0 +1,394 @@ +use comfy_table::{ContentArrangement, Table}; +use rusqlite::{Connection, ToSql}; + +use crate::error::ThothError; +use crate::search::fmt_timestamp; +use crate::search::parse_date; + +pub struct SessionsArgs { + pub project: Option, + pub since: Option, + pub until: Option, + pub limit: usize, +} + +pub struct SessionRow { + pub id: String, + pub project: String, + pub started_at: i64, + pub ended_at: i64, + pub command_count: i64, + pub tags: Vec, +} + +pub fn build_query( + args: &SessionsArgs, + now: i64, +) -> Result<(String, Vec>), ThothError> { + let mut fragments: Vec = Vec::new(); + let mut params: Vec> = Vec::new(); + + if let Some(ref p) = args.project { + fragments.push("s.project = ?".to_string()); + params.push(Box::new(p.clone())); + } + + if let Some(ref since_str) = args.since { + let ts = parse_date(since_str, now)?; + fragments.push("s.started_at >= ?".to_string()); + params.push(Box::new(ts)); + } + + if let Some(ref until_str) = args.until { + let ts = parse_date(until_str, now)?; + fragments.push("s.started_at <= ?".to_string()); + params.push(Box::new(ts)); + } + + params.push(Box::new(args.limit as i64)); + + let sql = if fragments.is_empty() { + "SELECT s.session_id, s.project, s.started_at, s.ended_at, s.command_count \ + FROM sessions s ORDER BY s.started_at DESC LIMIT ?" + .to_string() + } else { + let where_clause = fragments.join(" AND "); + format!( + "SELECT s.session_id, s.project, s.started_at, s.ended_at, s.command_count \ + FROM sessions s WHERE {where_clause} ORDER BY s.started_at DESC LIMIT ?" + ) + }; + + Ok((sql, params)) +} + +pub fn list_sessions( + conn: &Connection, + args: &SessionsArgs, + now: i64, +) -> Result, ThothError> { + let (sql, params) = build_query(args, now)?; + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map( + rusqlite::params_from_iter(params.iter().map(|p| p.as_ref())), + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, i64>(4)?, + )) + }, + )?; + + let mut result = Vec::new(); + for row in rows { + let (id, project, started_at, ended_at, command_count) = row?; + + let tags = collect_session_tags(conn, &id)?; + + result.push(SessionRow { + id, + project, + started_at, + ended_at, + command_count, + tags, + }); + } + Ok(result) +} + +fn collect_session_tags(conn: &Connection, session_id: &str) -> Result, ThothError> { + let mut stmt = conn.prepare( + "SELECT DISTINCT value FROM commands, json_each(commands.tags) \ + WHERE session_id = ? AND value != '' ORDER BY value", + )?; + let tags: rusqlite::Result> = stmt + .query_map(rusqlite::params![session_id], |row| row.get(0))? + .collect(); + Ok(tags?) +} + +pub fn render(rows: &[SessionRow]) -> String { + if rows.is_empty() { + return String::from("No sessions found.\n"); + } + + let mut table = Table::new(); + table.set_content_arrangement(ContentArrangement::Dynamic); + table.set_header(vec![ + "session", "project", "started", "ended", "commands", "tags", + ]); + + for row in rows { + let short_id = &row.id[..row.id.len().min(8)]; + let tags_str = if row.tags.is_empty() { + String::from("-") + } else { + row.tags.join(",") + }; + table.add_row(vec![ + short_id.to_string(), + row.project.clone(), + fmt_timestamp(row.started_at), + fmt_timestamp(row.ended_at), + row.command_count.to_string(), + tags_str, + ]); + } + + format!("{table}\n{} session(s)\n", rows.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::{apply_migrations, connect_memory}; + + const FIXED_NOW: i64 = 1_700_000_000; + + fn mem_conn() -> Connection { + let mut c = connect_memory().unwrap(); + apply_migrations(&mut c).unwrap(); + c + } + + fn default_args() -> SessionsArgs { + SessionsArgs { + project: None, + since: None, + until: None, + limit: 20, + } + } + + fn insert_session(conn: &Connection, id: &str, project: &str, started: i64, ended: i64) { + conn.execute( + "INSERT INTO sessions(session_id, project, started_at, ended_at, command_count) \ + VALUES(?1, ?2, ?3, ?4, 0)", + rusqlite::params![id, project, started, ended], + ) + .unwrap(); + } + + fn insert_command(conn: &Connection, session_id: &str, project: &str, ts: i64, tags: &str) { + conn.execute( + "INSERT INTO commands(command, directory, project, session_id, timestamp, exit_code, duration_ms, tags) \ + VALUES('cmd', '/tmp', ?1, ?2, ?3, 0, 100, ?4)", + rusqlite::params![project, session_id, ts, tags], + ) + .unwrap(); + conn.execute( + "UPDATE sessions SET command_count = command_count + 1, ended_at = MAX(ended_at, ?1) \ + WHERE session_id = ?2", + rusqlite::params![ts, session_id], + ) + .unwrap(); + } + + #[test] + fn build_query_no_filters_has_no_where() { + let args = default_args(); + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + assert!(!sql.contains("WHERE")); + assert!(sql.contains("ORDER BY s.started_at DESC")); + assert!(sql.contains("LIMIT ?")); + assert_eq!(params.len(), 1); + } + + #[test] + fn build_query_project_filter() { + let args = SessionsArgs { + project: Some("myapp".into()), + ..default_args() + }; + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + assert!(sql.contains("s.project = ?")); + assert_eq!(params.len(), 2); + } + + #[test] + fn build_query_since_filter() { + let args = SessionsArgs { + since: Some("today".into()), + ..default_args() + }; + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + assert!(sql.contains("s.started_at >= ?")); + assert_eq!(params.len(), 2); + } + + #[test] + fn build_query_until_filter() { + let args = SessionsArgs { + until: Some("today".into()), + ..default_args() + }; + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + assert!(sql.contains("s.started_at <= ?")); + assert_eq!(params.len(), 2); + } + + #[test] + fn build_query_combined_filters() { + let args = SessionsArgs { + project: Some("app".into()), + since: Some("today".into()), + until: Some("today".into()), + limit: 5, + }; + let (sql, params) = build_query(&args, FIXED_NOW).unwrap(); + assert!(sql.contains("s.project = ?")); + assert!(sql.contains("s.started_at >= ?")); + assert!(sql.contains("s.started_at <= ?")); + assert_eq!(params.len(), 4); + } + + #[test] + fn list_sessions_ordered_newest_first() { + let conn = mem_conn(); + insert_session(&conn, "sid-old", "app", 1_000, 2_000); + insert_session(&conn, "sid-new", "app", 3_000, 4_000); + let args = default_args(); + let rows = list_sessions(&conn, &args, FIXED_NOW).unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].id, "sid-new"); + assert_eq!(rows[1].id, "sid-old"); + } + + #[test] + fn list_sessions_command_count_correct() { + let conn = mem_conn(); + insert_session(&conn, "s1", "app", 1_000, 5_000); + insert_command(&conn, "s1", "app", 1_000, "[]"); + insert_command(&conn, "s1", "app", 2_000, "[]"); + insert_command(&conn, "s1", "app", 3_000, "[]"); + let args = default_args(); + let rows = list_sessions(&conn, &args, FIXED_NOW).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].command_count, 3); + } + + #[test] + fn list_sessions_tags_union_deduped() { + let conn = mem_conn(); + insert_session(&conn, "s1", "app", 1_000, 5_000); + insert_command(&conn, "s1", "app", 1_000, r#"["a"]"#); + insert_command(&conn, "s1", "app", 2_000, r#"["a","b"]"#); + let args = default_args(); + let rows = list_sessions(&conn, &args, FIXED_NOW).unwrap(); + assert_eq!(rows[0].tags, vec!["a", "b"]); + } + + #[test] + fn list_sessions_empty_tags_gives_empty_vec() { + let conn = mem_conn(); + insert_session(&conn, "s1", "app", 1_000, 2_000); + insert_command(&conn, "s1", "app", 1_000, "[]"); + let args = default_args(); + let rows = list_sessions(&conn, &args, FIXED_NOW).unwrap(); + assert!(rows[0].tags.is_empty()); + } + + #[test] + fn list_sessions_project_filter() { + let conn = mem_conn(); + insert_session(&conn, "s-alpha", "alpha", 1_000, 2_000); + insert_session(&conn, "s-beta", "beta", 3_000, 4_000); + let args = SessionsArgs { + project: Some("alpha".into()), + ..default_args() + }; + let rows = list_sessions(&conn, &args, FIXED_NOW).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].project, "alpha"); + } + + #[test] + fn list_sessions_limit_respected() { + let conn = mem_conn(); + for i in 0i64..5 { + insert_session(&conn, &format!("sid-{i}"), "app", i * 1000, i * 1000 + 100); + } + let args = SessionsArgs { + limit: 2, + ..default_args() + }; + let rows = list_sessions(&conn, &args, FIXED_NOW).unwrap(); + assert_eq!(rows.len(), 2); + } + + #[test] + fn render_empty_shows_no_sessions_found() { + let out = render(&[]); + assert_eq!(out, "No sessions found.\n"); + } + + #[test] + fn render_shows_headers_and_footer() { + let rows = vec![SessionRow { + id: "abcdefgh-1234-5678-90ab-cdef01234567".into(), + project: "myapp".into(), + started_at: 1_700_000_000, + ended_at: 1_700_003_600, + command_count: 5, + tags: vec!["rust".into(), "cli".into()], + }]; + let out = render(&rows); + assert!(out.contains("session"), "missing session header"); + assert!(out.contains("project"), "missing project header"); + assert!(out.contains("started"), "missing started header"); + assert!(out.contains("ended"), "missing ended header"); + assert!(out.contains("commands"), "missing commands header"); + assert!(out.contains("tags"), "missing tags header"); + assert!(out.contains("1 session(s)"), "missing footer"); + } + + #[test] + fn render_short_id_is_first_8_chars() { + let rows = vec![SessionRow { + id: "abcdefgh-1234".into(), + project: "p".into(), + started_at: 1_700_000_000, + ended_at: 1_700_000_000, + command_count: 1, + tags: vec![], + }]; + let out = render(&rows); + assert!(out.contains("abcdefgh"), "expected 8-char prefix"); + assert!( + !out.contains("abcdefgh-"), + "must not show more than 8 chars from id" + ); + } + + #[test] + fn render_empty_tags_shows_dash() { + let rows = vec![SessionRow { + id: "aaaaaaaa".into(), + project: "p".into(), + started_at: 1_700_000_000, + ended_at: 1_700_000_000, + command_count: 0, + tags: vec![], + }]; + let out = render(&rows); + assert!(out.contains('-'), "empty tags must render as dash"); + } + + #[test] + fn render_tags_joined_by_comma() { + let rows = vec![SessionRow { + id: "aaaaaaaa".into(), + project: "p".into(), + started_at: 1_700_000_000, + ended_at: 1_700_000_000, + command_count: 2, + tags: vec!["a".into(), "b".into()], + }]; + let out = render(&rows); + assert!(out.contains("a,b"), "tags must be joined by comma"); + } +} diff --git a/src/stats.rs b/src/stats.rs new file mode 100644 index 0000000..aefeebb --- /dev/null +++ b/src/stats.rs @@ -0,0 +1,584 @@ +use rusqlite::{Connection, ToSql}; + +use crate::error::ThothError; +use crate::search::parse_date; + +const TOP_PROJECTS_LIMIT: usize = 5; +const TOP_COMMANDS_LIMIT: usize = 10; +const TOP_ERROR_TOOLS_LIMIT: usize = 5; +const MIN_TOOL_COUNT: i64 = 5; +const HISTOGRAM_BAR_WIDTH: usize = 30; +const SECS_PER_DAY: i64 = 86_400; +const SECS_PER_HOUR: i64 = 3_600; + +pub struct StatsArgs { + pub project: Option, + pub since: Option, +} + +pub struct Stats { + pub total: i64, + pub distinct_projects: i64, + pub ok_count: i64, + pub top_projects: Vec<(String, i64)>, + pub top_commands: Vec<(String, i64)>, + pub hour_counts: [i64; 24], + pub error_tools: Vec<(String, i64, i64)>, +} + +pub fn first_token(cmd: &str) -> &str { + let trimmed = cmd.trim_start(); + match trimmed.find(|c: char| c.is_whitespace()) { + Some(pos) => &trimmed[..pos], + None => trimmed, + } +} + +struct FilterClause { + where_sql: String, + params: Vec>, +} + +fn build_filter(args: &StatsArgs, now: i64) -> Result { + let mut fragments: Vec = Vec::new(); + let mut params: Vec> = Vec::new(); + + if let Some(ref p) = args.project { + fragments.push("project = ?".to_string()); + params.push(Box::new(p.clone())); + } + + if let Some(ref since_str) = args.since { + let ts = parse_date(since_str, now)?; + fragments.push("timestamp >= ?".to_string()); + params.push(Box::new(ts)); + } + + let where_sql = if fragments.is_empty() { + String::new() + } else { + format!("WHERE {}", fragments.join(" AND ")) + }; + + Ok(FilterClause { where_sql, params }) +} + +pub fn compute(conn: &Connection, args: &StatsArgs, now: i64) -> Result { + let filter = build_filter(args, now)?; + + let total: i64 = conn.query_row( + &format!("SELECT COUNT(*) FROM commands {}", filter.where_sql), + rusqlite::params_from_iter(filter.params.iter().map(|p| p.as_ref())), + |r| r.get(0), + )?; + + let distinct_projects: i64 = conn.query_row( + &format!( + "SELECT COUNT(DISTINCT project) FROM commands {}", + filter.where_sql + ), + rusqlite::params_from_iter(filter.params.iter().map(|p| p.as_ref())), + |r| r.get(0), + )?; + + let ok_count: i64 = { + let ok_filter = rebuild_params(args, now)?; + let mut extra_fragments = Vec::new(); + if !ok_filter.where_sql.is_empty() { + extra_fragments.push(ok_filter.where_sql.trim_start_matches("WHERE ").to_string()); + } + extra_fragments.push("exit_code = 0".to_string()); + let where_sql = format!("WHERE {}", extra_fragments.join(" AND ")); + conn.query_row( + &format!("SELECT COUNT(*) FROM commands {where_sql}"), + rusqlite::params_from_iter(ok_filter.params.iter().map(|p| p.as_ref())), + |r| r.get(0), + )? + }; + + let top_projects: Vec<(String, i64)> = { + let filter2 = rebuild_params(args, now)?; + let sql = format!( + "SELECT project, COUNT(*) as cnt FROM commands {} GROUP BY project ORDER BY cnt DESC LIMIT {}", + filter2.where_sql, + TOP_PROJECTS_LIMIT + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map( + rusqlite::params_from_iter(filter2.params.iter().map(|p| p.as_ref())), + |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)), + )?; + let mut result = Vec::new(); + for row in rows { + result.push(row?); + } + result + }; + + let raw_rows: Vec<(String, i64, i64)> = { + let filter3 = rebuild_params(args, now)?; + let sql = format!( + "SELECT command, exit_code, timestamp FROM commands {}", + filter3.where_sql + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map( + rusqlite::params_from_iter(filter3.params.iter().map(|p| p.as_ref())), + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + )) + }, + )?; + let mut result = Vec::new(); + for row in rows { + result.push(row?); + } + result + }; + + let mut cmd_counts: std::collections::HashMap = std::collections::HashMap::new(); + let mut hour_counts = [0i64; 24]; + let mut tool_fails: std::collections::HashMap = + std::collections::HashMap::new(); + + for (cmd, exit_code, ts) in &raw_rows { + *cmd_counts.entry(cmd.clone()).or_insert(0) += 1; + + let hour = (ts.rem_euclid(SECS_PER_DAY) / SECS_PER_HOUR) as usize; + if hour < 24 { + hour_counts[hour] += 1; + } + + let tool = first_token(cmd).to_string(); + let entry = tool_fails.entry(tool).or_insert((0, 0)); + entry.1 += 1; + if *exit_code != 0 { + entry.0 += 1; + } + } + + let mut top_commands: Vec<(String, i64)> = cmd_counts.into_iter().collect(); + top_commands.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); + top_commands.truncate(TOP_COMMANDS_LIMIT); + + let mut error_tools: Vec<(String, i64, i64)> = tool_fails + .into_iter() + .filter(|(_, (_, total))| *total >= MIN_TOOL_COUNT) + .map(|(tool, (fails, total))| (tool, fails, total)) + .collect(); + error_tools.sort_by(|a, b| { + let rate_a = a.1 as f64 / a.2 as f64; + let rate_b = b.1 as f64 / b.2 as f64; + rate_b + .partial_cmp(&rate_a) + .unwrap_or(std::cmp::Ordering::Equal) + .then(a.0.cmp(&b.0)) + }); + error_tools.truncate(TOP_ERROR_TOOLS_LIMIT); + + Ok(Stats { + total, + distinct_projects, + ok_count, + top_projects, + top_commands, + hour_counts, + error_tools, + }) +} + +fn rebuild_params(args: &StatsArgs, now: i64) -> Result { + build_filter(args, now) +} + +pub fn render(stats: &Stats) -> String { + let mut out = String::new(); + + out.push_str("=== Overview ===\n"); + if stats.total == 0 { + out.push_str("No data recorded yet.\n"); + out.push('\n'); + out.push_str("=== Top Projects ===\n"); + out.push_str("(no data)\n"); + out.push('\n'); + out.push_str("=== Top Commands ===\n"); + out.push_str("(no data)\n"); + out.push('\n'); + out.push_str("=== Busiest Hour of Day (UTC) ===\n"); + for h in 0..24usize { + out.push_str(&format!("{h:02} | \n")); + } + out.push('\n'); + out.push_str("=== Tools with Highest Error Rate ===\n"); + out.push_str("(no data)\n"); + return out; + } + + let success_rate = (stats.ok_count as f64 / stats.total as f64) * 100.0; + out.push_str(&format!("Total commands: {}\n", stats.total)); + out.push_str(&format!("Active projects: {}\n", stats.distinct_projects)); + out.push_str(&format!("Global success: {:.1}%\n", success_rate)); + out.push('\n'); + + out.push_str("=== Top Projects ===\n"); + for (project, count) in &stats.top_projects { + out.push_str(&format!(" {project:<30} {count}\n")); + } + out.push('\n'); + + out.push_str("=== Top Commands ===\n"); + for (cmd, count) in &stats.top_commands { + out.push_str(&format!(" {cmd:<40} {count}\n")); + } + out.push('\n'); + + out.push_str("=== Busiest Hour of Day (UTC) ===\n"); + let max_hour = *stats.hour_counts.iter().max().unwrap_or(&0); + for h in 0..24usize { + let count = stats.hour_counts[h]; + let bar_len = if max_hour > 0 { + (count as usize * HISTOGRAM_BAR_WIDTH) / max_hour as usize + } else { + 0 + }; + let bar: String = "#".repeat(bar_len); + out.push_str(&format!( + "{h:02} | {bar: Connection { + let mut c = connect_memory().unwrap(); + apply_migrations(&mut c).unwrap(); + c + } + + fn insert(conn: &Connection, cmd: &str, project: &str, ts: i64, exit_code: i64) { + conn.execute( + "INSERT INTO commands(command, directory, project, session_id, timestamp, exit_code, duration_ms, tags) \ + VALUES(?1, '/tmp', ?2, 's1', ?3, ?4, 100, '[]')", + rusqlite::params![cmd, project, ts, exit_code], + ) + .unwrap(); + } + + const NOW: i64 = 1_700_000_000; + + #[test] + fn first_token_normal() { + assert_eq!(first_token("git status"), "git"); + } + + #[test] + fn first_token_leading_spaces() { + assert_eq!(first_token(" cargo build"), "cargo"); + } + + #[test] + fn first_token_empty() { + assert_eq!(first_token(""), ""); + } + + #[test] + fn first_token_single_word() { + assert_eq!(first_token("ls"), "ls"); + } + + #[test] + fn hour_bucket_maps_correctly() { + let ts_hour_14: i64 = 14 * SECS_PER_HOUR; + let hour = (ts_hour_14.rem_euclid(SECS_PER_DAY) / SECS_PER_HOUR) as usize; + assert_eq!(hour, 14); + } + + #[test] + fn hour_bucket_midnight() { + let ts: i64 = 0; + let hour = (ts.rem_euclid(SECS_PER_DAY) / SECS_PER_HOUR) as usize; + assert_eq!(hour, 0); + } + + #[test] + fn compute_total_and_distinct_projects() { + let conn = mem_conn(); + insert(&conn, "git status", "alpha", NOW - 100, 0); + insert(&conn, "cargo build", "alpha", NOW - 200, 0); + insert(&conn, "docker ps", "beta", NOW - 300, 1); + + let args = StatsArgs { + project: None, + since: None, + }; + let stats = compute(&conn, &args, NOW).unwrap(); + assert_eq!(stats.total, 3); + assert_eq!(stats.distinct_projects, 2); + } + + #[test] + fn compute_success_rate() { + let conn = mem_conn(); + insert(&conn, "cmd_ok", "p", NOW - 100, 0); + insert(&conn, "cmd_ok2", "p", NOW - 200, 0); + insert(&conn, "cmd_fail", "p", NOW - 300, 1); + + let args = StatsArgs { + project: None, + since: None, + }; + let stats = compute(&conn, &args, NOW).unwrap(); + assert_eq!(stats.ok_count, 2); + assert_eq!(stats.total, 3); + } + + #[test] + fn compute_top_projects_order() { + let conn = mem_conn(); + insert(&conn, "cmd", "alpha", NOW - 100, 0); + insert(&conn, "cmd", "alpha", NOW - 200, 0); + insert(&conn, "cmd", "alpha", NOW - 300, 0); + insert(&conn, "cmd", "beta", NOW - 400, 0); + insert(&conn, "cmd", "beta", NOW - 500, 0); + insert(&conn, "cmd", "gamma", NOW - 600, 0); + + let args = StatsArgs { + project: None, + since: None, + }; + let stats = compute(&conn, &args, NOW).unwrap(); + assert_eq!(stats.top_projects[0].0, "alpha"); + assert_eq!(stats.top_projects[0].1, 3); + assert_eq!(stats.top_projects[1].0, "beta"); + } + + #[test] + fn compute_top_commands_order() { + let conn = mem_conn(); + for _ in 0..5 { + insert(&conn, "git status", "p", NOW - 100, 0); + } + for _ in 0..3 { + insert(&conn, "cargo build", "p", NOW - 200, 0); + } + insert(&conn, "ls", "p", NOW - 300, 0); + + let args = StatsArgs { + project: None, + since: None, + }; + let stats = compute(&conn, &args, NOW).unwrap(); + assert_eq!(stats.top_commands[0].0, "git status"); + assert_eq!(stats.top_commands[0].1, 5); + assert_eq!(stats.top_commands[1].0, "cargo build"); + } + + #[test] + fn compute_hour_counts() { + let conn = mem_conn(); + let ts_h10 = 10 * SECS_PER_HOUR; + let ts_h14 = 14 * SECS_PER_HOUR; + insert(&conn, "cmd_a", "p", ts_h10, 0); + insert(&conn, "cmd_b", "p", ts_h10 + 60, 0); + insert(&conn, "cmd_c", "p", ts_h14, 0); + + let args = StatsArgs { + project: None, + since: None, + }; + let stats = compute(&conn, &args, NOW).unwrap(); + assert_eq!(stats.hour_counts[10], 2); + assert_eq!(stats.hour_counts[14], 1); + assert_eq!(stats.hour_counts[0], 0); + } + + #[test] + fn compute_error_tools_excludes_below_min_count() { + let conn = mem_conn(); + for _ in 0..4 { + insert(&conn, "rare_tool cmd", "p", NOW - 100, 1); + } + for _ in 0..6 { + insert(&conn, "cargo build", "p", NOW - 200, 1); + } + + let args = StatsArgs { + project: None, + since: None, + }; + let stats = compute(&conn, &args, NOW).unwrap(); + let tools: Vec<&str> = stats + .error_tools + .iter() + .map(|(t, _, _)| t.as_str()) + .collect(); + assert!( + !tools.contains(&"rare_tool"), + "rare_tool has only 4 occurrences, below MIN_TOOL_COUNT=5" + ); + assert!(tools.contains(&"cargo")); + } + + #[test] + fn compute_error_tools_rate() { + let conn = mem_conn(); + for _ in 0..5 { + insert(&conn, "docker ps", "p", NOW - 100, 0); + } + for _ in 0..5 { + insert(&conn, "docker run", "p", NOW - 200, 1); + } + + let args = StatsArgs { + project: None, + since: None, + }; + let stats = compute(&conn, &args, NOW).unwrap(); + let docker = stats.error_tools.iter().find(|(t, _, _)| t == "docker"); + assert!(docker.is_some()); + let (_, fails, total) = docker.unwrap(); + assert_eq!(*fails, 5); + assert_eq!(*total, 10); + } + + #[test] + fn compute_project_filter() { + let conn = mem_conn(); + insert(&conn, "cmd", "alpha", NOW - 100, 0); + insert(&conn, "cmd", "alpha", NOW - 200, 0); + insert(&conn, "cmd", "beta", NOW - 300, 1); + + let args = StatsArgs { + project: Some("alpha".into()), + since: None, + }; + let stats = compute(&conn, &args, NOW).unwrap(); + assert_eq!(stats.total, 2); + assert_eq!(stats.ok_count, 2); + } + + #[test] + fn compute_since_filter() { + let conn = mem_conn(); + insert(&conn, "old_cmd", "p", 1000, 0); + insert(&conn, "new_cmd", "p", NOW - 100, 0); + + let args = StatsArgs { + project: None, + since: Some("2020-01-01".into()), + }; + let stats = compute(&conn, &args, NOW).unwrap(); + assert_eq!(stats.total, 1); + assert_eq!(stats.top_commands[0].0, "new_cmd"); + } + + #[test] + fn render_contains_section_headers() { + let stats = Stats { + total: 10, + distinct_projects: 2, + ok_count: 8, + top_projects: vec![("alpha".into(), 7), ("beta".into(), 3)], + top_commands: vec![("git status".into(), 5)], + hour_counts: { + let mut h = [0i64; 24]; + h[14] = 3; + h + }, + error_tools: vec![("cargo".into(), 2, 6)], + }; + let out = render(&stats); + assert!(out.contains("=== Overview ===")); + assert!(out.contains("=== Top Projects ===")); + assert!(out.contains("=== Top Commands ===")); + assert!(out.contains("=== Busiest Hour of Day (UTC) ===")); + assert!(out.contains("=== Tools with Highest Error Rate ===")); + } + + #[test] + fn render_success_rate_format() { + let stats = Stats { + total: 3, + distinct_projects: 1, + ok_count: 2, + top_projects: vec![], + top_commands: vec![], + hour_counts: [0i64; 24], + error_tools: vec![], + }; + let out = render(&stats); + assert!(out.contains("66.7%")); + } + + #[test] + fn render_histogram_bar_line() { + let mut hour_counts = [0i64; 24]; + hour_counts[14] = 10; + let stats = Stats { + total: 10, + distinct_projects: 1, + ok_count: 10, + top_projects: vec![], + top_commands: vec![], + hour_counts, + error_tools: vec![], + }; + let out = render(&stats); + let line_14 = out.lines().find(|l| l.starts_with("14 |")).unwrap(); + assert!(line_14.contains('#'), "hour 14 should have bar chars"); + assert!(line_14.contains("10"), "hour 14 should show count 10"); + } + + #[test] + fn render_tool_error_line() { + let stats = Stats { + total: 10, + distinct_projects: 1, + ok_count: 5, + top_projects: vec![], + top_commands: vec![], + hour_counts: [0i64; 24], + error_tools: vec![("docker".into(), 3, 6)], + }; + let out = render(&stats); + assert!(out.contains("docker")); + assert!(out.contains("3/6")); + assert!(out.contains("50.0%")); + } + + #[test] + fn render_empty_db_no_panic() { + let stats = Stats { + total: 0, + distinct_projects: 0, + ok_count: 0, + top_projects: vec![], + top_commands: vec![], + hour_counts: [0i64; 24], + error_tools: vec![], + }; + let out = render(&stats); + assert!(out.contains("No data recorded yet.") || out.contains("(no data)")); + } +} diff --git a/src/tags.rs b/src/tags.rs new file mode 100644 index 0000000..6ab0197 --- /dev/null +++ b/src/tags.rs @@ -0,0 +1,209 @@ +use crate::error::ThothError; +use rusqlite::Connection; + +pub fn parse_active(json: &str) -> Vec { + match serde_json::from_str::(json) { + Ok(serde_json::Value::Array(arr)) => arr + .into_iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(), + _ => vec![], + } +} + +pub fn add_tag(json: &str, name: &str) -> String { + let mut tags = parse_active(json); + if !tags.iter().any(|t| t == name) { + tags.push(name.to_string()); + } + serde_json::to_string(&tags).unwrap_or_else(|_| "[]".to_string()) +} + +pub fn remove_tag(json: &str, name: &str) -> String { + let tags: Vec = parse_active(json) + .into_iter() + .filter(|t| t != name) + .collect(); + serde_json::to_string(&tags).unwrap_or_else(|_| "[]".to_string()) +} + +pub fn clear_tags() -> String { + "[]".to_string() +} + +pub fn export_line(json: &str) -> String { + let escaped = json.replace('\'', "'\\''"); + let prompt = format_prompt_segment(json); + let escaped_prompt = prompt.replace('\'', "'\\''"); + format!( + "export TTH_ACTIVE_TAGS='{}'\nexport TTH_PROMPT_TAGS='{}'", + escaped, escaped_prompt + ) +} + +pub fn format_prompt_segment(json: &str) -> String { + let tags = parse_active(json); + tags.iter().map(|t| format!("[{t}]")).collect() +} + +pub fn list_db_tags(conn: &Connection) -> Result, ThothError> { + let mut stmt = conn.prepare( + "SELECT value, COUNT(*) c FROM commands, json_each(commands.tags) \ + GROUP BY value ORDER BY c DESC, value ASC", + )?; + let rows = stmt.query_map([], |row| { + let tag: String = row.get(0)?; + let count: i64 = row.get(1)?; + Ok((tag, count)) + })?; + let mut result = Vec::new(); + for row in rows { + result.push(row?); + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mem_conn() -> Connection { + let mut c = crate::database::connect_memory().unwrap(); + crate::database::apply_migrations(&mut c).unwrap(); + c + } + + #[test] + fn parse_active_empty_string() { + assert_eq!(parse_active(""), Vec::::new()); + } + + #[test] + fn parse_active_invalid_json() { + assert_eq!(parse_active("not json"), Vec::::new()); + } + + #[test] + fn parse_active_empty_array() { + assert_eq!(parse_active("[]"), Vec::::new()); + } + + #[test] + fn parse_active_valid() { + assert_eq!(parse_active(r#"["a","b"]"#), vec!["a", "b"]); + } + + #[test] + fn parse_active_mixed_types_filters_non_strings() { + assert_eq!(parse_active(r#"["a",1,null]"#), vec!["a"]); + } + + #[test] + fn add_tag_to_empty() { + assert_eq!(add_tag("[]", "x"), r#"["x"]"#); + } + + #[test] + fn add_tag_appends() { + assert_eq!(add_tag(r#"["a"]"#, "b"), r#"["a","b"]"#); + } + + #[test] + fn add_tag_dedup_noop() { + assert_eq!(add_tag(r#"["x"]"#, "x"), r#"["x"]"#); + } + + #[test] + fn add_tag_invalid_json_treated_as_empty() { + assert_eq!(add_tag("bad", "x"), r#"["x"]"#); + } + + #[test] + fn remove_tag_removes() { + assert_eq!(remove_tag(r#"["a","b"]"#, "a"), r#"["b"]"#); + } + + #[test] + fn remove_tag_missing_noop() { + assert_eq!(remove_tag(r#"["a"]"#, "z"), r#"["a"]"#); + } + + #[test] + fn remove_tag_last_gives_empty() { + assert_eq!(remove_tag(r#"["a"]"#, "a"), "[]"); + } + + #[test] + fn clear_tags_gives_empty_array() { + assert_eq!(clear_tags(), "[]"); + } + + #[test] + fn export_line_basic() { + let line = export_line(r#"["x"]"#); + assert!(line.contains("export TTH_ACTIVE_TAGS='[\"x\"]'")); + assert!(line.contains("export TTH_PROMPT_TAGS='[x]'")); + } + + #[test] + fn export_line_empty() { + let line = export_line("[]"); + assert!(line.contains("export TTH_ACTIVE_TAGS='[]'")); + assert!(line.contains("export TTH_PROMPT_TAGS=''")); + } + + #[test] + fn export_line_escapes_single_quote() { + let json = r#"["it's"]"#; + let line = export_line(json); + assert!(line.contains("'\\''")); + } + + #[test] + fn format_prompt_segment_empty() { + assert_eq!(format_prompt_segment("[]"), ""); + } + + #[test] + fn format_prompt_segment_single() { + assert_eq!(format_prompt_segment(r#"["a"]"#), "[a]"); + } + + #[test] + fn format_prompt_segment_multiple() { + assert_eq!(format_prompt_segment(r#"["a","b"]"#), "[a][b]"); + } + + #[test] + fn list_db_tags_empty_db() { + let conn = mem_conn(); + let tags = list_db_tags(&conn).unwrap(); + assert!(tags.is_empty()); + } + + #[test] + fn list_db_tags_counts_and_order() { + let mut conn = mem_conn(); + let args_base = crate::cli::RecordArgs { + cmd: "test".into(), + dir: Some("/tmp".into()), + exit_code: 0, + duration: 0, + timestamp: Some(1000), + tags: r#"["fix","perf"]"#.into(), + terminal_id: None, + workspace: None, + }; + let mut a1 = args_base.clone(); + a1.timestamp = Some(1001); + crate::recorder::record_inner(&a1, 30, &mut conn).unwrap(); + let mut a2 = args_base.clone(); + a2.timestamp = Some(1002); + a2.tags = r#"["fix"]"#.into(); + crate::recorder::record_inner(&a2, 30, &mut conn).unwrap(); + + let tags = list_db_tags(&conn).unwrap(); + assert_eq!(tags[0], ("fix".to_string(), 2)); + assert_eq!(tags[1], ("perf".to_string(), 1)); + } +} diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..24975e1 --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,373 @@ +use ratatui::style::Color; + +use crate::error::ThothError; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Theme { + pub selection_bg: Color, + pub selection_fg: Color, + pub accent: Color, + pub dim: Color, + pub border: Color, + pub ok: Color, + pub fail: Color, + pub project: Color, + pub command: Color, + pub header: Color, + pub controls: Color, + pub directory: Color, + pub tags: Color, +} + +impl Default for Theme { + fn default() -> Self { + default_theme() + } +} + +const fn rgb(r: u8, g: u8, b: u8) -> Color { + Color::Rgb(r, g, b) +} + +fn default_theme() -> Theme { + Theme { + selection_bg: Color::Indexed(147), + selection_fg: Color::Black, + accent: Color::Cyan, + dim: Color::DarkGray, + border: Color::DarkGray, + ok: Color::Green, + fail: Color::Red, + project: Color::Blue, + command: Color::Reset, + header: Color::Green, + controls: Color::Green, + directory: Color::DarkGray, + tags: Color::Reset, + } +} + +fn ember_theme() -> Theme { + Theme { + selection_bg: rgb(0x66, 0x5c, 0x54), + selection_fg: rgb(0xfb, 0xf1, 0xc7), + accent: rgb(0xfa, 0xbd, 0x2f), + dim: rgb(0x92, 0x83, 0x74), + border: rgb(0x92, 0x83, 0x74), + ok: rgb(0xb8, 0xbb, 0x26), + fail: rgb(0xfb, 0x49, 0x34), + project: rgb(0x83, 0xa5, 0x98), + command: rgb(0xeb, 0xdb, 0xb2), + header: rgb(0xfe, 0x80, 0x19), + controls: rgb(0xfe, 0x80, 0x19), + directory: rgb(0x92, 0x83, 0x74), + tags: rgb(0xeb, 0xdb, 0xb2), + } +} + +fn frost_theme() -> Theme { + Theme { + selection_bg: rgb(0x43, 0x4c, 0x5e), + selection_fg: rgb(0xec, 0xef, 0xf4), + accent: rgb(0x88, 0xc0, 0xd0), + dim: rgb(0x4c, 0x56, 0x6a), + border: rgb(0x4c, 0x56, 0x6a), + ok: rgb(0xa3, 0xbe, 0x8c), + fail: rgb(0xbf, 0x61, 0x6a), + project: rgb(0x81, 0xa1, 0xc1), + command: rgb(0xd8, 0xde, 0xe9), + header: rgb(0x8f, 0xbc, 0xbb), + controls: rgb(0x88, 0xc0, 0xd0), + directory: rgb(0x4c, 0x56, 0x6a), + tags: rgb(0xd8, 0xde, 0xe9), + } +} + +fn latte_theme() -> Theme { + Theme { + selection_bg: rgb(0xcc, 0xd0, 0xda), + selection_fg: rgb(0x4c, 0x4f, 0x69), + accent: rgb(0x17, 0x92, 0x99), + dim: rgb(0x9c, 0xa0, 0xb0), + border: rgb(0x9c, 0xa0, 0xb0), + ok: rgb(0x40, 0xa0, 0x2b), + fail: rgb(0xd2, 0x0f, 0x39), + project: rgb(0x1e, 0x66, 0xf5), + command: rgb(0x4c, 0x4f, 0x69), + header: rgb(0x88, 0x39, 0xef), + controls: rgb(0x88, 0x39, 0xef), + directory: rgb(0x9c, 0xa0, 0xb0), + tags: rgb(0x4c, 0x4f, 0x69), + } +} + +fn frappe_theme() -> Theme { + Theme { + selection_bg: rgb(0x41, 0x45, 0x59), + selection_fg: rgb(0xc6, 0xd0, 0xf5), + accent: rgb(0x81, 0xc8, 0xbe), + dim: rgb(0x73, 0x79, 0x94), + border: rgb(0x73, 0x79, 0x94), + ok: rgb(0xa6, 0xd1, 0x89), + fail: rgb(0xe7, 0x82, 0x84), + project: rgb(0x8c, 0xaa, 0xee), + command: rgb(0xc6, 0xd0, 0xf5), + header: rgb(0xca, 0x9e, 0xe6), + controls: rgb(0xca, 0x9e, 0xe6), + directory: rgb(0x73, 0x79, 0x94), + tags: rgb(0xc6, 0xd0, 0xf5), + } +} + +fn macchiato_theme() -> Theme { + Theme { + selection_bg: rgb(0x36, 0x3a, 0x4f), + selection_fg: rgb(0xca, 0xd3, 0xf5), + accent: rgb(0x8b, 0xd5, 0xca), + dim: rgb(0x6e, 0x73, 0x8d), + border: rgb(0x6e, 0x73, 0x8d), + ok: rgb(0xa6, 0xda, 0x95), + fail: rgb(0xed, 0x87, 0x96), + project: rgb(0x8a, 0xad, 0xf4), + command: rgb(0xca, 0xd3, 0xf5), + header: rgb(0xc6, 0xa0, 0xf6), + controls: rgb(0xc6, 0xa0, 0xf6), + directory: rgb(0x6e, 0x73, 0x8d), + tags: rgb(0xca, 0xd3, 0xf5), + } +} + +fn mocha_theme() -> Theme { + Theme { + selection_bg: rgb(0x31, 0x32, 0x44), + selection_fg: rgb(0xcd, 0xd6, 0xf4), + accent: rgb(0x94, 0xe2, 0xd5), + dim: rgb(0x6c, 0x70, 0x86), + border: rgb(0x6c, 0x70, 0x86), + ok: rgb(0xa6, 0xe3, 0xa1), + fail: rgb(0xf3, 0x8b, 0xa8), + project: rgb(0x89, 0xb4, 0xfa), + command: rgb(0xcd, 0xd6, 0xf4), + header: rgb(0xcb, 0xa6, 0xf7), + controls: rgb(0xcb, 0xa6, 0xf7), + directory: rgb(0x6c, 0x70, 0x86), + tags: rgb(0xcd, 0xd6, 0xf4), + } +} + +pub fn builtin_names() -> &'static [&'static str] { + &[ + "default", + "ember", + "frost", + "latte", + "frappe", + "macchiato", + "mocha", + ] +} + +pub fn builtin(name: &str) -> Option { + match name.to_ascii_lowercase().as_str() { + "default" => Some(default_theme()), + "ember" => Some(ember_theme()), + "frost" => Some(frost_theme()), + "latte" => Some(latte_theme()), + "frappe" => Some(frappe_theme()), + "macchiato" => Some(macchiato_theme()), + "mocha" => Some(mocha_theme()), + _ => None, + } +} + +pub fn parse_color(s: &str) -> Result { + let trimmed = s.trim(); + if trimmed.is_empty() { + return Err(ThothError::Config(format!("invalid color: {s}"))); + } + + if let Some(hex) = trimmed.strip_prefix('#') { + return parse_hex(hex, s); + } + + if let Ok(n) = trimmed.parse::() { + if n <= 255 { + return Ok(Color::Indexed(n as u8)); + } + return Err(ThothError::Config(format!("invalid color: {s}"))); + } + + match trimmed.to_ascii_lowercase().as_str() { + "reset" | "default" => Ok(Color::Reset), + "black" => Ok(Color::Black), + "red" => Ok(Color::Red), + "green" => Ok(Color::Green), + "yellow" => Ok(Color::Yellow), + "blue" => Ok(Color::Blue), + "magenta" => Ok(Color::Magenta), + "cyan" => Ok(Color::Cyan), + "white" => Ok(Color::White), + "gray" | "grey" => Ok(Color::Gray), + "darkgray" | "darkgrey" => Ok(Color::DarkGray), + "brightblack" => Ok(Color::DarkGray), + "brightred" => Ok(Color::LightRed), + "brightgreen" => Ok(Color::LightGreen), + "brightyellow" => Ok(Color::LightYellow), + "brightblue" => Ok(Color::LightBlue), + "brightmagenta" => Ok(Color::LightMagenta), + "brightcyan" => Ok(Color::LightCyan), + "brightwhite" => Ok(Color::White), + _ => Err(ThothError::Config(format!("invalid color: {s}"))), + } +} + +fn parse_hex(hex: &str, original: &str) -> Result { + match hex.len() { + 6 => { + let r = u8::from_str_radix(&hex[0..2], 16) + .map_err(|_| ThothError::Config(format!("invalid color: {original}")))?; + let g = u8::from_str_radix(&hex[2..4], 16) + .map_err(|_| ThothError::Config(format!("invalid color: {original}")))?; + let b = u8::from_str_radix(&hex[4..6], 16) + .map_err(|_| ThothError::Config(format!("invalid color: {original}")))?; + Ok(Color::Rgb(r, g, b)) + } + 3 => { + let r = expand_nibble(&hex[0..1], original)?; + let g = expand_nibble(&hex[1..2], original)?; + let b = expand_nibble(&hex[2..3], original)?; + Ok(Color::Rgb(r, g, b)) + } + _ => Err(ThothError::Config(format!("invalid color: {original}"))), + } +} + +fn expand_nibble(h: &str, original: &str) -> Result { + let n = u8::from_str_radix(h, 16) + .map_err(|_| ThothError::Config(format!("invalid color: {original}")))?; + Ok(n << 4 | n) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_hex6_color() { + assert_eq!(parse_color("#ff8800").unwrap(), Color::Rgb(255, 136, 0)); + } + + #[test] + fn parse_hex3_color() { + assert_eq!(parse_color("#f80").unwrap(), Color::Rgb(0xff, 0x88, 0x00)); + } + + #[test] + fn parse_indexed_147() { + assert_eq!(parse_color("147").unwrap(), Color::Indexed(147)); + } + + #[test] + fn parse_indexed_boundaries() { + assert_eq!(parse_color("0").unwrap(), Color::Indexed(0)); + assert_eq!(parse_color("255").unwrap(), Color::Indexed(255)); + } + + #[test] + fn parse_indexed_out_of_range() { + assert!(parse_color("256").is_err()); + } + + #[test] + fn parse_reset_and_default() { + assert_eq!(parse_color("reset").unwrap(), Color::Reset); + assert_eq!(parse_color("default").unwrap(), Color::Reset); + } + + #[test] + fn parse_named_colors() { + assert_eq!(parse_color("red").unwrap(), Color::Red); + assert_eq!(parse_color("green").unwrap(), Color::Green); + assert_eq!(parse_color("blue").unwrap(), Color::Blue); + assert_eq!(parse_color("cyan").unwrap(), Color::Cyan); + assert_eq!(parse_color("darkgray").unwrap(), Color::DarkGray); + assert_eq!(parse_color("darkgrey").unwrap(), Color::DarkGray); + } + + #[test] + fn parse_bright_variants() { + assert_eq!(parse_color("brightblue").unwrap(), Color::LightBlue); + assert_eq!(parse_color("brightred").unwrap(), Color::LightRed); + assert_eq!(parse_color("brightblack").unwrap(), Color::DarkGray); + } + + #[test] + fn parse_uppercase_name() { + assert_eq!(parse_color("RED").unwrap(), Color::Red); + } + + #[test] + fn parse_invalid_name() { + assert!(parse_color("nope").is_err()); + } + + #[test] + fn parse_invalid_hex() { + assert!(parse_color("#xyz").is_err()); + assert!(parse_color("#12").is_err()); + } + + #[test] + fn parse_empty_string() { + assert!(parse_color("").is_err()); + assert!(parse_color(" ").is_err()); + } + + #[test] + fn builtin_mocha_values() { + let t = builtin("mocha").unwrap(); + assert_eq!(t.selection_bg, Color::Rgb(0x31, 0x32, 0x44)); + assert_eq!(t.ok, Color::Rgb(0xa6, 0xe3, 0xa1)); + } + + #[test] + fn builtin_mocha_case_insensitive() { + assert!(builtin("MOCHA").is_some()); + } + + #[test] + fn builtin_unknown_is_none() { + assert!(builtin("nope").is_none()); + } + + #[test] + fn builtin_names_has_seven() { + assert_eq!(builtin_names().len(), 7); + assert!(builtin_names().contains(&"default")); + assert!(builtin_names().contains(&"mocha")); + } + + #[test] + fn default_impl_matches_builtin() { + assert_eq!(Theme::default(), builtin("default").unwrap()); + } + + #[test] + fn default_theme_accent_and_selection() { + let t = Theme::default(); + assert_eq!(t.accent, Color::Cyan); + assert_eq!(t.selection_bg, Color::Indexed(147)); + } + + #[test] + fn all_builtins_retrievable_and_distinct() { + let themes: Vec = builtin_names() + .iter() + .map(|n| builtin(n).unwrap()) + .collect(); + assert_eq!(themes.len(), 7); + let mocha = builtin("mocha").unwrap(); + let latte = builtin("latte").unwrap(); + assert_ne!(mocha, latte); + } +} diff --git a/src/tui/app.rs b/src/tui/app.rs new file mode 100644 index 0000000..9e1b222 --- /dev/null +++ b/src/tui/app.rs @@ -0,0 +1,1396 @@ +use rusqlite::Connection; + +use crate::cli::SearchArgs; +use crate::error::ThothError; +use crate::search::{CommandRow, ExitFilter}; +use crate::theme::Theme; +use crate::tui::fuzzy; +use crate::workspaces::WorkspaceRow; + +const TUI_LIMIT: usize = 5000; +const LABEL_MAX: usize = 40; + +pub fn parse_query(input: &str, now: i64) -> (FilterState, String) { + let mut filters = FilterState::new(); + let mut free_words: Vec<&str> = Vec::new(); + + for token in input.split_whitespace() { + if let Some((key, value)) = token.split_once(':') { + if value.is_empty() { + free_words.push(token); + continue; + } + match key { + "project" | "p" => { + filters.project = Some(value.to_string()); + } + "tag" | "t" => { + let v = value.to_string(); + if !filters.tag.contains(&v) { + filters.tag.push(v); + } + } + "exit" | "e" => { + let parsed = match value.to_lowercase().as_str() { + "ok" => Some(ExitFilter::Ok), + "fail" => Some(ExitFilter::Fail), + "any" => Some(ExitFilter::Any), + _ => None, + }; + match parsed { + Some(ef) => filters.exit = Some(ef), + None => free_words.push(token), + } + } + "since" => { + if crate::search::parse_date(value, now).is_ok() { + filters.since = Some(value.to_string()); + } else { + free_words.push(token); + } + } + "until" => { + if crate::search::parse_date(value, now).is_ok() { + filters.until = Some(value.to_string()); + } else { + free_words.push(token); + } + } + "dur" | "duration" => { + filters.duration = Some(value.to_string()); + } + _ => { + free_words.push(token); + } + } + } else { + free_words.push(token); + } + } + + (filters, free_words.join(" ")) +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum Tab { + #[default] + History, + Workspaces, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum Mode { + #[default] + Insert, + Normal, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum WsPane { + #[default] + List, + Commands, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum DeleteOrigin { + History, + Workspace, +} + +pub struct ConfirmReplay { + pub workspace: String, + pub count: usize, +} + +pub struct ConfirmDelete { + pub id: i64, + pub label: String, + pub origin: DeleteOrigin, +} + +pub enum Confirm { + Replay(ConfirmReplay), + Delete(ConfirmDelete), +} + +pub struct EditState { + pub id: i64, + pub buffer: String, +} + +#[derive(PartialEq, Debug)] +pub struct FilterState { + pub project: Option, + pub tag: Vec, + pub exit: Option, + pub since: Option, + pub until: Option, + pub session: Option, + pub duration: Option, +} + +impl Default for FilterState { + fn default() -> Self { + Self::new() + } +} + +impl FilterState { + pub fn new() -> Self { + Self { + project: None, + tag: vec![], + exit: None, + since: None, + until: None, + session: None, + duration: None, + } + } + + pub fn to_search_args(&self) -> SearchArgs { + SearchArgs { + query: None, + project: self.project.clone(), + tag: self.tag.clone(), + exit: self.exit.clone(), + duration: self.duration.clone(), + since: self.since.clone(), + until: self.until.clone(), + session: self.session.clone(), + limit: Some(TUI_LIMIT), + show_session: false, + } + } +} + +pub enum Action { + Run(String), + Edit(String), + Replay(String), +} + +pub struct App { + pub query: String, + pub fuzzy_query: String, + pub cmdline: Option, + pub all_rows: Vec, + pub filtered: Vec, + pub selected: usize, + pub filters: FilterState, + pub action: Option, + pub tab: Tab, + pub mode: Mode, + pub show_help: bool, + pub workspaces: Vec, + pub ws_selected: usize, + pub ws_commands: Vec, + pub ws_pane: WsPane, + pub ws_cmd_selected: usize, + pub needs_ws_reload: bool, + pub needs_ws_commands_reload: bool, + pub needs_history_reload: bool, + pub confirm: Option, + pub replay_workspace: Option, + pub pending_delete: Option<(i64, DeleteOrigin)>, + pub edit: Option, + pub pending_edit: Option<(i64, String)>, + pub theme: Theme, +} + +impl Default for App { + fn default() -> Self { + Self::new() + } +} + +impl App { + pub fn new() -> Self { + Self { + query: String::new(), + fuzzy_query: String::new(), + cmdline: None, + all_rows: vec![], + filtered: vec![], + selected: 0, + filters: FilterState::new(), + action: None, + tab: Tab::History, + mode: Mode::Insert, + show_help: false, + workspaces: vec![], + ws_selected: 0, + ws_commands: vec![], + ws_pane: WsPane::List, + ws_cmd_selected: 0, + needs_ws_reload: false, + needs_ws_commands_reload: false, + needs_history_reload: false, + confirm: None, + replay_workspace: None, + pending_delete: None, + edit: None, + pending_edit: None, + theme: Theme::default(), + } + } + + pub fn enter_normal_mode(&mut self) { + self.mode = Mode::Normal; + } + + pub fn enter_insert_mode(&mut self) { + self.mode = Mode::Insert; + } + + pub fn toggle_ws_pane(&mut self) { + match self.ws_pane { + WsPane::List => { + self.ws_pane = WsPane::Commands; + let max = self.ws_commands.len().saturating_sub(1); + if self.ws_cmd_selected > max { + self.ws_cmd_selected = 0; + } + } + WsPane::Commands => { + self.ws_pane = WsPane::List; + } + } + } + + pub fn ws_cmd_move_up(&mut self) { + if self.ws_cmd_selected > 0 { + self.ws_cmd_selected -= 1; + } + } + + pub fn ws_cmd_move_down(&mut self) { + let max = self.ws_commands.len().saturating_sub(1); + if self.ws_cmd_selected < max { + self.ws_cmd_selected += 1; + } + } + + pub fn selected_history_id(&self) -> Option { + let idx = self.filtered.get(self.selected)?; + self.all_rows.get(*idx).map(|r| r.id) + } + + pub fn selected_ws_command_id(&self) -> Option { + self.ws_commands.get(self.ws_cmd_selected).map(|r| r.id) + } + + pub fn begin_delete_confirm_history(&mut self) { + if let Some(id) = self.selected_history_id() { + let label = self + .filtered + .get(self.selected) + .and_then(|idx| self.all_rows.get(*idx)) + .map(|r| truncate_label(&r.command)) + .unwrap_or_default(); + self.confirm = Some(Confirm::Delete(ConfirmDelete { + id, + label, + origin: DeleteOrigin::History, + })); + } + } + + pub fn begin_delete_confirm_ws(&mut self) { + if let Some(id) = self.selected_ws_command_id() { + let label = self + .ws_commands + .get(self.ws_cmd_selected) + .map(|r| truncate_label(&r.command)) + .unwrap_or_default(); + self.confirm = Some(Confirm::Delete(ConfirmDelete { + id, + label, + origin: DeleteOrigin::Workspace, + })); + } + } + + pub fn begin_replay_confirm(&mut self) { + if let Some(ws) = self.selected_workspace() { + let workspace = ws.name.clone(); + let count = self.ws_commands.len(); + self.confirm = Some(Confirm::Replay(ConfirmReplay { workspace, count })); + } + } + + pub fn cancel_confirm(&mut self) { + self.confirm = None; + } + + pub fn prev_tab(&mut self) { + self.tab = Tab::History; + } + + pub fn next_tab(&mut self) { + if self.tab == Tab::History { + self.tab = Tab::Workspaces; + if self.workspaces.is_empty() { + self.needs_ws_reload = true; + self.needs_ws_commands_reload = true; + } + } + } + + pub fn ws_move_up(&mut self) { + if self.ws_selected > 0 { + self.ws_selected -= 1; + self.needs_ws_commands_reload = true; + } + } + + pub fn ws_move_down(&mut self) { + let max = self.workspaces.len().saturating_sub(1); + if self.ws_selected < max { + self.ws_selected += 1; + self.needs_ws_commands_reload = true; + } + } + + pub fn selected_workspace(&self) -> Option<&WorkspaceRow> { + self.workspaces.get(self.ws_selected) + } + + pub fn reload(&mut self, conn: &Connection, now: i64) -> Result<(), ThothError> { + let args = self.filters.to_search_args(); + self.all_rows = crate::search::execute(&args, conn, now)?; + self.recompute(); + Ok(()) + } + + pub fn reload_workspaces(&mut self, conn: &Connection) -> Result<(), ThothError> { + self.workspaces = crate::workspaces::list_workspaces(conn)?; + let max = self.workspaces.len().saturating_sub(1); + if self.ws_selected > max { + self.ws_selected = 0; + } + self.needs_ws_reload = false; + self.needs_ws_commands_reload = true; + Ok(()) + } + + pub fn reload_ws_commands(&mut self, conn: &Connection) -> Result<(), ThothError> { + if let Some(ws) = self.selected_workspace() { + let name = ws.name.clone(); + self.ws_commands = crate::workspaces::list_workspace_commands(conn, &name)?; + } else { + self.ws_commands.clear(); + } + let max = self.ws_commands.len().saturating_sub(1); + if self.ws_cmd_selected > max { + self.ws_cmd_selected = 0; + } + self.needs_ws_commands_reload = false; + Ok(()) + } + + pub fn recompute(&mut self) { + self.filtered = fuzzy::rank(&self.query, &self.all_rows); + let max = self.filtered.len().saturating_sub(1); + if self.selected > max { + self.selected = max; + } + } + + pub fn apply_query(&mut self, now: i64) { + let (filters, free) = parse_query(&self.query, now); + let changed = filters != self.filters; + self.filters = filters; + self.fuzzy_query = free.clone(); + self.query = free; + if changed { + self.needs_history_reload = true; + } + self.recompute(); + } + + pub fn open_cmdline(&mut self) { + self.cmdline = Some(String::new()); + } + + pub fn cmdline_push(&mut self, c: char) { + if let Some(ref mut buf) = self.cmdline { + buf.push(c); + } + } + + pub fn cmdline_backspace(&mut self) { + if let Some(ref mut buf) = self.cmdline { + buf.pop(); + } + } + + pub fn cmdline_cancel(&mut self) { + self.cmdline = None; + } + + pub fn cmdline_submit(&mut self, now: i64) { + let buf = match self.cmdline.take() { + Some(b) => b, + None => return, + }; + let (filters, free) = parse_query(&buf, now); + let changed = filters != self.filters; + self.filters = filters; + self.query = free; + if changed { + self.needs_history_reload = true; + } + self.recompute(); + } + + pub fn selected_command(&self) -> Option<&str> { + let idx = self.filtered.get(self.selected)?; + self.all_rows.get(*idx).map(|r| r.command.as_str()) + } + + pub fn move_up(&mut self) { + let max = self.filtered.len().saturating_sub(1); + if self.selected < max { + self.selected += 1; + } + } + + pub fn move_down(&mut self) { + if self.selected > 0 { + self.selected -= 1; + } + } + + pub fn begin_edit_history(&mut self) { + if let Some(id) = self.selected_history_id() { + let buffer = self + .filtered + .get(self.selected) + .and_then(|idx| self.all_rows.get(*idx)) + .map(|r| r.command.clone()) + .unwrap_or_default(); + self.edit = Some(EditState { id, buffer }); + } + } + + pub fn begin_edit_ws(&mut self) { + if let Some(id) = self.selected_ws_command_id() { + let buffer = self + .ws_commands + .get(self.ws_cmd_selected) + .map(|r| r.command.clone()) + .unwrap_or_default(); + self.edit = Some(EditState { id, buffer }); + } + } + + pub fn edit_push(&mut self, c: char) { + if let Some(ref mut es) = self.edit { + es.buffer.push(c); + } + } + + pub fn edit_backspace(&mut self) { + if let Some(ref mut es) = self.edit { + es.buffer.pop(); + } + } + + pub fn edit_cancel(&mut self) { + self.edit = None; + } + + pub fn edit_commit(&mut self) { + if let Some(es) = self.edit.take() { + self.pending_edit = Some((es.id, es.buffer)); + } + } +} + +fn truncate_label(s: &str) -> String { + if s.chars().count() <= LABEL_MAX { + s.to_string() + } else { + let end = s + .char_indices() + .nth(LABEL_MAX.saturating_sub(1)) + .map(|(i, _)| i) + .unwrap_or(s.len()); + format!("{}...", &s[..end]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_conn() -> Connection { + let mut conn = crate::database::connect_memory().unwrap(); + crate::database::apply_migrations(&mut conn).unwrap(); + conn + } + + fn seed(conn: &Connection, cmd: &str, ts: i64) { + conn.execute( + "INSERT INTO commands(command, directory, project, session_id, timestamp, exit_code, duration_ms, tags) \ + VALUES(?1, '/tmp', 'p', 's1', ?2, 0, 100, '[]')", + rusqlite::params![cmd, ts], + ) + .unwrap(); + } + + fn seed_ws_command(conn: &Connection, ws: &str, cmd: &str, ts: i64) { + conn.execute( + "INSERT INTO commands(command, directory, project, session_id, timestamp, exit_code, duration_ms, tags, workspace) \ + VALUES(?1, '/tmp', 'p', 's1', ?2, 0, 100, '[]', ?3)", + rusqlite::params![cmd, ts, ws], + ) + .unwrap(); + } + + #[test] + fn to_search_args_empty_filters() { + let fs = FilterState::new(); + let args = fs.to_search_args(); + assert!(args.query.is_none()); + assert!(args.project.is_none()); + assert!(args.tag.is_empty()); + assert!(args.exit.is_none()); + assert!(args.since.is_none()); + assert!(args.until.is_none()); + assert!(args.session.is_none()); + assert_eq!(args.limit, Some(TUI_LIMIT)); + } + + #[test] + fn to_search_args_all_filter_fields_map_through() { + let mut fs = FilterState::new(); + fs.project = Some("myapp".into()); + fs.exit = Some(ExitFilter::Fail); + fs.tag = vec!["rust".into(), "cli".into()]; + fs.since = Some("2024-01-01".into()); + fs.until = Some("2024-12-31".into()); + fs.session = Some("ses-abc123".into()); + let args = fs.to_search_args(); + assert!(args.query.is_none()); + assert_eq!(args.project, Some("myapp".into())); + assert_eq!(args.exit, Some(ExitFilter::Fail)); + assert_eq!(args.tag, vec!["rust".to_string(), "cli".to_string()]); + assert_eq!(args.since, Some("2024-01-01".into())); + assert_eq!(args.until, Some("2024-12-31".into())); + assert_eq!(args.session, Some("ses-abc123".into())); + assert_eq!(args.limit, Some(TUI_LIMIT)); + } + + #[test] + fn filter_state_session_defaults_to_none() { + let fs = FilterState::new(); + assert!(fs.session.is_none()); + } + + #[test] + fn prev_tab_from_history_stays_history() { + let mut app = App::new(); + assert_eq!(app.tab, Tab::History); + app.prev_tab(); + assert_eq!(app.tab, Tab::History); + } + + #[test] + fn next_tab_from_history_goes_to_workspaces() { + let mut app = App::new(); + app.workspaces = vec![WorkspaceRow { + name: "ws1".into(), + command_count: 1, + first_ts: 1000, + last_ts: 2000, + }]; + app.next_tab(); + assert_eq!(app.tab, Tab::Workspaces); + } + + #[test] + fn next_tab_from_workspaces_stays_workspaces() { + let mut app = App::new(); + app.tab = Tab::Workspaces; + app.workspaces = vec![WorkspaceRow { + name: "ws1".into(), + command_count: 1, + first_ts: 1000, + last_ts: 2000, + }]; + app.next_tab(); + assert_eq!(app.tab, Tab::Workspaces); + } + + #[test] + fn prev_tab_from_workspaces_goes_to_history() { + let mut app = App::new(); + app.tab = Tab::Workspaces; + app.prev_tab(); + assert_eq!(app.tab, Tab::History); + } + + #[test] + fn next_tab_with_empty_workspaces_sets_reload_flag() { + let mut app = App::new(); + assert!(app.workspaces.is_empty()); + app.next_tab(); + assert!( + app.needs_ws_reload, + "must set needs_ws_reload when workspaces is empty" + ); + assert!(app.needs_ws_commands_reload); + } + + #[test] + fn next_tab_with_existing_workspaces_does_not_set_reload_flag() { + let mut app = App::new(); + app.workspaces = vec![WorkspaceRow { + name: "ws1".into(), + command_count: 1, + first_ts: 1000, + last_ts: 2000, + }]; + app.next_tab(); + assert!(!app.needs_ws_reload); + } + + #[test] + fn ws_move_up_decreases_index() { + let mut app = App::new(); + app.workspaces = vec![ + WorkspaceRow { + name: "ws-a".into(), + command_count: 2, + first_ts: 1000, + last_ts: 3000, + }, + WorkspaceRow { + name: "ws-b".into(), + command_count: 1, + first_ts: 2000, + last_ts: 2000, + }, + ]; + app.ws_selected = 1; + app.ws_move_up(); + assert_eq!(app.ws_selected, 0); + assert!(app.needs_ws_commands_reload); + } + + #[test] + fn ws_move_up_clamps_at_top() { + let mut app = App::new(); + app.workspaces = vec![WorkspaceRow { + name: "ws1".into(), + command_count: 1, + first_ts: 1000, + last_ts: 2000, + }]; + app.ws_selected = 0; + app.ws_move_up(); + assert_eq!(app.ws_selected, 0); + } + + #[test] + fn ws_move_down_increases_index() { + let mut app = App::new(); + app.workspaces = vec![ + WorkspaceRow { + name: "ws-a".into(), + command_count: 2, + first_ts: 1000, + last_ts: 3000, + }, + WorkspaceRow { + name: "ws-b".into(), + command_count: 1, + first_ts: 2000, + last_ts: 2000, + }, + ]; + app.ws_selected = 0; + app.ws_move_down(); + assert_eq!(app.ws_selected, 1); + assert!(app.needs_ws_commands_reload); + } + + #[test] + fn ws_move_down_clamps_at_bottom() { + let mut app = App::new(); + app.workspaces = vec![WorkspaceRow { + name: "ws1".into(), + command_count: 1, + first_ts: 1000, + last_ts: 2000, + }]; + app.ws_selected = 0; + app.ws_move_down(); + assert_eq!(app.ws_selected, 0); + } + + #[test] + fn reload_populates_all_rows() { + let conn = make_conn(); + seed(&conn, "git status", 1000); + seed(&conn, "ls -la", 2000); + let mut app = App::new(); + app.reload(&conn, 9999).unwrap(); + assert_eq!(app.all_rows.len(), 2); + } + + #[test] + fn reload_workspaces_populates_workspaces() { + let conn = make_conn(); + seed_ws_command(&conn, "ws-a", "cmd1", 1000); + seed_ws_command(&conn, "ws-b", "cmd2", 2000); + let mut app = App::new(); + app.reload_workspaces(&conn).unwrap(); + assert_eq!(app.workspaces.len(), 2); + assert!(!app.needs_ws_reload); + assert!(app.needs_ws_commands_reload); + } + + #[test] + fn reload_ws_commands_filters_by_selected_workspace() { + let conn = make_conn(); + seed_ws_command(&conn, "ws-a", "cmd-for-a", 1100); + seed_ws_command(&conn, "ws-b", "cmd-for-b", 2100); + + let mut app = App::new(); + app.reload_workspaces(&conn).unwrap(); + + app.ws_selected = 1; + app.reload_ws_commands(&conn).unwrap(); + assert_eq!(app.ws_commands.len(), 1); + assert_eq!(app.ws_commands[0].command, "cmd-for-a"); + + app.ws_selected = 0; + app.reload_ws_commands(&conn).unwrap(); + assert_eq!(app.ws_commands.len(), 1); + assert_eq!(app.ws_commands[0].command, "cmd-for-b"); + } + + #[test] + fn reload_ws_commands_returns_asc_order() { + let conn = make_conn(); + seed_ws_command(&conn, "ws-x", "first", 1000); + seed_ws_command(&conn, "ws-x", "second", 2000); + seed_ws_command(&conn, "ws-x", "third", 3000); + + let mut app = App::new(); + app.reload_workspaces(&conn).unwrap(); + app.ws_selected = 0; + app.reload_ws_commands(&conn).unwrap(); + assert_eq!(app.ws_commands.len(), 3); + assert_eq!(app.ws_commands[0].command, "first"); + assert_eq!(app.ws_commands[1].command, "second"); + assert_eq!(app.ws_commands[2].command, "third"); + } + + #[test] + fn recompute_filters_on_query() { + let conn = make_conn(); + seed(&conn, "docker run nginx", 1000); + seed(&conn, "ls -la", 2000); + let mut app = App::new(); + app.reload(&conn, 9999).unwrap(); + app.query = "docker".into(); + app.recompute(); + assert_eq!(app.filtered.len(), 1); + assert_eq!(app.all_rows[app.filtered[0]].command, "docker run nginx"); + } + + #[test] + fn selected_command_returns_correct_entry() { + let conn = make_conn(); + seed(&conn, "git status", 2000); + seed(&conn, "ls -la", 1000); + let mut app = App::new(); + app.reload(&conn, 9999).unwrap(); + app.selected = 0; + let cmd = app.selected_command().unwrap(); + assert_eq!(cmd, "git status"); + } + + #[test] + fn move_up_clamps_at_oldest() { + let conn = make_conn(); + seed(&conn, "a", 1000); + seed(&conn, "b", 2000); + let mut app = App::new(); + app.reload(&conn, 9999).unwrap(); + app.move_up(); + app.move_up(); + app.move_up(); + assert_eq!(app.selected, 1); + } + + #[test] + fn move_down_clamps_at_newest() { + let conn = make_conn(); + seed(&conn, "a", 1000); + seed(&conn, "b", 2000); + let mut app = App::new(); + app.reload(&conn, 9999).unwrap(); + app.move_down(); + app.move_down(); + assert_eq!(app.selected, 0); + } + + #[test] + fn selected_command_none_when_empty() { + let app = App::new(); + assert!(app.selected_command().is_none()); + } + + fn app_with_ws(name: &str, cmd_count: usize) -> App { + let mut app = App::new(); + app.tab = crate::tui::app::Tab::Workspaces; + app.workspaces = vec![WorkspaceRow { + name: name.into(), + command_count: cmd_count as i64, + first_ts: 1000, + last_ts: 2000, + }]; + app.ws_selected = 0; + app.ws_commands = (0..cmd_count) + .map(|i| crate::search::CommandRow { + id: (i + 1) as i64, + command: format!("cmd-{i}"), + directory: "/tmp".into(), + project: "p".into(), + session_id: "s1".into(), + timestamp: 1000 + i as i64, + exit_code: 0, + duration_ms: 100, + tags: "[]".into(), + workspace: Some(name.into()), + }) + .collect(); + app + } + + #[test] + fn begin_replay_confirm_sets_confirm_with_workspace_and_count() { + let mut app = app_with_ws("demo", 3); + app.begin_replay_confirm(); + let c = app.confirm.as_ref().unwrap(); + assert!(matches!(c, Confirm::Replay(r) if r.workspace == "demo" && r.count == 3)); + } + + #[test] + fn begin_replay_confirm_no_workspace_does_nothing() { + let mut app = App::new(); + app.begin_replay_confirm(); + assert!(app.confirm.is_none()); + } + + #[test] + fn cancel_confirm_clears_confirm() { + let mut app = app_with_ws("ws-x", 2); + app.begin_replay_confirm(); + assert!(app.confirm.is_some()); + app.cancel_confirm(); + assert!(app.confirm.is_none()); + } + + #[test] + fn enter_normal_mode_sets_normal() { + let mut app = App::new(); + assert_eq!(app.mode, Mode::Insert); + app.enter_normal_mode(); + assert_eq!(app.mode, Mode::Normal); + } + + #[test] + fn enter_insert_mode_sets_insert() { + let mut app = App::new(); + app.enter_normal_mode(); + assert_eq!(app.mode, Mode::Normal); + app.enter_insert_mode(); + assert_eq!(app.mode, Mode::Insert); + } + + #[test] + fn toggle_ws_pane_list_to_commands() { + let mut app = App::new(); + assert_eq!(app.ws_pane, WsPane::List); + app.toggle_ws_pane(); + assert_eq!(app.ws_pane, WsPane::Commands); + } + + #[test] + fn toggle_ws_pane_commands_to_list() { + let mut app = App::new(); + app.ws_pane = WsPane::Commands; + app.toggle_ws_pane(); + assert_eq!(app.ws_pane, WsPane::List); + } + + #[test] + fn toggle_ws_pane_clamps_cmd_selected() { + let mut app = app_with_ws("demo", 2); + app.ws_cmd_selected = 5; + app.toggle_ws_pane(); + assert!( + app.ws_cmd_selected <= 1, + "ws_cmd_selected must clamp to valid range after toggling to Commands" + ); + } + + #[test] + fn ws_cmd_move_up_decreases() { + let mut app = app_with_ws("demo", 3); + app.ws_pane = WsPane::Commands; + app.ws_cmd_selected = 2; + app.ws_cmd_move_up(); + assert_eq!(app.ws_cmd_selected, 1); + } + + #[test] + fn ws_cmd_move_up_clamps_at_zero() { + let mut app = app_with_ws("demo", 3); + app.ws_pane = WsPane::Commands; + app.ws_cmd_selected = 0; + app.ws_cmd_move_up(); + assert_eq!(app.ws_cmd_selected, 0); + } + + #[test] + fn ws_cmd_move_down_increases() { + let mut app = app_with_ws("demo", 3); + app.ws_pane = WsPane::Commands; + app.ws_cmd_selected = 0; + app.ws_cmd_move_down(); + assert_eq!(app.ws_cmd_selected, 1); + } + + #[test] + fn ws_cmd_move_down_clamps_at_max() { + let mut app = app_with_ws("demo", 3); + app.ws_pane = WsPane::Commands; + app.ws_cmd_selected = 2; + app.ws_cmd_move_down(); + assert_eq!(app.ws_cmd_selected, 2); + } + + #[test] + fn selected_history_id_returns_correct_id() { + let conn = make_conn(); + seed(&conn, "git status", 2000); + seed(&conn, "ls -la", 1000); + let mut app = App::new(); + app.reload(&conn, 9999).unwrap(); + app.selected = 0; + let id = app.selected_history_id().unwrap(); + assert!(id > 0); + let row = &app.all_rows[app.filtered[0]]; + assert_eq!(id, row.id); + } + + #[test] + fn selected_history_id_none_when_empty() { + let app = App::new(); + assert!(app.selected_history_id().is_none()); + } + + #[test] + fn selected_ws_command_id_returns_correct_id() { + let app = app_with_ws("demo", 3); + let id = app.selected_ws_command_id().unwrap(); + assert_eq!(id, app.ws_commands[0].id); + } + + #[test] + fn selected_ws_command_id_uses_ws_cmd_selected() { + let mut app = app_with_ws("demo", 3); + app.ws_cmd_selected = 2; + let id = app.selected_ws_command_id().unwrap(); + assert_eq!(id, app.ws_commands[2].id); + } + + #[test] + fn begin_delete_confirm_history_sets_delete_confirm() { + let conn = make_conn(); + seed(&conn, "git status", 2000); + let mut app = App::new(); + app.reload(&conn, 9999).unwrap(); + app.selected = 0; + let expected_id = app.selected_history_id().unwrap(); + app.begin_delete_confirm_history(); + let c = app.confirm.as_ref().unwrap(); + assert!( + matches!(c, Confirm::Delete(d) if d.id == expected_id && matches!(d.origin, DeleteOrigin::History)) + ); + } + + #[test] + fn begin_delete_confirm_history_no_rows_does_nothing() { + let mut app = App::new(); + app.begin_delete_confirm_history(); + assert!(app.confirm.is_none()); + } + + #[test] + fn begin_delete_confirm_ws_sets_delete_confirm() { + let mut app = app_with_ws("demo", 2); + app.ws_cmd_selected = 0; + let expected_id = app.ws_commands[0].id; + app.begin_delete_confirm_ws(); + let c = app.confirm.as_ref().unwrap(); + assert!( + matches!(c, Confirm::Delete(d) if d.id == expected_id && matches!(d.origin, DeleteOrigin::Workspace)) + ); + } + + #[test] + fn begin_delete_confirm_ws_no_commands_does_nothing() { + let mut app = App::new(); + app.begin_delete_confirm_ws(); + assert!(app.confirm.is_none()); + } + + #[test] + fn cancel_confirm_clears_delete_confirm() { + let mut app = app_with_ws("demo", 2); + app.begin_delete_confirm_ws(); + assert!(app.confirm.is_some()); + app.cancel_confirm(); + assert!(app.confirm.is_none()); + } + + #[test] + fn begin_edit_history_sets_edit_with_correct_id_and_buffer() { + let conn = make_conn(); + seed(&conn, "git status", 2000); + let mut app = App::new(); + app.reload(&conn, 9999).unwrap(); + app.selected = 0; + let expected_id = app.selected_history_id().unwrap(); + app.begin_edit_history(); + let es = app.edit.as_ref().unwrap(); + assert_eq!(es.id, expected_id); + assert_eq!(es.buffer, "git status"); + } + + #[test] + fn begin_edit_history_no_selection_is_noop() { + let mut app = App::new(); + app.begin_edit_history(); + assert!(app.edit.is_none()); + } + + #[test] + fn begin_edit_ws_sets_edit_with_correct_id_and_buffer() { + let mut app = app_with_ws("demo", 2); + app.ws_cmd_selected = 1; + let expected_id = app.ws_commands[1].id; + let expected_buf = app.ws_commands[1].command.clone(); + app.begin_edit_ws(); + let es = app.edit.as_ref().unwrap(); + assert_eq!(es.id, expected_id); + assert_eq!(es.buffer, expected_buf); + } + + #[test] + fn begin_edit_ws_no_selection_is_noop() { + let mut app = App::new(); + app.begin_edit_ws(); + assert!(app.edit.is_none()); + } + + #[test] + fn edit_push_appends_char_to_buffer() { + let mut app = App::new(); + app.edit = Some(EditState { + id: 1, + buffer: "git".into(), + }); + app.edit_push(' '); + app.edit_push('s'); + assert_eq!(app.edit.as_ref().unwrap().buffer, "git s"); + } + + #[test] + fn edit_backspace_removes_last_char() { + let mut app = App::new(); + app.edit = Some(EditState { + id: 1, + buffer: "git status".into(), + }); + app.edit_backspace(); + assert_eq!(app.edit.as_ref().unwrap().buffer, "git statu"); + } + + #[test] + fn edit_cancel_clears_edit() { + let mut app = App::new(); + app.edit = Some(EditState { + id: 1, + buffer: "something".into(), + }); + app.edit_cancel(); + assert!(app.edit.is_none()); + } + + const PARSE_NOW: i64 = 1_700_000_000; + + #[test] + fn parse_query_project_and_free_text() { + let (fs, free) = parse_query("project:thoth cargo", PARSE_NOW); + assert_eq!(fs.project, Some("thoth".into())); + assert_eq!(free, "cargo"); + } + + #[test] + fn parse_query_short_project_alias() { + let (fs, free) = parse_query("p:web t:rust t:cli build", PARSE_NOW); + assert_eq!(fs.project, Some("web".into())); + assert!(fs.tag.contains(&"rust".to_string())); + assert!(fs.tag.contains(&"cli".to_string())); + assert_eq!(free, "build"); + } + + #[test] + fn parse_query_exit_fail() { + let (fs, free) = parse_query("exit:fail", PARSE_NOW); + assert_eq!(fs.exit, Some(ExitFilter::Fail)); + assert!(free.is_empty()); + } + + #[test] + fn parse_query_exit_ok() { + let (fs, free) = parse_query("exit:ok", PARSE_NOW); + assert_eq!(fs.exit, Some(ExitFilter::Ok)); + assert!(free.is_empty()); + } + + #[test] + fn parse_query_exit_bogus_becomes_free_text() { + let (fs, free) = parse_query("exit:bogus x", PARSE_NOW); + assert!(fs.exit.is_none()); + assert!(free.contains("exit:bogus")); + assert!(free.contains('x')); + } + + #[test] + fn parse_query_since_valid() { + let (fs, free) = parse_query("since:today ls", PARSE_NOW); + assert_eq!(fs.since, Some("today".into())); + assert_eq!(free, "ls"); + } + + #[test] + fn parse_query_since_invalid_becomes_free_text() { + let (fs, free) = parse_query("since:notadate ls", PARSE_NOW); + assert!(fs.since.is_none()); + assert!(free.contains("since:notadate")); + } + + #[test] + fn parse_query_duration() { + let (fs, free) = parse_query("dur:>30 ls", PARSE_NOW); + assert_eq!(fs.duration, Some(">30".into())); + assert_eq!(free, "ls"); + } + + #[test] + fn parse_query_empty_value_becomes_free_text() { + let (fs, free) = parse_query("project: ls", PARSE_NOW); + assert!(fs.project.is_none()); + assert!(free.contains("project:")); + assert!(free.contains("ls")); + } + + #[test] + fn parse_query_only_free_text() { + let input = "git status"; + let (fs, free) = parse_query(input, PARSE_NOW); + assert!(fs.project.is_none()); + assert!(fs.exit.is_none()); + assert!(fs.tag.is_empty()); + assert!(fs.since.is_none()); + assert!(fs.until.is_none()); + assert!(fs.duration.is_none()); + assert_eq!(free, input); + } + + #[test] + fn parse_query_combined_project_exit_free() { + let (fs, free) = parse_query("project:thoth exit:fail cargo", PARSE_NOW); + assert_eq!(fs.project, Some("thoth".into())); + assert_eq!(fs.exit, Some(ExitFilter::Fail)); + assert_eq!(free, "cargo"); + } + + #[test] + fn apply_query_sets_filters_and_needs_reload_when_filters_change() { + let mut app = App::new(); + app.query = "project:myapp".into(); + app.apply_query(PARSE_NOW); + assert_eq!(app.filters.project, Some("myapp".into())); + assert!(app.needs_history_reload); + assert!( + app.query.is_empty(), + "query must become the free text after parsing" + ); + } + + #[test] + fn apply_query_does_not_set_needs_reload_when_only_free_text_changes() { + let mut app = App::new(); + app.query = "cargo".into(); + app.apply_query(PARSE_NOW); + assert!(!app.needs_history_reload); + assert_eq!(app.query, "cargo"); + } + + #[test] + fn apply_query_recompute_uses_free_text_as_query() { + let conn = make_conn(); + seed(&conn, "docker run nginx", 1000); + seed(&conn, "ls -la", 2000); + let mut app = App::new(); + app.reload(&conn, 9999).unwrap(); + app.query = "docker".into(); + app.apply_query(PARSE_NOW); + assert_eq!(app.filtered.len(), 1); + assert_eq!(app.all_rows[app.filtered[0]].command, "docker run nginx"); + } + + #[test] + fn to_search_args_passes_duration_through() { + let mut fs = FilterState::new(); + fs.duration = Some(">30".into()); + let args = fs.to_search_args(); + assert_eq!(args.duration, Some(">30".into())); + } + + const CMDLINE_NOW: i64 = 1_700_000_000; + + #[test] + fn open_cmdline_sets_some_empty_string() { + let mut app = App::new(); + assert!(app.cmdline.is_none()); + app.open_cmdline(); + assert_eq!(app.cmdline, Some(String::new())); + } + + #[test] + fn cmdline_push_appends_char() { + let mut app = App::new(); + app.open_cmdline(); + app.cmdline_push('p'); + app.cmdline_push(':'); + assert_eq!(app.cmdline, Some("p:".to_string())); + } + + #[test] + fn cmdline_backspace_removes_last_char() { + let mut app = App::new(); + app.open_cmdline(); + app.cmdline_push('a'); + app.cmdline_push('b'); + app.cmdline_backspace(); + assert_eq!(app.cmdline, Some("a".to_string())); + } + + #[test] + fn cmdline_cancel_closes_without_applying() { + let mut app = App::new(); + app.filters.project = Some("old".into()); + app.open_cmdline(); + app.cmdline_push('p'); + app.cmdline_push(':'); + app.cmdline_push('x'); + app.cmdline_cancel(); + assert!(app.cmdline.is_none()); + assert_eq!( + app.filters.project, + Some("old".into()), + "cancel must not change filters" + ); + } + + #[test] + fn cmdline_submit_project_and_free_text() { + let conn = make_conn(); + seed(&conn, "docker run nginx", 1000); + seed(&conn, "ls -la", 2000); + let mut app = App::new(); + app.reload(&conn, 9999).unwrap(); + app.open_cmdline(); + for c in "project:thoth cargo".chars() { + app.cmdline_push(c); + } + app.cmdline_submit(CMDLINE_NOW); + assert_eq!(app.filters.project, Some("thoth".into())); + assert_eq!(app.query, "cargo"); + assert!(app.needs_history_reload); + assert!(app.cmdline.is_none()); + } + + #[test] + fn cmdline_submit_only_free_text_does_not_set_reload() { + let mut app = App::new(); + app.open_cmdline(); + for c in "cargo".chars() { + app.cmdline_push(c); + } + app.cmdline_submit(CMDLINE_NOW); + assert_eq!(app.query, "cargo"); + assert!( + !app.needs_history_reload, + "free-text only must not trigger reload" + ); + assert!(app.cmdline.is_none()); + } + + #[test] + fn cmdline_submit_empty_clears_filters() { + let mut app = App::new(); + app.filters.project = Some("myapp".into()); + app.needs_history_reload = false; + app.open_cmdline(); + app.cmdline_submit(CMDLINE_NOW); + assert!( + app.filters.project.is_none(), + "empty submit must clear filters" + ); + assert_eq!(app.query, ""); + assert!( + app.needs_history_reload, + "clearing filters must trigger reload" + ); + assert!(app.cmdline.is_none()); + } + + #[test] + fn recompute_ranks_on_raw_query() { + let conn = make_conn(); + seed(&conn, "git status", 2000); + seed(&conn, "ls -la", 1000); + let mut app = App::new(); + app.reload(&conn, 9999).unwrap(); + app.query = "git".into(); + app.recompute(); + assert!(!app.filtered.is_empty()); + assert_eq!(app.all_rows[app.filtered[0]].command, "git status"); + } + + #[test] + fn edit_commit_sets_pending_edit_and_clears_edit() { + let mut app = App::new(); + app.edit = Some(EditState { + id: 42, + buffer: "corrected cmd".into(), + }); + app.edit_commit(); + assert!(app.edit.is_none()); + assert_eq!(app.pending_edit, Some((42, "corrected cmd".to_string()))); + } +} diff --git a/src/tui/event.rs b/src/tui/event.rs new file mode 100644 index 0000000..78b71a8 --- /dev/null +++ b/src/tui/event.rs @@ -0,0 +1,1119 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use crate::tui::app::{Action, App, Confirm, Mode, Tab, WsPane}; + +fn handle_edit_key(key: KeyEvent, app: &mut App) -> Outcome { + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + app.action = None; + return Outcome::Exit; + } + match key.code { + KeyCode::Esc => { + app.edit_cancel(); + Outcome::Continue + } + KeyCode::Enter => { + app.edit_commit(); + Outcome::Continue + } + KeyCode::Backspace => { + app.edit_backspace(); + Outcome::Continue + } + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + app.edit_push(c); + Outcome::Continue + } + _ => Outcome::Continue, + } +} + +fn handle_confirm_key(key: crossterm::event::KeyEvent, app: &mut App) -> Outcome { + use crossterm::event::KeyCode; + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + app.action = None; + return Outcome::Exit; + } + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => match app.confirm.take() { + Some(Confirm::Replay(r)) => { + app.replay_workspace = Some(r.workspace); + Outcome::Exit + } + Some(Confirm::Delete(d)) => { + app.pending_delete = Some((d.id, d.origin)); + Outcome::Continue + } + None => Outcome::Continue, + }, + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + app.cancel_confirm(); + Outcome::Continue + } + _ => Outcome::Continue, + } +} + +pub enum Outcome { + Continue, + Exit, +} + +fn handle_cmdline_key(key: KeyEvent, app: &mut App, now: i64) -> Outcome { + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + app.action = None; + return Outcome::Exit; + } + match key.code { + KeyCode::Enter => { + app.cmdline_submit(now); + Outcome::Continue + } + KeyCode::Esc => { + app.cmdline_cancel(); + Outcome::Continue + } + KeyCode::Backspace => { + app.cmdline_backspace(); + Outcome::Continue + } + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + app.cmdline_push(c); + Outcome::Continue + } + _ => Outcome::Continue, + } +} + +pub fn handle_key(key: KeyEvent, app: &mut App, now: i64) -> Outcome { + if app.confirm.is_some() { + return handle_confirm_key(key, app); + } + if app.edit.is_some() { + return handle_edit_key(key, app); + } + if app.cmdline.is_some() { + return handle_cmdline_key(key, app, now); + } + if app.show_help { + app.show_help = false; + return Outcome::Continue; + } + + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.action = None; + return Outcome::Exit; + } + KeyCode::Left => { + app.prev_tab(); + return Outcome::Continue; + } + KeyCode::Right => { + app.next_tab(); + return Outcome::Continue; + } + _ => {} + } + + match app.tab { + Tab::History => handle_history_key(key, app, now), + Tab::Workspaces => handle_ws_key(key, app), + } +} + +fn handle_history_key(key: KeyEvent, app: &mut App, now: i64) -> Outcome { + match app.mode { + Mode::Insert => handle_history_insert_key(key, app, now), + Mode::Normal => handle_history_normal_key(key, app), + } +} + +fn handle_history_insert_key(key: KeyEvent, app: &mut App, _now: i64) -> Outcome { + match key.code { + KeyCode::Esc => { + app.enter_normal_mode(); + Outcome::Continue + } + KeyCode::Enter => { + if let Some(cmd) = app.selected_command() { + app.action = Some(Action::Run(cmd.to_string())); + } + Outcome::Exit + } + KeyCode::Tab => { + if let Some(cmd) = app.selected_command() { + app.action = Some(Action::Edit(cmd.to_string())); + } + Outcome::Exit + } + KeyCode::Up => { + app.move_up(); + Outcome::Continue + } + KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.move_up(); + Outcome::Continue + } + KeyCode::Down => { + app.move_down(); + Outcome::Continue + } + KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.move_down(); + Outcome::Continue + } + KeyCode::Backspace => { + app.query.pop(); + app.recompute(); + Outcome::Continue + } + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + app.query.push(c); + app.recompute(); + Outcome::Continue + } + _ => Outcome::Continue, + } +} + +fn handle_history_normal_key(key: KeyEvent, app: &mut App) -> Outcome { + match key.code { + KeyCode::Char('?') => { + app.show_help = true; + Outcome::Continue + } + KeyCode::Char(':') => { + app.open_cmdline(); + Outcome::Continue + } + KeyCode::Char('i') | KeyCode::Char('/') => { + app.enter_insert_mode(); + Outcome::Continue + } + KeyCode::Char('j') | KeyCode::Down => { + app.move_down(); + Outcome::Continue + } + KeyCode::Char('k') | KeyCode::Up => { + app.move_up(); + Outcome::Continue + } + KeyCode::Char('d') => { + app.begin_delete_confirm_history(); + Outcome::Continue + } + KeyCode::Char('e') => { + app.begin_edit_history(); + Outcome::Continue + } + KeyCode::Enter => { + if let Some(cmd) = app.selected_command() { + app.action = Some(Action::Run(cmd.to_string())); + } + Outcome::Exit + } + KeyCode::Char('q') | KeyCode::Esc => { + app.action = None; + Outcome::Exit + } + _ => Outcome::Continue, + } +} + +fn handle_ws_key(key: KeyEvent, app: &mut App) -> Outcome { + match key.code { + KeyCode::Tab => { + app.toggle_ws_pane(); + return Outcome::Continue; + } + KeyCode::Esc => { + return Outcome::Exit; + } + _ => {} + } + + match app.ws_pane { + WsPane::List => handle_ws_list_key(key, app), + WsPane::Commands => handle_ws_commands_key(key, app), + } +} + +fn handle_ws_list_key(key: KeyEvent, app: &mut App) -> Outcome { + match key.code { + KeyCode::Enter => { + app.begin_replay_confirm(); + Outcome::Continue + } + KeyCode::Up | KeyCode::Char('k') => { + app.ws_move_up(); + Outcome::Continue + } + KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.ws_move_up(); + Outcome::Continue + } + KeyCode::Down | KeyCode::Char('j') => { + app.ws_move_down(); + Outcome::Continue + } + KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.ws_move_down(); + Outcome::Continue + } + _ => Outcome::Continue, + } +} + +fn handle_ws_commands_key(key: KeyEvent, app: &mut App) -> Outcome { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + app.ws_cmd_move_up(); + Outcome::Continue + } + KeyCode::Down | KeyCode::Char('j') => { + app.ws_cmd_move_down(); + Outcome::Continue + } + KeyCode::Char('d') => { + app.begin_delete_confirm_ws(); + Outcome::Continue + } + KeyCode::Char('e') => { + app.begin_edit_ws(); + Outcome::Continue + } + _ => Outcome::Continue, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::app::{DeleteOrigin, Mode, WsPane}; + use crate::workspaces::WorkspaceRow; + use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; + + const NOW: i64 = 1_700_000_000; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent { + code, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } + } + + fn ctrl(code: KeyCode) -> KeyEvent { + KeyEvent { + code, + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } + } + + fn app_with_rows() -> App { + let mut app = App::new(); + app.all_rows = vec![ + crate::search::CommandRow { + id: 1, + timestamp: 2000, + project: String::from("p"), + tags: String::from("[]"), + exit_code: 0, + duration_ms: 100, + directory: String::from("/tmp"), + command: String::from("git status"), + session_id: String::from("s1"), + workspace: None, + }, + crate::search::CommandRow { + id: 2, + timestamp: 1000, + project: String::from("p"), + tags: String::from("[]"), + exit_code: 0, + duration_ms: 100, + directory: String::from("/tmp"), + command: String::from("ls -la"), + session_id: String::from("s1"), + workspace: None, + }, + ]; + app.recompute(); + app + } + + fn app_with_workspaces() -> App { + let mut app = App::new(); + app.tab = Tab::Workspaces; + app.workspaces = vec![ + WorkspaceRow { + name: "ws-new".into(), + command_count: 3, + first_ts: 1000, + last_ts: 3000, + }, + WorkspaceRow { + name: "ws-old".into(), + command_count: 1, + first_ts: 500, + last_ts: 1000, + }, + ]; + app.ws_selected = 0; + app + } + + fn app_with_workspaces_and_commands() -> App { + let mut app = app_with_workspaces(); + app.ws_commands = vec![crate::search::CommandRow { + id: 10, + command: "git status".into(), + directory: "/tmp".into(), + project: "p".into(), + session_id: "s1".into(), + timestamp: 1000, + exit_code: 0, + duration_ms: 100, + tags: "[]".into(), + workspace: Some("ws-new".into()), + }]; + app + } + + #[test] + fn ctrl_c_sets_no_action_and_exits() { + let mut app = app_with_rows(); + let outcome = handle_key(ctrl(KeyCode::Char('c')), &mut app, NOW); + assert!(app.action.is_none()); + assert!(matches!(outcome, Outcome::Exit)); + } + + #[test] + fn history_insert_enter_sets_run_action_and_exits() { + let mut app = app_with_rows(); + let outcome = handle_key(key(KeyCode::Enter), &mut app, NOW); + assert!(matches!(outcome, Outcome::Exit)); + assert!(matches!(&app.action, Some(Action::Run(cmd)) if cmd == "git status")); + } + + #[test] + fn history_insert_tab_sets_edit_action_and_exits() { + let mut app = app_with_rows(); + let outcome = handle_key(key(KeyCode::Tab), &mut app, NOW); + assert!(matches!(outcome, Outcome::Exit)); + assert!(matches!(&app.action, Some(Action::Edit(cmd)) if cmd == "git status")); + } + + #[test] + fn history_insert_esc_enters_normal_mode_no_exit() { + let mut app = app_with_rows(); + assert_eq!(app.mode, Mode::Insert); + let outcome = handle_key(key(KeyCode::Esc), &mut app, NOW); + assert!(matches!(outcome, Outcome::Continue)); + assert_eq!(app.mode, Mode::Normal); + } + + #[test] + fn history_normal_i_enters_insert_mode() { + let mut app = app_with_rows(); + app.enter_normal_mode(); + let outcome = handle_key(key(KeyCode::Char('i')), &mut app, NOW); + assert!(matches!(outcome, Outcome::Continue)); + assert_eq!(app.mode, Mode::Insert); + } + + #[test] + fn history_normal_slash_enters_insert_mode() { + let mut app = app_with_rows(); + app.enter_normal_mode(); + let outcome = handle_key(key(KeyCode::Char('/')), &mut app, NOW); + assert!(matches!(outcome, Outcome::Continue)); + assert_eq!(app.mode, Mode::Insert); + } + + #[test] + fn history_normal_j_moves_down() { + let mut app = app_with_rows(); + app.enter_normal_mode(); + app.selected = 1; + handle_key(key(KeyCode::Char('j')), &mut app, NOW); + assert_eq!(app.selected, 0); + } + + #[test] + fn history_normal_k_moves_up() { + let mut app = app_with_rows(); + app.enter_normal_mode(); + app.selected = 0; + handle_key(key(KeyCode::Char('k')), &mut app, NOW); + assert_eq!(app.selected, 1); + } + + #[test] + fn history_normal_d_opens_delete_confirm() { + let mut app = app_with_rows(); + app.enter_normal_mode(); + let outcome = handle_key(key(KeyCode::Char('d')), &mut app, NOW); + assert!(matches!(outcome, Outcome::Continue)); + assert!(app.confirm.is_some()); + assert!(matches!( + app.confirm.as_ref().unwrap(), + Confirm::Delete(d) if matches!(d.origin, DeleteOrigin::History) + )); + } + + #[test] + fn history_normal_enter_runs_and_exits() { + let mut app = app_with_rows(); + app.enter_normal_mode(); + let outcome = handle_key(key(KeyCode::Enter), &mut app, NOW); + assert!(matches!(outcome, Outcome::Exit)); + assert!(matches!(&app.action, Some(Action::Run(_)))); + } + + #[test] + fn history_normal_q_exits() { + let mut app = app_with_rows(); + app.enter_normal_mode(); + let outcome = handle_key(key(KeyCode::Char('q')), &mut app, NOW); + assert!(matches!(outcome, Outcome::Exit)); + } + + #[test] + fn history_normal_esc_exits() { + let mut app = app_with_rows(); + app.enter_normal_mode(); + let outcome = handle_key(key(KeyCode::Esc), &mut app, NOW); + assert!(matches!(outcome, Outcome::Exit)); + } + + #[test] + fn history_insert_typing_still_filters_regression() { + let mut app = app_with_rows(); + assert_eq!(app.mode, Mode::Insert); + handle_key(key(KeyCode::Char('g')), &mut app, NOW); + assert_eq!(app.query, "g"); + } + + #[test] + fn history_insert_up_arrow_moves_to_older_command() { + let mut app = app_with_rows(); + app.selected = 0; + handle_key(key(KeyCode::Up), &mut app, NOW); + assert_eq!(app.selected, 1); + } + + #[test] + fn history_insert_down_arrow_moves_to_newer_command() { + let mut app = app_with_rows(); + app.selected = 1; + handle_key(key(KeyCode::Down), &mut app, NOW); + assert_eq!(app.selected, 0); + } + + #[test] + fn history_ctrl_p_moves_up() { + let mut app = app_with_rows(); + app.selected = 0; + handle_key(ctrl(KeyCode::Char('p')), &mut app, NOW); + assert_eq!(app.selected, 1); + } + + #[test] + fn history_ctrl_n_moves_down() { + let mut app = app_with_rows(); + app.selected = 1; + handle_key(ctrl(KeyCode::Char('n')), &mut app, NOW); + assert_eq!(app.selected, 0); + } + + #[test] + fn backspace_removes_last_char() { + let mut app = app_with_rows(); + app.query = String::from("git"); + app.recompute(); + handle_key(key(KeyCode::Backspace), &mut app, NOW); + assert_eq!(app.query, "gi"); + } + + #[test] + fn backspace_on_empty_query_is_noop() { + let mut app = app_with_rows(); + handle_key(key(KeyCode::Backspace), &mut app, NOW); + assert_eq!(app.query, ""); + } + + #[test] + fn typing_filters_results() { + let mut app = app_with_rows(); + handle_key(key(KeyCode::Char('g')), &mut app, NOW); + handle_key(key(KeyCode::Char('i')), &mut app, NOW); + handle_key(key(KeyCode::Char('t')), &mut app, NOW); + assert!(!app.filtered.is_empty()); + assert_eq!(app.all_rows[app.filtered[0]].command, "git status"); + } + + #[test] + fn enter_on_empty_list_exits_without_action() { + let mut app = App::new(); + let outcome = handle_key(key(KeyCode::Enter), &mut app, NOW); + assert!(matches!(outcome, Outcome::Exit)); + assert!(app.action.is_none()); + } + + #[test] + fn up_at_oldest_is_noop() { + let mut app = app_with_rows(); + app.selected = 1; + handle_key(key(KeyCode::Up), &mut app, NOW); + assert_eq!(app.selected, 1, "Up at oldest row must not go further"); + } + + #[test] + fn down_at_newest_is_noop() { + let mut app = app_with_rows(); + app.selected = 0; + handle_key(key(KeyCode::Down), &mut app, NOW); + assert_eq!(app.selected, 0, "Down at newest row must not go further"); + } + + #[test] + fn left_switches_to_history_tab() { + let mut app = App::new(); + app.tab = Tab::Workspaces; + handle_key(key(KeyCode::Left), &mut app, NOW); + assert_eq!(app.tab, Tab::History); + } + + #[test] + fn right_switches_to_workspaces_tab() { + let mut app = App::new(); + app.workspaces = vec![WorkspaceRow { + name: "ws1".into(), + command_count: 1, + first_ts: 1000, + last_ts: 2000, + }]; + handle_key(key(KeyCode::Right), &mut app, NOW); + assert_eq!(app.tab, Tab::Workspaces); + } + + #[test] + fn right_on_workspaces_stays_workspaces() { + let mut app = App::new(); + app.tab = Tab::Workspaces; + app.workspaces = vec![WorkspaceRow { + name: "ws1".into(), + command_count: 1, + first_ts: 1000, + last_ts: 2000, + }]; + handle_key(key(KeyCode::Right), &mut app, NOW); + assert_eq!(app.tab, Tab::Workspaces); + } + + #[test] + fn left_on_history_stays_history() { + let mut app = App::new(); + assert_eq!(app.tab, Tab::History); + handle_key(key(KeyCode::Left), &mut app, NOW); + assert_eq!(app.tab, Tab::History); + } + + #[test] + fn ws_up_changes_ws_selected_not_history_selected() { + let mut app = app_with_workspaces(); + app.ws_selected = 1; + let history_selected_before = app.selected; + handle_key(key(KeyCode::Up), &mut app, NOW); + assert_eq!( + app.selected, history_selected_before, + "history selected must not change" + ); + assert_eq!(app.ws_selected, 0); + } + + #[test] + fn ws_down_increases_ws_selected() { + let mut app = app_with_workspaces(); + app.ws_selected = 0; + handle_key(key(KeyCode::Down), &mut app, NOW); + assert_eq!(app.ws_selected, 1); + } + + #[test] + fn ws_enter_opens_confirm_modal() { + let mut app = app_with_workspaces_and_commands(); + let outcome = handle_key(key(KeyCode::Enter), &mut app, NOW); + assert!(matches!(outcome, Outcome::Continue)); + assert!(app.confirm.is_some(), "Enter must open confirm modal"); + } + + #[test] + fn ws_char_does_not_modify_query() { + let mut app = app_with_workspaces(); + handle_key(key(KeyCode::Char('g')), &mut app, NOW); + assert_eq!(app.query, "", "typing in Workspaces must not modify query"); + } + + #[test] + fn ws_backspace_does_not_modify_query() { + let mut app = app_with_workspaces(); + app.query = "existing".into(); + handle_key(key(KeyCode::Backspace), &mut app, NOW); + assert_eq!( + app.query, "existing", + "backspace in Workspaces must not modify query" + ); + } + + #[test] + fn history_enter_still_exits_with_run() { + let mut app = app_with_rows(); + assert_eq!(app.tab, Tab::History); + let outcome = handle_key(key(KeyCode::Enter), &mut app, NOW); + assert!(matches!(outcome, Outcome::Exit)); + assert!(matches!(&app.action, Some(Action::Run(_)))); + } + + #[test] + fn history_char_still_filters() { + let mut app = app_with_rows(); + assert_eq!(app.tab, Tab::History); + handle_key(key(KeyCode::Char('g')), &mut app, NOW); + assert_eq!(app.query, "g"); + } + + #[test] + fn confirm_replay_y_sets_replay_workspace_clears_confirm_and_exits() { + let mut app = app_with_workspaces_and_commands(); + handle_key(key(KeyCode::Enter), &mut app, NOW); + assert!(app.confirm.is_some()); + let outcome = handle_key(key(KeyCode::Char('y')), &mut app, NOW); + assert!(matches!(outcome, Outcome::Exit)); + assert!(app.confirm.is_none(), "confirm must be cleared after y"); + assert_eq!(app.replay_workspace, Some("ws-new".into())); + } + + #[test] + fn confirm_replay_n_cancels_and_continues() { + let mut app = app_with_workspaces_and_commands(); + handle_key(key(KeyCode::Enter), &mut app, NOW); + let outcome = handle_key(key(KeyCode::Char('n')), &mut app, NOW); + assert!(matches!(outcome, Outcome::Continue)); + assert!(app.confirm.is_none()); + assert!(app.replay_workspace.is_none()); + } + + #[test] + fn confirm_replay_esc_cancels_and_continues() { + let mut app = app_with_workspaces_and_commands(); + handle_key(key(KeyCode::Enter), &mut app, NOW); + let outcome = handle_key(key(KeyCode::Esc), &mut app, NOW); + assert!(matches!(outcome, Outcome::Continue)); + assert!(app.confirm.is_none()); + } + + #[test] + fn ctrl_c_exits_even_with_confirm_open() { + let mut app = app_with_workspaces_and_commands(); + handle_key(key(KeyCode::Enter), &mut app, NOW); + assert!(app.confirm.is_some()); + let outcome = handle_key(ctrl(KeyCode::Char('c')), &mut app, NOW); + assert!( + matches!(outcome, Outcome::Exit), + "Ctrl-C must exit from any state" + ); + assert!(app.action.is_none()); + } + + #[test] + fn delete_confirm_y_sets_pending_delete_continue_not_exit() { + let mut app = app_with_rows(); + app.enter_normal_mode(); + handle_key(key(KeyCode::Char('d')), &mut app, NOW); + assert!(app.confirm.is_some()); + let expected_id = if let Some(Confirm::Delete(d)) = app.confirm.as_ref() { + d.id + } else { + panic!("expected Delete confirm"); + }; + let outcome = handle_key(key(KeyCode::Char('y')), &mut app, NOW); + assert!( + matches!(outcome, Outcome::Continue), + "delete y must Continue, not Exit" + ); + assert!(app.confirm.is_none()); + assert!( + matches!(app.pending_delete, Some((id, DeleteOrigin::History)) if id == expected_id) + ); + } + + #[test] + fn delete_confirm_n_cancels_and_continues() { + let mut app = app_with_rows(); + app.enter_normal_mode(); + handle_key(key(KeyCode::Char('d')), &mut app, NOW); + let outcome = handle_key(key(KeyCode::Char('n')), &mut app, NOW); + assert!(matches!(outcome, Outcome::Continue)); + assert!(app.confirm.is_none()); + assert!(app.pending_delete.is_none()); + } + + #[test] + fn ws_tab_toggles_pane() { + let mut app = app_with_workspaces(); + assert_eq!(app.ws_pane, WsPane::List); + handle_key(key(KeyCode::Tab), &mut app, NOW); + assert_eq!(app.ws_pane, WsPane::Commands); + handle_key(key(KeyCode::Tab), &mut app, NOW); + assert_eq!(app.ws_pane, WsPane::List); + } + + #[test] + fn ws_commands_pane_d_opens_delete_confirm_with_ws_command_id() { + let mut app = app_with_workspaces_and_commands(); + app.ws_pane = WsPane::Commands; + app.ws_cmd_selected = 0; + let expected_id = app.ws_commands[0].id; + let outcome = handle_key(key(KeyCode::Char('d')), &mut app, NOW); + assert!(matches!(outcome, Outcome::Continue)); + assert!( + matches!(app.confirm.as_ref(), Some(Confirm::Delete(d)) if d.id == expected_id && matches!(d.origin, DeleteOrigin::Workspace)) + ); + } + + #[test] + fn ws_esc_exits() { + let mut app = app_with_workspaces(); + let outcome = handle_key(key(KeyCode::Esc), &mut app, NOW); + assert!(matches!(outcome, Outcome::Exit)); + } + + #[test] + fn confirm_other_key_does_not_leak_through() { + let mut app = app_with_workspaces_and_commands(); + handle_key(key(KeyCode::Enter), &mut app, NOW); + let query_before = app.query.clone(); + let outcome = handle_key(key(KeyCode::Char('g')), &mut app, NOW); + assert!(matches!(outcome, Outcome::Continue)); + assert_eq!( + app.query, query_before, + "typing must not leak to query while confirm is open" + ); + assert!( + app.confirm.is_some(), + "confirm must stay open on unknown key" + ); + } + + #[test] + fn confirm_left_does_not_change_tab() { + let mut app = app_with_workspaces_and_commands(); + handle_key(key(KeyCode::Enter), &mut app, NOW); + let tab_before = app.tab; + handle_key(key(KeyCode::Left), &mut app, NOW); + assert_eq!( + app.tab, tab_before, + "Left must not change tab while confirm is open" + ); + } + + #[test] + fn history_enter_still_exits_with_run_regression() { + let mut app = app_with_rows(); + assert_eq!(app.tab, Tab::History); + let outcome = handle_key(key(KeyCode::Enter), &mut app, NOW); + assert!(matches!(outcome, Outcome::Exit)); + assert!(matches!(&app.action, Some(Action::Run(_)))); + } + + #[test] + fn ws_enter_is_continue_not_exit() { + let mut app = app_with_workspaces(); + let outcome = handle_key(key(KeyCode::Enter), &mut app, NOW); + assert!( + matches!(outcome, Outcome::Continue), + "Workspaces Enter must be Continue" + ); + } + + #[test] + fn ws_enter_does_not_change_tab_or_filters() { + let mut app = app_with_workspaces(); + handle_key(key(KeyCode::Enter), &mut app, NOW); + assert_eq!(app.tab, Tab::Workspaces, "Enter must not change tab"); + assert!( + app.filters.session.is_none(), + "Enter must not set session filter" + ); + } + + #[test] + fn replay_confirm_is_still_replay_type() { + let mut app = app_with_workspaces_and_commands(); + handle_key(key(KeyCode::Enter), &mut app, NOW); + assert!( + matches!(app.confirm.as_ref(), Some(Confirm::Replay(_))), + "Enter in Workspaces List must open Replay confirm, not Delete" + ); + } + + #[test] + fn history_normal_e_opens_edit() { + let mut app = app_with_rows(); + app.enter_normal_mode(); + let outcome = handle_key(key(KeyCode::Char('e')), &mut app, NOW); + assert!(matches!(outcome, Outcome::Continue)); + assert!( + app.edit.is_some(), + "e must open edit modal in History Normal" + ); + } + + #[test] + fn ws_commands_e_opens_edit() { + let mut app = app_with_workspaces_and_commands(); + app.ws_pane = WsPane::Commands; + let outcome = handle_key(key(KeyCode::Char('e')), &mut app, NOW); + assert!(matches!(outcome, Outcome::Continue)); + assert!(app.edit.is_some(), "e must open edit modal in Ws Commands"); + } + + #[test] + fn edit_char_appends_to_buffer_not_query() { + let mut app = app_with_rows(); + app.enter_normal_mode(); + handle_key(key(KeyCode::Char('e')), &mut app, NOW); + assert!(app.edit.is_some()); + let query_before = app.query.clone(); + handle_key(key(KeyCode::Char('x')), &mut app, NOW); + assert_eq!( + app.query, query_before, + "typing while editing must not change query" + ); + assert!( + app.edit.as_ref().unwrap().buffer.ends_with('x'), + "x must append to edit buffer" + ); + } + + #[test] + fn edit_backspace_removes_from_buffer() { + let mut app = app_with_rows(); + app.enter_normal_mode(); + handle_key(key(KeyCode::Char('e')), &mut app, NOW); + let original_len = app.edit.as_ref().unwrap().buffer.len(); + handle_key(key(KeyCode::Backspace), &mut app, NOW); + assert_eq!( + app.edit.as_ref().unwrap().buffer.len(), + original_len.saturating_sub(1) + ); + } + + #[test] + fn edit_enter_commits_and_sets_pending_edit() { + let mut app = app_with_rows(); + app.enter_normal_mode(); + handle_key(key(KeyCode::Char('e')), &mut app, NOW); + assert!(app.edit.is_some()); + let outcome = handle_key(key(KeyCode::Enter), &mut app, NOW); + assert!(matches!(outcome, Outcome::Continue)); + assert!(app.edit.is_none(), "edit must be cleared after Enter"); + assert!( + app.pending_edit.is_some(), + "pending_edit must be set after Enter" + ); + } + + #[test] + fn edit_esc_cancels() { + let mut app = app_with_rows(); + app.enter_normal_mode(); + handle_key(key(KeyCode::Char('e')), &mut app, NOW); + assert!(app.edit.is_some()); + let outcome = handle_key(key(KeyCode::Esc), &mut app, NOW); + assert!(matches!(outcome, Outcome::Continue)); + assert!(app.edit.is_none(), "Esc must cancel edit"); + assert!(app.pending_edit.is_none()); + } + + #[test] + fn ctrl_c_exits_even_while_editing() { + let mut app = app_with_rows(); + app.enter_normal_mode(); + handle_key(key(KeyCode::Char('e')), &mut app, NOW); + assert!(app.edit.is_some()); + let outcome = handle_key(ctrl(KeyCode::Char('c')), &mut app, NOW); + assert!( + matches!(outcome, Outcome::Exit), + "Ctrl-C must exit from edit mode" + ); + } + + #[test] + fn left_right_do_not_switch_tabs_while_editing() { + let mut app = app_with_rows(); + app.enter_normal_mode(); + handle_key(key(KeyCode::Char('e')), &mut app, NOW); + assert!(app.edit.is_some()); + let tab_before = app.tab; + handle_key(key(KeyCode::Left), &mut app, NOW); + handle_key(key(KeyCode::Right), &mut app, NOW); + assert_eq!( + app.tab, tab_before, + "Left/Right must not switch tab while editing" + ); + } + + #[test] + fn typing_in_insert_does_not_parse_dsl_filters() { + let mut app = app_with_rows(); + for c in "project:thoth ".chars() { + handle_key(key(KeyCode::Char(c)), &mut app, NOW); + } + for c in "cargo".chars() { + handle_key(key(KeyCode::Char(c)), &mut app, NOW); + } + assert!( + app.filters.project.is_none(), + "typing in Insert must NOT parse DSL; filters.project must remain None" + ); + assert_eq!(app.query, "project:thoth cargo"); + } + + #[test] + fn backspace_removes_last_char_raw() { + let mut app = app_with_rows(); + for c in "project:x".chars() { + handle_key(key(KeyCode::Char(c)), &mut app, NOW); + } + handle_key(key(KeyCode::Backspace), &mut app, NOW); + assert_eq!(app.query, "project:"); + } + + #[test] + fn normal_question_mark_sets_show_help() { + let mut app = app_with_rows(); + app.enter_normal_mode(); + let outcome = handle_key(key(KeyCode::Char('?')), &mut app, NOW); + assert!(matches!(outcome, Outcome::Continue)); + assert!(app.show_help); + } + + #[test] + fn any_key_clears_show_help() { + let mut app = app_with_rows(); + app.show_help = true; + let outcome = handle_key(key(KeyCode::Char('j')), &mut app, NOW); + assert!(matches!(outcome, Outcome::Continue)); + assert!(!app.show_help); + } + + #[test] + fn plain_text_search_still_works() { + let mut app = app_with_rows(); + for c in "git".chars() { + handle_key(key(KeyCode::Char(c)), &mut app, NOW); + } + assert_eq!(app.query, "git"); + assert!(!app.filtered.is_empty()); + } + + #[test] + fn normal_colon_opens_cmdline() { + let mut app = app_with_rows(); + app.enter_normal_mode(); + let outcome = handle_key(key(KeyCode::Char(':')), &mut app, NOW); + assert!(matches!(outcome, Outcome::Continue)); + assert_eq!(app.cmdline, Some(String::new())); + } + + #[test] + fn cmdline_open_typing_appends_to_buffer_not_query() { + let mut app = app_with_rows(); + app.enter_normal_mode(); + handle_key(key(KeyCode::Char(':')), &mut app, NOW); + assert!(app.cmdline.is_some()); + let query_before = app.query.clone(); + handle_key(key(KeyCode::Char('p')), &mut app, NOW); + assert_eq!( + app.query, query_before, + "typing while cmdline is open must not change query" + ); + assert_eq!(app.cmdline, Some("p".to_string())); + } + + #[test] + fn cmdline_enter_applies_and_closes() { + let mut app = app_with_rows(); + app.enter_normal_mode(); + handle_key(key(KeyCode::Char(':')), &mut app, NOW); + for c in "project:thoth".chars() { + handle_key(key(KeyCode::Char(c)), &mut app, NOW); + } + let outcome = handle_key(key(KeyCode::Enter), &mut app, NOW); + assert!(matches!(outcome, Outcome::Continue)); + assert!(app.cmdline.is_none(), "Enter must close cmdline"); + assert_eq!(app.filters.project, Some("thoth".into())); + } + + #[test] + fn cmdline_esc_cancels_without_applying() { + let mut app = app_with_rows(); + app.enter_normal_mode(); + handle_key(key(KeyCode::Char(':')), &mut app, NOW); + for c in "project:thoth".chars() { + handle_key(key(KeyCode::Char(c)), &mut app, NOW); + } + let outcome = handle_key(key(KeyCode::Esc), &mut app, NOW); + assert!(matches!(outcome, Outcome::Continue)); + assert!(app.cmdline.is_none(), "Esc must close cmdline"); + assert!(app.filters.project.is_none(), "Esc must not apply filters"); + } + + #[test] + fn cmdline_ctrl_c_exits() { + let mut app = app_with_rows(); + app.enter_normal_mode(); + handle_key(key(KeyCode::Char(':')), &mut app, NOW); + let outcome = handle_key(ctrl(KeyCode::Char('c')), &mut app, NOW); + assert!( + matches!(outcome, Outcome::Exit), + "Ctrl-C must exit from cmdline" + ); + } + + #[test] + fn cmdline_left_right_tab_do_not_leak() { + let mut app = app_with_rows(); + app.enter_normal_mode(); + handle_key(key(KeyCode::Char(':')), &mut app, NOW); + let tab_before = app.tab; + handle_key(key(KeyCode::Left), &mut app, NOW); + handle_key(key(KeyCode::Right), &mut app, NOW); + handle_key(key(KeyCode::Tab), &mut app, NOW); + assert_eq!( + app.tab, tab_before, + "Left/Right/Tab must not leak while cmdline is open" + ); + assert!(app.cmdline.is_some(), "cmdline must still be open"); + } + + #[test] + fn insert_typing_project_word_does_not_set_filter_regression() { + let mut app = app_with_rows(); + assert_eq!(app.mode, Mode::Insert); + for c in "project".chars() { + handle_key(key(KeyCode::Char(c)), &mut app, NOW); + } + assert!( + app.filters.project.is_none(), + "typing 'project' in Insert mode must NOT set a project filter" + ); + } +} diff --git a/src/tui/fuzzy.rs b/src/tui/fuzzy.rs new file mode 100644 index 0000000..80f1f0a --- /dev/null +++ b/src/tui/fuzzy.rs @@ -0,0 +1,175 @@ +use nucleo_matcher::{ + pattern::{CaseMatching, Normalization, Pattern}, + Config, Matcher, Utf32Str, +}; + +use crate::search::CommandRow; + +pub fn rank(query: &str, items: &[CommandRow]) -> Vec { + if query.trim().is_empty() { + return (0..items.len()).collect(); + } + + let mut matcher = Matcher::new(Config::DEFAULT); + let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart); + + let mut scored: Vec<(usize, u32)> = items + .iter() + .enumerate() + .filter_map(|(i, row)| { + let tag_text = crate::tags::parse_active(&row.tags).join(" "); + let mut parts = vec![row.command.as_str()]; + if !tag_text.is_empty() { + parts.push(tag_text.as_str()); + } + if !row.project.is_empty() { + parts.push(row.project.as_str()); + } + if !row.directory.is_empty() { + parts.push(row.directory.as_str()); + } + let hay = parts.join(" "); + let mut haystack_buf = Vec::new(); + let haystack = Utf32Str::new(&hay, &mut haystack_buf); + pattern.score(haystack, &mut matcher).map(|s| (i, s)) + }) + .collect(); + + scored.sort_by(|a, b| b.1.cmp(&a.1)); + scored.into_iter().map(|(i, _)| i).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn row(command: &str) -> CommandRow { + CommandRow { + id: 0, + timestamp: 0, + project: String::new(), + tags: String::from("[]"), + exit_code: 0, + duration_ms: 0, + directory: String::new(), + command: command.to_string(), + session_id: String::new(), + workspace: None, + } + } + + fn row_with_tags(command: &str, tags: &str) -> CommandRow { + let mut r = row(command); + r.tags = tags.to_string(); + r + } + + fn row_with_project(command: &str, project: &str) -> CommandRow { + let mut r = row(command); + r.project = project.to_string(); + r + } + + fn row_with_directory(command: &str, directory: &str) -> CommandRow { + let mut r = row(command); + r.directory = directory.to_string(); + r + } + + #[test] + fn query_matches_a_tag_not_in_the_command() { + let items = vec![ + row_with_tags("cargo build", r#"["release","perf"]"#), + row_with_tags("ls -la", r#"["files"]"#), + ]; + let result = rank("release", &items); + assert_eq!( + result.first(), + Some(&0), + "command tagged 'release' must match" + ); + assert!( + !result.contains(&1), + "untagged-for-release row must not match" + ); + } + + #[test] + fn empty_query_returns_all_in_original_order() { + let items = vec![row("git status"), row("ls -la"), row("cargo build")]; + let result = rank("", &items); + assert_eq!(result, vec![0, 1, 2]); + } + + #[test] + fn whitespace_only_query_returns_all() { + let items = vec![row("git status"), row("ls -la")]; + let result = rank(" ", &items); + assert_eq!(result, vec![0, 1]); + } + + #[test] + fn fuzzy_dkr_matches_docker() { + let items = vec![row("docker run nginx"), row("ls -la"), row("cargo test")]; + let result = rank("dkr", &items); + assert!(!result.is_empty()); + assert_eq!(result[0], 0); + } + + #[test] + fn no_match_returns_empty() { + let items = vec![row("git status"), row("ls -la"), row("cargo build")]; + let result = rank("zzzzzzzzzzz", &items); + assert!(result.is_empty()); + } + + #[test] + fn query_matches_project_not_in_command_or_tags() { + let items = vec![row_with_project("ls -la", "myproject"), row("git status")]; + let result = rank("myproject", &items); + assert!( + result.contains(&0), + "row with matching project must appear in results" + ); + assert!( + !result.contains(&1), + "row with non-matching project must not appear" + ); + } + + #[test] + fn query_matches_directory_not_in_command_or_tags() { + let items = vec![ + row_with_directory("ls", "/home/jose/special-dir"), + row("git status"), + ]; + let result = rank("special-dir", &items); + assert!( + result.contains(&0), + "row with matching directory must appear in results" + ); + assert!( + !result.contains(&1), + "row with non-matching directory must not appear" + ); + } + + #[test] + fn command_tag_match_still_works_after_haystack_extension() { + let items = vec![ + row_with_tags("cargo build", r#"["release"]"#), + row("unrelated"), + ]; + let result = rank("release", &items); + assert!(result.contains(&0), "tag match must still work"); + assert!(!result.contains(&1), "non-matching row must not appear"); + } + + #[test] + fn ordering_better_match_comes_first() { + let items = vec![row("cargo test"), row("cargo build --tests")]; + let result = rank("cargo test", &items); + assert!(!result.is_empty()); + assert_eq!(result[0], 0); + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..b6f5c6c --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,186 @@ +pub mod app; +pub mod event; +pub mod fuzzy; +pub mod render; +pub mod time; + +use std::fs::OpenOptions; +use std::io::IsTerminal; +use std::panic; + +use crossterm::{ + event::{self as ct_event, Event}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{backend::CrosstermBackend, widgets::TableState, Terminal}; +use rusqlite::Connection; + +use crate::error::ThothError; +use crate::export::{self, ExportArgs}; +use crate::search::Column; +use crate::tui::app::{Action, App, DeleteOrigin}; +use crate::tui::event::{handle_key, Outcome}; +use crate::tui::render::format_action_line; + +struct TerminalGuard; + +impl TerminalGuard { + fn new() -> Result { + let mut tty = OpenOptions::new() + .write(true) + .open("/dev/tty") + .map_err(|e| ThothError::Tui(format!("cannot open /dev/tty for restore: {e}")))?; + enable_raw_mode().map_err(|e| ThothError::Tui(format!("enable_raw_mode failed: {e}")))?; + execute!(tty, EnterAlternateScreen) + .map_err(|e| ThothError::Tui(format!("EnterAlternateScreen failed: {e}")))?; + Ok(Self) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + if let Ok(mut tty) = OpenOptions::new().write(true).open("/dev/tty") { + let _ = execute!(tty, LeaveAlternateScreen); + } + } +} + +pub fn run( + conn: &mut Connection, + now: i64, + is_bottom: bool, + columns: Vec, +) -> Result<(), ThothError> { + let tty = OpenOptions::new() + .read(true) + .write(true) + .open("/dev/tty") + .map_err(|e| ThothError::Tui(format!("cannot open /dev/tty: {e}")))?; + + let prev_hook = panic::take_hook(); + panic::set_hook(Box::new(move |info| { + let _ = disable_raw_mode(); + if let Ok(mut t) = OpenOptions::new().write(true).open("/dev/tty") { + let _ = execute!(t, LeaveAlternateScreen); + } + prev_hook(info); + })); + + let _guard = TerminalGuard::new()?; + + let backend = CrosstermBackend::new(tty); + let mut terminal = Terminal::new(backend) + .map_err(|e| ThothError::Tui(format!("terminal init failed: {e}")))?; + + let mut app = App::new(); + app.reload(conn, now)?; + + let themes_dir = crate::config::resolve_themes_dir(); + let cfg_theme = crate::config::load().theme; + app.theme = crate::config::resolve_theme(&cfg_theme.name, &themes_dir); + + let mut table_state = TableState::default(); + + loop { + terminal + .draw(|f| render::draw(f, &app, now, is_bottom, &columns, &mut table_state)) + .map_err(|e| ThothError::Tui(format!("draw failed: {e}")))?; + + if ct_event::poll(std::time::Duration::from_millis(200)) + .map_err(|e| ThothError::Tui(format!("event poll failed: {e}")))? + { + if let Event::Key(key) = + ct_event::read().map_err(|e| ThothError::Tui(format!("event read failed: {e}")))? + { + match handle_key(key, &mut app, now) { + Outcome::Exit => break, + Outcome::Continue => { + if let Some((id, new_cmd)) = app.pending_edit.take() { + crate::edit::edit_command(conn, id, &new_cmd) + .map_err(|e| ThothError::Tui(format!("edit failed: {e}")))?; + app.reload(conn, now).map_err(|e| { + ThothError::Tui(format!("history reload after edit failed: {e}")) + })?; + app.reload_ws_commands(conn).map_err(|e| { + ThothError::Tui(format!( + "ws commands reload after edit failed: {e}" + )) + })?; + } + if let Some((id, origin)) = app.pending_delete.take() { + crate::forget::delete_targets(conn, &[id]) + .map_err(|e| ThothError::Tui(format!("delete failed: {e}")))?; + match origin { + DeleteOrigin::History => { + app.reload(conn, now).map_err(|e| { + ThothError::Tui(format!("history reload failed: {e}")) + })?; + } + DeleteOrigin::Workspace => { + app.reload_workspaces(conn).map_err(|e| { + ThothError::Tui(format!("workspace reload failed: {e}")) + })?; + app.reload_ws_commands(conn).map_err(|e| { + ThothError::Tui(format!( + "workspace commands reload failed: {e}" + )) + })?; + } + } + } + if app.needs_ws_reload { + app.reload_workspaces(conn).map_err(|e| { + ThothError::Tui(format!("workspace reload failed: {e}")) + })?; + } + if app.needs_ws_commands_reload { + app.reload_ws_commands(conn).map_err(|e| { + ThothError::Tui(format!("workspace commands reload failed: {e}")) + })?; + } + if app.needs_history_reload { + app.reload(conn, now).map_err(|e| { + ThothError::Tui(format!("history reload failed: {e}")) + })?; + app.needs_history_reload = false; + } + } + } + } + } + } + + drop(_guard); + + if let Some(ws_name) = app.replay_workspace.take() { + let args = ExportArgs { + workspace: Some(ws_name.clone()), + session: None, + tag: vec![], + project: None, + since: None, + exit: None, + }; + let rows = export::collect(conn, &args, now) + .map_err(|e| ThothError::Tui(format!("replay collect failed: {e}")))?; + let cmd = export::render_replay_command(&rows); + if std::io::stdout().is_terminal() { + println!("Replaying workspace '{ws_name}'..."); + let shell = std::env::var("SHELL").unwrap_or_else(|_| "bash".into()); + std::process::Command::new(shell) + .args(["-i", "-c", &cmd]) + .status() + .map_err(|e| ThothError::Tui(format!("replay run failed: {e}")))?; + } else { + app.action = Some(Action::Replay(cmd)); + } + } + + if let Some(line) = format_action_line(app.action.as_ref()) { + println!("{line}"); + } + + Ok(()) +} diff --git a/src/tui/render.rs b/src/tui/render.rs new file mode 100644 index 0000000..17da4b0 --- /dev/null +++ b/src/tui/render.rs @@ -0,0 +1,2994 @@ +use ratatui::{ + layout::{Alignment, Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, BorderType, Borders, Cell, Clear, Paragraph, Row, Table, TableState}, + Frame, +}; + +use crate::search::{Column, CommandRow}; +use crate::theme::Theme; +use crate::tui::app::{App, Confirm, Mode, Tab, WsPane}; +use crate::tui::time::format_relative; +use crate::workspaces::WorkspaceRow; + +const EDIT_MODAL_W: u16 = 60; +const EDIT_MODAL_H: u16 = 5; +const HELP_MODAL_W: u16 = 64; +const HELP_MODAL_H: u16 = 17; +const CMDLINE_MODAL_W: u16 = 60; +const CMDLINE_MODAL_H: u16 = 3; + +pub fn display_command(raw: &str) -> String { + let collapsed: String = raw + .chars() + .map(|c| { + if c == '\n' || c == '\r' || c == '\t' { + ' ' + } else { + c + } + }) + .collect(); + let mut result = String::with_capacity(collapsed.len()); + let mut prev_space = false; + for c in collapsed.chars() { + if c == ' ' { + if !prev_space { + result.push(c); + } + prev_space = true; + } else { + result.push(c); + prev_space = false; + } + } + result.trim().to_string() +} + +fn format_duration(ms: i64) -> String { + if ms < 1000 { + format!("{ms}ms") + } else { + format!("{}s", ms / 1000) + } +} + +fn exit_color(exit_code: i64, theme: &Theme) -> Color { + if exit_code == 0 { + theme.ok + } else { + theme.fail + } +} + +fn exit_label(exit_code: i64) -> &'static str { + if exit_code == 0 { + "ok" + } else { + "fail" + } +} + +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let end = s + .char_indices() + .nth(max.saturating_sub(1)) + .map(|(i, _)| i) + .unwrap_or(s.len()); + format!("{}...", &s[..end]) + } +} + +fn short_id(id: &str) -> &str { + let end = id.char_indices().nth(8).map(|(i, _)| i).unwrap_or(id.len()); + &id[..end] +} + +fn filter_chips(app: &App) -> String { + let mut parts = Vec::new(); + if let Some(ref p) = app.filters.project { + parts.push(format!("[project:{p}]")); + } + for tag in &app.filters.tag { + parts.push(format!("[tag:{tag}]")); + } + if let Some(ref e) = app.filters.exit { + parts.push(format!("[exit:{e:?}]")); + } + if let Some(ref s) = app.filters.since { + parts.push(format!("[since:{s}]")); + } + if let Some(ref u) = app.filters.until { + parts.push(format!("[until:{u}]")); + } + if let Some(ref s) = app.filters.session { + parts.push(format!("[session:{}]", short_id(s))); + } + if let Some(ref d) = app.filters.duration { + parts.push(format!("[dur:{d}]")); + } + parts.join(" ") +} + +pub fn tui_header(col: &Column) -> &'static str { + match col { + Column::Timestamp => "time", + Column::Duration => "dur", + Column::Exit => "exit", + Column::Project => "project", + Column::Command => "command", + Column::Tags => "tags", + Column::Directory => "dir", + } +} + +fn col_cap(col: &Column) -> u16 { + match col { + Column::Timestamp => 9, + Column::Duration => 7, + Column::Exit => 4, + Column::Project => 20, + Column::Tags => 16, + Column::Directory => 30, + Column::Command => u16::MAX, + } +} + +fn col_text(col: &Column, row: &CommandRow, now: i64) -> String { + match col { + Column::Timestamp => format_relative(row.timestamp, now), + Column::Duration => format_duration(row.duration_ms), + Column::Exit => exit_label(row.exit_code).to_string(), + Column::Project => row.project.clone(), + Column::Command => display_command(&row.command), + Column::Tags => crate::tags::format_prompt_segment(&row.tags), + Column::Directory => row.directory.clone(), + } +} + +fn tui_cell(col: &Column, row: &CommandRow, now: i64, width: u16, theme: &Theme) -> Cell<'static> { + let text = truncate(&col_text(col, row, now), width as usize); + let style = match col { + Column::Timestamp => Style::default().fg(theme.dim), + Column::Duration => Style::default().fg(theme.accent), + Column::Exit => Style::default().fg(exit_color(row.exit_code, theme)), + Column::Project => Style::default().fg(theme.project), + Column::Directory => Style::default().fg(theme.directory), + Column::Command => Style::default().fg(theme.command), + Column::Tags => Style::default().fg(theme.tags), + }; + Cell::from(Line::from(vec![Span::styled(text, style)])) +} + +pub fn resolve_tui_columns(names: &[String]) -> Vec { + use crate::config::default_tui_columns; + + if names.is_empty() { + eprintln!("thoth: unknown TUI column(s) in config, using defaults"); + return resolve_tui_columns(&default_tui_columns()); + } + + let resolved: Vec> = names.iter().map(|n| Column::from_name(n)).collect(); + if resolved.iter().any(|c| c.is_none()) { + eprintln!("thoth: unknown TUI column(s) in config, using defaults"); + return resolve_tui_columns(&default_tui_columns()); + } + resolved.into_iter().flatten().collect() +} + +fn render_tab_bar(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) { + let theme = &app.theme; + let dim_style = Style::default().fg(theme.dim).add_modifier(Modifier::DIM); + let active_style = Style::default() + .bg(theme.selection_bg) + .fg(theme.selection_fg) + .add_modifier(Modifier::BOLD); + + let (history_style, workspaces_style) = match app.tab { + Tab::History => (active_style, dim_style), + Tab::Workspaces => (dim_style, active_style), + }; + + let line = Line::from(vec![ + Span::styled(" History ", history_style), + Span::styled(" ", dim_style), + Span::styled(" Workspaces ", workspaces_style), + ]); + frame.render_widget(Paragraph::new(line), area); +} + +fn render_preview(frame: &mut Frame, area: ratatui::layout::Rect, app: &App, now: i64) { + let theme = &app.theme; + let dim_style = Style::default().fg(theme.dim).add_modifier(Modifier::DIM); + let border_style = Style::default().fg(theme.border); + + let preview_block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(border_style) + .title(Span::styled(" preview ", dim_style)); + + let inner = preview_block.inner(area); + frame.render_widget(preview_block, area); + + let selected_row: Option<&CommandRow> = app + .filtered + .get(app.selected) + .and_then(|&idx| app.all_rows.get(idx)); + + let Some(row) = selected_row else { + return; + }; + + let label_style = Style::default().fg(theme.dim); + let cmd_style = Style::default().fg(theme.command); + let project_style = Style::default().fg(theme.project); + let dir_style = Style::default().fg(theme.dim); + let tag_style = Style::default().fg(theme.tags); + let exit_style = Style::default().fg(exit_color(row.exit_code, theme)); + + let tag_text = crate::tags::format_prompt_segment(&row.tags); + let tag_display = if tag_text.is_empty() { + "none".to_string() + } else { + tag_text + }; + + let workspace_display = row.workspace.as_deref().unwrap_or("-").to_string(); + let duration_display = format_duration(row.duration_ms); + let when_display = format!( + "{} ({})", + crate::search::fmt_timestamp(row.timestamp), + format_relative(row.timestamp, now) + ); + let exit_display = format!("{} ({})", exit_label(row.exit_code), row.exit_code); + + let max_val_w = inner.width.saturating_sub(12) as usize; + + let lines = vec![ + Line::from(vec![Span::styled( + truncate(&display_command(&row.command), inner.width as usize), + cmd_style, + )]), + Line::from(vec![ + Span::styled("project ", label_style), + Span::styled(truncate(&row.project, max_val_w), project_style), + ]), + Line::from(vec![ + Span::styled("directory ", label_style), + Span::styled(truncate(&row.directory, max_val_w), dir_style), + ]), + Line::from(vec![ + Span::styled("exit ", label_style), + Span::styled(truncate(&exit_display, max_val_w), exit_style), + ]), + Line::from(vec![ + Span::styled("duration ", label_style), + Span::styled(truncate(&duration_display, max_val_w), label_style), + ]), + Line::from(vec![ + Span::styled("when ", label_style), + Span::styled(truncate(&when_display, max_val_w), label_style), + ]), + Line::from(vec![ + Span::styled("tags ", label_style), + Span::styled(truncate(&tag_display, max_val_w), tag_style), + ]), + Line::from(vec![ + Span::styled("workspace ", label_style), + Span::styled(truncate(&workspace_display, max_val_w), label_style), + ]), + Line::from(vec![ + Span::styled("session ", label_style), + Span::styled(truncate(&row.session_id, max_val_w), label_style), + ]), + ]; + + let paragraph = Paragraph::new(lines).wrap(ratatui::widgets::Wrap { trim: false }); + frame.render_widget(paragraph, inner); +} + +fn render_history_pane( + frame: &mut Frame, + area: ratatui::layout::Rect, + app: &App, + now: i64, + is_bottom: bool, + columns: &[Column], + table_state: &mut TableState, +) { + let h_chunks = Layout::horizontal([Constraint::Percentage(60), Constraint::Min(1)]).split(area); + let list_area = h_chunks[0]; + let preview_area = h_chunks[1]; + + render_preview(frame, preview_area, app, now); + + let theme = &app.theme; + let dim_style = Style::default().fg(theme.dim).add_modifier(Modifier::DIM); + let border_style = Style::default().fg(theme.border); + + let list_block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(border_style) + .title(Span::styled(" history ", dim_style)); + + let inner_list_area = list_block.inner(list_area); + frame.render_widget(list_block, list_area); + + let has_command_col = columns.iter().any(|c| matches!(c, Column::Command)); + let n_cols = columns.len(); + let gaps: u16 = n_cols.saturating_sub(1) as u16; + + let ordered: Vec = if is_bottom { + app.filtered.iter().rev().copied().collect() + } else { + app.filtered.to_vec() + }; + + let flex_idx: Option = if has_command_col { + columns.iter().position(|c| matches!(c, Column::Command)) + } else { + n_cols.checked_sub(1) + }; + + let content_w: Vec = columns + .iter() + .enumerate() + .map(|(i, col)| { + if Some(i) == flex_idx { + return 0; + } + let header_len = tui_header(col).chars().count() as u16; + let max_content = ordered + .iter() + .map(|&fi| col_text(col, &app.all_rows[fi], now).chars().count() as u16) + .max() + .unwrap_or(0); + header_len.max(max_content).min(col_cap(col)) + }) + .collect(); + + let fixed_total: u16 = content_w.iter().sum(); + let flex_w: u16 = inner_list_area + .width + .saturating_sub(fixed_total + gaps) + .max(1); + + let widths: Vec = columns + .iter() + .enumerate() + .map(|(i, _)| { + if Some(i) == flex_idx { + Constraint::Min(1) + } else { + Constraint::Length(content_w[i]) + } + }) + .collect(); + + let rows: Vec = ordered + .iter() + .map(|&fi| { + let row = &app.all_rows[fi]; + let cells: Vec = columns + .iter() + .enumerate() + .map(|(i, col)| { + let w = if Some(i) == flex_idx { + flex_w + } else { + content_w[i] + }; + tui_cell(col, row, now, w, theme) + }) + .collect(); + Row::new(cells) + }) + .collect(); + + let table_header_style = Style::default() + .fg(theme.dim) + .add_modifier(Modifier::DIM) + .add_modifier(Modifier::BOLD); + let header_cells: Vec = columns + .iter() + .map(|col| Cell::from(tui_header(col)).style(table_header_style)) + .collect(); + let table_header = Row::new(header_cells); + + let highlight_style = Style::default() + .bg(theme.selection_bg) + .fg(theme.selection_fg) + .add_modifier(Modifier::BOLD); + + let table = Table::new(rows, widths) + .header(table_header) + .block(Block::default()) + .row_highlight_style(highlight_style); + + if app.filtered.is_empty() { + table_state.select(None); + } else { + let display_idx = if is_bottom { + app.filtered.len() - 1 - app.selected + } else { + app.selected + }; + table_state.select(Some(display_idx)); + } + frame.render_stateful_widget(table, inner_list_area, table_state); +} + +fn render_workspaces_pane( + frame: &mut Frame, + area: ratatui::layout::Rect, + app: &App, + now: i64, + table_state: &mut TableState, + cmd_table_state: &mut TableState, +) { + let theme = &app.theme; + let dim_style = Style::default().fg(theme.dim).add_modifier(Modifier::DIM); + let highlight_style = Style::default() + .bg(theme.selection_bg) + .fg(theme.selection_fg) + .add_modifier(Modifier::BOLD); + + let list_focused = app.ws_pane == WsPane::List; + let accent_border = Style::default().fg(theme.accent); + let dim_border = Style::default().fg(theme.border); + + let h_chunks = Layout::horizontal([Constraint::Percentage(38), Constraint::Min(1)]).split(area); + + let left_area = h_chunks[0]; + let right_area = h_chunks[1]; + + let workspaces_block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(if list_focused { + accent_border + } else { + dim_border + }) + .title(Span::styled( + " workspaces ", + if list_focused { + Style::default().fg(theme.accent) + } else { + dim_style + }, + )); + + let inner_left = workspaces_block.inner(left_area); + frame.render_widget(workspaces_block, left_area); + + if app.workspaces.is_empty() { + let empty = Paragraph::new("no workspaces").style(dim_style); + frame.render_widget(empty, inner_left); + } else { + let rows: Vec = app + .workspaces + .iter() + .map(|w| ws_row(w, now, theme)) + .collect(); + + let widths = vec![ + Constraint::Min(1), + Constraint::Length(6), + Constraint::Length(9), + ]; + + let table = Table::new(rows, widths) + .block(Block::default()) + .row_highlight_style(highlight_style); + + table_state.select(Some(app.ws_selected)); + frame.render_stateful_widget(table, inner_left, table_state); + } + + let commands_focused = app.ws_pane == WsPane::Commands; + let commands_block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(if commands_focused { + accent_border + } else { + dim_border + }) + .title(Span::styled( + " commands ", + if commands_focused { + Style::default().fg(theme.accent) + } else { + dim_style + }, + )); + + let inner_right = commands_block.inner(right_area); + frame.render_widget(commands_block, right_area); + + if app.ws_commands.is_empty() { + let empty = Paragraph::new("no commands").style(dim_style); + frame.render_widget(empty, inner_right); + } else { + let rows: Vec = app + .ws_commands + .iter() + .map(|cmd| { + let time_text = truncate(&format_relative(cmd.timestamp, now), 9); + let cmd_text = truncate( + &display_command(&cmd.command), + inner_right.width.saturating_sub(16) as usize, + ); + Row::new(vec![ + Cell::from(Span::styled(time_text, Style::default().fg(theme.dim))), + Cell::from(Span::styled( + exit_label(cmd.exit_code), + Style::default().fg(exit_color(cmd.exit_code, theme)), + )), + Cell::from(cmd_text), + ]) + }) + .collect(); + + let widths = vec![ + Constraint::Length(9), + Constraint::Length(4), + Constraint::Min(1), + ]; + + if commands_focused { + let table = Table::new(rows, widths) + .block(Block::default()) + .row_highlight_style(highlight_style); + cmd_table_state.select(Some(app.ws_cmd_selected)); + frame.render_stateful_widget(table, inner_right, cmd_table_state); + } else { + let table = Table::new(rows, widths).block(Block::default()); + frame.render_widget(table, inner_right); + } + } +} + +fn ws_row(w: &WorkspaceRow, now: i64, theme: &Theme) -> Row<'static> { + let name_text = truncate(&w.name, 20); + let count_text = w.command_count.to_string(); + let last_text = truncate(&format_relative(w.last_ts, now), 9); + Row::new(vec![ + Cell::from(Span::styled(name_text, Style::default().fg(theme.project))), + Cell::from(Span::styled(count_text, Style::default().fg(theme.dim))), + Cell::from(Span::styled(last_text, Style::default().fg(theme.dim))), + ]) +} + +pub fn draw( + frame: &mut Frame, + app: &App, + now: i64, + is_bottom: bool, + columns: &[Column], + table_state: &mut TableState, +) { + draw_with_cmd_state( + frame, + app, + now, + is_bottom, + columns, + table_state, + &mut TableState::default(), + ); +} + +pub fn draw_with_cmd_state( + frame: &mut Frame, + app: &App, + now: i64, + is_bottom: bool, + columns: &[Column], + table_state: &mut TableState, + cmd_table_state: &mut TableState, +) { + let area = frame.area(); + + let chunks = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(area); + + let tabbar_area = chunks[0]; + let header_area = chunks[1]; + let middle_area = chunks[2]; + let query_area = chunks[3]; + let status_area = chunks[4]; + let controls_area = chunks[5]; + + render_tab_bar(frame, tabbar_area, app); + + let theme = &app.theme; + let version = env!("CARGO_PKG_VERSION"); + let accent_style = Style::default() + .fg(theme.controls) + .add_modifier(Modifier::BOLD); + let dim_style = Style::default().fg(theme.dim).add_modifier(Modifier::DIM); + let green_bold = Style::default() + .fg(theme.header) + .add_modifier(Modifier::BOLD); + + let accent_bar = Span::styled("▌ ", green_bold); + let name_span = Span::styled("Thoth", green_bold); + let sep_span = Span::styled(" · ", dim_style); + let version_span = Span::styled(format!("v{version}"), dim_style); + let count_sep = Span::styled(" ", dim_style); + let count_span = Span::styled(format!("{} commands", app.all_rows.len()), dim_style); + let history_right = format!("History count: {}", app.all_rows.len()); + let right_len = history_right.len(); + let left_len = + 2 + 5 + 3 + 1 + version.len() + 2 + format!("{} commands", app.all_rows.len()).len(); + let pad = (header_area.width as usize).saturating_sub(left_len + right_len); + let padding_span = Span::raw(format!("{:pad$}", "", pad = pad)); + let history_span = Span::styled(history_right, dim_style); + + let header_line = Line::from(vec![ + accent_bar, + name_span, + sep_span, + version_span, + count_sep, + count_span, + padding_span, + history_span, + ]); + frame.render_widget(Paragraph::new(header_line), header_area); + + match app.tab { + Tab::History => { + render_history_pane( + frame, + middle_area, + app, + now, + is_bottom, + columns, + table_state, + ); + } + Tab::Workspaces => { + render_workspaces_pane(frame, middle_area, app, now, table_state, cmd_table_state); + } + } + + if app.confirm.is_some() { + render_confirm_modal(frame, middle_area, app); + } + if let Some(ref es) = app.edit { + render_edit_modal(frame, middle_area, es, theme); + } + if app.show_help { + render_help_modal(frame, middle_area, theme); + } + if let Some(ref buf) = app.cmdline { + render_cmdline_modal(frame, middle_area, buf, theme); + } + + let query_text = format!("> {}", app.query); + frame.render_widget(Paragraph::new(query_text), query_area); + + let chips = filter_chips(app); + let result_count = app.filtered.len(); + let status_text = if chips.is_empty() { + format!("{result_count} results") + } else { + format!("{chips} {result_count} results") + }; + frame.render_widget( + Paragraph::new(status_text).alignment(Alignment::Left), + status_area, + ); + + let controls_line = match app.tab { + Tab::History => match app.mode { + Mode::Insert => Line::from(vec![ + Span::styled(" ↑↓", accent_style), + Span::styled(" nav", dim_style), + Span::styled(" · ", dim_style), + Span::styled("↵", accent_style), + Span::styled(" run", dim_style), + Span::styled(" · ", dim_style), + Span::styled("⇥", accent_style), + Span::styled(" edit", dim_style), + Span::styled(" · ", dim_style), + Span::styled("esc", accent_style), + Span::styled(" normal", dim_style), + Span::styled(" · ", dim_style), + Span::styled("^c", accent_style), + Span::styled(" quit", dim_style), + ]), + Mode::Normal => Line::from(vec![ + Span::styled(" j/k", accent_style), + Span::styled(" move", dim_style), + Span::styled(" · ", dim_style), + Span::styled("d", accent_style), + Span::styled(" delete", dim_style), + Span::styled(" · ", dim_style), + Span::styled("e", accent_style), + Span::styled(" edit", dim_style), + Span::styled(" · ", dim_style), + Span::styled("↵", accent_style), + Span::styled(" run", dim_style), + Span::styled(" · ", dim_style), + Span::styled("i", accent_style), + Span::styled(" search", dim_style), + Span::styled(" · ", dim_style), + Span::styled(":", accent_style), + Span::styled(" filter", dim_style), + Span::styled(" · ", dim_style), + Span::styled("?", accent_style), + Span::styled(" help", dim_style), + Span::styled(" · ", dim_style), + Span::styled("q", accent_style), + Span::styled(" quit", dim_style), + ]), + }, + Tab::Workspaces => match app.ws_pane { + WsPane::List => Line::from(vec![ + Span::styled(" ←→", accent_style), + Span::styled(" tabs", dim_style), + Span::styled(" · ", dim_style), + Span::styled("↑↓", accent_style), + Span::styled(" workspace", dim_style), + Span::styled(" · ", dim_style), + Span::styled("⇥", accent_style), + Span::styled(" commands", dim_style), + Span::styled(" · ", dim_style), + Span::styled("↵", accent_style), + Span::styled(" replay", dim_style), + Span::styled(" · ", dim_style), + Span::styled("esc", accent_style), + Span::styled(" quit", dim_style), + ]), + WsPane::Commands => Line::from(vec![ + Span::styled(" ←→", accent_style), + Span::styled(" tabs", dim_style), + Span::styled(" · ", dim_style), + Span::styled("↑↓", accent_style), + Span::styled(" command", dim_style), + Span::styled(" · ", dim_style), + Span::styled("d", accent_style), + Span::styled(" delete", dim_style), + Span::styled(" · ", dim_style), + Span::styled("e", accent_style), + Span::styled(" edit", dim_style), + Span::styled(" · ", dim_style), + Span::styled("⇥", accent_style), + Span::styled(" back", dim_style), + Span::styled(" · ", dim_style), + Span::styled("esc", accent_style), + Span::styled(" quit", dim_style), + ]), + }, + }; + frame.render_widget(Paragraph::new(controls_line), controls_area); +} + +pub fn format_action_line(action: Option<&crate::tui::app::Action>) -> Option { + use crate::tui::app::Action; + match action { + Some(Action::Run(cmd)) => Some(format!("RUN:{cmd}")), + Some(Action::Edit(cmd)) => Some(format!("EDIT:{cmd}")), + Some(Action::Replay(path)) => Some(format!("REPLAY:{path}")), + None => None, + } +} + +fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { + let x = area.x + area.width.saturating_sub(width) / 2; + let y = area.y + area.height.saturating_sub(height) / 2; + Rect { + x, + y, + width: width.min(area.width), + height: height.min(area.height), + } +} + +fn render_confirm_modal(frame: &mut Frame, area: Rect, app: &crate::tui::app::App) { + let Some(confirm) = &app.confirm else { return }; + let theme = &app.theme; + let dim_style = Style::default().fg(theme.dim); + let accent_style = Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD); + + let modal_w = 50u16; + let modal_h = 4u16; + let modal_area = centered_rect(modal_w, modal_h, area); + + frame.render_widget(Clear, modal_area); + + match confirm { + Confirm::Replay(r) => { + let ws_name = truncate(&r.workspace, 20); + let title = format!(" Replay '{ws_name}' ({} commands)? ", r.count); + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(theme.accent)) + .title(Span::styled(title, accent_style)); + + let inner = block.inner(modal_area); + frame.render_widget(block, modal_area); + + let hint = Paragraph::new(Line::from(vec![ + Span::styled("[y]", accent_style), + Span::styled(" run all ", dim_style), + Span::styled("[n]", accent_style), + Span::styled(" cancel", dim_style), + ])) + .alignment(Alignment::Center); + frame.render_widget(hint, inner); + } + Confirm::Delete(d) => { + let label = truncate(&d.label, 30); + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Color::Red)) + .title(Span::styled( + " Delete this command? ", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + )); + + let inner = block.inner(modal_area); + frame.render_widget(block, modal_area); + + let hint = Paragraph::new(Line::from(vec![ + Span::styled(&label, dim_style), + Span::styled(" ", dim_style), + Span::styled("[y]", accent_style), + Span::styled(" delete ", dim_style), + Span::styled("[n]", accent_style), + Span::styled(" cancel", dim_style), + ])) + .alignment(Alignment::Center); + frame.render_widget(hint, inner); + } + } +} + +fn render_help_modal(frame: &mut Frame, area: Rect, theme: &Theme) { + let dim_style = Style::default().fg(theme.dim); + let accent_style = Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD); + let kw_style = Style::default().fg(theme.command); + + let modal_area = centered_rect(HELP_MODAL_W, HELP_MODAL_H, area); + frame.render_widget(Clear, modal_area); + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(theme.accent)) + .title(Span::styled(" Help ", accent_style)); + + let inner = block.inner(modal_area); + frame.render_widget(block, modal_area); + + let field = |kw: &str, alias: &str, desc: &str| { + let left = format!(" {kw}{alias}"); + let pad = " ".repeat(28usize.saturating_sub(left.chars().count())); + Line::from(vec![ + Span::styled(format!(" {kw}"), kw_style), + Span::styled(format!("{alias}{pad}"), dim_style), + Span::styled(desc.to_string(), dim_style), + ]) + }; + let key = |k: &str, label: &str| { + vec![ + Span::styled(format!(" {k}"), accent_style), + Span::styled(format!(" {label}"), dim_style), + ] + }; + + let lines = vec![ + Line::from(vec![ + Span::styled(" Filters", accent_style), + Span::styled(" (press ", dim_style), + Span::styled(":", kw_style), + Span::styled(" to open the cmdline, then type)", dim_style), + ]), + Line::from(vec![Span::raw("")]), + field("project:NAME", " p:", "filter by project"), + field("tag:NAME", " t:", "filter by tag (repeatable)"), + field("exit:ok|fail", "", "filter by exit status"), + field( + "since:DATE", + " until:DATE", + "time range (2h, today, 2024-01-01)", + ), + field("dur:>30", " dur:<5", "duration in seconds"), + Line::from(vec![Span::raw("")]), + Line::from(vec![Span::styled(" Keys", accent_style)]), + Line::from( + [ + key("esc", "normal"), + key("i", "search"), + key(":", "filter"), + key("enter", "run"), + ] + .concat(), + ), + Line::from( + [ + key("j/k", "move"), + key("d", "delete"), + key("e", "edit"), + key("?", "help"), + ] + .concat(), + ), + Line::from(vec![Span::raw("")]), + Line::from(vec![Span::styled(" Press any key to close", dim_style)]), + ]; + + frame.render_widget(Paragraph::new(lines), inner); +} + +fn render_cmdline_modal(frame: &mut Frame, area: Rect, buf: &str, theme: &Theme) { + let accent_style = Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD); + let text_style = Style::default().fg(theme.command); + + let modal_area = centered_rect(CMDLINE_MODAL_W, CMDLINE_MODAL_H, area); + frame.render_widget(Clear, modal_area); + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(theme.accent)) + .title(Span::styled(" Cmdline ", accent_style)); + + let inner = block.inner(modal_area); + frame.render_widget(block, modal_area); + + let display = format!("> {buf}_"); + frame.render_widget(Paragraph::new(Span::styled(display, text_style)), inner); +} + +fn render_edit_modal( + frame: &mut Frame, + area: Rect, + es: &crate::tui::app::EditState, + theme: &Theme, +) { + let dim_style = Style::default().fg(theme.dim); + let accent_style = Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD); + + let modal_area = centered_rect(EDIT_MODAL_W, EDIT_MODAL_H, area); + frame.render_widget(Clear, modal_area); + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(theme.accent)) + .title(Span::styled(" Edit command ", accent_style)); + + let inner = block.inner(modal_area); + frame.render_widget(block, modal_area); + + let chunks = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(inner); + + let display = format!("{}_", es.buffer); + frame.render_widget( + Paragraph::new(Span::styled(display, Style::default())), + chunks[0], + ); + + let hint = Paragraph::new(Line::from(vec![ + Span::styled("↵", accent_style), + Span::styled(" save ", dim_style), + Span::styled("esc", accent_style), + Span::styled(" cancel", dim_style), + ])) + .alignment(Alignment::Center); + frame.render_widget(hint, chunks[1]); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::default_tui_columns; + use crate::theme::Theme; + use crate::tui::app::{Action, App, Confirm, ConfirmDelete, DeleteOrigin, Tab, WsPane}; + use crate::workspaces::WorkspaceRow; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + const TEST_NOW: i64 = 1_000_000_000; + const TEST_WIDTH: u16 = 80; + const TEST_HEIGHT: u16 = 24; + + fn make_row(cmd: &str, ts: i64, exit: i64, project: &str) -> CommandRow { + CommandRow { + id: 0, + command: cmd.to_string(), + timestamp: ts, + exit_code: exit, + project: project.to_string(), + directory: "/tmp".to_string(), + tags: "[]".to_string(), + session_id: "s1".to_string(), + duration_ms: 100, + workspace: None, + } + } + + fn make_workspace(name: &str, count: i64, last_ts: i64) -> WorkspaceRow { + WorkspaceRow { + name: name.to_string(), + command_count: count, + first_ts: last_ts - 3600, + last_ts, + } + } + + fn default_cols() -> Vec { + resolve_tui_columns(&default_tui_columns()) + } + + fn render_app(app: &App) -> String { + let backend = TestBackend::new(TEST_WIDTH, TEST_HEIGHT); + let mut terminal = Terminal::new(backend).unwrap(); + let mut ts = TableState::default(); + let cols = default_cols(); + terminal + .draw(|f| draw(f, app, TEST_NOW, true, &cols, &mut ts)) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut lines: Vec = Vec::new(); + for row in 0..TEST_HEIGHT { + let mut line = String::new(); + for col in 0..TEST_WIDTH { + line.push(buf[(col, row)].symbol().chars().next().unwrap_or(' ')); + } + lines.push(line.trim_end().to_string()); + } + lines.join("\n") + } + + fn render_app_buf(app: &App) -> ratatui::buffer::Buffer { + let backend = TestBackend::new(TEST_WIDTH, TEST_HEIGHT); + let mut terminal = Terminal::new(backend).unwrap(); + let mut ts = TableState::default(); + let cols = default_cols(); + terminal + .draw(|f| draw(f, app, TEST_NOW, true, &cols, &mut ts)) + .unwrap(); + terminal.backend().buffer().clone() + } + + fn app_with_rows() -> App { + let mut app = App::new(); + app.all_rows = vec![ + make_row("git status", TEST_NOW - 60, 0, "proj-alpha"), + make_row("docker run nginx", TEST_NOW - 3600, 1, "proj-beta"), + make_row("cargo build", TEST_NOW - 86400, 0, "proj-alpha"), + ]; + app.recompute(); + app + } + + fn app_with_workspaces() -> App { + let mut app = App::new(); + app.tab = Tab::Workspaces; + app.workspaces = vec![ + make_workspace("proj-alpha", 5, TEST_NOW - 3600), + make_workspace("proj-beta", 3, TEST_NOW - 7200), + ]; + app.ws_selected = 0; + app.ws_commands = vec![ + make_row("git status", TEST_NOW - 3500, 0, "proj-alpha"), + make_row("cargo build", TEST_NOW - 3400, 0, "proj-alpha"), + ]; + app + } + + #[test] + fn tab_bar_shows_both_labels() { + let app = app_with_rows(); + let text = render_app(&app); + assert!(text.contains("History"), "tab bar must show 'History'"); + assert!( + text.contains("Workspaces"), + "tab bar must show 'Workspaces'" + ); + } + + #[test] + fn tab_bar_active_history_is_highlighted() { + let app = app_with_rows(); + let buf = render_app_buf(&app); + + let active_bg_count = (0..TEST_WIDTH) + .filter(|&x| buf[(x, 0)].style().bg == Some(Theme::default().selection_bg)) + .count(); + + assert!( + active_bg_count > 0, + "tab bar row 0 must highlight the active History tab with the selection background" + ); + assert!( + active_bg_count < TEST_WIDTH as usize, + "tab bar must NOT be fully highlighted; only the active label (count={active_bg_count})" + ); + } + + #[test] + fn header_has_no_full_width_reversed_bar() { + let app = app_with_rows(); + let buf = render_app_buf(&app); + + let reversed_count = (0..TEST_WIDTH) + .filter(|&x| { + buf[(x, 1)] + .style() + .add_modifier + .contains(Modifier::REVERSED) + }) + .count(); + assert_eq!( + reversed_count, 0, + "header row (y=1) must have zero REVERSED cells; found {reversed_count}" + ); + } + + #[test] + fn table_header_not_reversed() { + let app = app_with_rows(); + let buf = render_app_buf(&app); + + let header_row_y = 3u16; + let reversed_count = (0..TEST_WIDTH) + .filter(|&x| { + buf[(x, header_row_y)] + .style() + .add_modifier + .contains(Modifier::REVERSED) + }) + .count(); + assert_eq!( + reversed_count, 0, + "table header row must have zero REVERSED cells; found {reversed_count}" + ); + } + + #[test] + fn workspaces_tab_renders_two_panes() { + let app = app_with_workspaces(); + let text = render_app(&app); + assert!( + text.contains("workspaces"), + "Workspaces tab must show 'workspaces' pane title" + ); + assert!( + text.contains("commands"), + "Workspaces tab must show 'commands' pane title" + ); + } + + #[test] + fn workspaces_tab_shows_workspace_row_highlighted() { + let app = app_with_workspaces(); + let buf = render_app_buf(&app); + + let any_cyan_bg = (0..TEST_HEIGHT).any(|row_y| { + (0..TEST_WIDTH) + .any(|x| buf[(x, row_y)].style().bg == Some(Theme::default().selection_bg)) + }); + assert!( + any_cyan_bg, + "Workspaces tab must have bg=Cyan on selected row" + ); + } + + #[test] + fn workspaces_tab_shows_command_text() { + let app = app_with_workspaces(); + let text = render_app(&app); + assert!( + text.contains("git status"), + "Workspaces tab right pane must show workspace command text" + ); + } + + #[test] + fn history_tab_still_renders_history_block() { + let app = app_with_rows(); + let text = render_app(&app); + assert!( + text.contains("history"), + "History tab must show 'history' block title" + ); + assert!( + text.contains("git status"), + "History tab must show commands" + ); + } + + #[test] + fn controls_history_insert_shows_nav_and_run() { + let app = app_with_rows(); + let text = render_app(&app); + assert!( + text.contains("nav"), + "History Insert controls must contain 'nav'" + ); + assert!( + text.contains("run"), + "History Insert controls must contain 'run'" + ); + assert!( + text.contains("edit"), + "History Insert controls must contain 'edit'" + ); + assert!( + text.contains("normal"), + "History Insert controls must hint 'normal' for esc" + ); + } + + #[test] + fn controls_history_normal_shows_jk_and_delete() { + let mut app = app_with_rows(); + app.mode = crate::tui::app::Mode::Normal; + let text = render_app(&app); + assert!( + text.contains("move"), + "History Normal controls must contain 'move'" + ); + assert!( + text.contains("delete"), + "History Normal controls must contain 'delete'" + ); + assert!( + text.contains("run"), + "History Normal controls must contain 'run'" + ); + assert!( + text.contains("search"), + "History Normal controls must contain 'search'" + ); + assert!( + text.contains("quit"), + "History Normal controls must contain 'quit'" + ); + } + + #[test] + fn controls_workspaces_shows_workspace_and_quit() { + let app = app_with_workspaces(); + let text = render_app(&app); + assert!( + text.contains("workspace"), + "Workspaces controls must contain 'workspace'" + ); + assert!( + !text.contains("open"), + "Workspaces controls must NOT contain 'open' in PR2" + ); + assert!( + text.contains("quit"), + "Workspaces controls must contain 'quit'" + ); + assert!( + text.contains("tabs"), + "Workspaces controls must hint 'tabs'" + ); + } + + #[test] + fn session_chip_shows_when_session_filter_set() { + let mut app = app_with_rows(); + app.filters.session = Some("abcdefgh-1234-5678".into()); + let text = render_app(&app); + assert!( + text.contains("[session:abcdefgh]"), + "status bar must show session chip with 8-char prefix; got:\n{text}" + ); + } + + #[test] + fn header_contains_version_and_history_count() { + let app = app_with_rows(); + let text = render_app(&app); + assert!( + text.contains("Thoth"), + "header must contain product name 'Thoth'" + ); + assert!( + text.contains("History count: 3"), + "header must contain history count: got {text}" + ); + } + + #[test] + fn results_list_contains_command_text() { + let app = app_with_rows(); + let text = render_app(&app); + assert!( + text.contains("git status"), + "list must contain 'git status'" + ); + assert!( + text.contains("docker run nginx"), + "list must contain 'docker run'" + ); + assert!( + text.contains("cargo build"), + "list must contain 'cargo build'" + ); + } + + #[test] + fn results_list_contains_relative_times() { + let app = app_with_rows(); + let text = render_app(&app); + assert!( + text.contains("ago"), + "rows must show relative times containing 'ago'" + ); + } + + #[test] + fn results_list_contains_project_names() { + let app = app_with_rows(); + let text = render_app(&app); + assert!(text.contains("proj-alpha"), "rows must show project name"); + } + + #[test] + fn query_line_shows_prompt() { + let mut app = App::new(); + app.all_rows = vec![make_row("ls", TEST_NOW - 10, 0, "p")]; + app.query = "my search".to_string(); + app.recompute(); + let text = render_app(&app); + assert!( + text.contains("> my search"), + "query line must show '> my search'; got:\n{text}" + ); + } + + #[test] + fn query_line_shows_empty_prompt_when_no_query() { + let app = app_with_rows(); + let text = render_app(&app); + assert!(text.contains('>'), "query line must contain '>'"); + } + + #[test] + fn selected_row_is_highlighted() { + let mut app = App::new(); + app.all_rows = vec![ + make_row("first-cmd", TEST_NOW - 10, 0, "p"), + make_row("second-cmd", TEST_NOW - 20, 0, "p"), + ]; + app.recompute(); + app.selected = 0; + + let buf = render_app_buf(&app); + + let any_cyan_bg = (0..TEST_HEIGHT).any(|row_y| { + (0..TEST_WIDTH) + .any(|x| buf[(x, row_y)].style().bg == Some(Theme::default().selection_bg)) + }); + assert!( + any_cyan_bg, + "selected row must have bg=Cyan highlight somewhere in the frame" + ); + } + + #[test] + fn status_bar_shows_result_count() { + let app = app_with_rows(); + let text = render_app(&app); + assert!( + text.contains("3 results"), + "status bar must show '3 results'; got:\n{text}" + ); + } + + #[test] + fn status_bar_shows_filter_chips_when_active() { + let mut app = App::new(); + app.all_rows = vec![make_row("ls", TEST_NOW - 10, 0, "myproject")]; + app.filters.project = Some("myproject".to_string()); + app.recompute(); + let text = render_app(&app); + assert!( + text.contains("[project:myproject]"), + "status bar must show project filter chip; got:\n{text}" + ); + } + + #[test] + fn exit_ok_renders_as_text_not_glyph() { + let mut app = App::new(); + app.all_rows = vec![make_row("ls", TEST_NOW - 10, 0, "p")]; + app.recompute(); + let text = render_app(&app); + assert!( + text.contains("ok"), + "exit 0 must render as 'ok'; got:\n{text}" + ); + assert!(!text.contains('✓'), "exit 0 must NOT render as glyph"); + } + + #[test] + fn exit_fail_renders_as_text_not_glyph() { + let mut app = App::new(); + app.all_rows = vec![make_row("bad-cmd", TEST_NOW - 10, 1, "p")]; + app.recompute(); + let text = render_app(&app); + assert!( + text.contains("fail"), + "exit nonzero must render as 'fail'; got:\n{text}" + ); + assert!(!text.contains('✗'), "exit nonzero must NOT render as glyph"); + } + + #[test] + fn border_title_history_appears() { + let app = app_with_rows(); + let text = render_app(&app); + assert!( + text.contains("history"), + "list border must show title 'history'; got:\n{text}" + ); + } + + #[test] + fn multiline_command_is_collapsed_in_render() { + let mut app = App::new(); + app.all_rows = vec![make_row("git\nstatus\n --short", TEST_NOW - 10, 0, "p")]; + app.recompute(); + let text = render_app(&app); + assert!( + !text.contains('\n') || !text.lines().any(|l| l.trim().starts_with("status")), + "multi-line command must be collapsed to one line" + ); + assert!( + text.contains("git status --short"), + "collapsed command must appear as single line; got:\n{text}" + ); + } + + #[test] + fn scroll_brings_selected_row_into_view() { + const SMALL_HEIGHT: u16 = 10; + const SMALL_WIDTH: u16 = 80; + let mut app = App::new(); + app.all_rows = (0..20) + .map(|i| make_row(&format!("cmd-row-{i:02}"), TEST_NOW - i * 60, 0, "p")) + .collect(); + app.recompute(); + app.selected = 15; + + let backend = TestBackend::new(SMALL_WIDTH, SMALL_HEIGHT); + let mut terminal = Terminal::new(backend).unwrap(); + let mut ts = TableState::default(); + let cols = default_cols(); + terminal + .draw(|f| draw(f, &app, TEST_NOW, true, &cols, &mut ts)) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + + let mut full_text = String::new(); + for row in 0..SMALL_HEIGHT { + for col in 0..SMALL_WIDTH { + full_text.push(buf[(col, row)].symbol().chars().next().unwrap_or(' ')); + } + full_text.push('\n'); + } + + assert!( + full_text.contains("cmd-row-15"), + "selected row 15 must be visible after scroll; buffer:\n{full_text}" + ); + } + + #[test] + fn format_action_line_run_produces_correct_prefix() { + let action = Action::Run("git pull".to_string()); + let result = format_action_line(Some(&action)); + assert_eq!(result, Some("RUN:git pull".to_string())); + } + + #[test] + fn format_action_line_edit_produces_correct_prefix() { + let action = Action::Edit("vim main.rs".to_string()); + let result = format_action_line(Some(&action)); + assert_eq!(result, Some("EDIT:vim main.rs".to_string())); + } + + #[test] + fn format_action_line_none_produces_no_output() { + let result = format_action_line(None); + assert!(result.is_none(), "None action must produce no stdout line"); + } + + #[test] + fn display_command_collapses_newlines() { + assert_eq!(display_command("git\nstatus"), "git status"); + } + + #[test] + fn display_command_collapses_carriage_returns() { + assert_eq!(display_command("git\r\nstatus"), "git status"); + } + + #[test] + fn display_command_collapses_tabs() { + assert_eq!(display_command("git\tstatus"), "git status"); + } + + #[test] + fn display_command_collapses_multiple_spaces() { + assert_eq!(display_command("git status"), "git status"); + } + + #[test] + fn display_command_trims_leading_trailing() { + assert_eq!(display_command(" git status "), "git status"); + } + + #[test] + fn display_command_multiline_with_indentation() { + assert_eq!( + display_command("git\nstatus\n --short"), + "git status --short" + ); + } + + #[test] + fn display_command_empty_string() { + assert_eq!(display_command(""), ""); + } + + #[test] + fn display_command_plain_command_unchanged() { + assert_eq!(display_command("ls -la"), "ls -la"); + } + + #[test] + fn dump_bottom_anchored_buffers() { + use crate::tui::event::handle_key; + use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; + + let mut app = App::new(); + app.all_rows = vec![ + make_row("cmd-newest", TEST_NOW - 10, 0, "proj"), + make_row("cmd-newer", TEST_NOW - 60, 0, "proj"), + make_row("cmd-middle", TEST_NOW - 300, 0, "proj"), + make_row("cmd-older", TEST_NOW - 1200, 0, "proj"), + make_row("cmd-oldest", TEST_NOW - 3600, 0, "proj"), + ]; + app.recompute(); + + let (default_buf, _) = render_app_small(&app, 80, 12); + println!("=== DEFAULT (newest at bottom, cursor on newest) ==="); + println!("{default_buf}"); + + let up_key = KeyEvent { + code: KeyCode::Up, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }; + for _ in 0..3 { + handle_key(up_key, &mut app, TEST_NOW); + } + + let (after_up3, _) = render_app_small(&app, 80, 12); + println!("\n=== AFTER Up x3 (cursor on cmd-older) ==="); + println!("{after_up3}"); + + assert!(default_buf.contains("cmd-newest")); + assert!(after_up3.contains("cmd-older")); + } + + fn app_bottom_anchored() -> App { + let mut app = App::new(); + app.all_rows = vec![ + make_row("newest-cmd", TEST_NOW - 60, 0, "p"), + make_row("middle-cmd", TEST_NOW - 3600, 0, "p"), + make_row("oldest-cmd", TEST_NOW - 7200, 0, "p"), + ]; + app.recompute(); + app + } + + fn render_app_small(app: &App, width: u16, height: u16) -> (String, ratatui::buffer::Buffer) { + let mut ts = TableState::default(); + render_app_small_with_state(app, width, height, &mut ts) + } + + fn render_app_small_with_state( + app: &App, + width: u16, + height: u16, + ts: &mut TableState, + ) -> (String, ratatui::buffer::Buffer) { + let cols = default_cols(); + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| draw(f, app, TEST_NOW, true, &cols, ts)) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut lines: Vec = Vec::new(); + for row in 0..height { + let mut line = String::new(); + for col in 0..width { + line.push(buf[(col, row)].symbol().chars().next().unwrap_or(' ')); + } + lines.push(line.trim_end().to_string()); + } + (lines.join("\n"), buf) + } + + fn row_y_of(text: &str, buf: &ratatui::buffer::Buffer, width: u16, height: u16) -> Option { + row_y_of_in_x_range(text, buf, 0, width, height) + } + + fn row_y_of_in_x_range( + text: &str, + buf: &ratatui::buffer::Buffer, + x_start: u16, + x_end: u16, + height: u16, + ) -> Option { + for row in 0..height { + let line: String = (x_start..x_end) + .map(|col| buf[(col, row)].symbol().chars().next().unwrap_or(' ')) + .collect(); + if line.contains(text) { + return Some(row); + } + } + None + } + + #[test] + fn newest_command_renders_below_oldest() { + let app = app_bottom_anchored(); + let (_, buf) = render_app_small(&app, TEST_WIDTH, TEST_HEIGHT); + let list_pane_right = TEST_WIDTH * 60 / 100; + let newest_y = row_y_of_in_x_range("newest-cmd", &buf, 0, list_pane_right, TEST_HEIGHT) + .expect("newest-cmd must appear in list pane"); + let oldest_y = row_y_of_in_x_range("oldest-cmd", &buf, 0, list_pane_right, TEST_HEIGHT) + .expect("oldest-cmd must appear in list pane"); + assert!( + newest_y > oldest_y, + "newest command must appear on a lower (higher y) row than oldest; newest_y={newest_y} oldest_y={oldest_y}" + ); + } + + #[test] + fn top_orientation_renders_newest_above_oldest() { + let app = app_bottom_anchored(); + let cols = default_cols(); + let backend = TestBackend::new(TEST_WIDTH, TEST_HEIGHT); + let mut terminal = Terminal::new(backend).unwrap(); + let mut ts = TableState::default(); + terminal + .draw(|f| draw(f, &app, TEST_NOW, false, &cols, &mut ts)) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + let newest_y = row_y_of("newest-cmd", &buf, TEST_WIDTH, TEST_HEIGHT) + .expect("newest-cmd must appear in buffer"); + let oldest_y = row_y_of("oldest-cmd", &buf, TEST_WIDTH, TEST_HEIGHT) + .expect("oldest-cmd must appear in buffer"); + assert!( + newest_y < oldest_y, + "with top orientation newest must be above oldest; newest_y={newest_y} oldest_y={oldest_y}" + ); + } + + #[test] + fn default_selection_is_newest_command() { + let app = app_bottom_anchored(); + let cmd = app.selected_command().expect("must have a selection"); + assert_eq!( + cmd, "newest-cmd", + "default selection must be the most-recent command" + ); + } + + #[test] + fn up_key_moves_to_older_command() { + use crate::tui::event::handle_key; + use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; + + let mut app = app_bottom_anchored(); + assert_eq!(app.selected_command(), Some("newest-cmd")); + + handle_key( + KeyEvent { + code: KeyCode::Up, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }, + &mut app, + TEST_NOW, + ); + assert_eq!( + app.selected_command(), + Some("middle-cmd"), + "Up from newest must select middle (older) command" + ); + } + + #[test] + fn down_key_moves_to_newer_command() { + use crate::tui::event::handle_key; + use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; + + let mut app = app_bottom_anchored(); + handle_key( + KeyEvent { + code: KeyCode::Up, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }, + &mut app, + TEST_NOW, + ); + assert_eq!(app.selected_command(), Some("middle-cmd")); + + handle_key( + KeyEvent { + code: KeyCode::Down, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }, + &mut app, + TEST_NOW, + ); + assert_eq!( + app.selected_command(), + Some("newest-cmd"), + "Down from middle must go back to newest" + ); + } + + #[test] + fn up_cannot_go_past_oldest() { + use crate::tui::event::handle_key; + use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; + + let mut app = app_bottom_anchored(); + let up_key = KeyEvent { + code: KeyCode::Up, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }; + for _ in 0..10 { + handle_key(up_key, &mut app, TEST_NOW); + } + assert_eq!( + app.selected_command(), + Some("oldest-cmd"), + "Up cannot go past the oldest command" + ); + } + + #[test] + fn down_cannot_go_past_newest() { + use crate::tui::event::handle_key; + use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; + + let mut app = app_bottom_anchored(); + let down_key = KeyEvent { + code: KeyCode::Down, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }; + for _ in 0..10 { + handle_key(down_key, &mut app, TEST_NOW); + } + assert_eq!( + app.selected_command(), + Some("newest-cmd"), + "Down cannot go past the newest command" + ); + } + + #[test] + fn bottom_anchored_scroll_keeps_older_row_visible() { + use crate::tui::event::handle_key; + use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; + + const H: u16 = 10; + const W: u16 = 80; + let mut app = App::new(); + app.all_rows = (0..20) + .map(|i| make_row(&format!("scmd-{i:02}"), TEST_NOW - i as i64 * 60, 0, "p")) + .collect(); + app.recompute(); + + let up_key = KeyEvent { + code: KeyCode::Up, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }; + for _ in 0..15 { + handle_key(up_key, &mut app, TEST_NOW); + } + + let (_, buf) = render_app_small(&app, W, H); + let mut full_text = String::new(); + for row in 0..H { + for col in 0..W { + full_text.push(buf[(col, row)].symbol().chars().next().unwrap_or(' ')); + } + full_text.push('\n'); + } + assert!( + full_text.contains("scmd-15"), + "after pressing Up 15 times, selected older row must be visible; buffer:\n{full_text}" + ); + } + + #[test] + fn dump_persistent_scroll_sequence() { + use crate::tui::event::handle_key; + use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; + + const W: u16 = 80; + const H: u16 = 12; + + let mut app = App::new(); + app.all_rows = (0..12) + .map(|i| make_row(&format!("nav-{i:02}"), TEST_NOW - i as i64 * 60, 0, "p")) + .collect(); + app.recompute(); + + let up_key = KeyEvent { + code: KeyCode::Up, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }; + + let mut ts = TableState::default(); + + let (s0, _) = render_app_small_with_state(&app, W, H, &mut ts); + println!("=== FRAME 0 (default, newest=nav-00 at bottom, highlighted) ===\n{s0}"); + + handle_key(up_key, &mut app, TEST_NOW); + let (s1, _) = render_app_small_with_state(&app, W, H, &mut ts); + println!("\n=== FRAME 1 (Up x1 - cursor moves UP within viewport, no scroll) ===\n{s1}"); + + handle_key(up_key, &mut app, TEST_NOW); + let (s2, _) = render_app_small_with_state(&app, W, H, &mut ts); + println!("\n=== FRAME 2 (Up x2 - cursor moves UP within viewport, no scroll) ===\n{s2}"); + + handle_key(up_key, &mut app, TEST_NOW); + handle_key(up_key, &mut app, TEST_NOW); + handle_key(up_key, &mut app, TEST_NOW); + handle_key(up_key, &mut app, TEST_NOW); + handle_key(up_key, &mut app, TEST_NOW); + handle_key(up_key, &mut app, TEST_NOW); + let (s8, _) = render_app_small_with_state(&app, W, H, &mut ts); + println!("\n=== FRAME 8 (Up x8 - content has scrolled, older commands visible) ===\n{s8}"); + } + + fn visible_commands(buf: &ratatui::buffer::Buffer, width: u16, height: u16) -> Vec { + let list_pane_right = width * 60 / 100; + let mut seen = Vec::new(); + for row in 0..height { + let line: String = (0..list_pane_right) + .map(|col| buf[(col, row)].symbol().chars().next().unwrap_or(' ')) + .collect(); + for i in 0..12u32 { + let name = format!("nav-{i:02}"); + if line.contains(&name) && !seen.contains(&name) { + seen.push(name); + } + } + } + seen + } + + fn highlighted_command( + buf: &ratatui::buffer::Buffer, + width: u16, + height: u16, + ) -> Option { + for row in 0..height { + let any_cyan_bg = (0..width) + .any(|col| buf[(col, row)].style().bg == Some(Theme::default().selection_bg)); + if any_cyan_bg { + let line: String = (0..width) + .map(|col| buf[(col, row)].symbol().chars().next().unwrap_or(' ')) + .collect(); + for i in 0..12u32 { + let name = format!("nav-{i:02}"); + if line.contains(&name) { + return Some(name); + } + } + } + } + None + } + + #[test] + fn persistent_state_cursor_moves_before_scroll() { + use crate::tui::event::handle_key; + use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; + + const W: u16 = 80; + const H: u16 = 12; + + let mut app = App::new(); + app.all_rows = (0..12) + .map(|i| make_row(&format!("nav-{i:02}"), TEST_NOW - i as i64 * 60, 0, "p")) + .collect(); + app.recompute(); + + let up_key = KeyEvent { + code: KeyCode::Up, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }; + + let mut ts = TableState::default(); + + let (_, buf0) = render_app_small_with_state(&app, W, H, &mut ts); + let visible0 = visible_commands(&buf0, W, H); + let highlighted0 = highlighted_command(&buf0, W, H); + + handle_key(up_key, &mut app, TEST_NOW); + let (_, buf1) = render_app_small_with_state(&app, W, H, &mut ts); + let visible1 = visible_commands(&buf1, W, H); + let highlighted1 = highlighted_command(&buf1, W, H); + + handle_key(up_key, &mut app, TEST_NOW); + let (_, buf2) = render_app_small_with_state(&app, W, H, &mut ts); + let visible2 = visible_commands(&buf2, W, H); + let highlighted2 = highlighted_command(&buf2, W, H); + + assert!( + highlighted0.is_some(), + "must have a highlighted row at the start" + ); + assert!( + highlighted1.is_some(), + "must have a highlighted row after Up x1" + ); + assert!( + highlighted2.is_some(), + "must have a highlighted row after Up x2" + ); + + assert_ne!( + highlighted0, highlighted1, + "highlight must change after Up x1: before={highlighted0:?} after={highlighted1:?}" + ); + assert_ne!( + highlighted1, highlighted2, + "highlight must change after Up x2: before={highlighted1:?} after={highlighted2:?}" + ); + + assert_eq!( + visible0, visible1, + "visible set must NOT change on the first Up press (cursor moves within viewport, no scroll yet)" + ); + + assert_eq!( + visible1, visible2, + "visible set must NOT change on the second Up press (still within viewport)" + ); + } + + #[test] + fn persistent_state_scrolls_only_at_viewport_edge() { + use crate::tui::event::handle_key; + use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; + + const W: u16 = 80; + const H: u16 = 12; + + let mut app = App::new(); + app.all_rows = (0..12) + .map(|i| make_row(&format!("nav-{i:02}"), TEST_NOW - i as i64 * 60, 0, "p")) + .collect(); + app.recompute(); + + let up_key = KeyEvent { + code: KeyCode::Up, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }; + + let mut ts = TableState::default(); + let (_, buf_start) = render_app_small_with_state(&app, W, H, &mut ts); + let visible_start = visible_commands(&buf_start, W, H); + + for _ in 0..8 { + handle_key(up_key, &mut app, TEST_NOW); + render_app_small_with_state(&app, W, H, &mut ts); + } + + let (_, buf_end) = render_app_small_with_state(&app, W, H, &mut ts); + let visible_end = visible_commands(&buf_end, W, H); + + assert_ne!( + visible_start, visible_end, + "after pressing Up enough times to leave the viewport, the visible set must have changed (scroll occurred)" + ); + + let highlighted_end = highlighted_command(&buf_end, W, H); + assert!( + highlighted_end.is_some(), + "selected row must be highlighted and visible after scrolling" + ); + } + + #[test] + fn table_header_shows_default_column_labels() { + let app = app_with_rows(); + let text = render_app(&app); + assert!( + text.contains("time"), + "table header must contain label 'time'; got:\n{text}" + ); + assert!( + text.contains("dur"), + "table header must contain label 'dur'; got:\n{text}" + ); + assert!( + text.contains("exit"), + "table header must contain label 'exit'; got:\n{text}" + ); + assert!( + text.contains("project"), + "table header must contain label 'project'; got:\n{text}" + ); + assert!( + text.contains("command"), + "table header must contain label 'command'; got:\n{text}" + ); + } + + #[test] + fn reduced_columns_only_show_selected_headers() { + let mut app = App::new(); + app.all_rows = vec![make_row("my-cmd", TEST_NOW - 10, 1, "proj")]; + app.recompute(); + + let cols = resolve_tui_columns(&["exit".to_string(), "command".to_string()]); + let backend = TestBackend::new(TEST_WIDTH, TEST_HEIGHT); + let mut terminal = Terminal::new(backend).unwrap(); + let mut ts = TableState::default(); + terminal + .draw(|f| draw(f, &app, TEST_NOW, true, &cols, &mut ts)) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut lines: Vec = Vec::new(); + for row in 0..TEST_HEIGHT { + let mut line = String::new(); + for col in 0..TEST_WIDTH { + line.push(buf[(col, row)].symbol().chars().next().unwrap_or(' ')); + } + lines.push(line.trim_end().to_string()); + } + let text = lines.join("\n"); + + assert!( + text.contains("exit"), + "reduced cols must show 'exit' header; got:\n{text}" + ); + assert!( + text.contains("command"), + "reduced cols must show 'command' header; got:\n{text}" + ); + assert!( + !text.contains("time"), + "reduced cols must NOT show 'time' header; got:\n{text}" + ); + assert!( + !text + .lines() + .any(|l| l.starts_with('│') && l.contains(" dur ") && l.len() < 30), + "reduced cols must NOT show 'dur' column header in the list pane; got:\n{text}" + ); + assert!( + text.contains("my-cmd"), + "command cell must render; got:\n{text}" + ); + } + + #[test] + fn resolve_tui_columns_all_valid_returns_them() { + let cols = resolve_tui_columns(&["exit".to_string(), "command".to_string()]); + assert_eq!(cols.len(), 2); + assert!(matches!(cols[0], Column::Exit)); + assert!(matches!(cols[1], Column::Command)); + } + + #[test] + fn resolve_tui_columns_unknown_name_returns_defaults() { + let cols = resolve_tui_columns(&["exit".to_string(), "bogus".to_string()]); + let defaults = resolve_tui_columns(&default_tui_columns()); + assert_eq!(cols.len(), defaults.len()); + } + + #[test] + fn resolve_tui_columns_empty_returns_defaults() { + let cols = resolve_tui_columns(&[]); + let defaults = resolve_tui_columns(&default_tui_columns()); + assert_eq!(cols.len(), defaults.len()); + } + + #[test] + fn dump_reduced_columns_buffer() { + let mut app = App::new(); + app.all_rows = vec![ + make_row("git status", TEST_NOW - 10, 0, "proj"), + make_row("cargo build", TEST_NOW - 60, 1, "proj"), + make_row("docker run nginx", TEST_NOW - 300, 0, "proj"), + ]; + app.recompute(); + + let cols_default = default_cols(); + let backend = TestBackend::new(80, 10); + let mut terminal = Terminal::new(backend).unwrap(); + let mut ts = TableState::default(); + terminal + .draw(|f| draw(f, &app, TEST_NOW, true, &cols_default, &mut ts)) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut lines: Vec = Vec::new(); + for row in 0..10u16 { + let mut line = String::new(); + for col in 0..80u16 { + line.push(buf[(col, row)].symbol().chars().next().unwrap_or(' ')); + } + lines.push(line.trim_end().to_string()); + } + println!("=== DEFAULT COLUMNS (80x10) ==="); + println!("{}", lines.join("\n")); + + let cols_reduced = resolve_tui_columns(&["exit".to_string(), "command".to_string()]); + let backend2 = TestBackend::new(80, 10); + let mut terminal2 = Terminal::new(backend2).unwrap(); + let mut ts2 = TableState::default(); + terminal2 + .draw(|f| draw(f, &app, TEST_NOW, true, &cols_reduced, &mut ts2)) + .unwrap(); + let buf2 = terminal2.backend().buffer().clone(); + let mut lines2: Vec = Vec::new(); + for row in 0..10u16 { + let mut line = String::new(); + for col in 0..80u16 { + line.push(buf2[(col, row)].symbol().chars().next().unwrap_or(' ')); + } + lines2.push(line.trim_end().to_string()); + } + println!("\n=== REDUCED COLUMNS [exit, command] (80x10) ==="); + println!("{}", lines2.join("\n")); + + assert!(lines.iter().any(|l| l.contains("time"))); + assert!(lines2 + .iter() + .any(|l| l.contains("exit") && l.contains("command"))); + assert!(lines2.iter().all(|l| !l.contains("time"))); + } + + #[test] + fn format_action_line_replay_produces_correct_prefix() { + let action = Action::Replay("( cd '/x' && ls )".to_string()); + let result = format_action_line(Some(&action)); + assert_eq!(result, Some("REPLAY:( cd '/x' && ls )".to_string())); + } + + #[test] + fn controls_workspaces_shows_replay_hint() { + let app = app_with_workspaces(); + let text = render_app(&app); + assert!( + text.contains("replay"), + "Workspaces controls must contain 'replay'; got:\n{text}" + ); + } + + #[test] + fn confirm_modal_shows_when_confirm_is_set() { + let mut app = app_with_workspaces(); + app.confirm = Some(crate::tui::app::Confirm::Replay( + crate::tui::app::ConfirmReplay { + workspace: "proj-alpha".into(), + count: 5, + }, + )); + let text = render_app(&app); + assert!( + text.contains("proj-alpha"), + "confirm modal must show workspace name; got:\n{text}" + ); + assert!( + text.contains("5 commands") || text.contains("5"), + "confirm modal must show command count; got:\n{text}" + ); + assert!( + text.contains("[y]") || text.contains("y"), + "confirm modal must show y hint; got:\n{text}" + ); + assert!( + text.contains("[n]") || text.contains("n"), + "confirm modal must show n hint; got:\n{text}" + ); + } + + #[test] + fn dump_workspaces_tab_buffer() { + const NOW: i64 = 1_000_000_000; + const W: u16 = 100; + const H: u16 = 16; + + let mut app = App::new(); + app.tab = Tab::Workspaces; + app.workspaces = vec![ + make_workspace("proj-alpha", 5, NOW - 3600), + make_workspace("proj-beta", 3, NOW - 7200), + ]; + app.ws_selected = 0; + app.ws_commands = vec![ + make_row("git status", NOW - 3500, 0, "proj-alpha"), + make_row("cargo build --release", NOW - 3400, 0, "proj-alpha"), + make_row("cargo test", NOW - 3200, 1, "proj-alpha"), + ]; + + let cols = default_cols(); + let backend = TestBackend::new(W, H); + let mut terminal = Terminal::new(backend).unwrap(); + let mut ts = TableState::default(); + terminal + .draw(|f| draw(f, &app, NOW, true, &cols, &mut ts)) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut lines: Vec = Vec::new(); + for row in 0..H { + let mut line = String::new(); + for col in 0..W { + line.push(buf[(col, row)].symbol().chars().next().unwrap_or(' ')); + } + lines.push(line.trim_end().to_string()); + } + let text = lines.join("\n"); + println!("=== WORKSPACES TAB (100x16) ==="); + println!("{text}"); + + assert!(text.contains("workspaces"), "must show workspaces pane"); + assert!(text.contains("commands"), "must show commands pane"); + } + + #[test] + fn delete_confirm_modal_shows_delete_text() { + let mut app = app_with_workspaces(); + app.confirm = Some(Confirm::Delete(ConfirmDelete { + id: 42, + label: "cargo build --release".into(), + origin: DeleteOrigin::History, + })); + let text = render_app(&app); + assert!( + text.contains("Delete"), + "delete confirm modal must show 'Delete'; got:\n{text}" + ); + assert!( + text.contains("[y]") || text.contains("y"), + "delete confirm modal must show y hint; got:\n{text}" + ); + assert!( + text.contains("[n]") || text.contains("n"), + "delete confirm modal must show n hint; got:\n{text}" + ); + } + + #[test] + fn ws_list_pane_focused_has_accent_border() { + let app = app_with_workspaces(); + assert_eq!(app.ws_pane, WsPane::List); + let buf = render_app_buf(&app); + let list_pane_area_x = 1u16; + let any_accent_in_left_border = (0..TEST_HEIGHT).any(|row_y| { + let cell = &buf[(list_pane_area_x, row_y)]; + cell.style().fg == Some(Theme::default().accent) + }); + assert!( + any_accent_in_left_border, + "focused List pane must have ACCENT border somewhere; ws_pane=List" + ); + } + + #[test] + fn ws_commands_pane_focused_has_accent_border() { + let mut app = app_with_workspaces(); + app.ws_pane = WsPane::Commands; + let buf = render_app_buf(&app); + let cmd_pane_x = (TEST_WIDTH as f32 * 0.38) as u16 + 1; + let any_accent_in_cmd_border = (0..TEST_HEIGHT).any(|row_y| { + let cell = &buf[(cmd_pane_x, row_y)]; + cell.style().fg == Some(Theme::default().accent) + }); + assert!( + any_accent_in_cmd_border, + "focused Commands pane must have ACCENT border somewhere; ws_pane=Commands" + ); + } + + #[test] + fn controls_workspaces_commands_pane_shows_delete_hint() { + let mut app = app_with_workspaces(); + app.ws_pane = WsPane::Commands; + let text = render_app(&app); + assert!( + text.contains("delete"), + "Workspaces Commands controls must contain 'delete'; got:\n{text}" + ); + assert!( + text.contains("back"), + "Workspaces Commands controls must contain 'back'; got:\n{text}" + ); + } + + #[test] + fn dump_pr4a_screenshots() { + const NOW: i64 = 1_000_000_000; + const W: u16 = 100; + const H: u16 = 18; + + let make_r = |cmd: &str, ts: i64, exit: i64| CommandRow { + id: 0, + command: cmd.to_string(), + timestamp: ts, + exit_code: exit, + project: "proj".to_string(), + directory: "/home/user/proj".to_string(), + tags: "[]".to_string(), + session_id: "s1".to_string(), + duration_ms: 100, + workspace: None, + }; + + let make_rows = || { + vec![ + make_r("git status", NOW - 60, 0), + make_r("cargo build --release", NOW - 300, 0), + make_r("cargo test", NOW - 1200, 0), + make_r("docker run -p 8080:80 nginx", NOW - 3600, 1), + ] + }; + + let render_text = |app: &App| -> String { + let backend = TestBackend::new(W, H); + let mut terminal = Terminal::new(backend).unwrap(); + let mut ts = TableState::default(); + let mut cts = TableState::default(); + let cols = default_cols(); + terminal + .draw(|f| draw_with_cmd_state(f, app, NOW, true, &cols, &mut ts, &mut cts)) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut lines: Vec = Vec::new(); + for row in 0..H { + let mut line = String::new(); + for col in 0..W { + line.push(buf[(col, row)].symbol().chars().next().unwrap_or(' ')); + } + lines.push(line.trim_end().to_string()); + } + lines.join("\n") + }; + + let mut app_a = App::new(); + app_a.all_rows = make_rows(); + app_a.recompute(); + app_a.enter_normal_mode(); + app_a.selected = 1; + let dump_a = render_text(&app_a); + println!("\n=== DUMP (a): History in Normal mode ===\n{dump_a}"); + + let mut app_b = App::new(); + app_b.all_rows = make_rows(); + app_b.recompute(); + app_b.selected = 0; + app_b.confirm = Some(Confirm::Delete(ConfirmDelete { + id: 42, + label: "cargo test".into(), + origin: DeleteOrigin::History, + })); + let dump_b = render_text(&app_b); + println!("\n=== DUMP (b): Delete confirm modal ===\n{dump_b}"); + + let ws_rows = vec![ + CommandRow { + id: 1, + command: "git status".into(), + timestamp: NOW - 500, + exit_code: 0, + project: "proj".into(), + directory: "/home/user".into(), + tags: "[]".into(), + session_id: "s1".into(), + duration_ms: 50, + workspace: Some("my-workspace".into()), + }, + CommandRow { + id: 2, + command: "cargo build --release".into(), + timestamp: NOW - 400, + exit_code: 0, + project: "proj".into(), + directory: "/home/user".into(), + tags: "[]".into(), + session_id: "s1".into(), + duration_ms: 8000, + workspace: Some("my-workspace".into()), + }, + CommandRow { + id: 3, + command: "cargo test -- --nocapture".into(), + timestamp: NOW - 300, + exit_code: 1, + project: "proj".into(), + directory: "/home/user".into(), + tags: "[]".into(), + session_id: "s1".into(), + duration_ms: 3000, + workspace: Some("my-workspace".into()), + }, + ]; + use crate::workspaces::WorkspaceRow; + let mut app_c = App::new(); + app_c.tab = Tab::Workspaces; + app_c.ws_pane = WsPane::Commands; + app_c.workspaces = vec![WorkspaceRow { + name: "my-workspace".into(), + command_count: 3, + first_ts: NOW - 500, + last_ts: NOW - 300, + }]; + app_c.ws_selected = 0; + app_c.ws_commands = ws_rows; + app_c.ws_cmd_selected = 1; + let dump_c = render_text(&app_c); + println!("\n=== DUMP (c): Workspaces with Commands pane focused ===\n{dump_c}"); + + assert!(dump_a.contains("j/k"), "Normal mode controls must show j/k"); + assert!(dump_b.contains("Delete"), "Delete modal must show 'Delete'"); + assert!( + dump_c.contains("back"), + "Commands pane controls must show 'back'" + ); + } + + #[test] + fn edit_modal_shows_buffer_text_and_hints() { + use crate::tui::app::EditState; + + let mut app = App::new(); + app.all_rows = vec![make_row("git status", TEST_NOW - 60, 0, "p")]; + app.recompute(); + app.edit = Some(EditState { + id: 1, + buffer: "git status --short".into(), + }); + let text = render_app(&app); + assert!( + text.contains("git status --short"), + "edit modal must show buffer text; got:\n{text}" + ); + assert!( + text.contains("save"), + "edit modal must show 'save' hint; got:\n{text}" + ); + assert!( + text.contains("cancel"), + "edit modal must show 'cancel' hint; got:\n{text}" + ); + assert!( + text.contains("Edit command"), + "edit modal must show 'Edit command' title; got:\n{text}" + ); + } + + #[test] + fn history_normal_controls_show_edit_hint() { + let mut app = app_with_rows(); + app.mode = crate::tui::app::Mode::Normal; + let text = render_app(&app); + assert!( + text.contains("edit"), + "History Normal controls must contain 'edit' hint; got:\n{text}" + ); + } + + #[test] + fn ws_commands_controls_show_edit_hint() { + let mut app = App::new(); + app.tab = Tab::Workspaces; + app.ws_pane = WsPane::Commands; + let text = render_app(&app); + assert!( + text.contains("edit"), + "Workspaces Commands controls must contain 'edit' hint; got:\n{text}" + ); + } + + fn render_app_wide(app: &App) -> (String, ratatui::buffer::Buffer) { + const W: u16 = 120; + const H: u16 = 16; + let cols = default_cols(); + let backend = TestBackend::new(W, H); + let mut terminal = Terminal::new(backend).unwrap(); + let mut ts = TableState::default(); + terminal + .draw(|f| draw(f, app, TEST_NOW, true, &cols, &mut ts)) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut lines: Vec = Vec::new(); + for row in 0..H { + let mut line = String::new(); + for col in 0..W { + line.push(buf[(col, row)].symbol().chars().next().unwrap_or(' ')); + } + lines.push(line.trim_end().to_string()); + } + (lines.join("\n"), buf) + } + + struct RowSpec<'a> { + cmd: &'a str, + ts: i64, + exit: i64, + project: &'a str, + directory: &'a str, + tags: &'a str, + workspace: Option<&'a str>, + session_id: &'a str, + duration_ms: i64, + } + + fn make_row_full(s: RowSpec<'_>) -> CommandRow { + CommandRow { + id: 0, + command: s.cmd.to_string(), + timestamp: s.ts, + exit_code: s.exit, + project: s.project.to_string(), + directory: s.directory.to_string(), + tags: s.tags.to_string(), + session_id: s.session_id.to_string(), + duration_ms: s.duration_ms, + workspace: s.workspace.map(|w| w.to_string()), + } + } + + #[test] + fn history_tab_shows_preview_block_title() { + let mut app = App::new(); + app.all_rows = vec![make_row_full(RowSpec { + cmd: "git status", + ts: TEST_NOW - 60, + exit: 0, + project: "myproject", + directory: "/home/user/myproject", + tags: "[]", + workspace: None, + session_id: "sess-abc", + duration_ms: 250, + })]; + app.recompute(); + let (text, _) = render_app_wide(&app); + assert!( + text.contains("preview"), + "History tab must show 'preview' block title; got:\n{text}" + ); + } + + #[test] + fn history_tab_preview_shows_selected_command() { + let mut app = App::new(); + app.all_rows = vec![make_row_full(RowSpec { + cmd: "cargo test --release", + ts: TEST_NOW - 60, + exit: 0, + project: "myproject", + directory: "/home/user/myproject", + tags: "[]", + workspace: None, + session_id: "sess-abc", + duration_ms: 1500, + })]; + app.recompute(); + let (text, _) = render_app_wide(&app); + assert!( + text.contains("cargo test --release"), + "preview pane must show selected command text; got:\n{text}" + ); + } + + #[test] + fn history_tab_preview_shows_project_and_directory() { + let mut app = App::new(); + app.all_rows = vec![make_row_full(RowSpec { + cmd: "ls", + ts: TEST_NOW - 60, + exit: 0, + project: "proj-preview", + directory: "/srv/preview-dir", + tags: "[]", + workspace: None, + session_id: "sess-1", + duration_ms: 10, + })]; + app.recompute(); + let (text, _) = render_app_wide(&app); + assert!( + text.contains("proj-preview"), + "preview pane must show project name; got:\n{text}" + ); + assert!( + text.contains("/srv/preview-dir"), + "preview pane must show directory; got:\n{text}" + ); + } + + #[test] + fn history_tab_preview_shows_exit_status() { + let mut app = App::new(); + app.all_rows = vec![make_row_full(RowSpec { + cmd: "bad-cmd", + ts: TEST_NOW - 60, + exit: 1, + project: "p", + directory: "/tmp", + tags: "[]", + workspace: None, + session_id: "sess-1", + duration_ms: 10, + })]; + app.recompute(); + let (text, _) = render_app_wide(&app); + assert!( + text.contains("fail"), + "preview pane must show exit status 'fail'; got:\n{text}" + ); + } + + #[test] + fn history_tab_preview_changes_on_selection_change() { + use crate::tui::event::handle_key; + use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; + + let mut app = App::new(); + app.all_rows = vec![ + make_row_full(RowSpec { + cmd: "first-unique-cmd", + ts: TEST_NOW - 60, + exit: 0, + project: "proj-a", + directory: "/tmp/a", + tags: "[]", + workspace: None, + session_id: "s1", + duration_ms: 100, + }), + make_row_full(RowSpec { + cmd: "second-unique-cmd", + ts: TEST_NOW - 120, + exit: 0, + project: "proj-b", + directory: "/tmp/b", + tags: "[]", + workspace: None, + session_id: "s2", + duration_ms: 200, + }), + ]; + app.recompute(); + + let (text_before, _) = render_app_wide(&app); + assert!( + text_before.contains("first-unique-cmd"), + "preview must show first command initially; got:\n{text_before}" + ); + + handle_key( + KeyEvent { + code: KeyCode::Up, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }, + &mut app, + TEST_NOW, + ); + + let (text_after, _) = render_app_wide(&app); + assert!( + text_after.contains("second-unique-cmd"), + "preview must show second command after Up; got:\n{text_after}" + ); + } + + #[test] + fn history_tab_list_pane_still_shows_history_block() { + let mut app = App::new(); + app.all_rows = vec![make_row_full(RowSpec { + cmd: "git log --oneline", + ts: TEST_NOW - 60, + exit: 0, + project: "p", + directory: "/tmp", + tags: "[]", + workspace: None, + session_id: "s1", + duration_ms: 50, + })]; + app.recompute(); + let (text, _) = render_app_wide(&app); + assert!( + text.contains("history"), + "left pane must still show 'history' block title; got:\n{text}" + ); + assert!( + text.contains("git log --oneline"), + "left pane must still show command in list; got:\n{text}" + ); + } + + #[test] + fn history_tab_empty_list_shows_empty_preview() { + let app = App::new(); + let (text, _) = render_app_wide(&app); + assert!( + text.contains("preview"), + "preview block must still render when list is empty; got:\n{text}" + ); + } + + #[test] + fn dump_history_preview_buffer() { + const W: u16 = 120; + const H: u16 = 16; + + let mut app = App::new(); + app.all_rows = vec![ + make_row_full(RowSpec { + cmd: "cargo test --release", + ts: TEST_NOW - 60, + exit: 0, + project: "thoth", + directory: "/home/jose/thoth", + tags: r#"["build","ci"]"#, + workspace: Some("dev"), + session_id: "sess-abc123", + duration_ms: 4200, + }), + make_row_full(RowSpec { + cmd: "git status", + ts: TEST_NOW - 120, + exit: 1, + project: "thoth", + directory: "/home/jose/thoth", + tags: "[]", + workspace: None, + session_id: "sess-abc123", + duration_ms: 10, + }), + ]; + app.recompute(); + + let cols = default_cols(); + let backend = TestBackend::new(W, H); + let mut terminal = Terminal::new(backend).unwrap(); + let mut ts = TableState::default(); + terminal + .draw(|f| draw(f, &app, TEST_NOW, true, &cols, &mut ts)) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut lines: Vec = Vec::new(); + for row in 0..H { + let mut line = String::new(); + for col in 0..W { + line.push(buf[(col, row)].symbol().chars().next().unwrap_or(' ')); + } + lines.push(line.trim_end().to_string()); + } + let text = lines.join("\n"); + println!("=== HISTORY WITH PREVIEW PANE (120x16) ==="); + println!("{text}"); + + assert!(text.contains("history"), "left pane title must appear"); + assert!(text.contains("preview"), "right pane title must appear"); + assert!( + text.contains("cargo test --release"), + "selected command must appear" + ); + } + + #[test] + fn filter_chips_shows_dur_chip_when_duration_set() { + let mut app = app_with_rows(); + app.filters.duration = Some(">30".into()); + let text = render_app(&app); + assert!( + text.contains("[dur:>30]"), + "status bar must show dur chip; got:\n{text}" + ); + } + + #[test] + fn filter_chips_shows_project_and_exit_fail_when_both_set() { + let mut app = app_with_rows(); + app.filters.project = Some("myproj".into()); + app.filters.exit = Some(crate::search::ExitFilter::Fail); + let text = render_app(&app); + assert!( + text.contains("[project:myproj]"), + "must show project chip; got:\n{text}" + ); + assert!( + text.contains("[exit:Fail]"), + "must show exit chip; got:\n{text}" + ); + } + + #[test] + fn help_overlay_renders_when_show_help_true() { + let mut app = app_with_rows(); + app.show_help = true; + let text = render_app(&app); + assert!( + text.contains("project:NAME") || text.contains("project"), + "help overlay must show filter fields; got:\n{text}" + ); + assert!( + text.contains("exit:ok|fail") || text.contains("exit"), + "help overlay must mention exit filter; got:\n{text}" + ); + } + + #[test] + fn help_overlay_not_shown_when_show_help_false() { + let mut app = app_with_rows(); + app.show_help = false; + let text = render_app(&app); + assert!( + !text.contains("Press any key to close"), + "help close hint must not appear when show_help is false; got:\n{text}" + ); + } + + #[test] + fn controls_normal_shows_help_hint() { + let mut app = app_with_rows(); + app.mode = crate::tui::app::Mode::Normal; + let text = render_app(&app); + assert!( + text.contains("help"), + "Normal mode controls must show 'help' hint; got:\n{text}" + ); + } + + #[test] + fn controls_normal_shows_filter_hint() { + let mut app = app_with_rows(); + app.mode = crate::tui::app::Mode::Normal; + let text = render_app(&app); + assert!( + text.contains("filter"), + "Normal mode controls must show 'filter' hint; got:\n{text}" + ); + } + + #[test] + fn cmdline_popup_renders_with_title_and_buffer() { + let mut app = app_with_rows(); + app.cmdline = Some("project:thoth exit:fail".to_string()); + let text = render_app(&app); + assert!( + text.contains("Cmdline"), + "cmdline popup must show 'Cmdline' title; got:\n{text}" + ); + assert!( + text.contains("project:thoth"), + "cmdline popup must show buffer text; got:\n{text}" + ); + } + + #[test] + fn cmdline_popup_not_shown_when_closed() { + let app = app_with_rows(); + let text = render_app(&app); + assert!( + !text.contains("Cmdline"), + "cmdline popup must not appear when cmdline is None; got:\n{text}" + ); + } + + #[test] + fn help_overlay_mentions_colon_cmdline() { + let mut app = app_with_rows(); + app.show_help = true; + let text = render_app(&app); + assert!( + text.contains("filter cmdline") || text.contains("cmdline"), + "help overlay must mention ':' cmdline; got:\n{text}" + ); + } + + fn render_cmdline_popup_120x18(buf_text: &str) -> String { + let mut app = app_with_rows(); + app.cmdline = Some(buf_text.to_string()); + let cols = default_cols(); + let backend = TestBackend::new(120, 18); + let mut terminal = Terminal::new(backend).unwrap(); + let mut ts = TableState::default(); + terminal + .draw(|f| draw(f, &app, TEST_NOW, true, &cols, &mut ts)) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut lines: Vec = Vec::new(); + for row in 0..18u16 { + let mut line = String::new(); + for col in 0..120u16 { + line.push(buf[(col, row)].symbol().chars().next().unwrap_or(' ')); + } + lines.push(line); + } + lines.join("\n") + } + + #[test] + fn cmdline_popup_120x18_dump_contains_buffer_and_title() { + let text = render_cmdline_popup_120x18("project:thoth exit:fail"); + assert!( + text.contains("Cmdline"), + "120x18 dump must contain 'Cmdline' title; got:\n{text}" + ); + assert!( + text.contains("project:thoth"), + "120x18 dump must show typed buffer; got:\n{text}" + ); + } +} diff --git a/src/tui/time.rs b/src/tui/time.rs new file mode 100644 index 0000000..04529d7 --- /dev/null +++ b/src/tui/time.rs @@ -0,0 +1,94 @@ +const MINUTE: i64 = 60; +const HOUR: i64 = 60 * MINUTE; +const DAY: i64 = 24 * HOUR; +const WEEK: i64 = 7 * DAY; +const MONTH: i64 = 30 * DAY; +const YEAR: i64 = 365 * DAY; + +pub fn format_relative(epoch: i64, now: i64) -> String { + let delta = now.saturating_sub(epoch); + if delta < 10 { + return String::from("just now"); + } + if delta < MINUTE { + return format!("{}s ago", delta); + } + if delta < HOUR { + return format!("{}m ago", delta / MINUTE); + } + if delta < DAY { + return format!("{}h ago", delta / HOUR); + } + if delta < WEEK { + return format!("{}d ago", delta / DAY); + } + if delta < MONTH { + return format!("{}w ago", delta / WEEK); + } + if delta < YEAR { + return format!("{}mo ago", delta / MONTH); + } + format!("{}y ago", delta / YEAR) +} + +#[cfg(test)] +mod tests { + use super::*; + + const NOW: i64 = 1_000_000_000; + + #[test] + fn future_clamps_to_just_now() { + assert_eq!(format_relative(NOW + 100, NOW), "just now"); + } + + #[test] + fn zero_delta_is_just_now() { + assert_eq!(format_relative(NOW, NOW), "just now"); + } + + #[test] + fn nine_seconds_is_just_now() { + assert_eq!(format_relative(NOW - 9, NOW), "just now"); + } + + #[test] + fn ten_seconds_is_seconds() { + assert_eq!(format_relative(NOW - 10, NOW), "10s ago"); + } + + #[test] + fn sixty_seconds_is_one_minute() { + assert_eq!(format_relative(NOW - MINUTE, NOW), "1m ago"); + } + + #[test] + fn one_hour() { + assert_eq!(format_relative(NOW - HOUR, NOW), "1h ago"); + } + + #[test] + fn one_day() { + assert_eq!(format_relative(NOW - DAY, NOW), "1d ago"); + } + + #[test] + fn one_week() { + assert_eq!(format_relative(NOW - WEEK, NOW), "1w ago"); + } + + #[test] + fn one_day_before_month_boundary() { + assert_eq!(format_relative(NOW - (MONTH - DAY), NOW), "4w ago"); + } + + #[test] + fn one_month() { + assert_eq!(format_relative(NOW - MONTH, NOW), "1mo ago"); + } + + #[test] + fn one_year() { + assert_eq!(format_relative(NOW - YEAR, NOW), "1y ago"); + } +} diff --git a/src/workspaces.rs b/src/workspaces.rs new file mode 100644 index 0000000..3c1fe52 --- /dev/null +++ b/src/workspaces.rs @@ -0,0 +1,255 @@ +use crate::error::ThothError; +use crate::search::CommandRow; +use rusqlite::Connection; + +pub fn start_line(name: &str) -> String { + let escaped = name.replace('\'', "'\\''"); + format!("export TTH_ACTIVE_WORKSPACE='{escaped}'") +} + +pub fn end_line() -> String { + String::from("unset TTH_ACTIVE_WORKSPACE") +} + +pub fn normalize_workspace(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +pub struct WorkspaceRow { + pub name: String, + pub command_count: i64, + pub first_ts: i64, + pub last_ts: i64, +} + +pub fn list_workspace_commands( + conn: &Connection, + name: &str, +) -> Result, ThothError> { + let mut stmt = conn.prepare( + "SELECT c.id, c.timestamp, c.project, c.tags, c.exit_code, c.duration_ms, \ + c.directory, c.command, c.session_id, c.workspace \ + FROM commands c \ + WHERE c.workspace = ? \ + ORDER BY c.timestamp ASC, c.id ASC", + )?; + let rows = stmt.query_map([name], |row| { + Ok(CommandRow { + id: row.get(0)?, + timestamp: row.get(1)?, + project: row.get(2)?, + tags: row.get(3)?, + exit_code: row.get(4)?, + duration_ms: row.get(5)?, + directory: row.get(6)?, + command: row.get(7)?, + session_id: row.get(8)?, + workspace: row.get(9)?, + }) + })?; + let mut result = Vec::new(); + for row in rows { + result.push(row?); + } + Ok(result) +} + +pub fn list_workspaces(conn: &Connection) -> Result, ThothError> { + let mut stmt = conn.prepare( + "SELECT workspace, COUNT(*), MIN(timestamp), MAX(timestamp) \ + FROM commands \ + WHERE workspace IS NOT NULL AND workspace <> '' \ + GROUP BY workspace \ + ORDER BY MAX(timestamp) DESC", + )?; + let rows = stmt.query_map([], |row| { + Ok(WorkspaceRow { + name: row.get(0)?, + command_count: row.get(1)?, + first_ts: row.get(2)?, + last_ts: row.get(3)?, + }) + })?; + let mut result = Vec::new(); + for row in rows { + result.push(row?); + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mem_conn() -> Connection { + let mut c = crate::database::connect_memory().unwrap(); + crate::database::apply_migrations(&mut c).unwrap(); + c + } + + #[test] + fn start_line_basic() { + assert_eq!(start_line("demo"), "export TTH_ACTIVE_WORKSPACE='demo'"); + } + + #[test] + fn start_line_escapes_single_quote() { + assert_eq!(start_line("it's"), "export TTH_ACTIVE_WORKSPACE='it'\\''s'"); + } + + #[test] + fn end_line_is_unset() { + assert_eq!(end_line(), "unset TTH_ACTIVE_WORKSPACE"); + } + + #[test] + fn normalize_workspace_empty_string_is_none() { + assert!(normalize_workspace("").is_none()); + } + + #[test] + fn normalize_workspace_whitespace_only_is_none() { + assert!(normalize_workspace(" ").is_none()); + } + + #[test] + fn normalize_workspace_trims_and_returns_some() { + assert_eq!(normalize_workspace(" a "), Some(String::from("a"))); + } + + #[test] + fn normalize_workspace_no_trim_needed() { + assert_eq!(normalize_workspace("ws1"), Some(String::from("ws1"))); + } + + fn seed_ws_command(conn: &Connection, ws: &str, cmd: &str, ts: i64, exit: i64) { + conn.execute( + "INSERT INTO commands(command, directory, project, session_id, timestamp, exit_code, duration_ms, tags, workspace) \ + VALUES(?1, '/tmp', 'p', 's1', ?2, ?3, 100, '[]', ?4)", + rusqlite::params![cmd, ts, exit, ws], + ) + .unwrap(); + } + + #[test] + fn list_workspace_commands_empty_returns_empty() { + let conn = mem_conn(); + let rows = list_workspace_commands(&conn, "ws-a").unwrap(); + assert!(rows.is_empty()); + } + + #[test] + fn list_workspace_commands_filters_by_workspace() { + let conn = mem_conn(); + seed_ws_command(&conn, "ws-a", "cmd-a1", 1000, 0); + seed_ws_command(&conn, "ws-b", "cmd-b1", 2000, 0); + let rows = list_workspace_commands(&conn, "ws-a").unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].command, "cmd-a1"); + } + + #[test] + fn list_workspace_commands_returns_asc_order() { + let conn = mem_conn(); + seed_ws_command(&conn, "ws-a", "first", 1000, 0); + seed_ws_command(&conn, "ws-a", "second", 2000, 0); + seed_ws_command(&conn, "ws-a", "third", 3000, 1); + let rows = list_workspace_commands(&conn, "ws-a").unwrap(); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0].command, "first"); + assert_eq!(rows[1].command, "second"); + assert_eq!(rows[2].command, "third"); + } + + #[test] + fn list_workspace_commands_populates_all_fields() { + let conn = mem_conn(); + seed_ws_command(&conn, "ws-x", "ls -la", 5000, 0); + let rows = list_workspace_commands(&conn, "ws-x").unwrap(); + assert_eq!(rows.len(), 1); + let r = &rows[0]; + assert_eq!(r.command, "ls -la"); + assert_eq!(r.timestamp, 5000); + assert_eq!(r.exit_code, 0); + assert_eq!(r.workspace, Some("ws-x".into())); + } + + #[test] + fn list_workspaces_empty_db() { + let conn = mem_conn(); + let rows = list_workspaces(&conn).unwrap(); + assert!(rows.is_empty()); + } + + #[test] + fn list_workspaces_excludes_null_and_empty() { + let mut conn = mem_conn(); + let base = crate::cli::RecordArgs { + cmd: String::from("echo"), + dir: Some(String::from("/tmp")), + exit_code: 0, + duration: 0, + timestamp: Some(1700000000), + tags: String::from("[]"), + terminal_id: None, + workspace: None, + }; + crate::recorder::record_inner(&base, 30, &mut conn).unwrap(); + let rows = list_workspaces(&conn).unwrap(); + assert!(rows.is_empty(), "NULL workspace must be excluded"); + } + + #[test] + fn list_workspaces_groups_counts_orders_by_last_desc() { + let mut conn = mem_conn(); + + let ws_a_early = crate::cli::RecordArgs { + cmd: String::from("cmd1"), + dir: Some(String::from("/tmp")), + exit_code: 0, + duration: 0, + timestamp: Some(1700000001), + tags: String::from("[]"), + terminal_id: None, + workspace: Some(String::from("ws-a")), + }; + crate::recorder::record_inner(&ws_a_early, 30, &mut conn).unwrap(); + + let ws_a_late = crate::cli::RecordArgs { + cmd: String::from("cmd2"), + dir: Some(String::from("/tmp")), + exit_code: 0, + duration: 0, + timestamp: Some(1700000003), + tags: String::from("[]"), + terminal_id: None, + workspace: Some(String::from("ws-a")), + }; + crate::recorder::record_inner(&ws_a_late, 30, &mut conn).unwrap(); + + let ws_b = crate::cli::RecordArgs { + cmd: String::from("cmd3"), + dir: Some(String::from("/tmp")), + exit_code: 0, + duration: 0, + timestamp: Some(1700000002), + tags: String::from("[]"), + terminal_id: None, + workspace: Some(String::from("ws-b")), + }; + crate::recorder::record_inner(&ws_b, 30, &mut conn).unwrap(); + + let rows = list_workspaces(&conn).unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].name, "ws-a"); + assert_eq!(rows[0].command_count, 2); + assert_eq!(rows[0].last_ts, 1700000003); + assert_eq!(rows[1].name, "ws-b"); + assert_eq!(rows[1].command_count, 1); + } +} diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..7d6c1a1 --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,587 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use rusqlite::Connection; +use tempfile::TempDir; + +fn tth() -> Command { + Command::cargo_bin("tth").unwrap() +} + +#[test] +fn cli_exits_0_on_success() { + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("history.db"); + tth() + .env("THOTH_DB", db_path.to_str().unwrap()) + .env( + "THOTH_ERROR_LOG", + dir.path().join("error.log").to_str().unwrap(), + ) + .args(["record", "--cmd", "ls"]) + .assert() + .code(0); +} + +#[test] +fn cli_all_six_flags_persisted() { + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("history.db"); + tth() + .env("THOTH_DB", db_path.to_str().unwrap()) + .env( + "THOTH_ERROR_LOG", + dir.path().join("error.log").to_str().unwrap(), + ) + .args([ + "record", + "--cmd", + "echo hi", + "--dir", + "/tmp", + "--exit", + "42", + "--duration", + "100", + "--timestamp", + "1700000000", + "--tags", + r#"["test"]"#, + ]) + .assert() + .code(0); + + let conn = Connection::open(&db_path).unwrap(); + let (cmd, dir_val, exit_code, duration, ts, tags): (String, String, i64, i64, i64, String) = + conn.query_row( + "SELECT command, directory, exit_code, duration_ms, timestamp, tags FROM commands", + [], + |r| { + Ok(( + r.get(0)?, + r.get(1)?, + r.get(2)?, + r.get(3)?, + r.get(4)?, + r.get(5)?, + )) + }, + ) + .unwrap(); + assert_eq!(cmd, "echo hi"); + assert_eq!(dir_val, "/tmp"); + assert_eq!(exit_code, 42); + assert_eq!(duration, 100); + assert_eq!(ts, 1700000000); + assert_eq!(tags, r#"["test"]"#); +} + +#[test] +fn cli_terminal_id_persisted() { + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("history.db"); + tth() + .env("THOTH_DB", db_path.to_str().unwrap()) + .env( + "THOTH_ERROR_LOG", + dir.path().join("error.log").to_str().unwrap(), + ) + .args([ + "record", + "--cmd", + "test-cmd", + "--exit", + "0", + "--duration", + "1", + "--timestamp", + "1700000000", + "--terminal-id", + "term-abc", + ]) + .assert() + .code(0); + + let conn = Connection::open(&db_path).unwrap(); + let terminal_id: Option = conn + .query_row( + "SELECT terminal_id FROM commands WHERE command='test-cmd'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(terminal_id, Some(String::from("term-abc"))); +} + +#[test] +fn cli_terminal_id_null_when_omitted() { + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("history.db"); + tth() + .env("THOTH_DB", db_path.to_str().unwrap()) + .env( + "THOTH_ERROR_LOG", + dir.path().join("error.log").to_str().unwrap(), + ) + .args(["record", "--cmd", "no-tid-cmd", "--exit", "0"]) + .assert() + .code(0); + + let conn = Connection::open(&db_path).unwrap(); + let terminal_id: Option = conn + .query_row( + "SELECT terminal_id FROM commands WHERE command='no-tid-cmd'", + [], + |r| r.get(0), + ) + .unwrap(); + assert!(terminal_id.is_none()); +} + +#[test] +fn cli_default_dir_is_cwd() { + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("history.db"); + let cwd = std::env::current_dir().unwrap(); + tth() + .env("THOTH_DB", db_path.to_str().unwrap()) + .env( + "THOTH_ERROR_LOG", + dir.path().join("error.log").to_str().unwrap(), + ) + .args(["record", "--cmd", "pwd"]) + .assert() + .code(0); + + let conn = Connection::open(&db_path).unwrap(); + let directory: String = conn + .query_row("SELECT directory FROM commands", [], |r| r.get(0)) + .unwrap(); + assert_eq!(directory, cwd.to_string_lossy().as_ref()); +} + +#[test] +fn new_session_id_prints_uuid() { + let output = tth().args(["new-session-id"]).output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + let trimmed = stdout.trim(); + assert_eq!(trimmed.len(), 36, "expected uuid v4 length"); + assert_eq!( + trimmed.chars().filter(|&c| c == '-').count(), + 4, + "expected 4 hyphens in uuid" + ); +} + +#[test] +fn new_session_id_differs_across_runs() { + let out1 = tth().args(["new-session-id"]).output().unwrap(); + let out2 = tth().args(["new-session-id"]).output().unwrap(); + let id1 = String::from_utf8(out1.stdout).unwrap(); + let id2 = String::from_utf8(out2.stdout).unwrap(); + assert_ne!( + id1.trim(), + id2.trim(), + "two runs must produce distinct uuids" + ); +} + +#[test] +fn install_writes_sentinel_to_rc_file() { + let dir = TempDir::new().unwrap(); + let rc = dir.path().join(".bashrc"); + let db_path = dir.path().join("history.db"); + tth() + .env("THOTH_DB", db_path.to_str().unwrap()) + .env( + "THOTH_ERROR_LOG", + dir.path().join("error.log").to_str().unwrap(), + ) + .args([ + "install", + "--shell", + "bash", + "--rc-file", + rc.to_str().unwrap(), + ]) + .assert() + .code(0) + .stdout(predicate::str::contains("Installed")); + let content = std::fs::read_to_string(&rc).unwrap(); + assert!(content.contains("# === THOTH HOOKS BEGIN ===")); +} + +#[test] +fn install_idempotent_no_duplicate_sentinel() { + let dir = TempDir::new().unwrap(); + let rc = dir.path().join(".zshrc"); + let db_path = dir.path().join("history.db"); + for _ in 0..2 { + tth() + .env("THOTH_DB", db_path.to_str().unwrap()) + .env( + "THOTH_ERROR_LOG", + dir.path().join("error.log").to_str().unwrap(), + ) + .args([ + "install", + "--shell", + "zsh", + "--rc-file", + rc.to_str().unwrap(), + ]) + .assert() + .code(0); + } + let content = std::fs::read_to_string(&rc).unwrap(); + assert_eq!(content.matches("# === THOTH HOOKS BEGIN ===").count(), 1); +} + +#[test] +fn uninstall_removes_sentinel_from_rc_file() { + let dir = TempDir::new().unwrap(); + let rc = dir.path().join(".bashrc"); + let db_path = dir.path().join("history.db"); + tth() + .env("THOTH_DB", db_path.to_str().unwrap()) + .env( + "THOTH_ERROR_LOG", + dir.path().join("error.log").to_str().unwrap(), + ) + .args([ + "install", + "--shell", + "bash", + "--rc-file", + rc.to_str().unwrap(), + ]) + .assert() + .code(0); + tth() + .env("THOTH_DB", db_path.to_str().unwrap()) + .env( + "THOTH_ERROR_LOG", + dir.path().join("error.log").to_str().unwrap(), + ) + .args(["uninstall", "--rc-file", rc.to_str().unwrap()]) + .assert() + .code(0); + let content = std::fs::read_to_string(&rc).unwrap(); + assert!(!content.contains("# === THOTH HOOKS BEGIN ===")); +} + +#[test] +fn init_zsh_prints_hook_script() { + let output = tth().args(["init", "zsh"]).output().unwrap(); + assert!(output.status.success(), "tth init zsh must exit 0"); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("_tth_preexec"), "missing _tth_preexec"); + assert!(stdout.contains("_tth_precmd"), "missing _tth_precmd"); + assert!(stdout.contains("bindkey '^R'"), "missing bindkey '^R'"); + assert!(stdout.contains("tth|tth\\ *"), "missing capture exclusion"); + assert!( + !stdout.contains("# === THOTH HOOKS BEGIN ==="), + "raw script must not contain sentinel" + ); +} + +#[test] +fn init_bash_prints_hook_script() { + let output = tth().args(["init", "bash"]).output().unwrap(); + assert!(output.status.success(), "tth init bash must exit 0"); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("_thoth_preexec"), "missing _thoth_preexec"); + assert!(stdout.contains("_thoth_precmd"), "missing _thoth_precmd"); + assert!(stdout.contains("bind -x"), "missing bind -x"); + assert!(stdout.contains("tth|tth\\ *"), "missing capture exclusion"); + assert!( + !stdout.contains("# === THOTH HOOKS BEGIN ==="), + "raw script must not contain sentinel" + ); +} + +#[test] +fn init_unknown_shell_exits_nonzero() { + tth() + .args(["init", "fish"]) + .assert() + .failure() + .stderr(predicate::str::starts_with("tth: ")); +} + +#[test] +fn install_writes_eval_line_not_full_body() { + let dir = TempDir::new().unwrap(); + let rc = dir.path().join(".zshrc"); + let db_path = dir.path().join("history.db"); + tth() + .env("THOTH_DB", db_path.to_str().unwrap()) + .env( + "THOTH_ERROR_LOG", + dir.path().join("error.log").to_str().unwrap(), + ) + .args([ + "install", + "--shell", + "zsh", + "--rc-file", + rc.to_str().unwrap(), + ]) + .assert() + .code(0); + let content = std::fs::read_to_string(&rc).unwrap(); + assert!( + content.contains("eval \"$(tth init zsh)\""), + "eval line not found; got:\n{content}" + ); + assert!( + !content.contains("_tth_preexec"), + "full hook body must not be written to rc" + ); +} + +#[test] +fn install_migration_replaces_old_full_block() { + let dir = TempDir::new().unwrap(); + let rc = dir.path().join(".zshrc"); + let db_path = dir.path().join("history.db"); + let old_block = + "# === THOTH HOOKS BEGIN ===\n_tth_preexec() { : ; }\n# === THOTH HOOKS END ===\n"; + std::fs::write(&rc, old_block).unwrap(); + tth() + .env("THOTH_DB", db_path.to_str().unwrap()) + .env( + "THOTH_ERROR_LOG", + dir.path().join("error.log").to_str().unwrap(), + ) + .args([ + "install", + "--shell", + "zsh", + "--rc-file", + rc.to_str().unwrap(), + ]) + .assert() + .code(0); + let content = std::fs::read_to_string(&rc).unwrap(); + assert_eq!( + content.matches("# === THOTH HOOKS BEGIN ===").count(), + 1, + "expected exactly one sentinel block" + ); + assert!( + content.contains("eval \"$(tth init zsh)\""), + "eval line not found after migration" + ); + assert!( + !content.contains("_tth_preexec() { : ; }"), + "old body still present after migration" + ); +} + +#[test] +fn status_exits_0_and_prints_info() { + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("history.db"); + tth() + .env("THOTH_DB", db_path.to_str().unwrap()) + .env( + "THOTH_ERROR_LOG", + dir.path().join("error.log").to_str().unwrap(), + ) + .args(["status"]) + .assert() + .code(0) + .stdout(predicate::str::contains("Schema version")); +} + +#[test] +fn lifecycle_error_exits_nonzero_and_prints_stderr() { + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("history.db"); + let unwritable = dir.path().join("no").join("such").join("path.rc"); + tth() + .env("THOTH_DB", db_path.to_str().unwrap()) + .env( + "THOTH_ERROR_LOG", + dir.path().join("error.log").to_str().unwrap(), + ) + .args([ + "install", + "--shell", + "bash", + "--rc-file", + unwritable.to_str().unwrap(), + ]) + .assert() + .failure() + .stderr(predicate::str::starts_with("tth: ")); +} + +#[test] +fn record_never_fail_contract_preserved() { + let dir = TempDir::new().unwrap(); + let ro_dir = dir.path().join("readonly"); + std::fs::create_dir_all(&ro_dir).unwrap(); + let mut perms = std::fs::metadata(&ro_dir).unwrap().permissions(); + use std::os::unix::fs::PermissionsExt; + perms.set_mode(0o444); + std::fs::set_permissions(&ro_dir, perms).unwrap(); + tth() + .env("THOTH_DB", ro_dir.join("history.db").to_str().unwrap()) + .env( + "THOTH_ERROR_LOG", + dir.path().join("error.log").to_str().unwrap(), + ) + .args(["record", "--cmd", "whoami"]) + .assert() + .code(0); +} + +#[test] +fn help_top_level_contains_about_and_command_descriptions() { + let output = tth().args(["--help"]).output().unwrap(); + assert!(output.status.success(), "tth --help must exit 0"); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + stdout.contains("Thoth records every command"), + "top-level about not found; got:\n{stdout}" + ); + assert!( + stdout.contains("Search history"), + "search description not found; got:\n{stdout}" + ); + assert!( + stdout.contains("List work sessions"), + "sessions description not found; got:\n{stdout}" + ); + assert!( + stdout.contains("Run diagnostics"), + "doctor description not found; got:\n{stdout}" + ); + assert!( + stdout.contains("Show history insights"), + "stats description not found; got:\n{stdout}" + ); +} + +#[test] +fn help_search_shows_about_and_flags() { + let output = tth().args(["help", "search"]).output().unwrap(); + assert!(output.status.success(), "tth help search must exit 0"); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + stdout.contains("Search history"), + "search about not found; got:\n{stdout}" + ); + assert!( + stdout.contains("--project"), + "--project flag not documented; got:\n{stdout}" + ); + assert!( + stdout.contains("--limit"), + "--limit flag not documented; got:\n{stdout}" + ); + assert!( + stdout.contains("--since"), + "--since flag not documented; got:\n{stdout}" + ); +} + +#[test] +fn help_forget_shows_about_and_flags() { + let output = tth().args(["help", "forget"]).output().unwrap(); + assert!(output.status.success(), "tth help forget must exit 0"); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + stdout.contains("Delete recent commands"), + "forget about not found; got:\n{stdout}" + ); + assert!( + stdout.contains("--dry-run"), + "--dry-run flag not documented; got:\n{stdout}" + ); +} + +#[test] +fn help_sessions_shows_about() { + let output = tth().args(["help", "sessions"]).output().unwrap(); + assert!(output.status.success(), "tth help sessions must exit 0"); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + stdout.contains("List work sessions"), + "sessions about not found; got:\n{stdout}" + ); +} + +fn tth_with_env(db_path: &std::path::Path, error_log: &std::path::Path) -> Command { + let mut cmd = tth(); + cmd.env("THOTH_DB", db_path.to_str().unwrap()) + .env("THOTH_ERROR_LOG", error_log.to_str().unwrap()); + cmd +} + +#[test] +fn prompt_framework_starship_prints_env_var_module() { + let dir = TempDir::new().unwrap(); + tth_with_env(&dir.path().join("h.db"), &dir.path().join("e.log")) + .args(["prompt", "--framework", "starship"]) + .assert() + .code(0) + .stdout(predicate::str::contains("env_var.thoth_tags")) + .stdout(predicate::str::contains("TTH_PROMPT_TAGS")); +} + +#[test] +fn prompt_unknown_framework_exits_nonzero() { + let dir = TempDir::new().unwrap(); + tth_with_env(&dir.path().join("h.db"), &dir.path().join("e.log")) + .args(["prompt", "--framework", "fish"]) + .assert() + .failure() + .stderr(predicate::str::contains("unknown framework")); +} + +#[test] +fn prompt_framework_generic_prints_generic_snippet() { + let dir = TempDir::new().unwrap(); + tth_with_env(&dir.path().join("h.db"), &dir.path().join("e.log")) + .args(["prompt", "--framework", "generic"]) + .assert() + .code(0) + .stdout(predicate::str::contains("PROMPT").or(predicate::str::contains("PS1"))); +} + +#[test] +fn install_output_includes_prompt_hint() { + let dir = TempDir::new().unwrap(); + let rc = dir.path().join(".bashrc"); + tth_with_env(&dir.path().join("h.db"), &dir.path().join("e.log")) + .args([ + "install", + "--shell", + "bash", + "--rc-file", + rc.to_str().unwrap(), + ]) + .assert() + .code(0) + .stdout(predicate::str::contains( + "To show active tags in your prompt:", + )); +} + +#[test] +fn doctor_exits_0_and_prints_checklist() { + let dir = TempDir::new().unwrap(); + tth_with_env(&dir.path().join("h.db"), &dir.path().join("e.log")) + .args(["doctor"]) + .assert() + .code(0) + .stdout( + predicate::str::contains("hooks installed") + .and(predicate::str::contains("database")) + .and(predicate::str::contains("tth on PATH")) + .and(predicate::str::contains("prompt tags visibility")), + ); +} diff --git a/tests/config_e2e.rs b/tests/config_e2e.rs new file mode 100644 index 0000000..fcda034 --- /dev/null +++ b/tests/config_e2e.rs @@ -0,0 +1,151 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::TempDir; + +fn tth_config_env(dir: &TempDir) -> (std::path::PathBuf, std::path::PathBuf) { + let db = dir.path().join("history.db"); + let cfg = dir.path().join("config.toml"); + (db, cfg) +} + +fn tth_with(db: &std::path::Path, cfg: &std::path::Path) -> Command { + let mut cmd = Command::cargo_bin("tth").unwrap(); + cmd.env("THOTH_DB", db.to_str().unwrap()) + .env("THOTH_CONFIG", cfg.to_str().unwrap()) + .env("THOTH_ERROR_LOG", "/dev/null") + .env("NO_COLOR", "1"); + cmd +} + +#[test] +fn config_set_then_get_orientation() { + let dir = TempDir::new().unwrap(); + let (db, cfg) = tth_config_env(&dir); + + tth_with(&db, &cfg) + .args(["config", "set", "tui.orientation", "top"]) + .assert() + .code(0) + .stdout(predicate::str::contains("set tui.orientation = top")); + + tth_with(&db, &cfg) + .args(["config", "get", "tui.orientation"]) + .assert() + .code(0) + .stdout(predicate::str::is_match("^top\n?$").unwrap()); +} + +#[test] +fn config_print_shows_toml_after_set() { + let dir = TempDir::new().unwrap(); + let (db, cfg) = tth_config_env(&dir); + + tth_with(&db, &cfg) + .args(["config", "set", "tui.orientation", "top"]) + .assert() + .code(0); + + tth_with(&db, &cfg) + .args(["config", "print"]) + .assert() + .code(0) + .stdout(predicate::str::contains("orientation")); +} + +#[test] +fn config_bare_prints_summary() { + let dir = TempDir::new().unwrap(); + let (db, cfg) = tth_config_env(&dir); + + tth_with(&db, &cfg) + .args(["config"]) + .assert() + .code(0) + .stdout( + predicate::str::contains("gap_minutes") + .and(predicate::str::contains("orientation")) + .and(predicate::str::contains("default_limit")), + ); +} + +#[test] +fn config_set_invalid_gap_minutes_exits_nonzero_and_file_unchanged() { + let dir = TempDir::new().unwrap(); + let (db, cfg) = tth_config_env(&dir); + + std::fs::write(&cfg, "# existing content\n").unwrap(); + let original = std::fs::read_to_string(&cfg).unwrap(); + + tth_with(&db, &cfg) + .args(["config", "set", "session.gap_minutes", "abc"]) + .assert() + .code(1); + + let after = std::fs::read_to_string(&cfg).unwrap(); + assert_eq!(original, after, "file must not change on error"); +} + +#[test] +fn config_get_unknown_key_exits_nonzero() { + let dir = TempDir::new().unwrap(); + let (db, cfg) = tth_config_env(&dir); + + tth_with(&db, &cfg) + .args(["config", "get", "foo.bar"]) + .assert() + .code(1); +} + +#[test] +fn install_writes_default_config_when_absent() { + let dir = TempDir::new().unwrap(); + let db = dir.path().join("history.db"); + let cfg = dir.path().join("subdir").join("config.toml"); + let rc = dir.path().join(".zshrc"); + + Command::cargo_bin("tth") + .unwrap() + .env("THOTH_DB", db.to_str().unwrap()) + .env("THOTH_CONFIG", cfg.to_str().unwrap()) + .env("THOTH_ERROR_LOG", "/dev/null") + .env("NO_COLOR", "1") + .env("SHELL", "/bin/zsh") + .args(["install", "--rc-file", rc.to_str().unwrap()]) + .assert() + .code(0) + .stdout(predicate::str::contains("Wrote default config to")); + + assert!(cfg.exists(), "config file must have been written"); + let content = std::fs::read_to_string(&cfg).unwrap(); + assert!( + content.contains("gap_minutes"), + "default config must have gap_minutes" + ); +} + +#[test] +fn install_does_not_overwrite_existing_config() { + let dir = TempDir::new().unwrap(); + let db = dir.path().join("history.db"); + let cfg = dir.path().join("config.toml"); + let rc = dir.path().join(".zshrc"); + + std::fs::write(&cfg, "# my custom config\n").unwrap(); + + Command::cargo_bin("tth") + .unwrap() + .env("THOTH_DB", db.to_str().unwrap()) + .env("THOTH_CONFIG", cfg.to_str().unwrap()) + .env("THOTH_ERROR_LOG", "/dev/null") + .env("NO_COLOR", "1") + .env("SHELL", "/bin/zsh") + .args(["install", "--rc-file", rc.to_str().unwrap()]) + .assert() + .code(0); + + let content = std::fs::read_to_string(&cfg).unwrap(); + assert_eq!( + content, "# my custom config\n", + "existing config must not be overwritten" + ); +} diff --git a/tests/export_e2e.rs b/tests/export_e2e.rs new file mode 100644 index 0000000..f22ee50 --- /dev/null +++ b/tests/export_e2e.rs @@ -0,0 +1,341 @@ +use assert_cmd::Command; +use tempfile::TempDir; + +fn tth() -> Command { + Command::cargo_bin("tth").unwrap() +} + +struct RecordInput<'a> { + cmd: &'a str, + dir: &'a str, + exit: i64, + dur: i64, + tags: &'a str, + ts: i64, +} + +fn record(db: &str, log: &str, r: RecordInput<'_>) { + tth() + .env("THOTH_DB", db) + .env("THOTH_ERROR_LOG", log) + .args([ + "record", + "--cmd", + r.cmd, + "--dir", + r.dir, + "--exit", + &r.exit.to_string(), + "--duration", + &r.dur.to_string(), + "--tags", + r.tags, + "--timestamp", + &r.ts.to_string(), + ]) + .assert() + .code(0); +} + +struct Fixture { + _dir: TempDir, + db: String, + log: String, + alpha_dir: String, + _beta_dir: String, +} + +fn setup() -> Fixture { + let dir = TempDir::new().unwrap(); + let db = dir.path().join("history.db").to_string_lossy().to_string(); + let log = dir.path().join("error.log").to_string_lossy().to_string(); + + let alpha = dir.path().join("alpha"); + let beta = dir.path().join("beta"); + std::fs::create_dir_all(alpha.join(".git")).unwrap(); + std::fs::create_dir_all(beta.join(".git")).unwrap(); + + let alpha_dir = alpha.to_string_lossy().to_string(); + let beta_dir = beta.to_string_lossy().to_string(); + + record( + &db, + &log, + RecordInput { + cmd: "cargo build", + dir: &alpha_dir, + exit: 0, + dur: 1200, + tags: r#"["rust"]"#, + ts: 1_700_000_000, + }, + ); + record( + &db, + &log, + RecordInput { + cmd: "make test", + dir: &beta_dir, + exit: 1, + dur: 500, + tags: "[]", + ts: 1_700_001_000, + }, + ); + record( + &db, + &log, + RecordInput { + cmd: "docker run nginx", + dir: &alpha_dir, + exit: 0, + dur: 3000, + tags: r#"["docker","infra"]"#, + ts: 1_700_002_000, + }, + ); + + Fixture { + _dir: dir, + db, + log, + alpha_dir, + _beta_dir: beta_dir.to_string(), + } +} + +#[test] +fn export_no_filters_exits_0_and_starts_with_shebang() { + let f = setup(); + let out = tth() + .env("THOTH_DB", &f.db) + .env("THOTH_ERROR_LOG", &f.log) + .args(["export"]) + .assert() + .code(0) + .get_output() + .stdout + .clone(); + let text = String::from_utf8_lossy(&out); + assert!( + text.starts_with("#!/usr/bin/env bash\n"), + "output must start with shebang; got:\n{text}" + ); + assert!(text.contains("# Thoth export")); + assert!(text.contains("cargo build")); + assert!(text.contains("make test")); + assert!(text.contains("docker run nginx")); +} + +#[test] +fn export_chronological_order() { + let f = setup(); + let out = tth() + .env("THOTH_DB", &f.db) + .env("THOTH_ERROR_LOG", &f.log) + .args(["export"]) + .assert() + .code(0) + .get_output() + .stdout + .clone(); + let text = String::from_utf8_lossy(&out); + let pos_first = text.find("cargo build").unwrap(); + let pos_second = text.find("make test").unwrap(); + let pos_third = text.find("docker run nginx").unwrap(); + assert!( + pos_first < pos_second && pos_second < pos_third, + "commands must appear in chronological order" + ); +} + +#[test] +fn export_row_comment_format() { + let f = setup(); + let out = tth() + .env("THOTH_DB", &f.db) + .env("THOTH_ERROR_LOG", &f.log) + .args(["export"]) + .assert() + .code(0) + .get_output() + .stdout + .clone(); + let text = String::from_utf8_lossy(&out); + assert!( + text.contains("[22:13:20]"), + "HH:MM:SS comment expected; got:\n{text}" + ); + assert!(text.contains("[exit: 0]")); + assert!(text.contains("[duration: 1.2s]")); + assert!(text.contains(&format!("[{}]", f.alpha_dir))); +} + +#[test] +fn export_project_filter() { + let f = setup(); + let out = tth() + .env("THOTH_DB", &f.db) + .env("THOTH_ERROR_LOG", &f.log) + .args(["export", "--project", "alpha"]) + .assert() + .code(0) + .get_output() + .stdout + .clone(); + let text = String::from_utf8_lossy(&out); + assert!(text.contains("cargo build")); + assert!(text.contains("docker run nginx")); + assert!(!text.contains("make test")); + assert!(text.contains("# Project: alpha")); +} + +#[test] +fn export_exit_fail_filter() { + let f = setup(); + let out = tth() + .env("THOTH_DB", &f.db) + .env("THOTH_ERROR_LOG", &f.log) + .args(["export", "--exit", "fail"]) + .assert() + .code(0) + .get_output() + .stdout + .clone(); + let text = String::from_utf8_lossy(&out); + assert!(text.contains("make test")); + assert!(!text.contains("cargo build")); +} + +#[test] +fn export_tag_filter() { + let f = setup(); + let out = tth() + .env("THOTH_DB", &f.db) + .env("THOTH_ERROR_LOG", &f.log) + .args(["export", "--tag", "rust"]) + .assert() + .code(0) + .get_output() + .stdout + .clone(); + let text = String::from_utf8_lossy(&out); + assert!(text.contains("cargo build")); + assert!(!text.contains("make test")); + assert!(!text.contains("docker run nginx")); + assert!(text.contains("# Tags: rust")); +} + +#[test] +fn export_session_filter() { + let dir = TempDir::new().unwrap(); + let db = dir.path().join("history.db").to_string_lossy().to_string(); + let log = dir.path().join("error.log").to_string_lossy().to_string(); + + let alpha = dir.path().join("alpha"); + let beta = dir.path().join("beta"); + std::fs::create_dir_all(alpha.join(".git")).unwrap(); + std::fs::create_dir_all(beta.join(".git")).unwrap(); + + let alpha_dir = alpha.to_string_lossy().to_string(); + let beta_dir = beta.to_string_lossy().to_string(); + + record( + &db, + &log, + RecordInput { + cmd: "alpha_cmd_1", + dir: &alpha_dir, + exit: 0, + dur: 100, + tags: "[]", + ts: 1_700_000_000, + }, + ); + record( + &db, + &log, + RecordInput { + cmd: "beta_cmd_1", + dir: &beta_dir, + exit: 0, + dur: 100, + tags: "[]", + ts: 1_700_000_001, + }, + ); + let conn = rusqlite::Connection::open(&db).unwrap(); + let alpha_session_id: String = conn + .query_row( + "SELECT session_id FROM commands WHERE command = 'alpha_cmd_1' LIMIT 1", + [], + |r| r.get(0), + ) + .unwrap(); + + let out = tth() + .env("THOTH_DB", &db) + .env("THOTH_ERROR_LOG", &log) + .args(["export", "--session", &alpha_session_id]) + .assert() + .code(0) + .get_output() + .stdout + .clone(); + let text = String::from_utf8_lossy(&out); + assert!( + text.contains("alpha_cmd_1"), + "alpha_cmd_1 must be in session export; got:\n{text}" + ); + assert!( + !text.contains("beta_cmd_1"), + "beta_cmd_1 must be excluded; got:\n{text}" + ); +} + +#[test] +fn export_empty_result_still_valid_bash() { + let f = setup(); + let out = tth() + .env("THOTH_DB", &f.db) + .env("THOTH_ERROR_LOG", &f.log) + .args(["export", "--project", "no-such-project"]) + .assert() + .code(0) + .get_output() + .stdout + .clone(); + let text = String::from_utf8_lossy(&out); + assert!( + text.starts_with("#!/usr/bin/env bash\n"), + "even empty export must start with shebang" + ); + assert!(text.contains("# (no commands matched)")); +} + +#[test] +fn export_output_parses_as_bash() { + let f = setup(); + let out = tth() + .env("THOTH_DB", &f.db) + .env("THOTH_ERROR_LOG", &f.log) + .args(["export"]) + .assert() + .code(0) + .get_output() + .stdout + .clone(); + + let dir = TempDir::new().unwrap(); + let script_path = dir.path().join("exported.sh"); + std::fs::write(&script_path, &out).unwrap(); + + let status = std::process::Command::new("bash") + .args(["-n", script_path.to_str().unwrap()]) + .status() + .unwrap(); + assert!( + status.success(), + "exported script must parse cleanly with bash -n" + ); +} diff --git a/tests/forget_e2e.rs b/tests/forget_e2e.rs new file mode 100644 index 0000000..f6089c2 --- /dev/null +++ b/tests/forget_e2e.rs @@ -0,0 +1,148 @@ +use assert_cmd::Command; +use tempfile::TempDir; + +fn tth(db_dir: &TempDir) -> Command { + let mut cmd = Command::cargo_bin("tth").unwrap(); + cmd.env("THOTH_DB", db_dir.path().join("history.db")); + cmd +} + +fn record(db_dir: &TempDir, command: &str) { + tth(db_dir) + .args([ + "record", + "--cmd", + command, + "--dir", + "/tmp", + "--exit", + "0", + "--duration", + "0", + "--timestamp", + "1700000000", + "--terminal-id", + "default-tid", + ]) + .assert() + .success(); +} + +fn record_with_terminal(db_dir: &TempDir, command: &str, terminal_id: &str) { + tth(db_dir) + .args([ + "record", + "--cmd", + command, + "--dir", + "/tmp", + "--exit", + "0", + "--duration", + "0", + "--timestamp", + "1700000000", + "--terminal-id", + terminal_id, + ]) + .assert() + .success(); +} + +#[test] +fn dry_run_shows_preview_and_does_not_delete() { + let dir = TempDir::new().unwrap(); + record(&dir, "echo alpha"); + record(&dir, "echo beta"); + record(&dir, "echo gamma"); + + tth(&dir) + .args(["forget", "--last", "2", "--dry-run"]) + .assert() + .success() + .stdout(predicates::str::contains("Would forget 2 command(s).")); + + let out = tth(&dir) + .args(["search", "--limit", "100"]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("3 result(s)"), + "dry-run must not delete; still expect 3 rows, got: {stdout}" + ); +} + +#[test] +fn forget_last_2_leaves_1() { + let dir = TempDir::new().unwrap(); + record(&dir, "echo one"); + record(&dir, "echo two"); + record(&dir, "echo three"); + + tth(&dir) + .args(["forget", "--last", "2"]) + .assert() + .success() + .stdout(predicates::str::contains("Forgot 2 command(s).")); + + let out = tth(&dir) + .args(["search", "--limit", "100"]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("1 result(s)"), + "expected 1 row after forgetting 2, got: {stdout}" + ); +} + +#[test] +fn forget_on_empty_db_prints_no_commands() { + let dir = TempDir::new().unwrap(); + + tth(&dir) + .arg("forget") + .assert() + .success() + .stdout(predicates::str::contains("No commands to forget.")); +} + +#[test] +fn terminal_scoping_only_removes_target_terminal() { + let dir = TempDir::new().unwrap(); + record_with_terminal(&dir, "cmd_x_1", "tid-x"); + record_with_terminal(&dir, "cmd_x_2", "tid-x"); + record_with_terminal(&dir, "cmd_y_1", "tid-y"); + + tth(&dir) + .args(["forget", "--last", "2", "--terminal-id", "tid-x"]) + .assert() + .success() + .stdout(predicates::str::contains("Forgot 2 command(s).")); + + let out = tth(&dir) + .args(["search", "--limit", "100"]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("1 result(s)"), + "expected 1 row (tid-y command) remaining, got: {stdout}" + ); + assert!( + stdout.contains("cmd_y_1"), + "tid-y command must survive, got: {stdout}" + ); +} + +#[test] +fn last_zero_returns_error() { + let dir = TempDir::new().unwrap(); + + tth(&dir) + .args(["forget", "--last", "0"]) + .assert() + .failure() + .stderr(predicates::str::contains("nothing to forget")); +} diff --git a/tests/search_e2e.rs b/tests/search_e2e.rs new file mode 100644 index 0000000..50771f8 --- /dev/null +++ b/tests/search_e2e.rs @@ -0,0 +1,412 @@ +use assert_cmd::Command; +use tempfile::TempDir; + +fn tth() -> Command { + Command::cargo_bin("tth").unwrap() +} + +struct RecordInput<'a> { + cmd: &'a str, + dir: &'a str, + exit: i64, + dur: i64, + tags: &'a str, + ts: i64, +} + +fn record(db: &str, log: &str, r: RecordInput<'_>) { + tth() + .env("THOTH_DB", db) + .env("THOTH_ERROR_LOG", log) + .args([ + "record", + "--cmd", + r.cmd, + "--dir", + r.dir, + "--exit", + &r.exit.to_string(), + "--duration", + &r.dur.to_string(), + "--tags", + r.tags, + "--timestamp", + &r.ts.to_string(), + ]) + .assert() + .code(0); +} + +struct Fixture { + _dir: TempDir, + db: String, + log: String, + _alpha_dir: String, + _beta_dir: String, +} + +fn setup() -> Fixture { + let dir = TempDir::new().unwrap(); + let db = dir.path().join("history.db").to_string_lossy().to_string(); + let log = dir.path().join("error.log").to_string_lossy().to_string(); + + let alpha = dir.path().join("alpha"); + let beta = dir.path().join("beta"); + std::fs::create_dir_all(alpha.join(".git")).unwrap(); + std::fs::create_dir_all(beta.join(".git")).unwrap(); + + let alpha_str = alpha.to_string_lossy().to_string(); + let beta_str = beta.to_string_lossy().to_string(); + + record( + &db, + &log, + RecordInput { + cmd: "cargo build", + dir: &alpha_str, + exit: 0, + dur: 1200, + tags: r#"["rust"]"#, + ts: 1_700_000_000, + }, + ); + record( + &db, + &log, + RecordInput { + cmd: "make test", + dir: &beta_str, + exit: 1, + dur: 500, + tags: "[]", + ts: 1_700_001_000, + }, + ); + record( + &db, + &log, + RecordInput { + cmd: "docker run nginx", + dir: &alpha_str, + exit: 0, + dur: 3000, + tags: r#"["docker","infra"]"#, + ts: 1_700_002_000, + }, + ); + record( + &db, + &log, + RecordInput { + cmd: "ls -la", + dir: &beta_str, + exit: 0, + dur: 50, + tags: "[]", + ts: 1_700_003_000, + }, + ); + + Fixture { + _dir: dir, + db, + log, + _alpha_dir: alpha_str, + _beta_dir: beta_str, + } +} + +#[test] +fn search_no_filters_exits_0_and_shows_commands() { + let f = setup(); + tth() + .env("THOTH_DB", &f.db) + .env("THOTH_ERROR_LOG", &f.log) + .args(["search"]) + .assert() + .code(0) + .stdout(predicates::str::contains("cargo build")); +} + +#[test] +fn search_project_filter_includes_and_excludes() { + let f = setup(); + let out = tth() + .env("THOTH_DB", &f.db) + .env("THOTH_ERROR_LOG", &f.log) + .args(["search", "--project", "alpha"]) + .assert() + .code(0) + .get_output() + .stdout + .clone(); + let text = String::from_utf8_lossy(&out); + assert!( + text.contains("cargo build"), + "expected cargo build in output" + ); + assert!( + !text.contains("make test"), + "expected make test to be excluded" + ); +} + +#[test] +fn search_exit_fail_filter() { + let f = setup(); + let out = tth() + .env("THOTH_DB", &f.db) + .env("THOTH_ERROR_LOG", &f.log) + .args(["search", "--exit", "fail"]) + .assert() + .code(0) + .get_output() + .stdout + .clone(); + let text = String::from_utf8_lossy(&out); + assert!(text.contains("make test")); + assert!(!text.contains("cargo build")); +} + +#[test] +fn search_tag_filter() { + let f = setup(); + let out = tth() + .env("THOTH_DB", &f.db) + .env("THOTH_ERROR_LOG", &f.log) + .args(["search", "--tag", "rust"]) + .assert() + .code(0) + .get_output() + .stdout + .clone(); + let text = String::from_utf8_lossy(&out); + assert!(text.contains("cargo build")); + assert!(!text.contains("make test")); +} + +#[test] +fn search_duration_bad_exits_nonzero_with_error() { + let f = setup(); + let cmd_out = tth() + .env("THOTH_DB", &f.db) + .env("THOTH_ERROR_LOG", &f.log) + .args(["search", "--duration", "bad"]) + .output() + .unwrap(); + assert!(!cmd_out.status.success(), "expected non-zero exit"); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&cmd_out.stdout), + String::from_utf8_lossy(&cmd_out.stderr) + ); + assert!( + combined.contains("duration") || combined.contains("bad") || combined.contains("Error"), + "expected error text, got: {combined}" + ); +} + +#[test] +fn search_since_bad_date_exits_nonzero() { + let f = setup(); + let cmd_out = tth() + .env("THOTH_DB", &f.db) + .env("THOTH_ERROR_LOG", &f.log) + .args(["search", "--since", "bad-date"]) + .output() + .unwrap(); + assert!( + !cmd_out.status.success(), + "expected non-zero exit for bad date" + ); +} + +fn setup_duration_boundary() -> Fixture { + let dir = TempDir::new().unwrap(); + let db = dir.path().join("history.db").to_string_lossy().to_string(); + let log = dir.path().join("error.log").to_string_lossy().to_string(); + let alpha = dir.path().join("alpha"); + std::fs::create_dir_all(alpha.join(".git")).unwrap(); + let alpha_str = alpha.to_string_lossy().to_string(); + let beta = dir.path().join("beta"); + std::fs::create_dir_all(beta.join(".git")).unwrap(); + let beta_str = beta.to_string_lossy().to_string(); + + record( + &db, + &log, + RecordInput { + cmd: "fast_cmd", + dir: &alpha_str, + exit: 0, + dur: 999, + tags: "[]", + ts: 1_700_000_000, + }, + ); + record( + &db, + &log, + RecordInput { + cmd: "slow_cmd", + dir: &alpha_str, + exit: 0, + dur: 3200, + tags: "[]", + ts: 1_700_001_000, + }, + ); + record( + &db, + &log, + RecordInput { + cmd: "both_tags_cmd", + dir: &alpha_str, + exit: 0, + dur: 500, + tags: r#"["tagA","tagB"]"#, + ts: 1_700_002_000, + }, + ); + record( + &db, + &log, + RecordInput { + cmd: "only_tagA_cmd", + dir: &beta_str, + exit: 0, + dur: 500, + tags: r#"["tagA"]"#, + ts: 1_700_003_000, + }, + ); + record( + &db, + &log, + RecordInput { + cmd: "session2_cmd", + dir: &beta_str, + exit: 0, + dur: 200, + tags: "[]", + ts: 1_700_010_000, + }, + ); + + Fixture { + _dir: dir, + db, + log, + _alpha_dir: alpha_str, + _beta_dir: beta_str, + } +} + +#[test] +fn search_duration_boundary_include_exclude() { + let f = setup_duration_boundary(); + let out = tth() + .env("THOTH_DB", &f.db) + .env("THOTH_ERROR_LOG", &f.log) + .args(["search", "--duration", ">1"]) + .assert() + .code(0) + .get_output() + .stdout + .clone(); + let text = String::from_utf8_lossy(&out); + assert!( + text.contains("slow_cmd"), + "slow_cmd (3200ms) should be included by >1s filter" + ); + assert!( + !text.contains("fast_cmd"), + "fast_cmd (999ms) should be excluded by >1s filter" + ); +} + +#[test] +fn search_tag_and_filter_requires_both() { + let f = setup_duration_boundary(); + let out = tth() + .env("THOTH_DB", &f.db) + .env("THOTH_ERROR_LOG", &f.log) + .args(["search", "--tag", "tagA", "--tag", "tagB"]) + .assert() + .code(0) + .get_output() + .stdout + .clone(); + let text = String::from_utf8_lossy(&out); + assert!( + text.contains("both_tags_cmd"), + "row with both tagA and tagB should be included" + ); + assert!( + !text.contains("only_tagA_cmd"), + "row with only tagA should be excluded by AND filter" + ); +} + +#[test] +fn search_show_session_two_sessions_shows_two_headers() { + let dir = TempDir::new().unwrap(); + let db = dir.path().join("history.db").to_string_lossy().to_string(); + let log = dir.path().join("error.log").to_string_lossy().to_string(); + let alpha = dir.path().join("alpha"); + std::fs::create_dir_all(alpha.join(".git")).unwrap(); + let alpha_str = alpha.to_string_lossy().to_string(); + let beta = dir.path().join("beta"); + std::fs::create_dir_all(beta.join(".git")).unwrap(); + let beta_str = beta.to_string_lossy().to_string(); + + record( + &db, + &log, + RecordInput { + cmd: "session1_cmd", + dir: &alpha_str, + exit: 0, + dur: 100, + tags: "[]", + ts: 1_700_000_000, + }, + ); + record( + &db, + &log, + RecordInput { + cmd: "session2_cmd", + dir: &beta_str, + exit: 0, + dur: 200, + tags: "[]", + ts: 1_700_100_000, + }, + ); + + let out = tth() + .env("THOTH_DB", &db) + .env("THOTH_ERROR_LOG", &log) + .args(["search", "--show-session"]) + .assert() + .code(0) + .get_output() + .stdout + .clone(); + let text = String::from_utf8_lossy(&out); + let header_count = text.lines().filter(|l| l.starts_with("---")).count(); + assert_eq!( + header_count, 2, + "expected two session headers; got: {header_count}\noutput:\n{text}" + ); + assert!( + text.contains("session1_cmd"), + "session1_cmd must appear in output" + ); + assert!( + text.contains("session2_cmd"), + "session2_cmd must appear in output" + ); + assert!(text.contains("2 result(s)"), "count line must be present"); +} diff --git a/tests/sessions_e2e.rs b/tests/sessions_e2e.rs new file mode 100644 index 0000000..da0bf4d --- /dev/null +++ b/tests/sessions_e2e.rs @@ -0,0 +1,222 @@ +use assert_cmd::Command; +use tempfile::TempDir; + +fn tth() -> Command { + Command::cargo_bin("tth").unwrap() +} + +struct RecordInput<'a> { + cmd: &'a str, + dir: &'a str, + tags: &'a str, + ts: i64, +} + +fn record(db: &str, log: &str, r: RecordInput<'_>) { + tth() + .env("THOTH_DB", db) + .env("THOTH_ERROR_LOG", log) + .args([ + "record", + "--cmd", + r.cmd, + "--dir", + r.dir, + "--exit", + "0", + "--duration", + "100", + "--tags", + r.tags, + "--timestamp", + &r.ts.to_string(), + ]) + .assert() + .code(0); +} + +struct Fixture { + _dir: TempDir, + db: String, + log: String, + beta_dir: String, +} + +fn setup() -> Fixture { + let dir = TempDir::new().unwrap(); + let db = dir.path().join("history.db").to_string_lossy().to_string(); + let log = dir.path().join("error.log").to_string_lossy().to_string(); + + let alpha = dir.path().join("alpha"); + let beta = dir.path().join("beta"); + std::fs::create_dir_all(alpha.join(".git")).unwrap(); + std::fs::create_dir_all(beta.join(".git")).unwrap(); + + let alpha_str = alpha.to_string_lossy().to_string(); + let beta_str = beta.to_string_lossy().to_string(); + + let base: i64 = 1_700_000_000; + + record( + &db, + &log, + RecordInput { + cmd: "cargo build", + dir: &alpha_str, + tags: r#"["rust","cli"]"#, + ts: base, + }, + ); + record( + &db, + &log, + RecordInput { + cmd: "cargo test", + dir: &alpha_str, + tags: r#"["rust"]"#, + ts: base + 300, + }, + ); + + let beta_start = base + 2 * 3600; + record( + &db, + &log, + RecordInput { + cmd: "make build", + dir: &beta_str, + tags: r#"["c"]"#, + ts: beta_start, + }, + ); + + Fixture { + _dir: dir, + db, + log, + beta_dir: beta_str, + } +} + +#[test] +fn sessions_lists_newest_first() { + let f = setup(); + let out = tth() + .env("THOTH_DB", &f.db) + .env("THOTH_ERROR_LOG", &f.log) + .args(["sessions"]) + .assert() + .code(0) + .get_output() + .stdout + .clone(); + let text = String::from_utf8_lossy(&out); + + assert!(text.contains("2 session(s)"), "expected footer with count"); + + let session_col_pos = text.find("session").unwrap_or(0); + let beta_pos = text + .find(&f.beta_dir.split('/').next_back().unwrap_or("beta")[..4.min(f.beta_dir.len())]) + .unwrap_or(usize::MAX); + let alpha_pos = text.find("alpha").unwrap_or(usize::MAX); + assert!( + beta_pos < alpha_pos || session_col_pos == 0, + "beta session (newer) should appear before alpha in output" + ); +} + +#[test] +fn sessions_project_filter() { + let f = setup(); + let out = tth() + .env("THOTH_DB", &f.db) + .env("THOTH_ERROR_LOG", &f.log) + .args(["sessions", "--project", "alpha"]) + .assert() + .code(0) + .get_output() + .stdout + .clone(); + let text = String::from_utf8_lossy(&out); + + assert!( + text.contains("1 session(s)"), + "expected 1 session for alpha; got:\n{text}" + ); + assert!(text.contains("alpha"), "expected alpha in output"); +} + +#[test] +fn sessions_limit_caps_results() { + let f = setup(); + let out = tth() + .env("THOTH_DB", &f.db) + .env("THOTH_ERROR_LOG", &f.log) + .args(["sessions", "--limit", "1"]) + .assert() + .code(0) + .get_output() + .stdout + .clone(); + let text = String::from_utf8_lossy(&out); + + assert!( + text.contains("1 session(s)"), + "expected 1 session(s) footer; got:\n{text}" + ); +} + +#[test] +fn sessions_empty_db_shows_no_sessions_found() { + let dir = TempDir::new().unwrap(); + let db = dir.path().join("history.db").to_string_lossy().to_string(); + let log = dir.path().join("error.log").to_string_lossy().to_string(); + + let out = tth() + .env("THOTH_DB", &db) + .env("THOTH_ERROR_LOG", &log) + .args(["sessions"]) + .assert() + .code(0) + .get_output() + .stdout + .clone(); + let text = String::from_utf8_lossy(&out); + assert!( + text.contains("No sessions found."), + "expected 'No sessions found.'; got:\n{text}" + ); +} + +#[test] +fn sessions_shows_tags_union() { + let f = setup(); + let out = tth() + .env("THOTH_DB", &f.db) + .env("THOTH_ERROR_LOG", &f.log) + .args(["sessions", "--project", "alpha"]) + .assert() + .code(0) + .get_output() + .stdout + .clone(); + let text = String::from_utf8_lossy(&out); + assert!( + text.contains("cli") && text.contains("rust"), + "expected both rust and cli tags for alpha session; got:\n{text}" + ); +} + +#[test] +fn sessions_bad_since_exits_nonzero() { + let dir = TempDir::new().unwrap(); + let db = dir.path().join("history.db").to_string_lossy().to_string(); + let log = dir.path().join("error.log").to_string_lossy().to_string(); + + tth() + .env("THOTH_DB", &db) + .env("THOTH_ERROR_LOG", &log) + .args(["sessions", "--since", "bad-date"]) + .assert() + .failure(); +} diff --git a/tests/tags_e2e.rs b/tests/tags_e2e.rs new file mode 100644 index 0000000..8f3b83e --- /dev/null +++ b/tests/tags_e2e.rs @@ -0,0 +1,167 @@ +use assert_cmd::Command; +use predicates::str::contains; +use tempfile::TempDir; + +fn tth(db_dir: &TempDir) -> Command { + let mut cmd = Command::cargo_bin("tth").unwrap(); + cmd.env("THOTH_DB", db_dir.path().join("history.db")); + cmd +} + +fn tth_with_tags(db_dir: &TempDir, active_tags: &str) -> Command { + let mut cmd = tth(db_dir); + cmd.env("TTH_ACTIVE_TAGS", active_tags); + cmd +} + +fn record_with_tags(db_dir: &TempDir, command: &str, tags: &str) { + tth(db_dir) + .args([ + "record", + "--cmd", + command, + "--dir", + "/tmp", + "--exit", + "0", + "--duration", + "0", + "--timestamp", + "1700000000", + "--tags", + tags, + ]) + .assert() + .success(); +} + +#[test] +fn tag_unset_env_adds_single_tag_to_stdout() { + let dir = TempDir::new().unwrap(); + let out = tth(&dir).args(["tag", "x"]).output().unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("export TTH_ACTIVE_TAGS='[\"x\"]'"), + "stdout: {stdout}" + ); + assert!( + stdout.contains("export TTH_PROMPT_TAGS='[x]'"), + "stdout: {stdout}" + ); + assert!(out.status.success()); +} + +#[test] +fn tag_with_existing_adds_second() { + let dir = TempDir::new().unwrap(); + let out = tth_with_tags(&dir, r#"["x"]"#) + .args(["tag", "y"]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains(r#"["x","y"]"#), "stdout: {stdout}"); + assert!(stdout.contains("[x][y]"), "stdout: {stdout}"); +} + +#[test] +fn tag_duplicate_is_noop() { + let dir = TempDir::new().unwrap(); + let out = tth_with_tags(&dir, r#"["x"]"#) + .args(["tag", "x"]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains(r#"["x"]"#), "stdout: {stdout}"); + assert!(!stdout.contains(r#"["x","x"]"#), "dup added: {stdout}"); +} + +#[test] +fn tag_empty_name_errors_nonzero() { + let dir = TempDir::new().unwrap(); + tth(&dir).args(["tag", ""]).assert().failure(); +} + +#[test] +fn untag_removes_one() { + let dir = TempDir::new().unwrap(); + let out = tth_with_tags(&dir, r#"["x","y"]"#) + .args(["untag", "x"]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains(r#"["y"]"#), "stdout: {stdout}"); + assert!(!stdout.contains("\"x\""), "x still present: {stdout}"); +} + +#[test] +fn untag_all_clears() { + let dir = TempDir::new().unwrap(); + let out = tth_with_tags(&dir, r#"["x","y"]"#) + .args(["untag", "--all"]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("TTH_ACTIVE_TAGS='[]'"), "stdout: {stdout}"); + assert!(stdout.contains("TTH_PROMPT_TAGS=''"), "stdout: {stdout}"); +} + +#[test] +fn untag_no_args_errors_nonzero() { + let dir = TempDir::new().unwrap(); + tth(&dir).args(["untag"]).assert().failure(); +} + +#[test] +fn tags_no_flag_reads_env_active() { + let dir = TempDir::new().unwrap(); + tth_with_tags(&dir, r#"["fix","perf"]"#) + .args(["tags"]) + .assert() + .success() + .stdout(contains("fix")) + .stdout(contains("perf")); +} + +#[test] +fn tags_no_flag_no_env_shows_none() { + let dir = TempDir::new().unwrap(); + let out = tth(&dir).args(["tags"]).output().unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("(none)") || stdout.trim().is_empty() || stdout.contains("no active"), + "expected none indicator, got: {stdout}" + ); +} + +#[test] +fn tags_list_queries_db() { + let dir = TempDir::new().unwrap(); + record_with_tags(&dir, "git commit", r#"["fix","perf"]"#); + record_with_tags(&dir, "cargo test", r#"["fix"]"#); + tth(&dir) + .args(["tags", "--list"]) + .assert() + .success() + .stdout(contains("fix")) + .stdout(contains("perf")); +} + +#[test] +fn prompt_prints_snippet() { + let dir = TempDir::new().unwrap(); + let out = tth(&dir).args(["prompt"]).output().unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + !stdout.trim().is_empty(), + "expected prompt snippet, got empty stdout" + ); + assert!(out.status.success()); +} + +#[test] +fn tag_confirmation_goes_to_stderr() { + let dir = TempDir::new().unwrap(); + let out = tth(&dir).args(["tag", "fix"]).output().unwrap(); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!stderr.is_empty(), "expected confirmation on stderr"); +} diff --git a/tests/theme_e2e.rs b/tests/theme_e2e.rs new file mode 100644 index 0000000..b7999b8 --- /dev/null +++ b/tests/theme_e2e.rs @@ -0,0 +1,158 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::TempDir; + +fn tth_with(db: &std::path::Path, cfg: &std::path::Path) -> Command { + let mut cmd = Command::cargo_bin("tth").unwrap(); + cmd.env("THOTH_DB", db.to_str().unwrap()) + .env("THOTH_CONFIG", cfg.to_str().unwrap()) + .env("THOTH_ERROR_LOG", "/dev/null") + .env("NO_COLOR", "1"); + cmd +} + +fn env_pair(dir: &TempDir) -> (std::path::PathBuf, std::path::PathBuf) { + let db = dir.path().join("history.db"); + let cfg = dir.path().join("config.toml"); + (db, cfg) +} + +#[test] +fn theme_bare_prints_current_default() { + let dir = TempDir::new().unwrap(); + let (db, cfg) = env_pair(&dir); + + tth_with(&db, &cfg) + .arg("theme") + .assert() + .code(0) + .stdout(predicate::str::contains("default")); +} + +#[test] +fn theme_list_shows_seven_builtins() { + let dir = TempDir::new().unwrap(); + let (db, cfg) = env_pair(&dir); + + let assert = tth_with(&db, &cfg).args(["theme", "list"]).assert().code(0); + + let output = assert.get_output(); + let stdout = String::from_utf8_lossy(&output.stdout); + + for name in &[ + "default", + "ember", + "frost", + "latte", + "frappe", + "macchiato", + "mocha", + ] { + assert!(stdout.contains(name), "missing builtin: {name}"); + } +} + +#[test] +fn theme_list_marks_default_as_current() { + let dir = TempDir::new().unwrap(); + let (db, cfg) = env_pair(&dir); + + tth_with(&db, &cfg) + .args(["theme", "list"]) + .assert() + .code(0) + .stdout(predicate::str::contains("(current)")); +} + +#[test] +fn theme_set_mocha_writes_config_and_prints_confirmation() { + let dir = TempDir::new().unwrap(); + let (db, cfg) = env_pair(&dir); + + tth_with(&db, &cfg) + .args(["theme", "mocha"]) + .assert() + .code(0) + .stdout(predicate::str::contains("mocha")); + + let content = std::fs::read_to_string(&cfg).unwrap(); + assert!( + content.contains("name = \"mocha\""), + "config should contain mocha, got: {content}" + ); +} + +#[test] +fn theme_bare_after_set_mocha_prints_mocha() { + let dir = TempDir::new().unwrap(); + let (db, cfg) = env_pair(&dir); + + tth_with(&db, &cfg) + .args(["theme", "mocha"]) + .assert() + .code(0); + + tth_with(&db, &cfg) + .arg("theme") + .assert() + .code(0) + .stdout(predicate::str::contains("mocha")); +} + +#[test] +fn theme_invalid_name_exits_nonzero_and_does_not_write_config() { + let dir = TempDir::new().unwrap(); + let (db, cfg) = env_pair(&dir); + + tth_with(&db, &cfg) + .args(["theme", "nonsense"]) + .assert() + .failure(); + + assert!( + !cfg.exists(), + "config should not be created for invalid theme" + ); +} + +#[test] +fn theme_list_after_set_mocha_marks_mocha_current() { + let dir = TempDir::new().unwrap(); + let (db, cfg) = env_pair(&dir); + + tth_with(&db, &cfg) + .args(["theme", "mocha"]) + .assert() + .code(0); + + let assert = tth_with(&db, &cfg).args(["theme", "list"]).assert().code(0); + + let stdout = String::from_utf8_lossy(&assert.get_output().stdout); + assert!( + stdout.contains("mocha") && stdout.contains("(current)"), + "expected mocha to be marked current, got: {stdout}" + ); +} + +#[test] +fn theme_user_file_is_valid_and_appears_in_list() { + let dir = TempDir::new().unwrap(); + let db = dir.path().join("history.db"); + let cfg = dir.path().join("config.toml"); + + let themes_dir = dir.path().join("themes"); + std::fs::create_dir_all(&themes_dir).unwrap(); + std::fs::write(themes_dir.join("mine.toml"), "").unwrap(); + + tth_with(&db, &cfg) + .args(["theme", "mine"]) + .assert() + .code(0) + .stdout(predicate::str::contains("mine")); + + tth_with(&db, &cfg) + .args(["theme", "list"]) + .assert() + .code(0) + .stdout(predicate::str::contains("mine")); +}