├── tests ├── static │ └── import │ │ ├── invalid.html │ │ ├── valid.txt │ │ ├── only-mandatory.json │ │ ├── invalid.json │ │ ├── missing-uri.json │ │ ├── valid.json │ │ ├── valid-with-some-invalid-attributes.json │ │ ├── valid.html │ │ └── valid-with-some-invalid-attributes.html ├── common │ └── mod.rs ├── help_test.rs ├── delete_test.rs ├── show_test.rs ├── data_dir_test.rs ├── list_test.rs ├── tags_test.rs ├── search_test.rs ├── save_all_test.rs ├── import_test.rs └── save_test.rs ├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.md ├── actionlint.yml ├── workflows │ ├── audit.yml │ ├── publish-crates.yml │ ├── bench.yml │ ├── bench-against-main.yml │ ├── main.yml │ └── pr.yml └── dependabot.yml ├── clippy.toml ├── rust-toolchain.toml ├── src ├── domain │ ├── mod.rs │ └── tags.rs ├── cli │ ├── tags │ │ ├── mod.rs │ │ ├── rename.rs │ │ ├── list.rs │ │ └── delete.rs │ ├── mod.rs │ ├── show.rs │ ├── list.rs │ ├── search.rs │ ├── delete.rs │ ├── save_all.rs │ └── display.rs ├── tui │ ├── mod.rs │ ├── commands.rs │ ├── static │ │ └── help.txt │ ├── common.rs │ ├── handle.rs │ ├── message.rs │ ├── app.rs │ ├── update.rs │ └── model.rs ├── persistence │ ├── mod.rs │ ├── test_fixtures.rs │ ├── errors.rs │ ├── init.rs │ ├── delete.rs │ └── update.rs ├── static │ ├── import-example.json │ ├── long-about.txt │ └── import-help.txt ├── common.rs ├── main.rs ├── utils.rs ├── handle.rs └── errors.rs ├── docker-compose.yml ├── tapes ├── tui.tape └── cli.tape ├── .sqlx ├── query-a95adf4a2adaed13581e09d46163a16dddfead2b229797ef9a1c45deae3891eb.json ├── query-b6975be98860dff9d5ba7f12faedfd43e4b410c877cc498eb62142c374aedd6b.json ├── query-9085329c5b0a5697887526021fafda62ba67d16d44e19b31df8afe4c2f74261b.json ├── query-d1b7d6e09b58eda92db618bee0cd204472ff4cc71f7660f3ba1b262d77676e6d.json ├── query-1ae841249d5f7f2850246531c1bd6f508aa0ce2f3e99502279cd1d191fbe9375.json ├── query-4b822e688a6be4a9a6b33dec956e3455af857af5837929b07ecc67400fcfd0da.json ├── query-bacb3ca4fbb8c3ac845c41164a11a622cf0679134a061d557b5f0235756e7128.json ├── query-93d7cef98773a2a4e83fbc22516bdd6888b94ce2b441d9c4d01fa3486b93c41e.json ├── query-77ca1c6680321890f5b4fcfd60fbabaa7e61fe8a487dfbda5e89fc6c54a05272.json ├── query-ed6334a9d919257dce56e5f6d8c4345d1627ebb4936fa06d2da60157249c79e8.json ├── query-505d5749d2d5f323c8e5d8f8691ea192f5cbf65139f3d63610d2ea93372edf99.json ├── query-5eb0463f1eb767bac0e8ea67ae2b3a9abcb327fef2feaba997c3cdde27d09bc1.json ├── query-b0ba67ed139a0bd24dbeeec12ab2b8ef417f085d59eebf05ac93e69d78079359.json ├── query-1ad2b41b434ea5de879c3b0691402c0eaafe1eafb1ecf8f8253aa920091d7dd6.json ├── query-82f4a83fe962e2d070c5b19db0c4f976493ca5eb3ed97a4ca7137970e4eb9216.json ├── query-8be126934cd3b27112637e219bb305edb05f933700346179504e45f2dde8bc4a.json ├── query-04fa957844fe1684e78f5361f0556eddaa34f1e96fac57e985ec2cd857c4114a.json ├── query-9a4571f59d7441ba64d040eb528b09dbad85c4ea9ea3b6696128b770345355f7.json └── query-6b26146f0910fa9f7a9dc6bb553e0eefaa743567f15fda1ebfd7083bf3bd957e.json ├── deny.toml ├── migrations └── 20250203201411_create_initial_tables.sql ├── dist-workspace.toml ├── LICENSE ├── CHANGELOG.md ├── Cargo.toml ├── bench ├── generate-data ├── bench-against-prev-version.sh └── bench-against-buku.sh └── README.md /tests/static/import/invalid.html: -------------------------------------------------------------------------------- 1 | invalid 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | devdb 3 | testdb 4 | tapes/*.gif 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /clippy.toml: -------------------------------------------------------------------------------- 1 | allow-unwrap-in-tests = true 2 | allow-expect-in-tests = true 3 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.91" 3 | profile = "minimal" 4 | -------------------------------------------------------------------------------- /src/domain/mod.rs: -------------------------------------------------------------------------------- 1 | mod bookmark; 2 | mod tags; 3 | 4 | pub use bookmark::*; 5 | pub use tags::*; 6 | -------------------------------------------------------------------------------- /.github/actionlint.yml: -------------------------------------------------------------------------------- 1 | paths: 2 | .github/workflows/release.yml: 3 | ignore: 4 | - 'shellcheck' # generated by cargo-dist 5 | -------------------------------------------------------------------------------- /src/cli/tags/mod.rs: -------------------------------------------------------------------------------- 1 | mod delete; 2 | mod list; 3 | mod rename; 4 | 5 | pub use delete::*; 6 | pub use list::*; 7 | pub use rename::*; 8 | -------------------------------------------------------------------------------- /tests/static/import/valid.txt: -------------------------------------------------------------------------------- 1 | https://crates.io/crates/sqlx 2 | https://github.com/dhth/omm 3 | https://github.com/dhth/hours 4 | https://github.com/dhth/bmm 5 | -------------------------------------------------------------------------------- /tests/static/import/only-mandatory.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "uri": "https://crates.io/crates/sqlx" 4 | }, 5 | { 6 | "uri": "https://github.com/dhth/omm" 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /src/tui/mod.rs: -------------------------------------------------------------------------------- 1 | mod app; 2 | mod commands; 3 | mod common; 4 | mod handle; 5 | mod message; 6 | mod model; 7 | mod update; 8 | mod view; 9 | 10 | pub use app::{AppTuiError, run_tui}; 11 | pub use model::TuiContext; 12 | -------------------------------------------------------------------------------- /tests/static/import/invalid.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "uri": "https://crates.io/crates/sqlx", 4 | "title": "sqlx - crates.io: Rust Package Registry", 5 | "tags": "crates,rust", 6 | "updated_at": 1739956500 7 | } 8 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # to test operations on linux 2 | services: 3 | bmm-dev: 4 | image: rust:1.85-slim-bullseye 5 | platform: linux/amd64 6 | volumes: 7 | - .:/usr/src/app 8 | working_dir: /usr/src/app 9 | command: sleep infinity 10 | -------------------------------------------------------------------------------- /src/persistence/mod.rs: -------------------------------------------------------------------------------- 1 | mod create; 2 | mod delete; 3 | mod errors; 4 | mod get; 5 | mod init; 6 | mod test_fixtures; 7 | mod update; 8 | 9 | pub use create::*; 10 | pub use delete::*; 11 | pub use errors::DBError; 12 | pub use get::*; 13 | pub use init::*; 14 | pub use update::*; 15 | -------------------------------------------------------------------------------- /src/tui/commands.rs: -------------------------------------------------------------------------------- 1 | use crate::persistence::SearchTerms; 2 | 3 | #[derive(Clone, Debug)] 4 | pub(super) enum Command { 5 | OpenInBrowser(String), 6 | SearchBookmarks(SearchTerms), 7 | FetchTags, 8 | FetchBookmarksForTag(String), 9 | CopyContentToClipboard(String), 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/audit.yml: -------------------------------------------------------------------------------- 1 | name: audit 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: '30 2 * * 2,6' 7 | 8 | jobs: 9 | audit: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v5 14 | - uses: dhth/composite-actions/.github/actions/cargo-deny@main 15 | -------------------------------------------------------------------------------- /src/static/import-example.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "uri": "https://github.com/dhth/bmm", 4 | "title": null, 5 | "tags": "tools,bookmarks" 6 | }, 7 | { 8 | "uri": "https://github.com/dhth/omm", 9 | "title": "on-my-mind: a keyboard-driven task manager for the command line", 10 | "tags": "tools,productivity" 11 | } 12 | ] 13 | -------------------------------------------------------------------------------- /src/common.rs: -------------------------------------------------------------------------------- 1 | pub const HTML: &str = "html"; 2 | pub const JSON: &str = "json"; 3 | pub const TXT: &str = "txt"; 4 | pub const IMPORT_FILE_FORMATS: [&str; 3] = [HTML, JSON, TXT]; 5 | pub const DEFAULT_LIMIT: u16 = 10000; 6 | pub const IMPORT_UPPER_LIMIT: usize = 9999; 7 | pub const ENV_VAR_BMM_EDITOR: &str = "BMM_EDITOR"; 8 | pub const ENV_VAR_EDITOR: &str = "EDITOR"; 9 | -------------------------------------------------------------------------------- /src/cli/mod.rs: -------------------------------------------------------------------------------- 1 | mod delete; 2 | mod display; 3 | mod import; 4 | mod list; 5 | mod save; 6 | mod save_all; 7 | mod search; 8 | mod show; 9 | mod tags; 10 | 11 | pub use delete::*; 12 | pub use display::*; 13 | pub use import::*; 14 | pub use list::*; 15 | pub use save::*; 16 | pub use save_all::*; 17 | pub use search::*; 18 | pub use show::*; 19 | pub use tags::*; 20 | -------------------------------------------------------------------------------- /tapes/tui.tape: -------------------------------------------------------------------------------- 1 | Output tui.gif 2 | Set FontSize 24 3 | Set Width 1920 4 | Set Height 1080 5 | Set Theme "GruvboxDark" 6 | 7 | Set Shell zsh 8 | Type@100ms "bmm tui" 9 | Enter 10 | Sleep 2000ms 11 | Type@100ms "rust" 12 | Enter 13 | Sleep 2000ms 14 | Type@300ms "jjj" 15 | Enter 16 | Sleep 1000ms 17 | Type "s" 18 | Sleep 300ms 19 | Type@300ms "vim" 20 | Enter 21 | Sleep 2000ms 22 | -------------------------------------------------------------------------------- /.sqlx/query-a95adf4a2adaed13581e09d46163a16dddfead2b229797ef9a1c45deae3891eb.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\nDELETE FROM\n tags\nWHERE\n id = ?\n", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 1 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "a95adf4a2adaed13581e09d46163a16dddfead2b229797ef9a1c45deae3891eb" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-b6975be98860dff9d5ba7f12faedfd43e4b410c877cc498eb62142c374aedd6b.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\nUPDATE\n tags\nSET\n name = ?\nWHERE\n id = ?\n", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 2 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "b6975be98860dff9d5ba7f12faedfd43e4b410c877cc498eb62142c374aedd6b" 12 | } 13 | -------------------------------------------------------------------------------- /src/static/long-about.txt: -------------------------------------------------------------------------------- 1 | bmm (stands for "bookmarks manager") lets you get to your bookmarks in a flash. 2 | 3 | It does so by storing your bookmarks locally, allowing you to quickly access, 4 | manage, and search through them using various commands. 5 | 6 | bmm has a traditional command line interface that can be used standalone or 7 | integrated with other tools, and a textual user interface for easy browsing. 8 | -------------------------------------------------------------------------------- /.sqlx/query-9085329c5b0a5697887526021fafda62ba67d16d44e19b31df8afe4c2f74261b.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\nDELETE FROM\n bookmark_tags\nWHERE\n bookmark_id = ?\n", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 1 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "9085329c5b0a5697887526021fafda62ba67d16d44e19b31df8afe4c2f74261b" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-d1b7d6e09b58eda92db618bee0cd204472ff4cc71f7660f3ba1b262d77676e6d.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\nINSERT INTO\n tags (name)\nVALUES\n (?) ON CONFLICT (name) DO NOTHING\n", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 1 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "d1b7d6e09b58eda92db618bee0cd204472ff4cc71f7660f3ba1b262d77676e6d" 12 | } 13 | -------------------------------------------------------------------------------- /tests/static/import/missing-uri.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "uri": "https://crates.io/crates/sqlx", 4 | "title": "sqlx - crates.io: Rust Package Registry", 5 | "tags": "crates,rust", 6 | "updated_at": 1739956500 7 | }, 8 | { 9 | "title": "GitHub - dhth/omm: on-my-mind: a keyboard-driven task manager for the command line", 10 | "tags": "productivity,tools", 11 | "updated_at": 1739956500 12 | } 13 | ] 14 | -------------------------------------------------------------------------------- /.sqlx/query-1ae841249d5f7f2850246531c1bd6f508aa0ce2f3e99502279cd1d191fbe9375.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\nINSERT INTO\n bookmark_tags (bookmark_id, tag_id)\nVALUES\n (?, ?) ON CONFLICT (bookmark_id, tag_id) DO NOTHING\n", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 2 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "1ae841249d5f7f2850246531c1bd6f508aa0ce2f3e99502279cd1d191fbe9375" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-4b822e688a6be4a9a6b33dec956e3455af857af5837929b07ecc67400fcfd0da.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\nDELETE FROM\n tags\nWHERE\n id NOT IN (\n SELECT\n tag_id\n FROM\n bookmark_tags\n )\n", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 0 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "4b822e688a6be4a9a6b33dec956e3455af857af5837929b07ecc67400fcfd0da" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-bacb3ca4fbb8c3ac845c41164a11a622cf0679134a061d557b5f0235756e7128.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\nINSERT INTO\n bookmarks (uri, title, created_at, updated_at)\nVALUES\n (?, ?, ?, ?) ON CONFLICT (uri) DO\nUPDATE\nSET\n title = excluded.title,\n updated_at = excluded.updated_at\n", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 4 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "bacb3ca4fbb8c3ac845c41164a11a622cf0679134a061d557b5f0235756e7128" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-93d7cef98773a2a4e83fbc22516bdd6888b94ce2b441d9c4d01fa3486b93c41e.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\nSELECT\n COUNT(*)\nFROM\n bookmarks\n", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "COUNT(*)", 8 | "ordinal": 0, 9 | "type_info": "Integer" 10 | } 11 | ], 12 | "parameters": { 13 | "Right": 0 14 | }, 15 | "nullable": [ 16 | false 17 | ] 18 | }, 19 | "hash": "93d7cef98773a2a4e83fbc22516bdd6888b94ce2b441d9c4d01fa3486b93c41e" 20 | } 21 | -------------------------------------------------------------------------------- /.sqlx/query-77ca1c6680321890f5b4fcfd60fbabaa7e61fe8a487dfbda5e89fc6c54a05272.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\nSELECT\n id\nFROM\n tags\nWHERE\n name = ?\n", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "id", 8 | "ordinal": 0, 9 | "type_info": "Integer" 10 | } 11 | ], 12 | "parameters": { 13 | "Right": 1 14 | }, 15 | "nullable": [ 16 | false 17 | ] 18 | }, 19 | "hash": "77ca1c6680321890f5b4fcfd60fbabaa7e61fe8a487dfbda5e89fc6c54a05272" 20 | } 21 | -------------------------------------------------------------------------------- /.sqlx/query-ed6334a9d919257dce56e5f6d8c4345d1627ebb4936fa06d2da60157249c79e8.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\nSELECT\n t.name\nFROM\n tags t\nORDER BY name\n", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "name", 8 | "ordinal": 0, 9 | "type_info": "Text" 10 | } 11 | ], 12 | "parameters": { 13 | "Right": 0 14 | }, 15 | "nullable": [ 16 | false 17 | ] 18 | }, 19 | "hash": "ed6334a9d919257dce56e5f6d8c4345d1627ebb4936fa06d2da60157249c79e8" 20 | } 21 | -------------------------------------------------------------------------------- /.sqlx/query-505d5749d2d5f323c8e5d8f8691ea192f5cbf65139f3d63610d2ea93372edf99.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\nUPDATE\n bookmark_tags\nSET\n tag_id = ?\nWHERE\n tag_id = ?\nAND NOT EXISTS (\n SELECT 1\n FROM bookmark_tags AS bt\n WHERE bt.bookmark_id = bookmark_tags.bookmark_id\n AND bt.tag_id = ?\n)\n", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 3 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "505d5749d2d5f323c8e5d8f8691ea192f5cbf65139f3d63610d2ea93372edf99" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-5eb0463f1eb767bac0e8ea67ae2b3a9abcb327fef2feaba997c3cdde27d09bc1.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\nINSERT INTO\n bookmarks (uri, title, created_at, updated_at)\nVALUES\n (?, ?, ?, ?) ON CONFLICT (uri) DO\nUPDATE\nSET\n title = COALESCE(excluded.title, bookmarks.title),\n updated_at = excluded.updated_at\n", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 4 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "5eb0463f1eb767bac0e8ea67ae2b3a9abcb327fef2feaba997c3cdde27d09bc1" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-b0ba67ed139a0bd24dbeeec12ab2b8ef417f085d59eebf05ac93e69d78079359.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\nSELECT\n id\nFROM\n bookmarks\nWHERE\n uri = ?\nLIMIT 1\n", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "id", 8 | "ordinal": 0, 9 | "type_info": "Integer" 10 | } 11 | ], 12 | "parameters": { 13 | "Right": 1 14 | }, 15 | "nullable": [ 16 | false 17 | ] 18 | }, 19 | "hash": "b0ba67ed139a0bd24dbeeec12ab2b8ef417f085d59eebf05ac93e69d78079359" 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/publish-crates.yml: -------------------------------------------------------------------------------- 1 | name: publish-crates 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | plan: 7 | required: true 8 | type: string 9 | 10 | jobs: 11 | publish-crates: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v5 16 | - name: Install toolchain 17 | uses: actions-rust-lang/setup-rust-toolchain@v1 18 | - name: Publish 19 | run: cargo publish --token "${CRATES_TOKEN}" 20 | env: 21 | CRATES_TOKEN: ${{ secrets.CRATES_TOKEN }} 22 | -------------------------------------------------------------------------------- /tapes/cli.tape: -------------------------------------------------------------------------------- 1 | Output cli.gif 2 | Set FontSize 24 3 | Set Width 1920 4 | Set Height 1080 5 | Set Theme "GruvboxDark" 6 | 7 | Set Shell zsh 8 | Type@50ms "bmm search rust" 9 | Enter 10 | Sleep 2000ms 11 | Type "clear" 12 | Enter 13 | Type@50ms "bmm list --uri github.com --title dark --tags vim-colorschemes -f delimited" 14 | Enter 15 | Sleep 3000ms 16 | Type "clear" 17 | Enter 18 | Type@30ms "bmm save https://github.com/dhth/omm --title 'manage tasks via the CLI' --tags tools,productivity" 19 | Enter 20 | Sleep 1000ms 21 | Type "clear" 22 | Enter 23 | Type@50ms "bmm search 'manage tasks' -f json" 24 | Enter 25 | Sleep 2000ms 26 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: cargo 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | groups: 8 | patch-updates: 9 | update-types: ["patch"] 10 | minor-updates: 11 | update-types: ["minor"] 12 | labels: 13 | - "dependencies" 14 | commit-message: 15 | prefix: "build" 16 | - package-ecosystem: github-actions 17 | directory: "/" 18 | schedule: 19 | interval: monthly 20 | exclude-paths: 21 | - .github/workflows/release.yml 22 | - .github/workflows/scan.yml 23 | labels: 24 | - "dependencies" 25 | commit-message: 26 | prefix: "ci" 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for bmm 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Is your feature request related to a problem? Please describe.** 14 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /src/persistence/test_fixtures.rs: -------------------------------------------------------------------------------- 1 | use sqlx::{Error as SqlxError, Pool, Sqlite, SqlitePool}; 2 | 3 | #[cfg(test)] 4 | pub(super) struct DBPoolFixture { 5 | pub(super) pool: Pool, 6 | } 7 | 8 | #[cfg(test)] 9 | impl DBPoolFixture { 10 | pub(super) async fn new() -> Self { 11 | let pool = get_in_memory_db_pool() 12 | .await 13 | .expect("in-memory sqlite pool should've been created"); 14 | 15 | Self { pool } 16 | } 17 | } 18 | 19 | #[allow(unused)] 20 | async fn get_in_memory_db_pool() -> Result, SqlxError> { 21 | let db = SqlitePool::connect("sqlite://:memory:").await?; 22 | 23 | sqlx::migrate!().run(&db).await?; 24 | 25 | Ok(db) 26 | } 27 | -------------------------------------------------------------------------------- /tests/static/import/valid.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "uri": "https://crates.io/crates/sqlx", 4 | "title": "sqlx - crates.io: Rust Package Registry", 5 | "tags": "rust,crates" 6 | }, 7 | { 8 | "uri": "https://github.com/dhth/omm", 9 | "title": "GitHub - dhth/omm: on-my-mind: a keyboard-driven task manager for the command line", 10 | "tags": "productivity,tools" 11 | }, 12 | { 13 | "uri": "https://github.com/dhth/hours", 14 | "title": "GitHub - dhth/hours: A no-frills time tracking toolkit for command line nerds", 15 | "tags": "tools,productivity" 16 | }, 17 | { 18 | "uri": "https://github.com/dhth/bmm", 19 | "title": "GitHub - dhth/bmm: get to your bookmarks in a flash", 20 | "tags": "tools" 21 | } 22 | ] 23 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | [advisories] 2 | ignore = [ 3 | "RUSTSEC-2024-0436", 4 | ] 5 | 6 | [licenses] 7 | allow = [ 8 | "Apache-2.0", 9 | "BSL-1.0", 10 | "MIT", 11 | "MPL-2.0", 12 | "Unicode-3.0", 13 | "Zlib", 14 | ] 15 | 16 | [bans] 17 | deny = [ 18 | "openssl", 19 | ] 20 | skip = [ 21 | "getrandom", 22 | "linux-raw-sys", 23 | "phf_generator", 24 | "phf_shared", 25 | "rustix", 26 | "siphasher", 27 | "syn", 28 | "unicode-width", 29 | "wasi", 30 | "windows_aarch64_gnullvm", 31 | "windows_aarch64_msvc", 32 | "windows_i686_gnu", 33 | "windows_i686_msvc", 34 | "windows_x86_64_gnu", 35 | "windows_x86_64_gnullvm", 36 | "windows_x86_64_msvc", 37 | "windows-sys", 38 | "windows-targets", 39 | ] 40 | -------------------------------------------------------------------------------- /src/cli/show.rs: -------------------------------------------------------------------------------- 1 | use super::display::display_bookmark_details; 2 | use crate::persistence::DBError; 3 | use crate::persistence::get_bookmark_with_exact_uri; 4 | use sqlx::{Pool, Sqlite}; 5 | 6 | #[derive(thiserror::Error, Debug)] 7 | pub enum ShowBookmarkError { 8 | #[error("couldn't get bookmark from db: {0}")] 9 | CouldntGetBookmarkFromDB(#[from] DBError), 10 | #[error("bookmark doesn't exist")] 11 | BookmarkDoesntExist, 12 | } 13 | 14 | pub async fn show_bookmark(pool: &Pool, uri: String) -> Result<(), ShowBookmarkError> { 15 | let maybe_bookmark = get_bookmark_with_exact_uri(pool, &uri).await?; 16 | 17 | let bookmark = maybe_bookmark.ok_or(ShowBookmarkError::BookmarkDoesntExist)?; 18 | 19 | display_bookmark_details(&bookmark); 20 | 21 | Ok(()) 22 | } 23 | -------------------------------------------------------------------------------- /.sqlx/query-1ad2b41b434ea5de879c3b0691402c0eaafe1eafb1ecf8f8253aa920091d7dd6.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\nSELECT\n t.name, count(bt.bookmark_id) as num_bookmarks\nFROM\n tags t\n LEFT JOIN bookmark_tags bt ON bt.tag_id = t.id\nGROUP BY\n\tt.id\nORDER BY name\n", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "name", 8 | "ordinal": 0, 9 | "type_info": "Text" 10 | }, 11 | { 12 | "name": "num_bookmarks", 13 | "ordinal": 1, 14 | "type_info": "Integer" 15 | } 16 | ], 17 | "parameters": { 18 | "Right": 0 19 | }, 20 | "nullable": [ 21 | false, 22 | false 23 | ] 24 | }, 25 | "hash": "1ad2b41b434ea5de879c3b0691402c0eaafe1eafb1ecf8f8253aa920091d7dd6" 26 | } 27 | -------------------------------------------------------------------------------- /src/tui/static/help.txt: -------------------------------------------------------------------------------- 1 | bmm has three views. 2 | 3 | - Bookmarks List View 4 | - Tags List View 5 | - Help View (this one) 6 | 7 | Keymaps 8 | --- 9 | 10 | General 11 | ? show/hide help view 12 | Esc / q go back/reset input/exit 13 | j / Down go down in a list 14 | k / Up go up in a list 15 | 16 | Bookmarks List View 17 | s show search input 18 | Enter submit search query 19 | t show Tags List View (when search is not active) 20 | o open URI in browser 21 | y copy URI under cursor to system clipboard 22 | Y copy all URIs to system clipboard 23 | 24 | Tags List View 25 | Enter show bookmarks that are tagged with the one under cursor 26 | -------------------------------------------------------------------------------- /.github/workflows/bench.yml: -------------------------------------------------------------------------------- 1 | name: bench 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | env: 7 | CARGO_TERM_COLOR: always 8 | SQLX_OFFLINE: true 9 | 10 | jobs: 11 | bench: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v5 15 | - name: Install rust toolchain 16 | uses: actions-rust-lang/setup-rust-toolchain@v1 17 | - name: Install bmm 18 | run: cargo install --path . 19 | - name: Install uv 20 | uses: astral-sh/setup-uv@v7 21 | - name: Install buku 22 | run: uv tool install buku 23 | - name: Install hyperfine 24 | uses: jaxxstorm/action-install-gh-release@6096f2a2bbfee498ced520b6922ac2c06e990ed2 # v2.1.0 25 | with: 26 | repo: sharkdp/hyperfine 27 | - name: Run benchmarks 28 | run: | 29 | cd bench 30 | ./bench-against-buku.sh 31 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod args; 2 | mod cli; 3 | mod common; 4 | mod domain; 5 | mod errors; 6 | mod handle; 7 | mod persistence; 8 | mod tui; 9 | mod utils; 10 | 11 | use args::Args; 12 | use clap::Parser; 13 | use handle::handle; 14 | 15 | #[tokio::main] 16 | async fn main() { 17 | let args = Args::parse(); 18 | let result = handle(args).await; 19 | 20 | if let Err(error) = &result { 21 | eprintln!("Error: {error}"); 22 | if let Some(c) = error.code() { 23 | eprintln!( 24 | " 25 | ------ 26 | 27 | This error is unexpected. 28 | Let @dhth know about this via https://github.com/dhth/bmm/issues (mention the error code E{c})." 29 | ); 30 | } 31 | 32 | if let Some(follow_up) = error.follow_up() { 33 | eprintln!( 34 | " 35 | {follow_up}" 36 | ); 37 | } 38 | std::process::exit(1); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/tui/common.rs: -------------------------------------------------------------------------------- 1 | use ratatui::style::Color; 2 | 3 | pub const FG_COLOR: Color = Color::from_u32(0x282828); 4 | pub const PRIMARY_COLOR: Color = Color::from_u32(0xd3869b); 5 | pub const HELP_COLOR: Color = Color::from_u32(0xfabd2f); 6 | pub const COLOR_TWO: Color = Color::from_u32(0x83a598); 7 | pub const COLOR_THREE: Color = Color::from_u32(0xfabd2f); 8 | pub const TAGS_COLOR: Color = Color::from_u32(0xb8bb26); 9 | pub const INFO_MESSAGE_COLOR: Color = Color::from_u32(0x83a598); 10 | pub const ERROR_MESSAGE_COLOR: Color = Color::from_u32(0xfb4934); 11 | pub const TITLE: &str = " bmm "; 12 | pub const MIN_TERMINAL_WIDTH: u16 = 96; 13 | pub const MIN_TERMINAL_HEIGHT: u16 = 24; 14 | 15 | #[derive(PartialEq, Debug, Clone, Copy)] 16 | pub(crate) enum ActivePane { 17 | List, 18 | TagsList, 19 | SearchInput, 20 | Help, 21 | } 22 | 23 | pub(super) struct TerminalDimensions { 24 | pub(super) width: u16, 25 | pub(super) height: u16, 26 | } 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help improve bmm 4 | title: '' 5 | labels: 'bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Setup** 14 | Please complete the following information along with version numbers, if 15 | applicable. 16 | - OS [e.g. Ubuntu, macOS] 17 | - Shell [e.g. zsh, fish] 18 | - Terminal Emulator [e.g. ghostty, alacritty, kitty, iterm2] 19 | - Terminal Multiplexer [e.g. tmux, zellij] 20 | - Locale [e.g. en_US.UTF-8, zh_CN.UTF-8, etc.] 21 | 22 | **To Reproduce** 23 | Steps to reproduce the behavior: 24 | 1. ... 25 | 2. ... 26 | 3. Error occurs 27 | 28 | **Expected behavior** 29 | A clear and concise description of what you expected to happen. 30 | 31 | **Screenshots** 32 | Add screenshots to help explain your problem. 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /migrations/20250203201411_create_initial_tables.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS bookmarks ( 2 | id INTEGER PRIMARY KEY NOT NULL, 3 | uri TEXT NOT NULL UNIQUE, 4 | title TEXT, 5 | created_at INTEGER NOT NULL, 6 | updated_at INTEGER NOT NULL 7 | ); 8 | 9 | CREATE INDEX IF NOT EXISTS idx_uri ON bookmarks (uri); 10 | 11 | -- 12 | 13 | CREATE TABLE IF NOT EXISTS tags ( 14 | id INTEGER PRIMARY KEY NOT NULL, 15 | name TEXT NOT NULL UNIQUE 16 | ); 17 | 18 | CREATE INDEX IF NOT EXISTS idx_name ON tags (name); 19 | 20 | -- 21 | 22 | CREATE TABLE IF NOT EXISTS bookmark_tags ( 23 | bookmark_id INTEGER, 24 | tag_id INTEGER, 25 | PRIMARY KEY (bookmark_id, tag_id), 26 | FOREIGN KEY (bookmark_id) REFERENCES bookmarks(id) ON DELETE CASCADE, 27 | FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE 28 | ); 29 | 30 | CREATE INDEX IF NOT EXISTS idx_bookmark_id ON bookmark_tags (bookmark_id); 31 | CREATE INDEX IF NOT EXISTS idx_tag_id ON bookmark_tags (tag_id); 32 | -------------------------------------------------------------------------------- /src/persistence/errors.rs: -------------------------------------------------------------------------------- 1 | use sqlx::Error as SqlxError; 2 | use sqlx::migrate::MigrateError; 3 | 4 | #[derive(Debug, thiserror::Error)] 5 | pub enum DBError { 6 | #[error("couldn't check if db exists: {0}")] 7 | CouldntCheckIfDbExists(#[source] SqlxError), 8 | #[error("couldn't create database: {0}")] 9 | CouldntCreateDatabase(#[source] SqlxError), 10 | #[error("couldn't connect to database: {0}")] 11 | CouldntConnectToDB(#[source] SqlxError), 12 | #[error("couldn't migrate database: {0}")] 13 | CouldntMigrateDB(#[source] MigrateError), 14 | #[error("couldn't execute query ({0}): {1}")] 15 | CouldntExecuteQuery(String, #[source] SqlxError), 16 | #[error("couldn't convert from sql: {0}")] 17 | CouldntConvertFromSQL(#[source] SqlxError), 18 | #[error("couldn't begin transation: {0}")] 19 | CouldntBeginTransaction(#[source] SqlxError), 20 | #[error("couldn't commit transation: {0}")] 21 | CouldntCommitTransaction(#[source] SqlxError), 22 | } 23 | -------------------------------------------------------------------------------- /dist-workspace.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["cargo:."] 3 | 4 | # Config for 'dist' 5 | [dist] 6 | # The preferred dist version to use in CI (Cargo.toml SemVer syntax) 7 | cargo-dist-version = "0.28.4" 8 | # CI backends to support 9 | ci = "github" 10 | # The installers to generate for each app 11 | installers = ["shell", "homebrew"] 12 | # A GitHub repo to push Homebrew formulas to 13 | tap = "dhth/homebrew-tap" 14 | # Target platforms to build apps for (Rust target-triple syntax) 15 | targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl"] 16 | # Path that installers should place binaries in 17 | install-path = "CARGO_HOME" 18 | # Publish jobs to run in CI 19 | publish-jobs = ["homebrew"] 20 | # Whether to install an updater program 21 | install-updater = false 22 | # Post-announce jobs to run in CI 23 | post-announce-jobs = ["./publish-crates"] 24 | # Whether to enable GitHub Attestations 25 | github-attestations = true 26 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | #[derive(thiserror::Error, Debug)] 4 | pub enum DataDirError { 5 | #[cfg(target_family = "unix")] 6 | #[error("XDG_DATA_HOME is not an absolute path")] 7 | XDGDataHomeNotAbsolute, 8 | #[error("couldn't get your data directory")] 9 | CouldntGetDataDir, 10 | } 11 | 12 | pub fn get_data_dir() -> Result { 13 | #[cfg(target_family = "unix")] 14 | let data_dir = match std::env::var_os("XDG_DATA_HOME").map(PathBuf::from) { 15 | Some(p) => { 16 | if p.is_absolute() { 17 | Ok(p) 18 | } else { 19 | Err(DataDirError::XDGDataHomeNotAbsolute) 20 | } 21 | } 22 | None => match dirs::data_dir() { 23 | Some(p) => Ok(p), 24 | None => Err(DataDirError::CouldntGetDataDir), 25 | }, 26 | }?; 27 | 28 | #[cfg(not(target_family = "unix"))] 29 | let data_dir = dirs::data_dir().ok_or(DataDirError::CouldntGetDataDir)?; 30 | 31 | Ok(data_dir) 32 | } 33 | -------------------------------------------------------------------------------- /.sqlx/query-82f4a83fe962e2d070c5b19db0c4f976493ca5eb3ed97a4ca7137970e4eb9216.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\nSELECT\n uri,\n title,\n (\n SELECT\n GROUP_CONCAT(t.name, ',' ORDER BY t.name ASC)\n FROM\n tags t\n JOIN bookmark_tags bt ON t.id = bt.tag_id\n WHERE\n bt.bookmark_id = b.id\n ) AS \"tags: String\"\nFROM\n bookmarks b\nWHERE\n uri = ?\n", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "uri", 8 | "ordinal": 0, 9 | "type_info": "Text" 10 | }, 11 | { 12 | "name": "title", 13 | "ordinal": 1, 14 | "type_info": "Text" 15 | }, 16 | { 17 | "name": "tags: String", 18 | "ordinal": 2, 19 | "type_info": "Null" 20 | } 21 | ], 22 | "parameters": { 23 | "Right": 1 24 | }, 25 | "nullable": [ 26 | false, 27 | true, 28 | null 29 | ] 30 | }, 31 | "hash": "82f4a83fe962e2d070c5b19db0c4f976493ca5eb3ed97a4ca7137970e4eb9216" 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/bench-against-main.yml: -------------------------------------------------------------------------------- 1 | name: bench-against-main 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | env: 7 | CARGO_TERM_COLOR: always 8 | SQLX_OFFLINE: true 9 | 10 | jobs: 11 | bench: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Install rust toolchain 15 | uses: actions-rust-lang/setup-rust-toolchain@v1 16 | - name: Install hyperfine 17 | uses: jaxxstorm/action-install-gh-release@6096f2a2bbfee498ced520b6922ac2c06e990ed2 # v2.1.0 18 | with: 19 | repo: sharkdp/hyperfine 20 | - name: Install uv 21 | uses: astral-sh/setup-uv@v7 22 | - uses: actions/checkout@v5 23 | with: 24 | ref: main 25 | - name: Build main 26 | run: cargo build --release --target-dir /var/tmp/main 27 | - uses: actions/checkout@v5 28 | - name: Build head 29 | run: cargo build --release --target-dir /var/tmp/head 30 | - name: Run benchmarks 31 | run: | 32 | cd bench 33 | ./bench-against-prev-version.sh /var/tmp/main/release/bmm /var/tmp/head/release/bmm 34 | -------------------------------------------------------------------------------- /src/cli/list.rs: -------------------------------------------------------------------------------- 1 | use super::DisplayError; 2 | use super::display::display_bookmarks; 3 | use crate::args::OutputFormat; 4 | use crate::persistence::DBError; 5 | use crate::persistence::get_bookmarks; 6 | use sqlx::{Pool, Sqlite}; 7 | 8 | #[derive(thiserror::Error, Debug)] 9 | pub enum ListBookmarksError { 10 | #[error("couldn't get bookmarks from db: {0}")] 11 | CouldntGetBookmarksFromDB(DBError), 12 | #[error("couldn't display results: {0}")] 13 | CouldntDisplayResults(DisplayError), 14 | } 15 | 16 | pub async fn list_bookmarks( 17 | pool: &Pool, 18 | uri: Option, 19 | title: Option, 20 | tags: Vec, 21 | format: OutputFormat, 22 | limit: u16, 23 | ) -> Result<(), ListBookmarksError> { 24 | let bookmarks = get_bookmarks(pool, uri, title, tags, limit) 25 | .await 26 | .map_err(ListBookmarksError::CouldntGetBookmarksFromDB)?; 27 | 28 | if bookmarks.is_empty() { 29 | return Ok(()); 30 | } 31 | 32 | display_bookmarks(&bookmarks, &format).map_err(ListBookmarksError::CouldntDisplayResults)?; 33 | 34 | Ok(()) 35 | } 36 | -------------------------------------------------------------------------------- /src/cli/tags/rename.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::{TAG_REGEX_STR, Tag}; 2 | use crate::persistence::DBError; 3 | use crate::persistence::rename_tag_name; 4 | use sqlx::{Pool, Sqlite}; 5 | 6 | #[derive(thiserror::Error, Debug)] 7 | pub enum RenameTagError { 8 | #[error("source and target tag are the same")] 9 | SourceAndTargetSame, 10 | #[error("no such tag")] 11 | NoSuchTag, 12 | #[error(transparent)] 13 | CouldntRenameTag(#[from] DBError), 14 | #[error("new tag is invalid (valid regex: {TAG_REGEX_STR})")] 15 | TagIsInvalid, 16 | } 17 | 18 | pub async fn rename_tag( 19 | pool: &Pool, 20 | source_tag: String, 21 | target_tag: String, 22 | ) -> Result<(), RenameTagError> { 23 | if source_tag.trim() == target_tag.trim() { 24 | return Err(RenameTagError::SourceAndTargetSame); 25 | } 26 | 27 | let new_tag = Tag::try_from(target_tag.as_str()).map_err(|_| RenameTagError::TagIsInvalid)?; 28 | let result = rename_tag_name(pool, source_tag, new_tag).await?; 29 | if result == 0 { 30 | return Err(RenameTagError::NoSuchTag); 31 | } 32 | 33 | Ok(()) 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Dhruv Thakur 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [v0.3.0] - Mar 10, 2025 9 | 10 | ### Added 11 | 12 | - Allow ignoring attribute errors while saving/importing bookmarks (longer 13 | titles will be trimmed, some invalid tags will be corrected) 14 | - Allow copying URI(s) to the system clipboard via the TUI 15 | 16 | ### Fixed 17 | 18 | - Listing bookmarks by tag(s) shows all tags for each bookmark returned 19 | 20 | ## [v0.2.0] - Feb 27, 2025 21 | 22 | ### Changed 23 | 24 | - Allow searching over multiple terms 25 | - Respect XDG_DATA_HOME on MacOS as well, if set 26 | 27 | ## [v0.1.0] - Feb 20, 2025 28 | 29 | ### Added 30 | 31 | - Initial release 32 | 33 | [unreleased]: https://github.com/dhth/bmm/compare/v0.3.0...HEAD 34 | [v0.3.0]: https://github.com/dhth/bmm/compare/v0.2.0...v0.3.0 35 | [v0.2.0]: https://github.com/dhth/bmm/compare/v0.1.0...v0.2.0 36 | [v0.1.0]: https://github.com/dhth/bmm/commits/v0.1.0/ 37 | -------------------------------------------------------------------------------- /.sqlx/query-8be126934cd3b27112637e219bb305edb05f933700346179504e45f2dde8bc4a.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\nSELECT\n uri,\n title,\n (\n SELECT\n GROUP_CONCAT(\n t.name,\n ','\n ORDER BY\n t.name ASC\n )\n FROM\n tags t\n JOIN bookmark_tags bt ON t.id = bt.tag_id\n WHERE\n bt.bookmark_id = b.id\n ) AS tags\nFROM\n bookmarks b\nORDER BY\n updated_at DESC\nLIMIT\n ?\n", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "uri", 8 | "ordinal": 0, 9 | "type_info": "Text" 10 | }, 11 | { 12 | "name": "title", 13 | "ordinal": 1, 14 | "type_info": "Text" 15 | }, 16 | { 17 | "name": "tags", 18 | "ordinal": 2, 19 | "type_info": "Text" 20 | } 21 | ], 22 | "parameters": { 23 | "Right": 1 24 | }, 25 | "nullable": [ 26 | false, 27 | true, 28 | true 29 | ] 30 | }, 31 | "hash": "8be126934cd3b27112637e219bb305edb05f933700346179504e45f2dde8bc4a" 32 | } 33 | -------------------------------------------------------------------------------- /.sqlx/query-04fa957844fe1684e78f5361f0556eddaa34f1e96fac57e985ec2cd857c4114a.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\nSELECT\n uri,\n title,\n (\n SELECT\n GROUP_CONCAT(\n t.name,\n ','\n ORDER BY\n t.name ASC\n )\n FROM\n tags t\n JOIN bookmark_tags bt ON t.id = bt.tag_id\n WHERE\n bt.bookmark_id = b.id\n ) AS tags\nFROM\n bookmarks b\nWHERE\n title LIKE ?\nORDER BY\n updated_at DESC\nLIMIT\n ?\n", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "uri", 8 | "ordinal": 0, 9 | "type_info": "Text" 10 | }, 11 | { 12 | "name": "title", 13 | "ordinal": 1, 14 | "type_info": "Text" 15 | }, 16 | { 17 | "name": "tags", 18 | "ordinal": 2, 19 | "type_info": "Text" 20 | } 21 | ], 22 | "parameters": { 23 | "Right": 2 24 | }, 25 | "nullable": [ 26 | false, 27 | true, 28 | true 29 | ] 30 | }, 31 | "hash": "04fa957844fe1684e78f5361f0556eddaa34f1e96fac57e985ec2cd857c4114a" 32 | } 33 | -------------------------------------------------------------------------------- /.sqlx/query-9a4571f59d7441ba64d040eb528b09dbad85c4ea9ea3b6696128b770345355f7.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\nSELECT\n uri,\n title,\n (\n SELECT\n GROUP_CONCAT(\n t.name,\n ','\n ORDER BY\n t.name ASC\n )\n FROM\n tags t\n JOIN bookmark_tags bt ON t.id = bt.tag_id\n WHERE\n bt.bookmark_id = b.id\n ) AS tags\nFROM\n bookmarks b\nWHERE\n b.uri LIKE ?\nORDER BY\n b.updated_at DESC\nLIMIT\n ?\n", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "uri", 8 | "ordinal": 0, 9 | "type_info": "Text" 10 | }, 11 | { 12 | "name": "title", 13 | "ordinal": 1, 14 | "type_info": "Text" 15 | }, 16 | { 17 | "name": "tags", 18 | "ordinal": 2, 19 | "type_info": "Text" 20 | } 21 | ], 22 | "parameters": { 23 | "Right": 2 24 | }, 25 | "nullable": [ 26 | false, 27 | true, 28 | true 29 | ] 30 | }, 31 | "hash": "9a4571f59d7441ba64d040eb528b09dbad85c4ea9ea3b6696128b770345355f7" 32 | } 33 | -------------------------------------------------------------------------------- /.sqlx/query-6b26146f0910fa9f7a9dc6bb553e0eefaa743567f15fda1ebfd7083bf3bd957e.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\nSELECT\n uri,\n title,\n (\n SELECT\n GROUP_CONCAT(\n t.name,\n ','\n ORDER BY\n t.name ASC\n )\n FROM\n tags t\n JOIN bookmark_tags bt ON t.id = bt.tag_id\n WHERE\n bt.bookmark_id = b.id\n ) AS tags\nFROM\n bookmarks b\nWHERE\n uri LIKE ?\n AND title LIKE ?\nORDER BY\n updated_at DESC\nLIMIT\n ?\n", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "uri", 8 | "ordinal": 0, 9 | "type_info": "Text" 10 | }, 11 | { 12 | "name": "title", 13 | "ordinal": 1, 14 | "type_info": "Text" 15 | }, 16 | { 17 | "name": "tags", 18 | "ordinal": 2, 19 | "type_info": "Text" 20 | } 21 | ], 22 | "parameters": { 23 | "Right": 3 24 | }, 25 | "nullable": [ 26 | false, 27 | true, 28 | true 29 | ] 30 | }, 31 | "hash": "6b26146f0910fa9f7a9dc6bb553e0eefaa743567f15fda1ebfd7083bf3bd957e" 32 | } 33 | -------------------------------------------------------------------------------- /tests/static/import/valid-with-some-invalid-attributes.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "uri": "https://crates.io/crates/sqlx", 4 | "title": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 5 | "tags": "crates,rust" 6 | }, 7 | { 8 | "uri": "https://github.com/dhth/omm", 9 | "title": "GitHub - dhth/omm: on-my-mind: a keyboard-driven task manager for the command line", 10 | "tags": "productivity, invalid tag, another invalid tag " 11 | }, 12 | { 13 | "uri": "https://github.com/dhth/hours", 14 | "title": "GitHub - dhth/hours: A no-frills time tracking toolkit for command line nerds", 15 | "tags": "productivity,tools" 16 | }, 17 | { 18 | "uri": "https://github.com/dhth/bmm", 19 | "title": "GitHub - dhth/bmm: get to your bookmarks in a flash", 20 | "tags": "tools" 21 | } 22 | ] 23 | -------------------------------------------------------------------------------- /tests/common/mod.rs: -------------------------------------------------------------------------------- 1 | use insta_cmd::get_cargo_bin; 2 | use std::{ffi::OsStr, path::PathBuf, process::Command}; 3 | use tempfile::{TempDir, tempdir}; 4 | 5 | pub struct Fixture { 6 | _bin_path: PathBuf, 7 | _temp_dir: TempDir, 8 | data_file_path: String, 9 | } 10 | 11 | #[cfg(test)] 12 | #[allow(unused)] 13 | impl Fixture { 14 | pub fn new() -> Self { 15 | let bin_path = get_cargo_bin("bmm"); 16 | let temp_dir = tempdir().expect("temporary directory should've been created"); 17 | let data_file_path = temp_dir 18 | .path() 19 | .join("bmm.db") 20 | .to_str() 21 | .expect("temporary directory path is not valid utf-8") 22 | .to_string(); 23 | 24 | Self { 25 | _bin_path: bin_path, 26 | _temp_dir: temp_dir, 27 | data_file_path, 28 | } 29 | } 30 | 31 | pub fn base_cmd(&self) -> Command { 32 | Command::new(&self._bin_path) 33 | } 34 | 35 | pub fn cmd(&self, args: I) -> Command 36 | where 37 | I: IntoIterator, 38 | S: AsRef, 39 | { 40 | let mut command = Command::new(&self._bin_path); 41 | command.args(args); 42 | command.args(["--db-path", &self.data_file_path]); 43 | command 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/cli/tags/list.rs: -------------------------------------------------------------------------------- 1 | use super::super::DisplayError; 2 | use super::super::display::{display_tags, display_tags_with_stats}; 3 | use crate::args::OutputFormat; 4 | use crate::persistence::DBError; 5 | use crate::persistence::{get_tags, get_tags_with_stats}; 6 | use crate::tui::{AppTuiError, TuiContext, run_tui}; 7 | use sqlx::{Pool, Sqlite}; 8 | 9 | #[derive(thiserror::Error, Debug)] 10 | pub enum ListTagsError { 11 | #[error("couldn't get tags from db: {0}")] 12 | CouldntGetTagsFromDB(#[from] DBError), 13 | #[error("couldn't display results: {0}")] 14 | CouldntDisplayResults(#[from] DisplayError), 15 | #[error(transparent)] 16 | CouldntRunTui(#[from] AppTuiError), 17 | } 18 | 19 | pub async fn list_tags( 20 | pool: &Pool, 21 | format: OutputFormat, 22 | show_stats: bool, 23 | tui: bool, 24 | ) -> Result<(), ListTagsError> { 25 | if tui { 26 | run_tui(pool, TuiContext::Tags).await?; 27 | return Ok(()); 28 | } 29 | 30 | match show_stats { 31 | true => { 32 | let tags_stats = get_tags_with_stats(pool).await?; 33 | 34 | display_tags_with_stats(&tags_stats, &format)?; 35 | } 36 | false => { 37 | let tags = get_tags(pool).await?; 38 | 39 | display_tags(&tags, &format)?; 40 | } 41 | } 42 | 43 | Ok(()) 44 | } 45 | -------------------------------------------------------------------------------- /src/cli/search.rs: -------------------------------------------------------------------------------- 1 | use super::DisplayError; 2 | use super::display::display_bookmarks; 3 | use crate::args::OutputFormat; 4 | use crate::persistence::DBError; 5 | use crate::persistence::{SearchTerms, SearchTermsError, get_bookmarks_by_query}; 6 | use crate::tui::run_tui; 7 | use crate::tui::{AppTuiError, TuiContext}; 8 | use sqlx::{Pool, Sqlite}; 9 | 10 | #[derive(thiserror::Error, Debug)] 11 | pub enum SearchBookmarksError { 12 | #[error("search query is invalid: {0}")] 13 | SearchQueryInvalid(#[from] SearchTermsError), 14 | #[error("couldn't get bookmarks from db: {0}")] 15 | CouldntGetBookmarksFromDB(DBError), 16 | #[error("couldn't display results: {0}")] 17 | CouldntDisplayResults(DisplayError), 18 | #[error(transparent)] 19 | CouldntRunTui(#[from] AppTuiError), 20 | } 21 | 22 | pub async fn search_bookmarks( 23 | pool: &Pool, 24 | query_terms: &Vec, 25 | format: OutputFormat, 26 | limit: u16, 27 | tui: bool, 28 | ) -> Result<(), SearchBookmarksError> { 29 | let search_terms = SearchTerms::try_from(query_terms)?; 30 | 31 | if tui { 32 | run_tui(pool, TuiContext::Search(search_terms)).await?; 33 | return Ok(()); 34 | } 35 | 36 | let bookmarks = get_bookmarks_by_query(pool, &search_terms, limit) 37 | .await 38 | .map_err(SearchBookmarksError::CouldntGetBookmarksFromDB)?; 39 | 40 | if bookmarks.is_empty() { 41 | return Ok(()); 42 | } 43 | 44 | display_bookmarks(&bookmarks, &format).map_err(SearchBookmarksError::CouldntDisplayResults)?; 45 | 46 | Ok(()) 47 | } 48 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bmm" 3 | version = "0.3.0" 4 | edition = "2024" 5 | authors = ["Dhruv Thakur"] 6 | repository = "https://github.com/dhth/bmm" 7 | description = "bmm lets you get to your bookmarks in a flash" 8 | homepage = "https://tools.dhruvs.space/bmm/" 9 | license = "MIT" 10 | keywords = [ 11 | "cli", 12 | "bookmarks", 13 | "bookmarks-manager", 14 | ] 15 | categories = [ 16 | "command-line-utilities", 17 | ] 18 | exclude = [ 19 | ".github", 20 | "bench", 21 | "tapes", 22 | ] 23 | 24 | [dependencies] 25 | arboard = { version = "3.6.1", default-features = false } 26 | clap = { version = "4.5.51", features = ["derive"] } 27 | csv = "1.4.0" 28 | dirs = "6.0.0" 29 | lazy_static = "1.5.0" 30 | once_cell = "1.21.3" 31 | open = "5.3.2" 32 | ratatui = "0.29.0" 33 | regex = "1.12.2" 34 | select = "0.6.1" 35 | serde = { version = "1.0.228", features = ["derive"] } 36 | serde_json = "1.0.145" 37 | sqlx = { version = "0.8.6", default-features = false, features = ["json", "macros", "migrate", "runtime-tokio", "sqlite"] } 38 | tempfile = "3.23.0" 39 | thiserror = "2.0.17" 40 | tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread"] } 41 | tui-input = "0.14.0" 42 | url = { version= "2.5.4", features = ["serde"] } 43 | which = "8.0.0" 44 | 45 | [dev-dependencies] 46 | insta = { version = "1.43.2", features = ["yaml"] } 47 | insta-cmd = "0.6.0" 48 | 49 | [profile.dev.package] 50 | insta.opt-level = 3 51 | similar.opt-level = 3 52 | 53 | [lints.clippy] 54 | unwrap_used = "deny" 55 | expect_used = "deny" 56 | 57 | [profile.release] 58 | codegen-units = 1 59 | lto = "fat" 60 | strip = "symbols" 61 | 62 | # The profile that 'cargo dist' will build with 63 | [profile.dist] 64 | inherits = "release" 65 | -------------------------------------------------------------------------------- /src/cli/delete.rs: -------------------------------------------------------------------------------- 1 | use crate::persistence::DBError; 2 | use crate::persistence::delete_bookmarks_with_uris; 3 | use sqlx::{Pool, Sqlite}; 4 | use std::io::{Error as IOError, Write}; 5 | 6 | #[derive(thiserror::Error, Debug)] 7 | pub enum DeleteBookmarksError { 8 | #[error(transparent)] 9 | CouldntDeleteBookmarksInDB(#[from] DBError), 10 | #[error("couldn't flush stdout: {0}")] 11 | CouldntFlushStdout(IOError), 12 | #[error("couldn't read your input: {0}")] 13 | CouldntReadUserInput(IOError), 14 | } 15 | 16 | pub async fn delete_bookmarks( 17 | pool: &Pool, 18 | uris: Vec, 19 | skip_confirmation: bool, 20 | ) -> Result<(), DeleteBookmarksError> { 21 | if uris.is_empty() { 22 | return Ok(()); 23 | } 24 | 25 | if !skip_confirmation { 26 | if uris.len() == 1 { 27 | println!("Deleting 1 bookmark; enter \"y\" to confirm."); 28 | } else { 29 | println!("Deleting {} bookmarks; enter \"y\" to confirm.", uris.len()); 30 | } 31 | 32 | std::io::stdout() 33 | .flush() 34 | .map_err(DeleteBookmarksError::CouldntFlushStdout)?; 35 | 36 | let mut input = String::new(); 37 | std::io::stdin() 38 | .read_line(&mut input) 39 | .map_err(DeleteBookmarksError::CouldntReadUserInput)?; 40 | 41 | if input.trim() != "y" { 42 | return Ok(()); 43 | } 44 | } 45 | 46 | let num_bookmarks = delete_bookmarks_with_uris(pool, &uris).await?; 47 | 48 | match num_bookmarks { 49 | 0 => println!("nothing got deleted"), 50 | 1 => println!("deleted 1 bookmark"), 51 | n => println!("deleted {n} bookmarks"), 52 | } 53 | 54 | Ok(()) 55 | } 56 | -------------------------------------------------------------------------------- /src/persistence/init.rs: -------------------------------------------------------------------------------- 1 | use super::DBError; 2 | use sqlx::{Pool, Sqlite, SqlitePool, migrate::MigrateDatabase}; 3 | 4 | pub async fn get_db_pool(uri: &str) -> Result, DBError> { 5 | let db_exists = Sqlite::database_exists(uri) 6 | .await 7 | .map_err(DBError::CouldntCheckIfDbExists)?; 8 | 9 | if !db_exists { 10 | Sqlite::create_database(uri) 11 | .await 12 | .map_err(DBError::CouldntCreateDatabase)?; 13 | } 14 | 15 | let db = SqlitePool::connect(uri) 16 | .await 17 | .map_err(DBError::CouldntConnectToDB)?; 18 | 19 | sqlx::migrate!() 20 | .run(&db) 21 | .await 22 | .map_err(DBError::CouldntMigrateDB)?; 23 | 24 | Ok(db) 25 | } 26 | 27 | #[cfg(test)] 28 | pub(super) async fn get_in_memory_db_pool() -> Result, DBError> { 29 | let db = SqlitePool::connect("sqlite://:memory:") 30 | .await 31 | .map_err(DBError::CouldntConnectToDB)?; 32 | 33 | sqlx::migrate!() 34 | .run(&db) 35 | .await 36 | .map_err(DBError::CouldntMigrateDB)?; 37 | 38 | Ok(db) 39 | } 40 | 41 | #[cfg(test)] 42 | mod tests { 43 | use super::*; 44 | 45 | #[tokio::test] 46 | async fn migrating_db_works() { 47 | // GIVEN 48 | // WHEN 49 | let result = get_in_memory_db_pool().await; 50 | 51 | // THEN 52 | assert!(result.is_ok()); 53 | } 54 | 55 | #[tokio::test] 56 | async fn get_conn_fails_if_path_doesnt_exist() { 57 | // GIVEN 58 | let path = "nonexistent/nonexistent/nonexistent.db"; 59 | 60 | // WHEN 61 | let result = get_db_pool(path).await; 62 | 63 | // THEN 64 | assert!(result.is_err()); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/help_test.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | 3 | use common::Fixture; 4 | use insta_cmd::assert_cmd_snapshot; 5 | 6 | //-------------// 7 | // SUCCESSES // 8 | //-------------// 9 | 10 | #[test] 11 | fn shows_help() { 12 | // GIVEN 13 | let fx = Fixture::new(); 14 | let mut cmd = fx.cmd(["--help"]); 15 | 16 | // WHEN 17 | // THEN 18 | assert_cmd_snapshot!(cmd, @r#" 19 | success: true 20 | exit_code: 0 21 | ----- stdout ----- 22 | bmm (stands for "bookmarks manager") lets you get to your bookmarks in a flash. 23 | 24 | It does so by storing your bookmarks locally, allowing you to quickly access, 25 | manage, and search through them using various commands. 26 | 27 | bmm has a traditional command line interface that can be used standalone or 28 | integrated with other tools, and a textual user interface for easy browsing. 29 | 30 | Usage: bmm [OPTIONS] 31 | 32 | Commands: 33 | import Import bookmarks from various sources 34 | delete Delete bookmarks 35 | list List bookmarks based on several kinds of queries 36 | save Save/update a bookmark 37 | save-all Save/update multiple bookmarks 38 | search Search bookmarks by matching over terms 39 | show Show bookmark details 40 | tags Interact with tags 41 | tui Open bmm's TUI 42 | help Print this message or the help of the given subcommand(s) 43 | 44 | Options: 45 | --db-path 46 | Override bmm's database location (default: /bmm/bmm.db) 47 | 48 | --debug 49 | Output debug information without doing anything 50 | 51 | -h, --help 52 | Print help (see a summary with '-h') 53 | 54 | ----- stderr ----- 55 | "#); 56 | } 57 | -------------------------------------------------------------------------------- /tests/static/import/valid.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 8 | Bookmarks 9 |

Bookmarks Menu

10 | 11 |

12 |

Bookmarks Toolbar

13 |

14 |

productivity

15 |

16 |

crates

17 |

18 |

sqlx - crates.io: Rust Package Registry 19 |

20 |

GitHub - dhth/omm: on-my-mind: a keyboard-driven task manager for the command line 21 |
GitHub - dhth/hours: A no-frills time tracking toolkit for command line nerds 22 |

23 |

GitHub - dhth/bmm: get to your bookmarks in a flash 24 |

25 |

26 | -------------------------------------------------------------------------------- /tests/delete_test.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | 3 | use common::Fixture; 4 | use insta_cmd::assert_cmd_snapshot; 5 | 6 | const URI_ONE: &str = "https://github.com/dhth/bmm"; 7 | const URI_TWO: &str = "https://github.com/dhth/omm"; 8 | const URI_THREE: &str = "https://github.com/dhth/hours"; 9 | 10 | //-------------// 11 | // SUCCESSES // 12 | //-------------// 13 | 14 | #[test] 15 | fn deleting_multiple_bookmarks_works() { 16 | // GIVEN 17 | let fx = Fixture::new(); 18 | let mut save_cmd = fx.cmd(["save-all", URI_ONE, URI_TWO, URI_THREE]); 19 | assert_cmd_snapshot!(save_cmd, @r" 20 | success: true 21 | exit_code: 0 22 | ----- stdout ----- 23 | saved 3 bookmarks 24 | 25 | ----- stderr ----- 26 | "); 27 | 28 | let mut cmd = fx.cmd(["delete", "--yes", URI_ONE, URI_TWO]); 29 | 30 | // WHEN 31 | // THEN 32 | assert_cmd_snapshot!(cmd, @r" 33 | success: true 34 | exit_code: 0 35 | ----- stdout ----- 36 | deleted 2 bookmarks 37 | 38 | ----- stderr ----- 39 | "); 40 | 41 | let mut list_cmd = fx.cmd(["list"]); 42 | assert_cmd_snapshot!(list_cmd, @r" 43 | success: true 44 | exit_code: 0 45 | ----- stdout ----- 46 | https://github.com/dhth/hours 47 | 48 | ----- stderr ----- 49 | "); 50 | } 51 | 52 | #[test] 53 | fn deleting_shouldnt_fail_if_bookmarks_dont_exist() { 54 | // GIVEN 55 | let fx = Fixture::new(); 56 | let mut save_cmd = fx.cmd(["save-all", URI_ONE, URI_TWO, URI_THREE]); 57 | assert_cmd_snapshot!(save_cmd, @r" 58 | success: true 59 | exit_code: 0 60 | ----- stdout ----- 61 | saved 3 bookmarks 62 | 63 | ----- stderr ----- 64 | "); 65 | 66 | let mut cmd = fx.cmd(["delete", "--yes", "https://nonexistent-uri.com"]); 67 | 68 | // WHEN 69 | // THEN 70 | assert_cmd_snapshot!(cmd, @r" 71 | success: true 72 | exit_code: 0 73 | ----- stdout ----- 74 | nothing got deleted 75 | 76 | ----- stderr ----- 77 | "); 78 | } 79 | -------------------------------------------------------------------------------- /bench/generate-data: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S uv run --script 2 | 3 | import json 4 | import random 5 | import sys 6 | 7 | SEED = 42 8 | 9 | 10 | def generate_entries(num_entries, tags, output_file): 11 | random.seed(SEED) 12 | 13 | entries = [] 14 | for i in range(num_entries): 15 | entry_tags = [random.choice(tags)] 16 | if random.random() < 0.3: 17 | additional_tag = random.choice(tags) 18 | if additional_tag not in entry_tags: 19 | entry_tags.append(additional_tag) 20 | if random.random() < 0.2: 21 | additional_tag = random.choice(tags) 22 | if additional_tag not in entry_tags: 23 | entry_tags.append(additional_tag) 24 | uri = f"https://example-title-{i}.blah.com/{i}" 25 | title = f"example-title-{i}.com" 26 | entry = { 27 | "tags": ",".join(entry_tags), 28 | "title": title, 29 | "uri": uri, 30 | } 31 | entries.append(entry) 32 | 33 | try: 34 | with open(output_file, "w") as f: 35 | f.write('\n') 36 | f.write('\n') 37 | f.write('Bookmarks\n') 38 | f.write('

Bookmarks

\n') 39 | f.write('

\n') 40 | for entry in entries: 41 | f.write(f'

{entry["title"]}\n') 42 | f.write('

\n') 43 | except IOError as e: 44 | print(f"Error writing to {output_file}: {e}", file=sys.stderr) 45 | sys.exit(1) 46 | 47 | if __name__ == "__main__": 48 | if len(sys.argv) != 4: 49 | print("Usage: ./generate-data ") 50 | sys.exit(1) 51 | 52 | num_entries = int(sys.argv[1]) 53 | tags = sys.argv[2].split(",") 54 | output_file = sys.argv[3] 55 | 56 | generate_entries(num_entries, tags, output_file) 57 | -------------------------------------------------------------------------------- /tests/show_test.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | 3 | use common::Fixture; 4 | use insta_cmd::assert_cmd_snapshot; 5 | 6 | const URI: &str = "https://crates.io/crates/sqlx"; 7 | 8 | //-------------// 9 | // SUCCESSES // 10 | //-------------// 11 | 12 | #[test] 13 | fn showing_bookmarks_details_works() { 14 | // GIVEN 15 | let fx = Fixture::new(); 16 | let mut import_cmd = fx.cmd(["import", "tests/static/import/valid.json"]); 17 | assert_cmd_snapshot!(import_cmd, @r" 18 | success: true 19 | exit_code: 0 20 | ----- stdout ----- 21 | imported 4 bookmarks 22 | 23 | ----- stderr ----- 24 | "); 25 | 26 | let mut cmd = fx.cmd(["show", URI]); 27 | 28 | // WHEN 29 | // THEN 30 | assert_cmd_snapshot!(cmd, @r" 31 | success: true 32 | exit_code: 0 33 | ----- stdout ----- 34 | Bookmark details 35 | --- 36 | 37 | Title: sqlx - crates.io: Rust Package Registry 38 | URI : https://crates.io/crates/sqlx 39 | Tags : crates,rust 40 | 41 | ----- stderr ----- 42 | "); 43 | } 44 | 45 | #[test] 46 | fn show_details_output_marks_attributes_that_are_missing() { 47 | // GIVEN 48 | let fx = Fixture::new(); 49 | let mut save_cmd = fx.cmd(["save", URI]); 50 | assert_cmd_snapshot!(save_cmd, @r" 51 | success: true 52 | exit_code: 0 53 | ----- stdout ----- 54 | 55 | ----- stderr ----- 56 | "); 57 | 58 | let mut cmd = fx.cmd(["show", URI]); 59 | 60 | // WHEN 61 | // THEN 62 | assert_cmd_snapshot!(cmd, @r" 63 | success: true 64 | exit_code: 0 65 | ----- stdout ----- 66 | Bookmark details 67 | --- 68 | 69 | Title: 70 | URI : https://crates.io/crates/sqlx 71 | Tags : 72 | 73 | ----- stderr ----- 74 | "); 75 | } 76 | 77 | //------------// 78 | // FAILURES // 79 | //------------// 80 | 81 | #[test] 82 | fn showing_bookmarks_fails_if_bookmark_doesnt_exist() { 83 | // GIVEN 84 | let fx = Fixture::new(); 85 | let mut cmd = fx.cmd(["show", URI]); 86 | 87 | // WHEN 88 | // THEN 89 | assert_cmd_snapshot!(cmd, @r" 90 | success: false 91 | exit_code: 1 92 | ----- stdout ----- 93 | 94 | ----- stderr ----- 95 | Error: couldn't show bookmark details: bookmark doesn't exist 96 | "); 97 | } 98 | -------------------------------------------------------------------------------- /tests/static/import/valid-with-some-invalid-attributes.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 8 | Bookmarks 9 |

Bookmarks Menu

10 | 11 |

12 |

Bookmarks Toolbar

13 |

14 |

productivity

15 |

16 |

crates

17 |

18 |

aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 19 |

20 |

GitHub - dhth/omm: on-my-mind: a keyboard-driven task manager for the command line 21 |
GitHub - dhth/hours: A no-frills time tracking toolkit for command line nerds 22 |

23 |

GitHub - dhth/bmm: get to your bookmarks in a flash 24 |

25 |

26 | -------------------------------------------------------------------------------- /src/cli/tags/delete.rs: -------------------------------------------------------------------------------- 1 | use crate::persistence::DBError; 2 | use crate::persistence::{delete_tags_by_name, get_tags}; 3 | use sqlx::{Pool, Sqlite}; 4 | use std::collections::HashSet; 5 | use std::io::{Error as IOError, Write}; 6 | 7 | #[derive(thiserror::Error, Debug)] 8 | pub enum DeleteTagsError { 9 | #[error("couldn't flush stdout: {0}")] 10 | CouldntFlushStdout(IOError), 11 | #[error("couldn't read your input: {0}")] 12 | CouldntReadUserInput(IOError), 13 | #[error("couldn't check if tags exist: {0}")] 14 | CouldntCheckIfTagsExist(DBError), 15 | #[error("tags do not exist: {0:?}")] 16 | TagsDoNotExist(Vec), 17 | #[error(transparent)] 18 | CouldntDeleteTags(DBError), 19 | } 20 | 21 | pub async fn delete_tags( 22 | pool: &Pool, 23 | tags: Vec, 24 | skip_confirmation: bool, 25 | ) -> Result<(), DeleteTagsError> { 26 | if tags.is_empty() { 27 | return Ok(()); 28 | } 29 | 30 | if !skip_confirmation { 31 | if tags.len() == 1 { 32 | println!("Deleting 1 tag; enter \"y\" to confirm."); 33 | } else { 34 | println!("Deleting {} tags; enter \"y\" to confirm.", tags.len()); 35 | } 36 | 37 | std::io::stdout() 38 | .flush() 39 | .map_err(DeleteTagsError::CouldntFlushStdout)?; 40 | 41 | let mut input = String::new(); 42 | std::io::stdin() 43 | .read_line(&mut input) 44 | .map_err(DeleteTagsError::CouldntReadUserInput)?; 45 | 46 | if input.trim() != "y" { 47 | return Ok(()); 48 | } 49 | } 50 | 51 | let all_tags = get_tags(pool) 52 | .await 53 | .map_err(DeleteTagsError::CouldntCheckIfTagsExist)?; 54 | 55 | let non_existent_tags = get_set_difference(&tags, &all_tags); 56 | 57 | if !non_existent_tags.is_empty() { 58 | return Err(DeleteTagsError::TagsDoNotExist(non_existent_tags)); 59 | } 60 | 61 | let num_tags_deleted = delete_tags_by_name(pool, &tags) 62 | .await 63 | .map_err(DeleteTagsError::CouldntDeleteTags)?; 64 | 65 | match num_tags_deleted { 66 | 1 => println!("deleted 1 tag"), 67 | n => println!("deleted {n} tags"), 68 | } 69 | 70 | Ok(()) 71 | } 72 | 73 | fn get_set_difference(smaller: &[String], larger: &[String]) -> Vec { 74 | let set: HashSet<_> = larger.iter().collect(); 75 | smaller 76 | .iter() 77 | .filter(|item| !set.contains(item)) 78 | .cloned() 79 | .collect() 80 | } 81 | -------------------------------------------------------------------------------- /tests/data_dir_test.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | 3 | use common::Fixture; 4 | use insta_cmd::assert_cmd_snapshot; 5 | use tempfile::tempdir; 6 | 7 | const URI: &str = "https://crates.io/crates/sqlx"; 8 | 9 | //-------------// 10 | // SUCCESSES // 11 | //-------------// 12 | 13 | #[test] 14 | fn xdg_data_home_is_respected() { 15 | // GIVEN 16 | let fx = Fixture::new(); 17 | let temp_dir = tempdir().expect("temporary directory should've been created"); 18 | let data_dir_path = temp_dir 19 | .path() 20 | .to_str() 21 | .expect("temporary directory path is not valid utf-8") 22 | .to_string(); 23 | let mut import_cmd = fx.base_cmd(); 24 | import_cmd.args(["import", "tests/static/import/valid.json"]); 25 | import_cmd.env("XDG_DATA_HOME", &data_dir_path); 26 | assert_cmd_snapshot!(import_cmd, @r" 27 | success: true 28 | exit_code: 0 29 | ----- stdout ----- 30 | imported 4 bookmarks 31 | 32 | ----- stderr ----- 33 | "); 34 | 35 | let mut cmd_without_env_var = fx.cmd(["show", URI]); 36 | let mut cmd = fx.base_cmd(); 37 | cmd.args(["show", URI]); 38 | cmd.env("XDG_DATA_HOME", &data_dir_path); 39 | 40 | // WHEN 41 | // THEN 42 | assert_cmd_snapshot!(cmd_without_env_var, @r" 43 | success: false 44 | exit_code: 1 45 | ----- stdout ----- 46 | 47 | ----- stderr ----- 48 | Error: couldn't show bookmark details: bookmark doesn't exist 49 | "); 50 | 51 | assert_cmd_snapshot!(cmd, @r" 52 | success: true 53 | exit_code: 0 54 | ----- stdout ----- 55 | Bookmark details 56 | --- 57 | 58 | Title: sqlx - crates.io: Rust Package Registry 59 | URI : https://crates.io/crates/sqlx 60 | Tags : crates,rust 61 | 62 | ----- stderr ----- 63 | "); 64 | } 65 | 66 | //------------// 67 | // FAILURES // 68 | //------------// 69 | 70 | #[cfg(target_family = "unix")] 71 | #[test] 72 | fn fails_if_xdg_data_home_is_non_absolute() { 73 | // GIVEN 74 | let fx = Fixture::new(); 75 | let mut cmd = fx.base_cmd(); 76 | cmd.args(["show", URI]); 77 | cmd.env("XDG_DATA_HOME", "../not/an/absolute/path"); 78 | 79 | // WHEN 80 | // THEN 81 | assert_cmd_snapshot!(cmd, @r" 82 | success: false 83 | exit_code: 1 84 | ----- stdout ----- 85 | 86 | ----- stderr ----- 87 | Error: XDG_DATA_HOME is not an absolute path 88 | 89 | Context: XDG specifications dictate that XDG_DATA_HOME must be an absolute path. 90 | Read more here: https://specifications.freedesktop.org/basedir-spec/latest/#basics 91 | "); 92 | } 93 | -------------------------------------------------------------------------------- /bench/bench-against-prev-version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | IFS=$'\n\t' 5 | 6 | required_binaries=("hyperfine") 7 | 8 | check_binaries() { 9 | for binary in "${required_binaries[@]}"; do 10 | if ! command -v "$binary" &>/dev/null; then 11 | echo "Error: $binary is not installed." >&2 12 | exit 1 13 | fi 14 | done 15 | } 16 | 17 | check_binaries 18 | 19 | if [ "$#" -ne 2 ]; then 20 | echo "Usage: $0 " 21 | exit 1 22 | fi 23 | 24 | prev_bin=$1 25 | new_bin=$2 26 | 27 | temp_dir=$(mktemp -d) 28 | echo "temp_dir: $temp_dir" 29 | 30 | if [[ ! -d "$temp_dir" ]]; then 31 | echo "Error: Failed to create temporary directory." >&2 32 | exit 1 33 | fi 34 | 35 | tags=$(printf "tag%s," {1..100} | sed 's/,$/\n/') 36 | echo "100 tags" 37 | 38 | data_file="${temp_dir}/bookmarks.html" 39 | 40 | ./generate-data 8000 "${tags}" "${data_file}" 41 | 42 | ${prev_bin} --db-path "${temp_dir}/bmm.db" import "${data_file}" >/dev/null 43 | 44 | num_bookmarks=$(bmm --db-path "${temp_dir}/bmm.db" list -f plain -l 8000 | wc -l | xargs) 45 | 46 | cat < 18 | 21 | 22 | 24 | Bookmarks 25 |

Bookmarks Menu

26 | 27 |

28 |

Bookmarks Toolbar

29 |

30 |

productivity

31 |

32 |

crates

33 |

34 |

sqlx - crates.io: Rust Package Registry 35 |

36 |

GitHub - dhth/omm: on-my-mind: a keyboard-driven task manager for the command line 37 |
GitHub - dhth/hours: A no-frills time tracking toolkit for command line nerds 38 |

39 |

GitHub - dhth/bmm: get to your bookmarks in a flash 40 |

41 |

42 | 43 | JSON 44 | --- 45 | 46 | An example file: 47 | 48 | [ 49 | { 50 | "uri": "https://github.com/dhth/bmm", 51 | "title": null, 52 | "tags": "tools,bookmarks" 53 | }, 54 | { 55 | "uri": "https://github.com/dhth/omm", 56 | "title": "on-my-mind: a keyboard-driven task manager for the command line", 57 | "tags": "tools,productivity" 58 | } 59 | ] 60 | 61 | TXT 62 | --- 63 | 64 | An example file: 65 | 66 | https://github.com/dhth/bmm 67 | https://github.com/dhth/omm 68 | https://github.com/dhth/hours 69 | -------------------------------------------------------------------------------- /src/tui/handle.rs: -------------------------------------------------------------------------------- 1 | use super::commands::Command; 2 | use super::message::{Message, UrlsOpenedResult}; 3 | use crate::common::DEFAULT_LIMIT; 4 | use crate::persistence::{get_bookmarks, get_bookmarks_by_query, get_tags_with_stats}; 5 | use arboard::Clipboard; 6 | use sqlx::{Pool, Sqlite}; 7 | use tokio::sync::mpsc::Sender; 8 | 9 | pub(super) async fn handle_command( 10 | pool: &Pool, 11 | command: Command, 12 | event_tx: Sender, 13 | ) { 14 | match command { 15 | // TODO: handle errors here 16 | Command::OpenInBrowser(url) => { 17 | tokio::spawn(async move { 18 | let message = match open::that(url) { 19 | Ok(_) => Message::UrlsOpenedInBrowser(UrlsOpenedResult::Success), 20 | Err(e) => Message::UrlsOpenedInBrowser(UrlsOpenedResult::Failure(e)), 21 | }; 22 | 23 | let _ = event_tx.try_send(message); 24 | }); 25 | } 26 | Command::SearchBookmarks(search_query) => { 27 | let pool = pool.clone(); 28 | tokio::spawn(async move { 29 | let result = get_bookmarks_by_query(&pool, &search_query, DEFAULT_LIMIT).await; 30 | let message = Message::SearchFinished(result); 31 | let _ = event_tx.try_send(message); 32 | }); 33 | } 34 | Command::FetchTags => { 35 | let pool = pool.clone(); 36 | tokio::spawn(async move { 37 | let result = get_tags_with_stats(&pool).await; 38 | let message = Message::TagsFetched(result); 39 | let _ = event_tx.try_send(message); 40 | }); 41 | } 42 | Command::FetchBookmarksForTag(tag) => { 43 | let pool = pool.clone(); 44 | tokio::spawn(async move { 45 | let result = get_bookmarks(&pool, None, None, vec![tag], DEFAULT_LIMIT).await; 46 | let message = Message::BookmarksForTagFetched(result); 47 | let _ = event_tx.try_send(message); 48 | }); 49 | } 50 | Command::CopyContentToClipboard(content) => { 51 | tokio::task::spawn_blocking(move || { 52 | let result = copy_content_to_clipboard(&content); 53 | let _ = event_tx.try_send(Message::ContentCopiedToClipboard(result)); 54 | }); 55 | } 56 | } 57 | } 58 | 59 | fn copy_content_to_clipboard(content: &str) -> Result<(), String> { 60 | let mut clipboard = 61 | Clipboard::new().map_err(|e| format!("couldn't get system clipboard: {e}"))?; 62 | 63 | clipboard.set_text(content).map_err(|e| e.to_string())?; 64 | 65 | Ok(()) 66 | } 67 | -------------------------------------------------------------------------------- /bench/bench-against-buku.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | IFS=$'\n\t' 5 | 6 | required_binaries=("buku" "bmm" "hyperfine") 7 | 8 | check_binaries() { 9 | for binary in "${required_binaries[@]}"; do 10 | if ! command -v "$binary" &>/dev/null; then 11 | echo "Error: $binary is not installed." >&2 12 | exit 1 13 | fi 14 | done 15 | } 16 | 17 | check_binaries 18 | 19 | temp_dir=$(mktemp -d) 20 | echo "temp_dir: $temp_dir" 21 | 22 | if [[ ! -d "$temp_dir" ]]; then 23 | echo "Error: Failed to create temporary directory." >&2 24 | exit 1 25 | fi 26 | 27 | tags=$(printf "tag%s," {1..100} | sed 's/,$/\n/') 28 | echo "100 tags" 29 | 30 | data_file="${temp_dir}/bookmarks.html" 31 | 32 | ./generate-data 8000 "${tags}" "${data_file}" 33 | XDG_DATA_HOME="${temp_dir}" buku --nostdin --offline --import "${data_file}" >/dev/null </dev/null 40 | 41 | buku_num=$(XDG_DATA_HOME="${temp_dir}" buku --nostdin --np --nc -p -f 10 | wc -l | xargs) 42 | bmm_num=$(bmm --db-path="${temp_dir}/bmm.db" list -f plain -l 8000 | wc -l | xargs) 43 | 44 | cat </var/tmp/buku.txt 60 | bmm --db-path "${temp_dir}/bmm.db" search -f plain -l 500 1000.com >/var/tmp/bmm.txt 61 | 62 | git --no-pager diff --no-index /var/tmp/buku.txt /var/tmp/bmm.txt || { 63 | echo "command outputs differ" 64 | exit 1 65 | } 66 | 67 | BUKU_COMMAND="XDG_DATA_HOME=${temp_dir} buku --nostdin --np -f 10 --nc -n 500 -s 1000.com" 68 | BMM_COMMAND="bmm --db-path=${temp_dir}/bmm.db search -f plain -l 500 1000.com" 69 | 70 | cat < buku.txt 88 | bmm --db-path "${temp_dir}/bmm.db" list -f plain -l 1000 -t tag1 >bmm.txt 89 | 90 | git --no-pager diff --no-index /var/tmp/buku.txt /var/tmp/bmm.txt || { 91 | echo "command outputs differ" 92 | exit 1 93 | } 94 | 95 | BUKU_COMMAND="XDG_DATA_HOME=${temp_dir} buku --nostdin --np -f 10 --nc -n 1000 --stag tag1" 96 | BMM_COMMAND="bmm --db-path=${temp_dir}/bmm.db list -f plain -l 1000 -t tag1" 97 | 98 | cat <, 30 | uris: Option>, 31 | tags: Vec, 32 | use_stdin: bool, 33 | reset_missing: bool, 34 | ignore_attribute_errors: bool, 35 | ) -> Result, SaveBookmarksError> { 36 | let mut uris_to_save = uris.unwrap_or_default(); 37 | 38 | if use_stdin { 39 | let stdin = std::io::stdin(); 40 | for line in stdin.lock().lines() { 41 | uris_to_save.push(line?); 42 | } 43 | } 44 | 45 | if uris_to_save.len() > IMPORT_UPPER_LIMIT { 46 | return Err(SaveBookmarksError::TooManyBookmarks(uris_to_save.len())); 47 | } 48 | 49 | let mut validation_errors = Vec::new(); 50 | let mut draft_bookmarks = Vec::new(); 51 | 52 | for (index, uri) in uris_to_save.into_iter().enumerate() { 53 | let potential_bookmark = PotentialBookmark::from((uri, None, &tags)); 54 | let db_result = DraftBookmark::try_from((potential_bookmark, ignore_attribute_errors)); 55 | match db_result { 56 | Ok(db) => draft_bookmarks.push(db), 57 | Err(e) => validation_errors.push((index, e)), 58 | } 59 | } 60 | 61 | if !validation_errors.is_empty() { 62 | return Err(SaveBookmarksError::ValidationError { 63 | errors: DraftBookmarkErrors { 64 | errors: validation_errors, 65 | }, 66 | }); 67 | } 68 | 69 | let start = SystemTime::now(); 70 | let since_the_epoch = start 71 | .duration_since(UNIX_EPOCH) 72 | .map_err(|e| SaveBookmarksError::UnexpectedError(format!("system time error: {e}")))?; 73 | let now = since_the_epoch.as_secs() as i64; 74 | let save_options = SaveBookmarkOptions { 75 | reset_missing_attributes: false, 76 | reset_tags: reset_missing, 77 | }; 78 | create_or_update_bookmarks(pool, &draft_bookmarks, now, save_options).await?; 79 | 80 | Ok(Some(SaveAllStats { 81 | num_bookmarks: draft_bookmarks.len(), 82 | })) 83 | } 84 | -------------------------------------------------------------------------------- /src/domain/tags.rs: -------------------------------------------------------------------------------- 1 | use once_cell::sync::Lazy; 2 | use regex::Regex; 3 | use serde::Serialize; 4 | 5 | pub const TAG_REGEX_STR: &str = r"^[a-zA-Z0-9_-]{1,30}$"; 6 | 7 | #[derive(PartialEq, Eq, Serialize, Debug, PartialOrd, Ord)] 8 | pub struct Tag(String); 9 | 10 | impl Tag { 11 | pub fn name(&self) -> &str { 12 | self.0.as_str() 13 | } 14 | } 15 | 16 | impl TryFrom<&&str> for Tag { 17 | type Error = (); 18 | 19 | #[allow(clippy::expect_used)] 20 | fn try_from(tag: &&str) -> Result { 21 | static RE: Lazy = Lazy::new(|| Regex::new(TAG_REGEX_STR).expect("regex is invalid")); 22 | 23 | let trimmed_tag = tag.trim(); 24 | if trimmed_tag.is_empty() { 25 | return Err(()); 26 | } 27 | if !RE.is_match(trimmed_tag) { 28 | return Err(()); 29 | } 30 | 31 | Ok(Self(trimmed_tag.to_lowercase().to_string())) 32 | } 33 | } 34 | 35 | impl TryFrom<&str> for Tag { 36 | type Error = (); 37 | 38 | fn try_from(tag: &str) -> Result { 39 | Self::try_from(&tag) 40 | } 41 | } 42 | 43 | #[derive(Debug, Serialize)] 44 | pub struct TagStats { 45 | pub name: String, 46 | pub num_bookmarks: i64, 47 | } 48 | 49 | impl std::fmt::Display for TagStats { 50 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 51 | if self.num_bookmarks == 1 { 52 | write!(f, "{} (1 bookmark)", self.name)?; 53 | } else { 54 | write!(f, "{} ({} bookmarks)", self.name, self.num_bookmarks)?; 55 | } 56 | 57 | Ok(()) 58 | } 59 | } 60 | 61 | #[cfg(test)] 62 | mod tests { 63 | use super::*; 64 | 65 | //-------------// 66 | // SUCCESSES // 67 | //-------------// 68 | 69 | #[test] 70 | fn parsing_valid_tag_works() { 71 | let valid_tags = vec!["tag", "tAg", "tag1", "t1ag2", "tag-1", "tag_1"]; 72 | for tag in valid_tags { 73 | // GIVEN 74 | // WHEN 75 | let result = Tag::try_from(tag); 76 | 77 | // THEN 78 | assert!(result.is_ok()) 79 | } 80 | } 81 | 82 | #[test] 83 | fn tags_get_trimmed_during_parsing() { 84 | // GIVEN 85 | // WHEN 86 | let result = Tag::try_from(" a-tag-with-spaces-at-each-end ") 87 | .expect("result should've been a success"); 88 | 89 | // THEN 90 | assert_eq!(result.name(), "a-tag-with-spaces-at-each-end"); 91 | } 92 | 93 | #[test] 94 | fn tags_get_converted_to_lowercase_during_parsing() { 95 | // GIVEN 96 | // WHEN 97 | let result = 98 | Tag::try_from("UPPER-and-lower-case-chars").expect("result should've been a success"); 99 | // THEN 100 | assert_eq!(result.name(), "upper-and-lower-case-chars"); 101 | } 102 | 103 | //------------// 104 | // FAILURES // 105 | //------------// 106 | 107 | #[test] 108 | fn parsing_invalid_tag_fails() { 109 | let invalid_tags = vec!["", "t ag", "tag??", "ta!g", "[tag]", "tag$"]; 110 | for tag in invalid_tags { 111 | // GIVEN 112 | // WHEN 113 | let result = Tag::try_from(tag); 114 | 115 | // THEN 116 | assert!(result.is_err()) 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/cli/display.rs: -------------------------------------------------------------------------------- 1 | use crate::args::Args; 2 | use crate::args::OutputFormat; 3 | use crate::domain::{SavedBookmark, TagStats}; 4 | use csv::Error as CsvError; 5 | use serde_json::Error as SerdeJsonError; 6 | 7 | const NOT_SET: &str = ""; 8 | 9 | #[derive(thiserror::Error, Debug)] 10 | pub enum DisplayError { 11 | #[error("couldn't serialize response to JSON: {0}")] 12 | CouldntSerializeToJSON(#[from] SerdeJsonError), 13 | #[error("couldn't serialize response to CSV: {0}")] 14 | CouldntSerializeToCSV(#[from] CsvError), 15 | #[error("couldn't flush contents to csv writer: {0}")] 16 | CouldntFlushResultsToCSVWriter(#[from] std::io::Error), 17 | } 18 | 19 | pub fn display_bookmarks( 20 | bookmarks: &Vec, 21 | format: &OutputFormat, 22 | ) -> Result<(), DisplayError> { 23 | match format { 24 | OutputFormat::Plain => { 25 | for b in bookmarks { 26 | println!("{}", b.uri); 27 | } 28 | } 29 | OutputFormat::Json => { 30 | let output = serde_json::to_string_pretty(&bookmarks) 31 | .map_err(DisplayError::CouldntSerializeToJSON)?; 32 | println!("{output}"); 33 | } 34 | OutputFormat::Delimited => { 35 | let mut wtr = csv::Writer::from_writer(std::io::stdout()); 36 | for b in bookmarks { 37 | wtr.serialize(b)?; 38 | } 39 | wtr.flush()?; 40 | } 41 | } 42 | 43 | Ok(()) 44 | } 45 | 46 | pub fn display_bookmark_details(bookmark: &SavedBookmark) { 47 | println!( 48 | r#"Bookmark details 49 | --- 50 | 51 | Title: {} 52 | URI : {} 53 | Tags : {}"#, 54 | bookmark.title.as_deref().unwrap_or(NOT_SET), 55 | bookmark.uri, 56 | bookmark.tags.as_deref().unwrap_or(NOT_SET), 57 | ) 58 | } 59 | 60 | pub fn display_tags(tags: &Vec, format: &OutputFormat) -> Result<(), DisplayError> { 61 | match format { 62 | OutputFormat::Plain => { 63 | println!("{}", tags.join("\n")) 64 | } 65 | OutputFormat::Json => { 66 | let output = serde_json::to_string_pretty(tags)?; 67 | println!("{output}"); 68 | } 69 | OutputFormat::Delimited => { 70 | let mut wtr = csv::Writer::from_writer(std::io::stdout()); 71 | for t in tags { 72 | wtr.serialize(t)?; 73 | } 74 | wtr.flush()?; 75 | } 76 | } 77 | 78 | Ok(()) 79 | } 80 | 81 | pub fn display_tags_with_stats( 82 | tags: &Vec, 83 | format: &OutputFormat, 84 | ) -> Result<(), DisplayError> { 85 | match format { 86 | OutputFormat::Plain => { 87 | for t in tags { 88 | println!("{t}"); 89 | } 90 | } 91 | OutputFormat::Json => { 92 | let output = serde_json::to_string_pretty(tags)?; 93 | println!("{output}"); 94 | } 95 | OutputFormat::Delimited => { 96 | let mut wtr = csv::Writer::from_writer(std::io::stdout()); 97 | for t in tags { 98 | wtr.serialize(t)?; 99 | } 100 | wtr.flush()?; 101 | } 102 | } 103 | 104 | Ok(()) 105 | } 106 | 107 | pub fn display_debug_info(args: &Args, db_path: &str) { 108 | println!( 109 | r#"DEBUG INFO: 110 | 111 | {args} 112 | 113 | db path: {db_path} 114 | "# 115 | ) 116 | } 117 | -------------------------------------------------------------------------------- /src/tui/message.rs: -------------------------------------------------------------------------------- 1 | use super::common::ActivePane; 2 | use super::model::Model; 3 | use crate::domain::{SavedBookmark, TagStats}; 4 | use crate::persistence::DBError; 5 | use ratatui::crossterm::event::{Event, KeyCode, KeyEventKind}; 6 | use std::io::Error as IOError; 7 | 8 | pub enum Message { 9 | TerminalResize(u16, u16), 10 | GoToNextListItem, 11 | GoToPreviousListItem, 12 | GoToFirstListItem, 13 | GoToLastListItem, 14 | OpenInBrowser, 15 | UrlsOpenedInBrowser(UrlsOpenedResult), 16 | SearchFinished(Result, DBError>), 17 | TagsFetched(Result, DBError>), 18 | ShowView(ActivePane), 19 | SearchInputGotEvent(Event), 20 | CopyURIToClipboard, 21 | CopyURIsToClipboard, 22 | SubmitSearch, 23 | ShowBookmarksForTag, 24 | BookmarksForTagFetched(Result, DBError>), 25 | ContentCopiedToClipboard(Result<(), String>), 26 | GoBackOrQuit, 27 | } 28 | 29 | pub enum UrlsOpenedResult { 30 | Success, 31 | Failure(IOError), 32 | } 33 | 34 | pub fn get_event_handling_msg(model: &Model, event: Event) -> Option { 35 | match event { 36 | Event::Key(key_event) => match model.terminal_too_small { 37 | true => match key_event.kind { 38 | KeyEventKind::Press => match key_event.code { 39 | KeyCode::Esc | KeyCode::Char('q') => Some(Message::GoBackOrQuit), 40 | _ => None, 41 | }, 42 | _ => None, 43 | }, 44 | false => match key_event.kind { 45 | KeyEventKind::Press => match model.active_pane { 46 | ActivePane::List => match key_event.code { 47 | KeyCode::Char('j') | KeyCode::Down => Some(Message::GoToNextListItem), 48 | KeyCode::Char('k') | KeyCode::Up => Some(Message::GoToPreviousListItem), 49 | KeyCode::Char('g') => Some(Message::GoToFirstListItem), 50 | KeyCode::Char('G') => Some(Message::GoToLastListItem), 51 | KeyCode::Char('o') => Some(Message::OpenInBrowser), 52 | KeyCode::Char('s') => Some(Message::ShowView(ActivePane::SearchInput)), 53 | KeyCode::Char('t') | KeyCode::Tab => { 54 | Some(Message::ShowView(ActivePane::TagsList)) 55 | } 56 | KeyCode::Char('y') => Some(Message::CopyURIToClipboard), 57 | KeyCode::Char('Y') => Some(Message::CopyURIsToClipboard), 58 | KeyCode::Esc | KeyCode::Char('q') => Some(Message::GoBackOrQuit), 59 | KeyCode::Char('?') => Some(Message::ShowView(ActivePane::Help)), 60 | _ => None, 61 | }, 62 | ActivePane::Help => match key_event.code { 63 | KeyCode::Esc | KeyCode::Char('q') => Some(Message::GoBackOrQuit), 64 | KeyCode::Char('?') => Some(Message::ShowView(ActivePane::List)), 65 | _ => None, 66 | }, 67 | ActivePane::SearchInput => match key_event.code { 68 | KeyCode::Esc => Some(Message::GoBackOrQuit), 69 | KeyCode::Enter => Some(Message::SubmitSearch), 70 | KeyCode::Down => Some(Message::GoToNextListItem), 71 | KeyCode::Up => Some(Message::GoToPreviousListItem), 72 | _ => Some(Message::SearchInputGotEvent(event)), 73 | }, 74 | ActivePane::TagsList => match key_event.code { 75 | KeyCode::Char('j') | KeyCode::Down => Some(Message::GoToNextListItem), 76 | KeyCode::Char('k') | KeyCode::Up => Some(Message::GoToPreviousListItem), 77 | KeyCode::Char('g') => Some(Message::GoToFirstListItem), 78 | KeyCode::Char('G') => Some(Message::GoToLastListItem), 79 | KeyCode::Enter => Some(Message::ShowBookmarksForTag), 80 | KeyCode::Esc | KeyCode::Char('q') => Some(Message::GoBackOrQuit), 81 | _ => None, 82 | }, 83 | }, 84 | _ => None, 85 | }, 86 | }, 87 | Event::Resize(w, h) => Some(Message::TerminalResize(w, h)), 88 | _ => None, 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/handle.rs: -------------------------------------------------------------------------------- 1 | use crate::args::{Args, BmmCommand, TagsCommand}; 2 | use crate::cli::*; 3 | use crate::domain::PotentialBookmark; 4 | use crate::errors::AppError; 5 | use crate::persistence::get_db_pool; 6 | use crate::tui::{TuiContext, run_tui}; 7 | use crate::utils::get_data_dir; 8 | use std::fs; 9 | use std::path::PathBuf; 10 | 11 | const DATA_DIR: &str = "bmm"; 12 | const DATA_FILE: &str = "bmm.db"; 13 | 14 | pub async fn handle(args: Args) -> Result<(), AppError> { 15 | let db_path = match &args.db_path { 16 | Some(p) => PathBuf::from(p), 17 | None => { 18 | let user_data_dir = get_data_dir().map_err(AppError::CouldntGetDataDirectory)?; 19 | let data_dir = user_data_dir.join(PathBuf::from(DATA_DIR)); 20 | 21 | if !data_dir.exists() { 22 | fs::create_dir_all(&data_dir).map_err(AppError::CouldntCreateDataDirectory)?; 23 | } 24 | 25 | data_dir.join(PathBuf::from(DATA_FILE)) 26 | } 27 | }; 28 | 29 | let db_path = db_path.to_str().ok_or(AppError::DBPathNotValidStr)?; 30 | 31 | if args.debug { 32 | display_debug_info(&args, db_path); 33 | return Ok(()); 34 | } 35 | 36 | let pool = get_db_pool(db_path).await?; 37 | 38 | match args.command { 39 | BmmCommand::Delete { 40 | uris, 41 | skip_confirmation, 42 | } => { 43 | delete_bookmarks(&pool, uris, skip_confirmation).await?; 44 | } 45 | 46 | BmmCommand::Import { 47 | file, 48 | reset_missing, 49 | dry_run, 50 | ignore_attribute_errors, 51 | } => { 52 | let result = import_bookmarks( 53 | &pool, 54 | &file, 55 | reset_missing, 56 | dry_run, 57 | ignore_attribute_errors, 58 | ) 59 | .await?; 60 | if let Some(stats) = result { 61 | println!("imported {} bookmarks", stats.num_bookmarks_imported); 62 | } 63 | } 64 | 65 | BmmCommand::List { 66 | uri, 67 | title, 68 | tags, 69 | format, 70 | limit, 71 | } => list_bookmarks(&pool, uri, title, tags, format, limit).await?, 72 | 73 | BmmCommand::Search { 74 | query_terms, 75 | format, 76 | limit, 77 | tui, 78 | } => search_bookmarks(&pool, &query_terms, format, limit, tui).await?, 79 | 80 | BmmCommand::Save { 81 | uri, 82 | title, 83 | tags, 84 | use_editor, 85 | fail_if_uri_already_saved, 86 | reset_missing, 87 | ignore_attribute_errors, 88 | } => { 89 | let potential_bookmark = PotentialBookmark::from((uri, title, &tags)); 90 | 91 | save_bookmark( 92 | &pool, 93 | potential_bookmark, 94 | use_editor, 95 | fail_if_uri_already_saved, 96 | reset_missing, 97 | ignore_attribute_errors, 98 | ) 99 | .await? 100 | } 101 | 102 | BmmCommand::SaveAll { 103 | uris, 104 | tags, 105 | use_stdin, 106 | reset_missing, 107 | ignore_attribute_errors, 108 | } => { 109 | let result = save_all_bookmarks( 110 | &pool, 111 | uris, 112 | tags, 113 | use_stdin, 114 | reset_missing, 115 | ignore_attribute_errors, 116 | ) 117 | .await?; 118 | if let Some(stats) = result { 119 | if stats.num_bookmarks == 1 { 120 | println!("saved 1 bookmark"); 121 | } else { 122 | println!("saved {} bookmarks", stats.num_bookmarks); 123 | } 124 | } 125 | } 126 | 127 | BmmCommand::Show { uri } => show_bookmark(&pool, uri).await?, 128 | 129 | BmmCommand::Tags { tags_command } => match tags_command { 130 | TagsCommand::List { 131 | format, 132 | show_stats, 133 | tui, 134 | } => list_tags(&pool, format, show_stats, tui).await?, 135 | TagsCommand::Rename { 136 | source_tag, 137 | target_tag, 138 | } => rename_tag(&pool, source_tag, target_tag).await?, 139 | TagsCommand::Delete { 140 | tags, 141 | skip_confirmation, 142 | } => delete_tags(&pool, tags, skip_confirmation).await?, 143 | }, 144 | BmmCommand::Tui => run_tui(&pool, TuiContext::Initial).await?, 145 | } 146 | 147 | Ok(()) 148 | } 149 | -------------------------------------------------------------------------------- /tests/list_test.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | 3 | use common::Fixture; 4 | use insta_cmd::assert_cmd_snapshot; 5 | 6 | const URI_ONE: &str = "https://github.com/dhth/bmm"; 7 | const URI_TWO: &str = "https://github.com/dhth/omm"; 8 | const URI_THREE: &str = "https://github.com/dhth/hours"; 9 | 10 | //-------------// 11 | // SUCCESSES // 12 | //-------------// 13 | 14 | #[test] 15 | fn listing_bookmarks_works() { 16 | // GIVEN 17 | let fx = Fixture::new(); 18 | let mut save_cmd = fx.cmd(["save-all", URI_ONE, URI_TWO, URI_THREE]); 19 | assert_cmd_snapshot!(save_cmd, @r" 20 | success: true 21 | exit_code: 0 22 | ----- stdout ----- 23 | saved 3 bookmarks 24 | 25 | ----- stderr ----- 26 | "); 27 | 28 | let mut cmd = fx.cmd(["list"]); 29 | 30 | // WHEN 31 | // THEN 32 | assert_cmd_snapshot!(cmd, @r" 33 | success: true 34 | exit_code: 0 35 | ----- stdout ----- 36 | https://github.com/dhth/bmm 37 | https://github.com/dhth/omm 38 | https://github.com/dhth/hours 39 | 40 | ----- stderr ----- 41 | "); 42 | } 43 | 44 | #[test] 45 | fn listing_bookmarks_with_queries_works() { 46 | // GIVEN 47 | let fx = Fixture::new(); 48 | let mut import_cmd = fx.cmd(["import", "tests/static/import/valid.json"]); 49 | assert_cmd_snapshot!(import_cmd, @r" 50 | success: true 51 | exit_code: 0 52 | ----- stdout ----- 53 | imported 4 bookmarks 54 | 55 | ----- stderr ----- 56 | "); 57 | 58 | let mut cmd = fx.cmd([ 59 | "list", 60 | "--uri", 61 | "github.com", 62 | "--title", 63 | "on-my-mind", 64 | "--tags", 65 | "tools,productivity", 66 | ]); 67 | 68 | // WHEN 69 | // THEN 70 | assert_cmd_snapshot!(cmd, @r" 71 | success: true 72 | exit_code: 0 73 | ----- stdout ----- 74 | https://github.com/dhth/omm 75 | 76 | ----- stderr ----- 77 | "); 78 | } 79 | 80 | #[test] 81 | fn listing_bookmarks_fetches_all_data_for_each_bookmark() { 82 | // GIVEN 83 | let fx = Fixture::new(); 84 | let mut import_cmd = fx.cmd(["import", "tests/static/import/valid.json"]); 85 | assert_cmd_snapshot!(import_cmd, @r" 86 | success: true 87 | exit_code: 0 88 | ----- stdout ----- 89 | imported 4 bookmarks 90 | 91 | ----- stderr ----- 92 | "); 93 | 94 | let mut cmd = fx.cmd(["list", "--tags", "tools", "--format", "json"]); 95 | 96 | // WHEN 97 | // THEN 98 | assert_cmd_snapshot!(cmd, @r#" 99 | success: true 100 | exit_code: 0 101 | ----- stdout ----- 102 | [ 103 | { 104 | "uri": "https://github.com/dhth/omm", 105 | "title": "GitHub - dhth/omm: on-my-mind: a keyboard-driven task manager for the command line", 106 | "tags": "productivity,tools" 107 | }, 108 | { 109 | "uri": "https://github.com/dhth/hours", 110 | "title": "GitHub - dhth/hours: A no-frills time tracking toolkit for command line nerds", 111 | "tags": "productivity,tools" 112 | }, 113 | { 114 | "uri": "https://github.com/dhth/bmm", 115 | "title": "GitHub - dhth/bmm: get to your bookmarks in a flash", 116 | "tags": "tools" 117 | } 118 | ] 119 | 120 | ----- stderr ----- 121 | "#); 122 | } 123 | 124 | #[test] 125 | fn listing_bookmarks_in_json_format_works() { 126 | // GIVEN 127 | let fx = Fixture::new(); 128 | let mut save_cmd = fx.cmd(["save-all", URI_ONE, URI_TWO, URI_THREE]); 129 | assert_cmd_snapshot!(save_cmd, @r" 130 | success: true 131 | exit_code: 0 132 | ----- stdout ----- 133 | saved 3 bookmarks 134 | 135 | ----- stderr ----- 136 | "); 137 | 138 | let mut cmd = fx.cmd(["list", "--uri", "hours", "--format", "json"]); 139 | 140 | // WHEN 141 | // THEN 142 | assert_cmd_snapshot!(cmd, @r#" 143 | success: true 144 | exit_code: 0 145 | ----- stdout ----- 146 | [ 147 | { 148 | "uri": "https://github.com/dhth/hours", 149 | "title": null, 150 | "tags": null 151 | } 152 | ] 153 | 154 | ----- stderr ----- 155 | "#); 156 | } 157 | 158 | #[test] 159 | fn listing_bookmarks_in_delimited_format_works() { 160 | // GIVEN 161 | let fx = Fixture::new(); 162 | let mut import_cmd = fx.cmd(["import", "tests/static/import/valid.json"]); 163 | assert_cmd_snapshot!(import_cmd, @r" 164 | success: true 165 | exit_code: 0 166 | ----- stdout ----- 167 | imported 4 bookmarks 168 | 169 | ----- stderr ----- 170 | "); 171 | 172 | let mut cmd = fx.cmd(["list", "--uri", "hours", "--format", "delimited"]); 173 | 174 | // WHEN 175 | // THEN 176 | assert_cmd_snapshot!(cmd, @r#" 177 | success: true 178 | exit_code: 0 179 | ----- stdout ----- 180 | uri,title,tags 181 | https://github.com/dhth/hours,GitHub - dhth/hours: A no-frills time tracking toolkit for command line nerds,"productivity,tools" 182 | 183 | ----- stderr ----- 184 | "#); 185 | } 186 | -------------------------------------------------------------------------------- /tests/tags_test.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | 3 | use common::Fixture; 4 | use insta_cmd::assert_cmd_snapshot; 5 | 6 | //-------------// 7 | // SUCCESSES // 8 | //-------------// 9 | 10 | #[test] 11 | fn listing_tags_works() { 12 | // GIVEN 13 | let fx = Fixture::new(); 14 | let mut import_cmd = fx.cmd(["import", "tests/static/import/valid.json"]); 15 | assert_cmd_snapshot!(import_cmd, @r" 16 | success: true 17 | exit_code: 0 18 | ----- stdout ----- 19 | imported 4 bookmarks 20 | 21 | ----- stderr ----- 22 | "); 23 | 24 | let mut cmd = fx.cmd(["tags", "list"]); 25 | 26 | // WHEN 27 | // THEN 28 | assert_cmd_snapshot!(cmd, @r" 29 | success: true 30 | exit_code: 0 31 | ----- stdout ----- 32 | crates 33 | productivity 34 | rust 35 | tools 36 | 37 | ----- stderr ----- 38 | "); 39 | } 40 | 41 | #[test] 42 | fn listing_tags_with_stats_works() { 43 | // GIVEN 44 | let fx = Fixture::new(); 45 | let mut import_cmd = fx.cmd(["import", "tests/static/import/valid.json"]); 46 | assert_cmd_snapshot!(import_cmd, @r" 47 | success: true 48 | exit_code: 0 49 | ----- stdout ----- 50 | imported 4 bookmarks 51 | 52 | ----- stderr ----- 53 | "); 54 | 55 | let mut cmd = fx.cmd(["tags", "list", "--show-stats"]); 56 | 57 | // WHEN 58 | // THEN 59 | assert_cmd_snapshot!(cmd, @r" 60 | success: true 61 | exit_code: 0 62 | ----- stdout ----- 63 | crates (1 bookmark) 64 | productivity (2 bookmarks) 65 | rust (1 bookmark) 66 | tools (3 bookmarks) 67 | 68 | ----- stderr ----- 69 | "); 70 | } 71 | 72 | #[test] 73 | fn deleting_tags_works() { 74 | // GIVEN 75 | let fx = Fixture::new(); 76 | let mut import_cmd = fx.cmd(["import", "tests/static/import/valid.json"]); 77 | assert_cmd_snapshot!(import_cmd, @r" 78 | success: true 79 | exit_code: 0 80 | ----- stdout ----- 81 | imported 4 bookmarks 82 | 83 | ----- stderr ----- 84 | "); 85 | 86 | // WHEN 87 | // THEN 88 | let mut cmd = fx.cmd(["tags", "delete", "--yes", "productivity", "crates"]); 89 | assert_cmd_snapshot!(cmd, @r" 90 | success: true 91 | exit_code: 0 92 | ----- stdout ----- 93 | deleted 2 tags 94 | 95 | ----- stderr ----- 96 | "); 97 | 98 | let mut list_cmd = fx.cmd(["tags", "list"]); 99 | assert_cmd_snapshot!(list_cmd, @r" 100 | success: true 101 | exit_code: 0 102 | ----- stdout ----- 103 | rust 104 | tools 105 | 106 | ----- stderr ----- 107 | "); 108 | } 109 | 110 | #[test] 111 | fn renaming_tags_works() { 112 | // GIVEN 113 | let fx = Fixture::new(); 114 | let mut import_cmd = fx.cmd(["import", "tests/static/import/valid.json"]); 115 | assert_cmd_snapshot!(import_cmd, @r" 116 | success: true 117 | exit_code: 0 118 | ----- stdout ----- 119 | imported 4 bookmarks 120 | 121 | ----- stderr ----- 122 | "); 123 | 124 | let mut cmd = fx.cmd(["tags", "rename", "tools", "cli-tools"]); 125 | 126 | // WHEN 127 | // THEN 128 | assert_cmd_snapshot!(cmd, @r" 129 | success: true 130 | exit_code: 0 131 | ----- stdout ----- 132 | 133 | ----- stderr ----- 134 | "); 135 | 136 | let mut list_cmd = fx.cmd(["tags", "list"]); 137 | assert_cmd_snapshot!(list_cmd, @r" 138 | success: true 139 | exit_code: 0 140 | ----- stdout ----- 141 | cli-tools 142 | crates 143 | productivity 144 | rust 145 | 146 | ----- stderr ----- 147 | "); 148 | } 149 | 150 | //------------// 151 | // FAILURES // 152 | //------------// 153 | 154 | #[test] 155 | fn deleting_tags_fails_if_tag_doesnt_exist() { 156 | // GIVEN 157 | let fx = Fixture::new(); 158 | let mut import_cmd = fx.cmd(["import", "tests/static/import/valid.json"]); 159 | assert_cmd_snapshot!(import_cmd, @r" 160 | success: true 161 | exit_code: 0 162 | ----- stdout ----- 163 | imported 4 bookmarks 164 | 165 | ----- stderr ----- 166 | "); 167 | 168 | let mut cmd = fx.cmd(["tags", "delete", "--yes", "productivity", "absent"]); 169 | 170 | // WHEN 171 | // THEN 172 | assert_cmd_snapshot!(cmd, @r#" 173 | success: false 174 | exit_code: 1 175 | ----- stdout ----- 176 | 177 | ----- stderr ----- 178 | Error: couldn't delete tag(s): tags do not exist: ["absent"] 179 | "#); 180 | } 181 | 182 | #[test] 183 | fn renaming_tags_fails_if_tag_doesnt_exist() { 184 | // GIVEN 185 | let fx = Fixture::new(); 186 | let mut import_cmd = fx.cmd(["import", "tests/static/import/valid.json"]); 187 | assert_cmd_snapshot!(import_cmd, @r" 188 | success: true 189 | exit_code: 0 190 | ----- stdout ----- 191 | imported 4 bookmarks 192 | 193 | ----- stderr ----- 194 | "); 195 | 196 | let mut cmd = fx.cmd(["tags", "rename", "absent", "target"]); 197 | 198 | // WHEN 199 | // THEN 200 | assert_cmd_snapshot!(cmd, @r" 201 | success: false 202 | exit_code: 1 203 | ----- stdout ----- 204 | 205 | ----- stderr ----- 206 | Error: couldn't rename tag: no such tag 207 | "); 208 | } 209 | -------------------------------------------------------------------------------- /src/tui/app.rs: -------------------------------------------------------------------------------- 1 | use super::commands::Command; 2 | use super::common::*; 3 | use super::handle::handle_command; 4 | use super::message::{Message, get_event_handling_msg}; 5 | use super::model::*; 6 | use super::update::update; 7 | use super::view::view; 8 | use ratatui::Terminal; 9 | use ratatui::backend::CrosstermBackend; 10 | use sqlx::{Pool, Sqlite}; 11 | use std::io::Error as IOError; 12 | use std::time::Duration; 13 | use tokio::sync::mpsc; 14 | use tokio::sync::mpsc::error::TrySendError; 15 | use tokio::sync::mpsc::{Receiver, Sender}; 16 | 17 | const EVENT_POLL_DURATION_MS: u64 = 16; 18 | 19 | #[derive(thiserror::Error, Debug)] 20 | pub enum AppTuiError { 21 | #[error("couldn't initialize bmm's TUI: {0}")] 22 | InitializeTerminal(IOError), 23 | #[error("couldn't determine terminal size: {0}")] 24 | DetermineTerminalSize(IOError), 25 | #[error("couldn't restore terminal to its original state: {0}")] 26 | RestoreTerminal(IOError), 27 | #[error("couldn't send a message to internal async queue: {0}")] 28 | SendMsg(#[from] TrySendError), 29 | #[error("couldn't draw a TUI frame: {0}")] 30 | DrawFrame(IOError), 31 | #[error("couldn't poll for internal events: {0}")] 32 | PollForEvents(IOError), 33 | #[error("couldn't read internal event: {0}")] 34 | ReadEvent(IOError), 35 | } 36 | 37 | pub async fn run_tui(pool: &Pool, context: TuiContext) -> Result<(), AppTuiError> { 38 | let mut tui = AppTui::new(pool, context)?; 39 | tui.run().await?; 40 | 41 | Ok(()) 42 | } 43 | 44 | impl AppTuiError { 45 | pub fn code(&self) -> u16 { 46 | match self { 47 | AppTuiError::DetermineTerminalSize(_) => 5000, 48 | AppTuiError::InitializeTerminal(_) => 5001, 49 | AppTuiError::RestoreTerminal(_) => 5002, 50 | AppTuiError::SendMsg(_) => 5003, 51 | AppTuiError::DrawFrame(_) => 5004, 52 | AppTuiError::PollForEvents(_) => 5005, 53 | AppTuiError::ReadEvent(_) => 5006, 54 | } 55 | } 56 | } 57 | 58 | struct AppTui { 59 | pub(super) terminal: Terminal>, 60 | pub(super) event_tx: Sender, 61 | pub(super) event_rx: Receiver, 62 | pub(super) model: Model, 63 | pub(super) initial_commands: Vec, 64 | } 65 | 66 | impl AppTui { 67 | pub fn new(pool: &Pool, context: TuiContext) -> Result { 68 | let terminal = ratatui::try_init().map_err(AppTuiError::InitializeTerminal)?; 69 | let (event_tx, event_rx) = mpsc::channel(10); 70 | let mut initial_commands = Vec::new(); 71 | 72 | let (width, height) = 73 | ratatui::crossterm::terminal::size().map_err(AppTuiError::DetermineTerminalSize)?; 74 | 75 | let terminal_dimensions = TerminalDimensions { width, height }; 76 | 77 | match &context { 78 | TuiContext::Initial => {} 79 | TuiContext::Search(q) => { 80 | initial_commands.push(Command::SearchBookmarks(q.clone())); 81 | } 82 | TuiContext::Tags => { 83 | initial_commands.push(Command::FetchTags); 84 | } 85 | } 86 | 87 | let model = Model::default(pool, context, terminal_dimensions); 88 | 89 | Ok(Self { 90 | terminal, 91 | event_tx, 92 | event_rx, 93 | model, 94 | initial_commands, 95 | }) 96 | } 97 | 98 | pub async fn run(&mut self) -> Result<(), AppTuiError> { 99 | let _ = self.terminal.clear(); 100 | 101 | for cmd in &self.initial_commands { 102 | handle_command(&self.model.pool, cmd.clone(), self.event_tx.clone()).await; 103 | } 104 | 105 | // first render 106 | self.model.render_counter += 1; 107 | self.terminal 108 | .draw(|f| view(&mut self.model, f)) 109 | .map_err(AppTuiError::DrawFrame)?; 110 | 111 | loop { 112 | tokio::select! { 113 | Some(message) = self.event_rx.recv() => { 114 | let cmds = update(&mut self.model, message); 115 | 116 | if self.model.running_state == RunningState::Done { 117 | self.exit().map_err(AppTuiError::RestoreTerminal)?; 118 | return Ok(()); 119 | } 120 | 121 | self.model.render_counter += 1; 122 | self.terminal.draw(|f| view(&mut self.model, f)).map_err(AppTuiError::DrawFrame)?; 123 | 124 | for cmd in cmds { 125 | handle_command(&self.model.pool, cmd, self.event_tx.clone()).await; 126 | } 127 | } 128 | 129 | Ok(ready) = tokio::task::spawn_blocking(|| ratatui::crossterm::event::poll(Duration::from_millis(EVENT_POLL_DURATION_MS))) => { 130 | match ready { 131 | Ok(true) => { 132 | let event = ratatui::crossterm::event::read().map_err(AppTuiError::ReadEvent)?; 133 | self.model.event_counter += 1; 134 | if let Some(handling_msg) = get_event_handling_msg(&self.model, event) { 135 | self.event_tx.try_send(handling_msg)?; 136 | } 137 | } 138 | Ok(false) => continue, 139 | Err(e) => { 140 | return Err(AppTuiError::PollForEvents(e)); 141 | } 142 | } 143 | } 144 | } 145 | } 146 | } 147 | 148 | fn exit(&mut self) -> Result<(), IOError> { 149 | ratatui::try_restore() 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | 8 | env: 9 | CARGO_TERM_COLOR: always 10 | 11 | jobs: 12 | changes: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | outputs: 17 | deps: ${{ steps.filter.outputs.deps }} 18 | code: ${{ steps.filter.outputs.code }} 19 | shell: ${{ steps.filter.outputs.shell }} 20 | workflows: ${{ steps.filter.outputs.workflows }} 21 | yml: ${{ steps.filter.outputs.yml }} 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v5 25 | - uses: dorny/paths-filter@v3 26 | id: filter 27 | with: 28 | filters: | 29 | deps: 30 | - "Cargo.toml" 31 | - "Cargo.lock" 32 | - "deny.toml" 33 | - ".github/workflows/main.yml" 34 | code: 35 | - ".sqlx/**" 36 | - "migrations/**" 37 | - "src/**" 38 | - "tests/**" 39 | - "**/*.rs" 40 | - Cargo.* 41 | - clippy.toml 42 | - dist-workspace.toml 43 | - rust-toolchain.toml 44 | - ".github/actions/**/*.yml" 45 | - ".github/workflows/main.yml" 46 | shell: 47 | - "**.sh" 48 | - ".github/workflows/main.yml" 49 | workflows: 50 | - ".github/**/*.yml" 51 | yml: 52 | - "**.yml" 53 | - "**.yaml" 54 | 55 | lint: 56 | needs: changes 57 | if: ${{ needs.changes.outputs.code == 'true' }} 58 | runs-on: ubuntu-latest 59 | env: 60 | SQLX_OFFLINE: true 61 | steps: 62 | - uses: actions/checkout@v5 63 | - name: Install toolchain 64 | uses: actions-rust-lang/setup-rust-toolchain@v1 65 | with: 66 | components: clippy, rustfmt 67 | - name: Check formatting 68 | run: cargo fmt --all -- --check 69 | - name: Lint 70 | run: cargo clippy 71 | 72 | build: 73 | needs: changes 74 | if: ${{ needs.changes.outputs.code == 'true' }} 75 | strategy: 76 | matrix: 77 | os: [ubuntu-latest, macos-latest] 78 | runs-on: ${{ matrix.os }} 79 | env: 80 | SQLX_OFFLINE: true 81 | steps: 82 | - uses: actions/checkout@v5 83 | - name: Install toolchain 84 | uses: actions-rust-lang/setup-rust-toolchain@v1 85 | - name: Build 86 | run: cargo build 87 | 88 | test: 89 | needs: changes 90 | if: ${{ needs.changes.outputs.code == 'true' }} 91 | strategy: 92 | matrix: 93 | os: [ubuntu-latest, macos-latest] 94 | env: 95 | SQLX_VERSION: 0.8.3 96 | SQLX_FEATURES: sqlite 97 | DATABASE_URL: sqlite://testdb/db.db 98 | runs-on: ${{ matrix.os }} 99 | steps: 100 | - uses: actions/checkout@v5 101 | - name: Install toolchain 102 | uses: actions-rust-lang/setup-rust-toolchain@v1 103 | - name: Install sqlx-cli 104 | run: cargo install sqlx-cli --version=${{ env.SQLX_VERSION }} --features ${{ env.SQLX_FEATURES }} --no-default-features --locked 105 | - name: Install nextest 106 | uses: taiki-e/install-action@f535147c22906d77695e11cb199e764aa610a4fc # v2.62.46 107 | with: 108 | tool: cargo-nextest 109 | - name: Create test database 110 | run: | 111 | mkdir testdb 112 | cargo sqlx database create 113 | cargo sqlx migrate run 114 | cargo sqlx prepare --check 115 | - name: Run tests 116 | env: 117 | RUST_BACKTRACE: 0 118 | run: cargo nextest run 119 | 120 | back-compat: 121 | needs: changes 122 | if: ${{ needs.changes.outputs.code == 'true' }} 123 | runs-on: ubuntu-latest 124 | env: 125 | SQLX_OFFLINE: true 126 | steps: 127 | - uses: actions/checkout@v5 128 | with: 129 | fetch-depth: 2 130 | - run: git checkout HEAD~1 131 | - name: Install rust toolchain for previous version 132 | uses: actions-rust-lang/setup-rust-toolchain@v1 133 | - name: Build last commit 134 | run: cargo build --target-dir /var/tmp/last 135 | - name: Save bookmarks with bmm on last commit 136 | run: /var/tmp/last/debug/bmm save https://github.com/dhth/bmm 137 | - run: git checkout main 138 | - name: Install rust toolchain for new version 139 | uses: actions-rust-lang/setup-rust-toolchain@v1 140 | - name: Build head 141 | run: cargo build --target-dir /var/tmp/head 142 | - name: Run bmm on head 143 | run: /var/tmp/head/debug/bmm list -f json 144 | 145 | lint-sh: 146 | needs: changes 147 | if: ${{ needs.changes.outputs.shell == 'true' }} 148 | runs-on: ubuntu-latest 149 | steps: 150 | - name: Checkout 151 | uses: actions/checkout@v5 152 | - uses: dhth/composite-actions/.github/actions/lint-sh@main 153 | 154 | lint-workflows: 155 | needs: changes 156 | if: ${{ needs.changes.outputs.workflows == 'true' }} 157 | runs-on: ubuntu-latest 158 | steps: 159 | - name: Checkout 160 | uses: actions/checkout@v5 161 | - uses: dhth/composite-actions/.github/actions/lint-actions@main 162 | 163 | lint-yaml: 164 | needs: changes 165 | if: ${{ needs.changes.outputs.yml == 'true' }} 166 | runs-on: ubuntu-latest 167 | steps: 168 | - name: Checkout 169 | uses: actions/checkout@v5 170 | - uses: dhth/composite-actions/.github/actions/lint-yaml@main 171 | 172 | audit: 173 | needs: changes 174 | if: ${{ needs.changes.outputs.deps == 'true' }} 175 | runs-on: ubuntu-latest 176 | steps: 177 | - name: Checkout 178 | uses: actions/checkout@v5 179 | - uses: dhth/composite-actions/.github/actions/cargo-deny@main 180 | -------------------------------------------------------------------------------- /src/tui/update.rs: -------------------------------------------------------------------------------- 1 | use super::commands::Command; 2 | use super::common::*; 3 | use super::message::{Message, UrlsOpenedResult}; 4 | use super::model::*; 5 | use crate::persistence::SearchTerms; 6 | use tui_input::backend::crossterm::EventHandler; 7 | 8 | pub fn update(model: &mut Model, msg: Message) -> Vec { 9 | let mut cmds = Vec::new(); 10 | match msg { 11 | Message::GoToNextListItem => model.select_next_list_item(), 12 | Message::GoToPreviousListItem => model.select_previous_list_item(), 13 | Message::OpenInBrowser => { 14 | if let Some(c) = model.get_cmd_to_open_selection_in_browser() { 15 | cmds.push(c) 16 | } 17 | } 18 | Message::UrlsOpenedInBrowser(result) => { 19 | if let UrlsOpenedResult::Failure(e) = result { 20 | model.user_message = 21 | Some(UserMessage::error(&format!("urls couldn't be opened: {e}"))); 22 | } 23 | } 24 | Message::GoBackOrQuit => model.go_back_or_quit(), 25 | Message::ShowView(view) => { 26 | if let Some(c) = model.show_view(view) { 27 | cmds.push(c); 28 | } 29 | } 30 | Message::GoToFirstListItem => model.select_first_list_item(), 31 | Message::GoToLastListItem => model.select_last_list_item(), 32 | Message::SearchFinished(result) => match result { 33 | Ok(bookmarks) => { 34 | if bookmarks.is_empty() { 35 | model.user_message = Some(UserMessage::info("no bookmarks found for query")); 36 | model.bookmark_items = BookmarkItems::from(vec![]); 37 | } else { 38 | let bookmarks_len = bookmarks.len(); 39 | if let Some(current_index) = model.bookmark_items.state.selected() { 40 | if current_index < bookmarks_len { 41 | model.bookmark_items = BookmarkItems::from((bookmarks, current_index)); 42 | } else { 43 | model.bookmark_items = 44 | BookmarkItems::from((bookmarks, bookmarks_len - 1)); 45 | } 46 | } else { 47 | model.bookmark_items = BookmarkItems::from(bookmarks); 48 | } 49 | } 50 | } 51 | Err(e) => model.user_message = Some(UserMessage::error(&format!("{e}"))), 52 | }, 53 | Message::TagsFetched(result) => match result { 54 | Ok(t) => { 55 | model.tag_items = TagItems::from(t); 56 | model.active_pane = ActivePane::TagsList; 57 | } 58 | Err(e) => model.user_message = Some(UserMessage::error(&format!("{e}"))), 59 | }, 60 | Message::SearchInputGotEvent(event) => { 61 | model.search_input.handle_event(&event); 62 | } 63 | Message::SubmitSearch => { 64 | let search_query = model.search_input.value(); 65 | match SearchTerms::try_from(search_query) { 66 | Ok(search_terms) => { 67 | if !search_query.is_empty() { 68 | cmds.push(Command::SearchBookmarks(search_terms)); 69 | if model.initial { 70 | model.initial = false; 71 | } 72 | } 73 | model.search_input.reset(); 74 | model.active_pane = ActivePane::List; 75 | } 76 | Err(e) => model.user_message = Some(UserMessage::error(&format!("{e}"))), 77 | } 78 | } 79 | Message::TerminalResize(width, height) => { 80 | model.terminal_dimensions = TerminalDimensions { width, height }; 81 | model.terminal_too_small = 82 | !(width >= MIN_TERMINAL_WIDTH && height >= MIN_TERMINAL_HEIGHT); 83 | } 84 | Message::ShowBookmarksForTag => { 85 | if let Some(current_tag_index) = model.tag_items.state.selected() 86 | && let Some(selected_tag) = model.tag_items.items.get(current_tag_index) 87 | { 88 | cmds.push(Command::FetchBookmarksForTag(selected_tag.name.to_string())); 89 | } 90 | } 91 | Message::BookmarksForTagFetched(result) => match result { 92 | Ok(bookmarks) => { 93 | model.bookmark_items = BookmarkItems::from(bookmarks); 94 | model.active_pane = ActivePane::List; 95 | } 96 | Err(e) => model.user_message = Some(UserMessage::error(&format!("{e}"))), 97 | }, 98 | Message::CopyURIToClipboard => { 99 | if let Some(uri) = model.get_uri_under_cursor() { 100 | cmds.push(Command::CopyContentToClipboard(uri)); 101 | } 102 | } 103 | Message::CopyURIsToClipboard => { 104 | let uris = model 105 | .bookmark_items 106 | .items 107 | .iter() 108 | .map(|bi| bi.bookmark.uri.as_str()) 109 | .collect::>(); 110 | 111 | if !uris.is_empty() { 112 | cmds.push(Command::CopyContentToClipboard(uris.join("\n"))); 113 | } 114 | } 115 | Message::ContentCopiedToClipboard(result) => { 116 | if let Err(error) = result { 117 | model.user_message = Some(UserMessage::error(&format!( 118 | "couldn't copy uri to clipboard: {error}" 119 | ))); 120 | } else { 121 | model.user_message = Some(UserMessage::info("copied!").with_frames_left(1)); 122 | } 123 | } 124 | } 125 | 126 | if let Some(message) = &mut model.user_message { 127 | let clear = if message.frames_left == 0 { 128 | true 129 | } else { 130 | message.frames_left -= 1; 131 | false 132 | }; 133 | 134 | if clear { 135 | model.user_message = None; 136 | } 137 | } 138 | 139 | cmds 140 | } 141 | -------------------------------------------------------------------------------- /tests/search_test.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | 3 | use common::Fixture; 4 | use insta_cmd::assert_cmd_snapshot; 5 | 6 | //-------------// 7 | // SUCCESSES // 8 | //-------------// 9 | 10 | #[test] 11 | fn searching_bookmarks_by_uri_works() { 12 | // GIVEN 13 | let fx = Fixture::new(); 14 | let mut import_cmd = fx.cmd(["import", "tests/static/import/valid.json"]); 15 | assert_cmd_snapshot!(import_cmd, @r" 16 | success: true 17 | exit_code: 0 18 | ----- stdout ----- 19 | imported 4 bookmarks 20 | 21 | ----- stderr ----- 22 | "); 23 | 24 | let mut cmd = fx.cmd(["search", "crates"]); 25 | 26 | // WHEN 27 | // THEN 28 | assert_cmd_snapshot!(cmd, @r" 29 | success: true 30 | exit_code: 0 31 | ----- stdout ----- 32 | https://crates.io/crates/sqlx 33 | 34 | ----- stderr ----- 35 | "); 36 | } 37 | 38 | #[test] 39 | fn searching_bookmarks_by_title_works() { 40 | // GIVEN 41 | let fx = Fixture::new(); 42 | let mut import_cmd = fx.cmd(["import", "tests/static/import/valid.json"]); 43 | assert_cmd_snapshot!(import_cmd, @r" 44 | success: true 45 | exit_code: 0 46 | ----- stdout ----- 47 | imported 4 bookmarks 48 | 49 | ----- stderr ----- 50 | "); 51 | 52 | let mut cmd = fx.cmd(["search", "keyboard-driven"]); 53 | 54 | // WHEN 55 | // THEN 56 | assert_cmd_snapshot!(cmd, @r" 57 | success: true 58 | exit_code: 0 59 | ----- stdout ----- 60 | https://github.com/dhth/omm 61 | 62 | ----- stderr ----- 63 | "); 64 | } 65 | 66 | #[test] 67 | fn searching_bookmarks_by_tags_works() { 68 | // GIVEN 69 | let fx = Fixture::new(); 70 | let mut import_cmd = fx.cmd(["import", "tests/static/import/valid.json"]); 71 | assert_cmd_snapshot!(import_cmd, @r" 72 | success: true 73 | exit_code: 0 74 | ----- stdout ----- 75 | imported 4 bookmarks 76 | 77 | ----- stderr ----- 78 | "); 79 | 80 | let mut cmd = fx.cmd(["search", "tools"]); 81 | 82 | // WHEN 83 | // THEN 84 | assert_cmd_snapshot!(cmd, @r" 85 | success: true 86 | exit_code: 0 87 | ----- stdout ----- 88 | https://github.com/dhth/omm 89 | https://github.com/dhth/hours 90 | https://github.com/dhth/bmm 91 | 92 | ----- stderr ----- 93 | "); 94 | } 95 | 96 | #[test] 97 | fn search_shows_all_details_for_each_bookmark() { 98 | // GIVEN 99 | let fx = Fixture::new(); 100 | let mut import_cmd = fx.cmd(["import", "tests/static/import/valid.json"]); 101 | assert_cmd_snapshot!(import_cmd, @r" 102 | success: true 103 | exit_code: 0 104 | ----- stdout ----- 105 | imported 4 bookmarks 106 | 107 | ----- stderr ----- 108 | "); 109 | 110 | let mut cmd = fx.cmd(["search", "tools", "--format", "json"]); 111 | 112 | // WHEN 113 | // THEN 114 | assert_cmd_snapshot!(cmd, @r#" 115 | success: true 116 | exit_code: 0 117 | ----- stdout ----- 118 | [ 119 | { 120 | "uri": "https://github.com/dhth/omm", 121 | "title": "GitHub - dhth/omm: on-my-mind: a keyboard-driven task manager for the command line", 122 | "tags": "productivity,tools" 123 | }, 124 | { 125 | "uri": "https://github.com/dhth/hours", 126 | "title": "GitHub - dhth/hours: A no-frills time tracking toolkit for command line nerds", 127 | "tags": "productivity,tools" 128 | }, 129 | { 130 | "uri": "https://github.com/dhth/bmm", 131 | "title": "GitHub - dhth/bmm: get to your bookmarks in a flash", 132 | "tags": "tools" 133 | } 134 | ] 135 | 136 | ----- stderr ----- 137 | "#); 138 | } 139 | 140 | #[test] 141 | fn searching_bookmarks_by_multiple_terms_works() { 142 | // GIVEN 143 | let fx = Fixture::new(); 144 | let mut import_cmd = fx.cmd(["import", "tests/static/import/valid.json"]); 145 | assert_cmd_snapshot!(import_cmd, @r" 146 | success: true 147 | exit_code: 0 148 | ----- stdout ----- 149 | imported 4 bookmarks 150 | 151 | ----- stderr ----- 152 | "); 153 | 154 | let mut cmd = fx.cmd([ 155 | "search", 156 | "github", 157 | "tools", 158 | "productivity", 159 | "command", 160 | "time", 161 | ]); 162 | 163 | // WHEN 164 | // THEN 165 | assert_cmd_snapshot!(cmd, @r" 166 | success: true 167 | exit_code: 0 168 | ----- stdout ----- 169 | https://github.com/dhth/hours 170 | 171 | ----- stderr ----- 172 | "); 173 | } 174 | 175 | //------------// 176 | // FAILURES // 177 | //------------// 178 | 179 | #[test] 180 | fn searching_bookmarks_fails_if_search_terms_exceeds_limit() { 181 | // GIVEN 182 | let fx = Fixture::new(); 183 | let mut import_cmd = fx.cmd(["import", "tests/static/import/valid.json"]); 184 | assert_cmd_snapshot!(import_cmd, @r" 185 | success: true 186 | exit_code: 0 187 | ----- stdout ----- 188 | imported 4 bookmarks 189 | 190 | ----- stderr ----- 191 | "); 192 | 193 | let mut cmd = fx.cmd(["search"]); 194 | cmd.args((1..=11).map(|i| format!("term-{i}")).collect::>()); 195 | 196 | // WHEN 197 | // THEN 198 | assert_cmd_snapshot!(cmd, @r" 199 | success: false 200 | exit_code: 1 201 | ----- stdout ----- 202 | 203 | ----- stderr ----- 204 | Error: couldn't search bookmarks: search query is invalid: too many terms (maximum allowed: 10) 205 | "); 206 | } 207 | 208 | #[test] 209 | fn searching_bookmarks_fails_if_search_query_empty() { 210 | // GIVEN 211 | let fx = Fixture::new(); 212 | let mut import_cmd = fx.cmd(["import", "tests/static/import/valid.json"]); 213 | assert_cmd_snapshot!(import_cmd, @r" 214 | success: true 215 | exit_code: 0 216 | ----- stdout ----- 217 | imported 4 bookmarks 218 | 219 | ----- stderr ----- 220 | "); 221 | 222 | let mut cmd = fx.cmd(["search"]); 223 | 224 | // WHEN 225 | // THEN 226 | assert_cmd_snapshot!(cmd, @r" 227 | success: false 228 | exit_code: 1 229 | ----- stdout ----- 230 | 231 | ----- stderr ----- 232 | Error: couldn't search bookmarks: search query is invalid: query is empty 233 | "); 234 | } 235 | -------------------------------------------------------------------------------- /tests/save_all_test.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | 3 | use common::Fixture; 4 | use insta_cmd::assert_cmd_snapshot; 5 | 6 | const URI_ONE: &str = "https://github.com/dhth/bmm"; 7 | const URI_TWO: &str = "https://github.com/dhth/omm"; 8 | const URI_THREE: &str = "https://github.com/dhth/hours"; 9 | 10 | //-------------// 11 | // SUCCESSES // 12 | //-------------// 13 | 14 | #[test] 15 | fn saving_multiple_bookmarks_works() { 16 | // GIVEN 17 | let fx = Fixture::new(); 18 | let mut cmd = fx.cmd(["save-all", URI_ONE, URI_TWO, URI_THREE]); 19 | 20 | // WHEN 21 | // THEN 22 | assert_cmd_snapshot!(cmd, @r" 23 | success: true 24 | exit_code: 0 25 | ----- stdout ----- 26 | saved 3 bookmarks 27 | 28 | ----- stderr ----- 29 | "); 30 | 31 | let mut list_cmd = fx.cmd(["list"]); 32 | assert_cmd_snapshot!(list_cmd, @r" 33 | success: true 34 | exit_code: 0 35 | ----- stdout ----- 36 | https://github.com/dhth/bmm 37 | https://github.com/dhth/omm 38 | https://github.com/dhth/hours 39 | 40 | ----- stderr ----- 41 | "); 42 | } 43 | 44 | #[test] 45 | fn saving_multiple_bookmarks_with_tags_works() { 46 | // GIVEN 47 | let fx = Fixture::new(); 48 | let mut cmd = fx.cmd([ 49 | "save-all", 50 | URI_ONE, 51 | URI_TWO, 52 | URI_THREE, 53 | "--tags", 54 | "tools,productivity", 55 | ]); 56 | 57 | // WHEN 58 | // THEN 59 | assert_cmd_snapshot!(cmd, @r" 60 | success: true 61 | exit_code: 0 62 | ----- stdout ----- 63 | saved 3 bookmarks 64 | 65 | ----- stderr ----- 66 | "); 67 | 68 | let mut list_tags_cmd = fx.cmd(["tags", "list"]); 69 | assert_cmd_snapshot!(list_tags_cmd, @r" 70 | success: true 71 | exit_code: 0 72 | ----- stdout ----- 73 | productivity 74 | tools 75 | 76 | ----- stderr ----- 77 | "); 78 | } 79 | 80 | #[test] 81 | fn saving_multiple_bookmarks_extends_previously_saved_tags() { 82 | // GIVEN 83 | let fx = Fixture::new(); 84 | let mut create_cmd = fx.cmd([ 85 | "save", 86 | URI_ONE, 87 | "--title", 88 | "bmm's github page", 89 | "--tags", 90 | "productivity", 91 | ]); 92 | assert_cmd_snapshot!(create_cmd, @r" 93 | success: true 94 | exit_code: 0 95 | ----- stdout ----- 96 | 97 | ----- stderr ----- 98 | "); 99 | 100 | let mut cmd = fx.cmd(["save-all", URI_ONE, URI_TWO, URI_THREE, "--tags", "tools"]); 101 | 102 | // WHEN 103 | // THEN 104 | assert_cmd_snapshot!(cmd, @r" 105 | success: true 106 | exit_code: 0 107 | ----- stdout ----- 108 | saved 3 bookmarks 109 | 110 | ----- stderr ----- 111 | "); 112 | 113 | let mut show_cmd = fx.cmd(["show", URI_ONE]); 114 | assert_cmd_snapshot!(show_cmd, @r" 115 | success: true 116 | exit_code: 0 117 | ----- stdout ----- 118 | Bookmark details 119 | --- 120 | 121 | Title: bmm's github page 122 | URI : https://github.com/dhth/bmm 123 | Tags : productivity,tools 124 | 125 | ----- stderr ----- 126 | "); 127 | } 128 | 129 | #[test] 130 | fn saving_multiple_bookmarks_resets_previously_saved_tags_if_requested() { 131 | // GIVEN 132 | let fx = Fixture::new(); 133 | let mut create_cmd = fx.cmd([ 134 | "save", 135 | URI_ONE, 136 | "--title", 137 | "bmm's github page", 138 | "--tags", 139 | "productivity", 140 | ]); 141 | assert_cmd_snapshot!(create_cmd, @r" 142 | success: true 143 | exit_code: 0 144 | ----- stdout ----- 145 | 146 | ----- stderr ----- 147 | "); 148 | 149 | let mut cmd = fx.cmd([ 150 | "save-all", 151 | URI_ONE, 152 | URI_TWO, 153 | URI_THREE, 154 | "--tags", 155 | "tools", 156 | "--reset-missing-details", 157 | ]); 158 | 159 | // WHEN 160 | // THEN 161 | assert_cmd_snapshot!(cmd, @r" 162 | success: true 163 | exit_code: 0 164 | ----- stdout ----- 165 | saved 3 bookmarks 166 | 167 | ----- stderr ----- 168 | "); 169 | 170 | let mut show_cmd = fx.cmd(["show", URI_ONE]); 171 | assert_cmd_snapshot!(show_cmd, @r" 172 | success: true 173 | exit_code: 0 174 | ----- stdout ----- 175 | Bookmark details 176 | --- 177 | 178 | Title: bmm's github page 179 | URI : https://github.com/dhth/bmm 180 | Tags : tools 181 | 182 | ----- stderr ----- 183 | "); 184 | } 185 | 186 | #[test] 187 | fn force_saving_multiple_bookmarks_with_invalid_tags_works() { 188 | // GIVEN 189 | let fx = Fixture::new(); 190 | let mut cmd = fx.cmd([ 191 | "save-all", 192 | URI_ONE, 193 | URI_TWO, 194 | URI_THREE, 195 | "--tags", 196 | "tag1,invalid tag, another invalid\t\ttag ", 197 | "--ignore-attribute-errors", 198 | ]); 199 | 200 | // WHEN 201 | // THEN 202 | assert_cmd_snapshot!(cmd, @r" 203 | success: true 204 | exit_code: 0 205 | ----- stdout ----- 206 | saved 3 bookmarks 207 | 208 | ----- stderr ----- 209 | "); 210 | 211 | let mut list_tags_cmd = fx.cmd(["tags", "list"]); 212 | assert_cmd_snapshot!(list_tags_cmd, @r" 213 | success: true 214 | exit_code: 0 215 | ----- stdout ----- 216 | another-invalid-tag 217 | invalid-tag 218 | tag1 219 | 220 | ----- stderr ----- 221 | "); 222 | } 223 | 224 | //------------// 225 | // FAILURES // 226 | //------------// 227 | 228 | #[test] 229 | fn saving_multiple_bookmarks_fails_for_incorrect_uris() { 230 | // GIVEN 231 | let fx = Fixture::new(); 232 | let mut cmd = fx.cmd([ 233 | "save-all", 234 | "this is not a uri", 235 | URI_TWO, 236 | "https:/ this!!isn't-either.com", 237 | ]); 238 | 239 | // WHEN 240 | // THEN 241 | assert_cmd_snapshot!(cmd, @r" 242 | success: false 243 | exit_code: 1 244 | ----- stdout ----- 245 | 246 | ----- stderr ----- 247 | Error: couldn't save bookmarks: there were 2 validation errors 248 | 249 | - entry 1: couldn't parse provided uri value: relative URL without a base 250 | - entry 3: couldn't parse provided uri value: invalid international domain name 251 | 252 | Possible workaround: running with -i/--ignore-attribute-errors might fix some attribute errors. 253 | If a title is too long, it'll will be trimmed, and some invalid tags might be transformed to fit bmm's requirements. 254 | "); 255 | } 256 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: pr 2 | 3 | on: 4 | pull_request: 5 | 6 | env: 7 | CARGO_TERM_COLOR: always 8 | 9 | jobs: 10 | changes: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | pull-requests: read 14 | outputs: 15 | deps: ${{ steps.filter.outputs.deps }} 16 | code: ${{ steps.filter.outputs.code }} 17 | rust: ${{ steps.filter.outputs.rust }} 18 | shell: ${{ steps.filter.outputs.shell }} 19 | workflows: ${{ steps.filter.outputs.workflows }} 20 | yml: ${{ steps.filter.outputs.yml }} 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v5 24 | - uses: dorny/paths-filter@v3 25 | id: filter 26 | with: 27 | filters: | 28 | deps: 29 | - "Cargo.toml" 30 | - "Cargo.lock" 31 | - "deny.toml" 32 | - ".github/workflows/pr.yml" 33 | code: 34 | - ".sqlx/**" 35 | - "migrations/**" 36 | - "src/**" 37 | - "tests/**" 38 | - "**/*.rs" 39 | - Cargo.* 40 | - clippy.toml 41 | - dist-workspace.toml 42 | - rust-toolchain.toml 43 | - ".github/actions/**/*.yml" 44 | - ".github/workflows/pr.yml" 45 | rust: 46 | - "**/*.rs" 47 | shell: 48 | - "**.sh" 49 | - ".github/workflows/pr.yml" 50 | workflows: 51 | - ".github/**/*.yml" 52 | yml: 53 | - "**.yml" 54 | - "**.yaml" 55 | 56 | lint: 57 | needs: changes 58 | if: ${{ needs.changes.outputs.code == 'true' }} 59 | runs-on: ubuntu-latest 60 | env: 61 | SQLX_OFFLINE: true 62 | steps: 63 | - uses: actions/checkout@v5 64 | - name: Install toolchain 65 | uses: actions-rust-lang/setup-rust-toolchain@v1 66 | with: 67 | components: clippy, rustfmt 68 | - name: Check formatting 69 | run: cargo fmt --all -- --check 70 | - name: Lint 71 | run: cargo clippy 72 | 73 | build: 74 | needs: changes 75 | if: ${{ needs.changes.outputs.code == 'true' }} 76 | strategy: 77 | matrix: 78 | os: [ubuntu-latest, macos-latest] 79 | runs-on: ${{ matrix.os }} 80 | env: 81 | SQLX_OFFLINE: true 82 | steps: 83 | - uses: actions/checkout@v5 84 | - name: Install toolchain 85 | uses: actions-rust-lang/setup-rust-toolchain@v1 86 | - name: Build 87 | run: cargo build 88 | 89 | test: 90 | needs: changes 91 | if: ${{ needs.changes.outputs.code == 'true' }} 92 | strategy: 93 | matrix: 94 | os: [ubuntu-latest, macos-latest] 95 | env: 96 | SQLX_VERSION: 0.8.3 97 | SQLX_FEATURES: sqlite 98 | DATABASE_URL: sqlite://testdb/db.db 99 | runs-on: ${{ matrix.os }} 100 | steps: 101 | - uses: actions/checkout@v5 102 | - name: Install toolchain 103 | uses: actions-rust-lang/setup-rust-toolchain@v1 104 | - name: Install sqlx-cli 105 | run: cargo install sqlx-cli --version=${{ env.SQLX_VERSION }} --features ${{ env.SQLX_FEATURES }} --no-default-features --locked 106 | - name: Install nextest 107 | uses: taiki-e/install-action@f535147c22906d77695e11cb199e764aa610a4fc # v2.62.46 108 | with: 109 | tool: cargo-nextest 110 | - name: Create test database 111 | run: | 112 | mkdir testdb 113 | cargo sqlx database create 114 | cargo sqlx migrate run 115 | cargo sqlx prepare --check 116 | - name: Run tests 117 | env: 118 | RUST_BACKTRACE: 0 119 | run: cargo nextest run 120 | 121 | back-compat: 122 | needs: changes 123 | if: ${{ needs.changes.outputs.code == 'true' }} 124 | runs-on: ubuntu-latest 125 | permissions: 126 | contents: read 127 | env: 128 | SQLX_OFFLINE: true 129 | steps: 130 | - uses: actions/checkout@v5 131 | with: 132 | ref: main 133 | - name: Install rust toolchain for previous version 134 | uses: actions-rust-lang/setup-rust-toolchain@v1 135 | - name: Build main 136 | run: cargo build --target-dir /var/tmp/main 137 | - name: Save bookmarks with bmm on main 138 | run: /var/tmp/main/debug/bmm save https://github.com/dhth/bmm 139 | - uses: actions/checkout@v5 140 | - name: Install rust toolchain for new version 141 | uses: actions-rust-lang/setup-rust-toolchain@v1 142 | - name: Build head 143 | run: cargo build --target-dir /var/tmp/head 144 | - name: Run bmm on head 145 | run: /var/tmp/head/debug/bmm list -f json 146 | 147 | lint-sh: 148 | needs: changes 149 | if: ${{ needs.changes.outputs.shell == 'true' }} 150 | runs-on: ubuntu-latest 151 | steps: 152 | - name: Checkout 153 | uses: actions/checkout@v5 154 | - uses: dhth/composite-actions/.github/actions/lint-sh@main 155 | 156 | lint-workflows: 157 | needs: changes 158 | if: ${{ needs.changes.outputs.workflows == 'true' }} 159 | runs-on: ubuntu-latest 160 | steps: 161 | - name: Checkout 162 | uses: actions/checkout@v5 163 | - uses: dhth/composite-actions/.github/actions/lint-actions@main 164 | 165 | lint-yaml: 166 | needs: changes 167 | if: ${{ needs.changes.outputs.yml == 'true' }} 168 | runs-on: ubuntu-latest 169 | steps: 170 | - name: Checkout 171 | uses: actions/checkout@v5 172 | - uses: dhth/composite-actions/.github/actions/lint-yaml@main 173 | 174 | dstlled-diff: 175 | needs: changes 176 | if: ${{ needs.changes.outputs.rust == 'true' }} 177 | runs-on: ubuntu-latest 178 | permissions: 179 | contents: read 180 | pull-requests: write 181 | steps: 182 | - uses: actions/checkout@v5 183 | with: 184 | fetch-depth: 0 185 | - id: get-dstlled-diff 186 | uses: dhth/dstlled-diff-action@0ab616345f8816e9046fdefec81b14ada815aaca # v0.2.0 187 | with: 188 | pattern: '**.rs' 189 | starting-commit: ${{ github.event.pull_request.base.sha }} 190 | ending-commit: ${{ github.event.pull_request.head.sha }} 191 | post-comment-on-pr: 'true' 192 | 193 | audit: 194 | needs: changes 195 | if: ${{ needs.changes.outputs.deps == 'true' }} 196 | runs-on: ubuntu-latest 197 | steps: 198 | - name: Checkout 199 | uses: actions/checkout@v5 200 | - uses: dhth/composite-actions/.github/actions/cargo-deny@main 201 | -------------------------------------------------------------------------------- /tests/import_test.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | 3 | use common::Fixture; 4 | use insta_cmd::assert_cmd_snapshot; 5 | 6 | //-------------// 7 | // SUCCESSES // 8 | //-------------// 9 | 10 | #[test] 11 | fn importing_from_an_html_file_works() { 12 | // GIVEN 13 | let fx = Fixture::new(); 14 | let mut cmd = fx.cmd(["import", "tests/static/import/valid.html"]); 15 | 16 | // WHEN 17 | // THEN 18 | assert_cmd_snapshot!(cmd, @r" 19 | success: true 20 | exit_code: 0 21 | ----- stdout ----- 22 | imported 4 bookmarks 23 | 24 | ----- stderr ----- 25 | "); 26 | } 27 | 28 | #[test] 29 | fn importing_from_an_invalid_html_file_doesnt_fail() { 30 | // GIVEN 31 | let fx = Fixture::new(); 32 | let mut cmd = fx.cmd(["import", "tests/static/import/invalid.html"]); 33 | 34 | // WHEN 35 | // THEN 36 | assert_cmd_snapshot!(cmd, @r" 37 | success: true 38 | exit_code: 0 39 | ----- stdout ----- 40 | imported 0 bookmarks 41 | 42 | ----- stderr ----- 43 | "); 44 | } 45 | 46 | #[test] 47 | fn force_importing_from_an_html_file_with_some_invalid_attrs_works() { 48 | // GIVEN 49 | let fx = Fixture::new(); 50 | let mut cmd = fx.cmd([ 51 | "import", 52 | "tests/static/import/valid-with-some-invalid-attributes.html", 53 | "--ignore-attribute-errors", 54 | ]); 55 | 56 | // WHEN 57 | // THEN 58 | assert_cmd_snapshot!(cmd, @r" 59 | success: true 60 | exit_code: 0 61 | ----- stdout ----- 62 | imported 4 bookmarks 63 | 64 | ----- stderr ----- 65 | "); 66 | } 67 | 68 | #[test] 69 | fn importing_from_a_valid_json_file_works() { 70 | // GIVEN 71 | let fx = Fixture::new(); 72 | let mut cmd = fx.cmd(["import", "tests/static/import/valid.json"]); 73 | 74 | // WHEN 75 | // THEN 76 | assert_cmd_snapshot!(cmd, @r" 77 | success: true 78 | exit_code: 0 79 | ----- stdout ----- 80 | imported 4 bookmarks 81 | 82 | ----- stderr ----- 83 | "); 84 | } 85 | 86 | #[test] 87 | fn importing_from_a_json_file_with_only_mandatory_details_works() { 88 | // GIVEN 89 | let fx = Fixture::new(); 90 | let mut cmd = fx.cmd(["import", "tests/static/import/only-mandatory.json"]); 91 | 92 | // WHEN 93 | // THEN 94 | assert_cmd_snapshot!(cmd, @r" 95 | success: true 96 | exit_code: 0 97 | ----- stdout ----- 98 | imported 2 bookmarks 99 | 100 | ----- stderr ----- 101 | "); 102 | } 103 | 104 | #[test] 105 | fn force_importing_from_a_json_file_with_some_invalid_attrs_works() { 106 | // GIVEN 107 | let fx = Fixture::new(); 108 | let mut cmd = fx.cmd([ 109 | "import", 110 | "tests/static/import/valid-with-some-invalid-attributes.json", 111 | "--ignore-attribute-errors", 112 | ]); 113 | 114 | // WHEN 115 | // THEN 116 | assert_cmd_snapshot!(cmd, @r" 117 | success: true 118 | exit_code: 0 119 | ----- stdout ----- 120 | imported 4 bookmarks 121 | 122 | ----- stderr ----- 123 | "); 124 | } 125 | 126 | #[test] 127 | fn importing_from_a_valid_txt_file_works() { 128 | // GIVEN 129 | let fx = Fixture::new(); 130 | let mut cmd = fx.cmd(["import", "tests/static/import/valid.txt"]); 131 | 132 | // WHEN 133 | // THEN 134 | assert_cmd_snapshot!(cmd, @r" 135 | success: true 136 | exit_code: 0 137 | ----- stdout ----- 138 | imported 4 bookmarks 139 | 140 | ----- stderr ----- 141 | "); 142 | } 143 | 144 | #[test] 145 | fn importing_extends_previously_saved_info() { 146 | // GIVEN 147 | let uri = "https://github.com/dhth/bmm"; 148 | let fx = Fixture::new(); 149 | let mut create_cmd = fx.cmd([ 150 | "save", 151 | uri, 152 | "--title", 153 | "bmm's github page", 154 | "--tags", 155 | "productivity", 156 | ]); 157 | assert_cmd_snapshot!(create_cmd, @r" 158 | success: true 159 | exit_code: 0 160 | ----- stdout ----- 161 | 162 | ----- stderr ----- 163 | "); 164 | 165 | let mut cmd = fx.cmd(["import", "tests/static/import/valid.json"]); 166 | 167 | // WHEN 168 | // THEN 169 | assert_cmd_snapshot!(cmd, @r" 170 | success: true 171 | exit_code: 0 172 | ----- stdout ----- 173 | imported 4 bookmarks 174 | 175 | ----- stderr ----- 176 | "); 177 | 178 | let mut show_cmd = fx.cmd(["show", uri]); 179 | assert_cmd_snapshot!(show_cmd, @r" 180 | success: true 181 | exit_code: 0 182 | ----- stdout ----- 183 | Bookmark details 184 | --- 185 | 186 | Title: GitHub - dhth/bmm: get to your bookmarks in a flash 187 | URI : https://github.com/dhth/bmm 188 | Tags : productivity,tools 189 | 190 | ----- stderr ----- 191 | "); 192 | } 193 | 194 | #[test] 195 | fn importing_resets_previously_saved_info_if_requested() { 196 | // GIVEN 197 | let uri = "https://github.com/dhth/omm"; 198 | let fx = Fixture::new(); 199 | let mut create_cmd = fx.cmd([ 200 | "save", 201 | uri, 202 | "--title", 203 | "omm's github page", 204 | "--tags", 205 | "task-management,productivity", 206 | ]); 207 | assert_cmd_snapshot!(create_cmd, @r" 208 | success: true 209 | exit_code: 0 210 | ----- stdout ----- 211 | 212 | ----- stderr ----- 213 | "); 214 | 215 | let mut cmd = fx.cmd(["import", "tests/static/import/only-mandatory.json", "-r"]); 216 | 217 | // WHEN 218 | // THEN 219 | assert_cmd_snapshot!(cmd, @r" 220 | success: true 221 | exit_code: 0 222 | ----- stdout ----- 223 | imported 2 bookmarks 224 | 225 | ----- stderr ----- 226 | "); 227 | 228 | let mut show_cmd = fx.cmd(["show", uri]); 229 | assert_cmd_snapshot!(show_cmd, @r" 230 | success: true 231 | exit_code: 0 232 | ----- stdout ----- 233 | Bookmark details 234 | --- 235 | 236 | Title: 237 | URI : https://github.com/dhth/omm 238 | Tags : 239 | 240 | ----- stderr ----- 241 | "); 242 | } 243 | 244 | //------------// 245 | // FAILURES // 246 | //------------// 247 | 248 | #[test] 249 | fn importing_from_an_invalid_json_file_fails() { 250 | // GIVEN 251 | let fx = Fixture::new(); 252 | let mut cmd = fx.cmd(["import", "tests/static/import/invalid.json"]); 253 | 254 | // WHEN 255 | // THEN 256 | assert_cmd_snapshot!(cmd, @r#" 257 | success: false 258 | exit_code: 1 259 | ----- stdout ----- 260 | 261 | ----- stderr ----- 262 | Error: couldn't import bookmarks: couldn't parse JSON input: EOF while parsing a list at line 8 column 0 263 | 264 | Suggestion: ensure the file is valid JSON and looks like the following: 265 | 266 | [ 267 | { 268 | "uri": "https://github.com/dhth/bmm", 269 | "title": null, 270 | "tags": "tools,bookmarks" 271 | }, 272 | { 273 | "uri": "https://github.com/dhth/omm", 274 | "title": "on-my-mind: a keyboard-driven task manager for the command line", 275 | "tags": "tools,productivity" 276 | } 277 | ] 278 | "#); 279 | } 280 | 281 | #[test] 282 | fn importing_from_a_json_file_fails_if_missing_uri() { 283 | // GIVEN 284 | let fx = Fixture::new(); 285 | let mut cmd = fx.cmd(["import", "tests/static/import/missing-uri.json"]); 286 | 287 | // WHEN 288 | // THEN 289 | assert_cmd_snapshot!(cmd, @r#" 290 | success: false 291 | exit_code: 1 292 | ----- stdout ----- 293 | 294 | ----- stderr ----- 295 | Error: couldn't import bookmarks: couldn't parse JSON input: missing field `uri` at line 12 column 3 296 | 297 | Suggestion: ensure the file is valid JSON and looks like the following: 298 | 299 | [ 300 | { 301 | "uri": "https://github.com/dhth/bmm", 302 | "title": null, 303 | "tags": "tools,bookmarks" 304 | }, 305 | { 306 | "uri": "https://github.com/dhth/omm", 307 | "title": "on-my-mind: a keyboard-driven task manager for the command line", 308 | "tags": "tools,productivity" 309 | } 310 | ] 311 | "#); 312 | } 313 | 314 | #[test] 315 | fn importing_from_a_json_file_fails_if_missing_uri_even_when_forced() { 316 | // GIVEN 317 | let fx = Fixture::new(); 318 | let mut cmd = fx.cmd([ 319 | "import", 320 | "tests/static/import/missing-uri.json", 321 | "--ignore-attribute-errors", 322 | ]); 323 | 324 | // WHEN 325 | // THEN 326 | assert_cmd_snapshot!(cmd, @r#" 327 | success: false 328 | exit_code: 1 329 | ----- stdout ----- 330 | 331 | ----- stderr ----- 332 | Error: couldn't import bookmarks: couldn't parse JSON input: missing field `uri` at line 12 column 3 333 | 334 | Suggestion: ensure the file is valid JSON and looks like the following: 335 | 336 | [ 337 | { 338 | "uri": "https://github.com/dhth/bmm", 339 | "title": null, 340 | "tags": "tools,bookmarks" 341 | }, 342 | { 343 | "uri": "https://github.com/dhth/omm", 344 | "title": "on-my-mind: a keyboard-driven task manager for the command line", 345 | "tags": "tools,productivity" 346 | } 347 | ] 348 | "#); 349 | } 350 | -------------------------------------------------------------------------------- /src/persistence/delete.rs: -------------------------------------------------------------------------------- 1 | use super::DBError; 2 | use sqlx::{Pool, Sqlite}; 3 | 4 | pub async fn delete_bookmarks_with_uris( 5 | pool: &Pool, 6 | uris: &Vec, 7 | ) -> Result { 8 | let mut tx = pool 9 | .begin() 10 | .await 11 | .map_err(DBError::CouldntBeginTransaction)?; 12 | 13 | let rows_affected = { 14 | let query = format!( 15 | r#" 16 | DELETE FROM 17 | bookmarks 18 | WHERE 19 | id IN ( 20 | SELECT 21 | id 22 | FROM 23 | bookmarks 24 | WHERE 25 | uri IN ({}) 26 | ) 27 | "#, 28 | uris.iter().map(|_| "?").collect::>().join(", ") 29 | ); 30 | 31 | let mut query_builder = sqlx::query::<_>(&query); 32 | for uri in uris { 33 | query_builder = query_builder.bind(uri.as_str()); 34 | } 35 | 36 | let result = query_builder 37 | .execute(&mut *tx) 38 | .await 39 | .map_err(|e| DBError::CouldntExecuteQuery("delete bookmarks with uris".into(), e))?; 40 | 41 | sqlx::query!( 42 | " 43 | DELETE FROM 44 | tags 45 | WHERE 46 | id NOT IN ( 47 | SELECT 48 | tag_id 49 | FROM 50 | bookmark_tags 51 | ) 52 | ", 53 | ) 54 | .execute(&mut *tx) 55 | .await 56 | .map_err(|e| DBError::CouldntExecuteQuery("clean up unused tags".into(), e))?; 57 | 58 | result.rows_affected() 59 | }; 60 | 61 | tx.commit() 62 | .await 63 | .map_err(DBError::CouldntCommitTransaction)?; 64 | 65 | Ok(rows_affected) 66 | } 67 | 68 | pub async fn delete_tags_by_name(pool: &Pool, tags: &[String]) -> Result { 69 | let query = format!( 70 | r#" 71 | DELETE FROM 72 | tags 73 | WHERE 74 | name IN ({}) 75 | "#, 76 | tags.iter().map(|_| "?").collect::>().join(", ") 77 | ); 78 | let mut query_builder = sqlx::query(&query); 79 | for tag in tags { 80 | query_builder = query_builder.bind(tag); 81 | } 82 | 83 | let result = query_builder 84 | .execute(pool) 85 | .await 86 | .map_err(|e| DBError::CouldntExecuteQuery("delete tags".into(), e))?; 87 | 88 | Ok(result.rows_affected()) 89 | } 90 | 91 | #[cfg(test)] 92 | mod tests { 93 | use super::super::test_fixtures::DBPoolFixture; 94 | use super::super::{create_or_update_bookmark, get_num_bookmarks, get_tags}; 95 | use super::*; 96 | use crate::domain::{DraftBookmark, PotentialBookmark}; 97 | use crate::persistence::SaveBookmarkOptions; 98 | use insta::assert_yaml_snapshot; 99 | 100 | use std::time::{SystemTime, UNIX_EPOCH}; 101 | 102 | //-------------// 103 | // SUCCESSES // 104 | //-------------// 105 | 106 | #[tokio::test] 107 | async fn deleting_uris_works() { 108 | // GIVEN 109 | let fx = DBPoolFixture::new().await; 110 | let start = SystemTime::now(); 111 | let since_the_epoch = start.duration_since(UNIX_EPOCH).unwrap(); 112 | let now = since_the_epoch.as_secs() as i64; 113 | let num_bookmarks = 10; 114 | 115 | for i in 1..=num_bookmarks { 116 | let uri = format!("https://uri-{i}.com"); 117 | let draft_bookmark = 118 | DraftBookmark::try_from(PotentialBookmark::from((uri.as_str(), None, &vec![]))) 119 | .expect("draft bookmark should've been created"); 120 | println!("draft_bookmark: {}", draft_bookmark.uri()); 121 | create_or_update_bookmark( 122 | &fx.pool, 123 | &draft_bookmark, 124 | now, 125 | SaveBookmarkOptions::default(), 126 | ) 127 | .await 128 | .expect("bookmark should've been saved in db"); 129 | } 130 | 131 | // WHEN 132 | let uris_to_delete = vec!["https://uri-1.com".into(), "https://uri-4.com".into()]; 133 | 134 | let result = delete_bookmarks_with_uris(&fx.pool, &uris_to_delete) 135 | .await 136 | .expect("result should've been a success"); 137 | assert_eq!(result, uris_to_delete.len() as u64); 138 | 139 | let num_bookmarks_in_db = get_num_bookmarks(&fx.pool) 140 | .await 141 | .expect("number of bookmarks should've been fetched"); 142 | assert_eq!( 143 | num_bookmarks_in_db, 144 | num_bookmarks - uris_to_delete.len() as i64 145 | ); 146 | } 147 | 148 | #[tokio::test] 149 | async fn deleting_uris_works_when_uris_dont_exist() { 150 | // GIVEN 151 | let fx = DBPoolFixture::new().await; 152 | let start = SystemTime::now(); 153 | let since_the_epoch = start.duration_since(UNIX_EPOCH).unwrap(); 154 | let now = since_the_epoch.as_secs() as i64; 155 | 156 | let uri = "https://uri.com"; 157 | let draft_bookmark = DraftBookmark::try_from(PotentialBookmark::from((uri, None, &vec![]))) 158 | .expect("draft bookmark should've been created"); 159 | println!("draft_bookmark: {}", draft_bookmark.uri()); 160 | create_or_update_bookmark( 161 | &fx.pool, 162 | &draft_bookmark, 163 | now, 164 | SaveBookmarkOptions::default(), 165 | ) 166 | .await 167 | .expect("bookmark should've been saved in db"); 168 | 169 | // WHEN 170 | let uris_to_delete = vec![ 171 | "https://unknown-uri-1.com".into(), 172 | "https://unknown-uri-2.com".into(), 173 | ]; 174 | 175 | let result = delete_bookmarks_with_uris(&fx.pool, &uris_to_delete) 176 | .await 177 | .expect("result should've been a success"); 178 | assert_eq!(result, 0); 179 | 180 | let num_bookmarks_in_db = get_num_bookmarks(&fx.pool) 181 | .await 182 | .expect("number of bookmarks should've been fetched"); 183 | assert_eq!(num_bookmarks_in_db, 1); 184 | } 185 | 186 | #[tokio::test] 187 | async fn deleting_uris_cleans_up_unused_tags() { 188 | // GIVEN 189 | let fx = DBPoolFixture::new().await; 190 | let start = SystemTime::now(); 191 | let since_the_epoch = start.duration_since(UNIX_EPOCH).unwrap(); 192 | let now = since_the_epoch.as_secs() as i64; 193 | 194 | let uri = "https://uri.com"; 195 | let draft_bookmark = 196 | DraftBookmark::try_from(PotentialBookmark::from((uri, None, &vec!["tag"]))) 197 | .expect("draft bookmark should've been created"); 198 | println!("draft_bookmark: {}", draft_bookmark.uri()); 199 | create_or_update_bookmark( 200 | &fx.pool, 201 | &draft_bookmark, 202 | now, 203 | SaveBookmarkOptions::default(), 204 | ) 205 | .await 206 | .expect("bookmark should've been saved in db"); 207 | 208 | // WHEN 209 | let uris_to_delete = vec![uri.to_string()]; 210 | 211 | let result = delete_bookmarks_with_uris(&fx.pool, &uris_to_delete) 212 | .await 213 | .expect("result should've been a success"); 214 | assert_eq!(result, 1); 215 | 216 | let tags_in_db = get_tags(&fx.pool) 217 | .await 218 | .expect("tags should've been fetched"); 219 | assert_yaml_snapshot!(tags_in_db, @"[]"); 220 | } 221 | 222 | #[tokio::test] 223 | async fn deleting_tags_works() { 224 | // GIVEN 225 | let fx = DBPoolFixture::new().await; 226 | let uris = [ 227 | ("https://uri-one.com", None, vec!["tag5", "tag2"]), 228 | ("https://uri-two.com", None, vec!["tag2", "tag3"]), 229 | ("https://uri-three.com", None, vec!["tag2", "tag3"]), 230 | ("https://uri-four.com", None, vec!["tag1", "tag3"]), 231 | ("https://uri-five.com", None, vec!["tag3", "tag4"]), 232 | ]; 233 | 234 | let start = SystemTime::now(); 235 | let since_the_epoch = start.duration_since(UNIX_EPOCH).unwrap(); 236 | let now = since_the_epoch.as_secs() as i64; 237 | 238 | for (uri, title, tags) in uris { 239 | let draft_bookmark = 240 | DraftBookmark::try_from(PotentialBookmark::from((uri, title, &tags))) 241 | .expect("draft bookmark should be initialized"); 242 | create_or_update_bookmark( 243 | &fx.pool, 244 | &draft_bookmark, 245 | now, 246 | SaveBookmarkOptions::default(), 247 | ) 248 | .await 249 | .expect("bookmark should be saved in db"); 250 | } 251 | 252 | // WHEN 253 | let tags_to_delete = ["tag1", "tag2", "absent-tag"] 254 | .iter() 255 | .map(|t| t.to_string()) 256 | .collect::>(); 257 | let num_rows_deleted = delete_tags_by_name(&fx.pool, &tags_to_delete) 258 | .await 259 | .expect("result should've been a success"); 260 | 261 | // THEN 262 | assert_eq!(num_rows_deleted, 2); 263 | 264 | let tags_left = get_tags(&fx.pool) 265 | .await 266 | .expect("tags should've been fetched"); 267 | assert_yaml_snapshot!(tags_left, @r" 268 | - tag3 269 | - tag4 270 | - tag5 271 | "); 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /tests/save_test.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | 3 | use common::Fixture; 4 | use insta_cmd::assert_cmd_snapshot; 5 | 6 | const URI_ONE: &str = "https://github.com/dhth/bmm"; 7 | 8 | //-------------// 9 | // SUCCESSES // 10 | //-------------// 11 | 12 | #[test] 13 | fn saving_a_new_bookmark_works() { 14 | // GIVEN 15 | let fx = Fixture::new(); 16 | let mut cmd = fx.cmd(["save", URI_ONE]); 17 | 18 | // WHEN 19 | // THEN 20 | assert_cmd_snapshot!(cmd, @r" 21 | success: true 22 | exit_code: 0 23 | ----- stdout ----- 24 | 25 | ----- stderr ----- 26 | "); 27 | 28 | let mut list_cmd = fx.cmd(["list"]); 29 | assert_cmd_snapshot!(list_cmd, @r" 30 | success: true 31 | exit_code: 0 32 | ----- stdout ----- 33 | https://github.com/dhth/bmm 34 | 35 | ----- stderr ----- 36 | "); 37 | } 38 | 39 | #[test] 40 | fn saving_a_new_bookmark_with_title_and_tags_works() { 41 | // GIVEN 42 | let fx = Fixture::new(); 43 | let mut cmd = fx.cmd([ 44 | "save", 45 | URI_ONE, 46 | "--title", 47 | "bmm's github page", 48 | "--tags", 49 | "tools,productivity", 50 | ]); 51 | 52 | // WHEN 53 | // THEN 54 | assert_cmd_snapshot!(cmd, @r" 55 | success: true 56 | exit_code: 0 57 | ----- stdout ----- 58 | 59 | ----- stderr ----- 60 | "); 61 | 62 | let mut list_cmd = fx.cmd(["list", "--format", "delimited"]); 63 | assert_cmd_snapshot!(list_cmd, @r#" 64 | success: true 65 | exit_code: 0 66 | ----- stdout ----- 67 | uri,title,tags 68 | https://github.com/dhth/bmm,bmm's github page,"productivity,tools" 69 | 70 | ----- stderr ----- 71 | "#); 72 | } 73 | 74 | #[test] 75 | fn extending_tags_for_a_saved_bookmark_works() { 76 | // GIVEN 77 | let fx = Fixture::new(); 78 | let mut create_cmd = fx.cmd([ 79 | "save", 80 | URI_ONE, 81 | "--title", 82 | "bmm's github page", 83 | "--tags", 84 | "tools,productivity", 85 | ]); 86 | assert_cmd_snapshot!(create_cmd, @r" 87 | success: true 88 | exit_code: 0 89 | ----- stdout ----- 90 | 91 | ----- stderr ----- 92 | "); 93 | 94 | let mut cmd = fx.cmd(["save", URI_ONE, "--tags", "bookmarks"]); 95 | 96 | // WHEN 97 | // THEN 98 | assert_cmd_snapshot!(cmd, @r" 99 | success: true 100 | exit_code: 0 101 | ----- stdout ----- 102 | 103 | ----- stderr ----- 104 | "); 105 | 106 | let mut list_cmd = fx.cmd(["list", "--format", "delimited"]); 107 | assert_cmd_snapshot!(list_cmd, @r#" 108 | success: true 109 | exit_code: 0 110 | ----- stdout ----- 111 | uri,title,tags 112 | https://github.com/dhth/bmm,bmm's github page,"bookmarks,productivity,tools" 113 | 114 | ----- stderr ----- 115 | "#); 116 | } 117 | 118 | #[test] 119 | fn resetting_properties_on_bookmark_update_works() { 120 | // GIVEN 121 | let fx = Fixture::new(); 122 | let mut create_cmd = fx.cmd([ 123 | "save", 124 | URI_ONE, 125 | "--title", 126 | "bmm's github page", 127 | "--tags", 128 | "tools,productivity", 129 | ]); 130 | assert_cmd_snapshot!(create_cmd, @r" 131 | success: true 132 | exit_code: 0 133 | ----- stdout ----- 134 | 135 | ----- stderr ----- 136 | "); 137 | 138 | let mut cmd = fx.cmd([ 139 | "save", 140 | URI_ONE, 141 | "--tags", 142 | "cli,bookmarks", 143 | "--reset-missing-details", 144 | ]); 145 | 146 | // WHEN 147 | // THEN 148 | assert_cmd_snapshot!(cmd, @r" 149 | success: true 150 | exit_code: 0 151 | ----- stdout ----- 152 | 153 | ----- stderr ----- 154 | "); 155 | 156 | let mut list_cmd = fx.cmd(["list", "--format", "delimited"]); 157 | assert_cmd_snapshot!(list_cmd, @r#" 158 | success: true 159 | exit_code: 0 160 | ----- stdout ----- 161 | uri,title,tags 162 | https://github.com/dhth/bmm,,"bookmarks,cli" 163 | 164 | ----- stderr ----- 165 | "#); 166 | } 167 | 168 | #[test] 169 | fn force_saving_a_new_bookmark_with_a_long_title_works() { 170 | // GIVEN 171 | let fx = Fixture::new(); 172 | let title = "a".repeat(501); 173 | let mut cmd = fx.cmd([ 174 | "save", 175 | URI_ONE, 176 | "--title", 177 | title.as_str(), 178 | "--ignore-attribute-errors", 179 | ]); 180 | 181 | // WHEN 182 | // THEN 183 | assert_cmd_snapshot!(cmd, @r" 184 | success: true 185 | exit_code: 0 186 | ----- stdout ----- 187 | 188 | ----- stderr ----- 189 | "); 190 | 191 | let mut show_cmd = fx.cmd(["show", URI_ONE]); 192 | assert_cmd_snapshot!(show_cmd, @r" 193 | success: true 194 | exit_code: 0 195 | ----- stdout ----- 196 | Bookmark details 197 | --- 198 | 199 | Title: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 200 | URI : https://github.com/dhth/bmm 201 | Tags : 202 | 203 | ----- stderr ----- 204 | "); 205 | } 206 | 207 | #[test] 208 | fn force_saving_a_new_bookmark_with_invalid_tags_works() { 209 | // GIVEN 210 | let fx = Fixture::new(); 211 | let mut cmd = fx.cmd([ 212 | "save", 213 | URI_ONE, 214 | "--tags", 215 | "tag1,invalid tag, another invalid\t\ttag ", 216 | "--ignore-attribute-errors", 217 | ]); 218 | 219 | // WHEN 220 | // THEN 221 | assert_cmd_snapshot!(cmd, @r" 222 | success: true 223 | exit_code: 0 224 | ----- stdout ----- 225 | 226 | ----- stderr ----- 227 | "); 228 | 229 | let mut show_cmd = fx.cmd(["show", URI_ONE]); 230 | assert_cmd_snapshot!(show_cmd, @r" 231 | success: true 232 | exit_code: 0 233 | ----- stdout ----- 234 | Bookmark details 235 | --- 236 | 237 | Title: 238 | URI : https://github.com/dhth/bmm 239 | Tags : another-invalid-tag,invalid-tag,tag1 240 | 241 | ----- stderr ----- 242 | "); 243 | } 244 | 245 | //------------// 246 | // FAILURES // 247 | //------------// 248 | 249 | #[test] 250 | fn saving_a_new_bookmark_with_a_long_title_fails() { 251 | // GIVEN 252 | let fx = Fixture::new(); 253 | let title = "a".repeat(501); 254 | let mut cmd = fx.cmd(["save", URI_ONE, "--title", title.as_str()]); 255 | 256 | // WHEN 257 | // THEN 258 | assert_cmd_snapshot!(cmd, @r" 259 | success: false 260 | exit_code: 1 261 | ----- stdout ----- 262 | 263 | ----- stderr ----- 264 | Error: couldn't save bookmark: title is too long: 501 (max: 500) 265 | 266 | Possible workaround: running with -i/--ignore-attribute-errors might fix some attribute errors. 267 | If a title is too long, it'll will be trimmed, and some invalid tags might be transformed to fit bmm's requirements. 268 | "); 269 | } 270 | 271 | #[test] 272 | fn saving_a_new_bookmark_with_an_invalid_tag_fails() { 273 | // GIVEN 274 | let fx = Fixture::new(); 275 | let mut cmd = fx.cmd([ 276 | "save", 277 | URI_ONE, 278 | "--tags", 279 | "tag1,invalid tag, another invalid\t\ttag ", 280 | ]); 281 | 282 | // WHEN 283 | // THEN 284 | assert_cmd_snapshot!(cmd, @r#" 285 | success: false 286 | exit_code: 1 287 | ----- stdout ----- 288 | 289 | ----- stderr ----- 290 | Error: couldn't save bookmark: tags ["invalid tag", " another invalid\t\ttag "] are invalid (valid regex: ^[a-zA-Z0-9_-]{1,30}$) 291 | 292 | Possible workaround: running with -i/--ignore-attribute-errors might fix some attribute errors. 293 | If a title is too long, it'll will be trimmed, and some invalid tags might be transformed to fit bmm's requirements. 294 | "#); 295 | } 296 | 297 | #[test] 298 | fn saving_a_new_bookmark_with_no_text_editor_configured_fails() { 299 | // GIVEN 300 | let fx = Fixture::new(); 301 | let mut cmd = fx.cmd(["save", URI_ONE, "--editor"]); 302 | cmd.env("BMM_EDITOR", ""); 303 | cmd.env("EDITOR", ""); 304 | 305 | // WHEN 306 | // THEN 307 | assert_cmd_snapshot!(cmd, @r" 308 | success: false 309 | exit_code: 1 310 | ----- stdout ----- 311 | 312 | ----- stderr ----- 313 | Error: couldn't save bookmark: no editor configured 314 | 315 | Suggestion: set the environment variables BMM_EDITOR or EDITOR to use this feature 316 | "); 317 | } 318 | 319 | #[test] 320 | fn saving_a_new_bookmark_with_incorrect_text_editor_configured_fails() { 321 | // GIVEN 322 | let fx = Fixture::new(); 323 | let mut cmd = fx.cmd(["save", URI_ONE, "--editor"]); 324 | cmd.env("BMM_EDITOR", "non-existent-4d56150d"); 325 | cmd.env("EDITOR", "non-existent-4d56150d"); 326 | 327 | // WHEN 328 | // THEN 329 | assert_cmd_snapshot!(cmd, @r#" 330 | success: false 331 | exit_code: 1 332 | ----- stdout ----- 333 | 334 | ----- stderr ----- 335 | Error: couldn't save bookmark: couldn't find editor executable "non-existent-4d56150d": cannot find binary path 336 | 337 | Context: bmm used the environment variable BMM_EDITOR to determine your text editor. 338 | Check if "non-existent-4d56150d" actually points to your text editor's executable. 339 | "#); 340 | } 341 | -------------------------------------------------------------------------------- /src/tui/model.rs: -------------------------------------------------------------------------------- 1 | use super::{commands::Command, common::*}; 2 | use crate::{ 3 | domain::{SavedBookmark, TagStats}, 4 | persistence::SearchTerms, 5 | }; 6 | use ratatui::{ 7 | style::Style, 8 | text::Line, 9 | widgets::{ListItem, ListState}, 10 | }; 11 | use sqlx::{Pool, Sqlite}; 12 | use tui_input::Input; 13 | 14 | #[derive(Debug, Default, PartialEq, Eq)] 15 | pub(crate) enum RunningState { 16 | #[default] 17 | Running, 18 | Done, 19 | } 20 | 21 | #[derive(Debug)] 22 | pub(crate) struct BookmarkItem { 23 | pub(crate) bookmark: SavedBookmark, 24 | pub(crate) status: bool, 25 | } 26 | 27 | #[derive(Debug)] 28 | pub(crate) struct BookmarkItems { 29 | pub(crate) items: Vec, 30 | pub(crate) state: ListState, 31 | } 32 | 33 | #[derive(Debug)] 34 | pub(crate) struct TagItems { 35 | pub(crate) items: Vec, 36 | pub(crate) state: ListState, 37 | } 38 | 39 | #[derive(Debug)] 40 | pub enum MessageKind { 41 | Info, 42 | Error, 43 | } 44 | 45 | pub struct UserMessage { 46 | pub frames_left: u8, 47 | pub value: String, 48 | pub kind: MessageKind, 49 | } 50 | 51 | impl UserMessage { 52 | pub(super) fn info(message: &str) -> Self { 53 | UserMessage { 54 | frames_left: 1, 55 | value: message.to_string(), 56 | kind: MessageKind::Info, 57 | } 58 | } 59 | pub(super) fn error(message: &str) -> Self { 60 | UserMessage { 61 | frames_left: 4, 62 | value: message.to_string(), 63 | kind: MessageKind::Error, 64 | } 65 | } 66 | 67 | pub(super) fn with_frames_left(mut self, frames_left: u8) -> Self { 68 | self.frames_left = frames_left; 69 | self 70 | } 71 | } 72 | 73 | pub enum TuiContext { 74 | Initial, 75 | Search(SearchTerms), 76 | Tags, 77 | } 78 | 79 | impl BookmarkItems { 80 | fn default() -> Self { 81 | let state = ListState::default().with_selected(None); 82 | 83 | Self { 84 | items: vec![], 85 | state, 86 | } 87 | } 88 | } 89 | 90 | impl TagItems { 91 | fn default() -> Self { 92 | let state = ListState::default().with_selected(None); 93 | 94 | Self { 95 | items: vec![], 96 | state, 97 | } 98 | } 99 | } 100 | 101 | impl From> for BookmarkItems { 102 | fn from(bookmarks: Vec) -> Self { 103 | let items = bookmarks 104 | .into_iter() 105 | .map(|bookmark| BookmarkItem::new(bookmark, false)) 106 | .collect(); 107 | let state = ListState::default().with_selected(Some(0)); 108 | 109 | Self { items, state } 110 | } 111 | } 112 | 113 | impl From<(Vec, usize)> for BookmarkItems { 114 | fn from(value: (Vec, usize)) -> Self { 115 | let bookmarks = value.0; 116 | let index = value.1; 117 | let items = bookmarks 118 | .into_iter() 119 | .map(|bookmark| BookmarkItem::new(bookmark, false)) 120 | .collect(); 121 | let state = ListState::default().with_selected(Some(index)); 122 | 123 | Self { items, state } 124 | } 125 | } 126 | 127 | impl From> for TagItems { 128 | fn from(tags: Vec) -> Self { 129 | let state = ListState::default().with_selected(Some(0)); 130 | 131 | Self { items: tags, state } 132 | } 133 | } 134 | 135 | impl BookmarkItem { 136 | fn new(bookmark: SavedBookmark, status: bool) -> Self { 137 | Self { bookmark, status } 138 | } 139 | } 140 | 141 | impl From<&BookmarkItem> for ListItem<'_> { 142 | fn from(value: &BookmarkItem) -> Self { 143 | let line = match value.status { 144 | false => Line::from(value.bookmark.uri.clone()), 145 | true => Line::styled( 146 | format!("> {}", value.bookmark.uri.clone()), 147 | Style::new().fg(COLOR_TWO), 148 | ), 149 | }; 150 | ListItem::new(line) 151 | } 152 | } 153 | 154 | impl From<&TagStats> for ListItem<'_> { 155 | fn from(tag_with_stats: &TagStats) -> Self { 156 | let line = Line::from(tag_with_stats.name.clone()); 157 | ListItem::new(line) 158 | } 159 | } 160 | 161 | pub(super) struct Model { 162 | pub(super) pool: Pool, 163 | pub(super) active_pane: ActivePane, 164 | pub(super) bookmark_items: BookmarkItems, 165 | pub(super) tag_items: TagItems, 166 | pub(super) running_state: RunningState, 167 | pub(super) user_message: Option, 168 | pub(super) render_counter: u64, 169 | pub(super) event_counter: u64, 170 | pub(super) search_input: Input, 171 | pub(super) initial: bool, 172 | pub(super) terminal_dimensions: TerminalDimensions, 173 | pub(super) terminal_too_small: bool, 174 | pub(super) debug: bool, 175 | } 176 | 177 | impl Model { 178 | pub(crate) fn default( 179 | pool: &Pool, 180 | context: TuiContext, 181 | terminal_dimensions: TerminalDimensions, 182 | ) -> Self { 183 | let debug = std::env::var("BMM_DEBUG").unwrap_or_default().trim() == "1"; 184 | 185 | let active_pane = match context { 186 | TuiContext::Search(_) => ActivePane::List, 187 | TuiContext::Tags => ActivePane::TagsList, 188 | TuiContext::Initial => ActivePane::SearchInput, 189 | }; 190 | 191 | let initial = matches!(context, TuiContext::Initial); 192 | 193 | let terminal_too_small = terminal_dimensions.width < MIN_TERMINAL_WIDTH 194 | || terminal_dimensions.height < MIN_TERMINAL_HEIGHT; 195 | 196 | Self { 197 | pool: pool.clone(), 198 | active_pane, 199 | running_state: RunningState::Running, 200 | bookmark_items: BookmarkItems::default(), 201 | tag_items: TagItems::default(), 202 | user_message: None, 203 | render_counter: 0, 204 | event_counter: 0, 205 | search_input: Input::default(), 206 | initial, 207 | terminal_dimensions, 208 | terminal_too_small, 209 | debug, 210 | } 211 | } 212 | 213 | pub(super) fn select_next_list_item(&mut self) { 214 | match self.active_pane { 215 | ActivePane::List => self.bookmark_items.state.select_next(), 216 | ActivePane::TagsList => self.tag_items.state.select_next(), 217 | ActivePane::SearchInput => {} 218 | ActivePane::Help => {} 219 | } 220 | } 221 | 222 | pub(super) fn select_previous_list_item(&mut self) { 223 | match self.active_pane { 224 | ActivePane::List => self.bookmark_items.state.select_previous(), 225 | ActivePane::TagsList => self.tag_items.state.select_previous(), 226 | ActivePane::SearchInput => {} 227 | ActivePane::Help => {} 228 | } 229 | } 230 | 231 | pub(super) fn select_first_list_item(&mut self) { 232 | match self.active_pane { 233 | ActivePane::List => self.bookmark_items.state.select_first(), 234 | ActivePane::TagsList => self.tag_items.state.select_first(), 235 | ActivePane::SearchInput => {} 236 | ActivePane::Help => {} 237 | } 238 | } 239 | pub(super) fn select_last_list_item(&mut self) { 240 | match self.active_pane { 241 | ActivePane::List => self.bookmark_items.state.select_last(), 242 | ActivePane::TagsList => self.tag_items.state.select_last(), 243 | ActivePane::SearchInput => {} 244 | ActivePane::Help => {} 245 | } 246 | } 247 | 248 | pub(super) fn show_view(&mut self, view: ActivePane) -> Option { 249 | self.active_pane = match self.active_pane { 250 | ActivePane::Help => ActivePane::List, 251 | ActivePane::List => view, 252 | ActivePane::TagsList => view, 253 | ActivePane::SearchInput => view, 254 | }; 255 | 256 | match view { 257 | ActivePane::Help => None, 258 | ActivePane::List => None, 259 | ActivePane::TagsList => { 260 | if self.tag_items.items.is_empty() { 261 | Some(Command::FetchTags) 262 | } else { 263 | None 264 | } 265 | } 266 | ActivePane::SearchInput => None, 267 | } 268 | } 269 | 270 | pub(super) fn go_back_or_quit(&mut self) { 271 | if self.terminal_too_small { 272 | self.running_state = RunningState::Done; 273 | return; 274 | } 275 | 276 | match self.active_pane { 277 | ActivePane::List => self.running_state = RunningState::Done, 278 | ActivePane::Help => self.active_pane = ActivePane::List, 279 | ActivePane::SearchInput => { 280 | self.search_input.reset(); 281 | self.active_pane = ActivePane::List; 282 | } 283 | ActivePane::TagsList => { 284 | if self.bookmark_items.items.is_empty() { 285 | self.running_state = RunningState::Done; 286 | } else { 287 | self.active_pane = ActivePane::List; 288 | } 289 | } 290 | }; 291 | } 292 | 293 | pub(super) fn get_cmd_to_open_selection_in_browser(&self) -> Option { 294 | let url = match self.bookmark_items.state.selected() { 295 | Some(i) => match self.bookmark_items.items.get(i) { 296 | Some(bi) => bi.bookmark.uri.clone(), 297 | None => return None, 298 | }, 299 | None => return None, 300 | }; 301 | 302 | Some(Command::OpenInBrowser(url)) 303 | } 304 | 305 | pub(super) fn get_uri_under_cursor(&self) -> Option { 306 | if let ActivePane::List = self.active_pane { 307 | self.bookmark_items 308 | .state 309 | .selected() 310 | .and_then(|i| self.bookmark_items.items.get(i)) 311 | .map(|bi| bi.bookmark.uri.clone()) 312 | } else { 313 | None 314 | } 315 | } 316 | } 317 | -------------------------------------------------------------------------------- /src/persistence/update.rs: -------------------------------------------------------------------------------- 1 | use super::errors::DBError; 2 | use crate::domain::Tag; 3 | use sqlx::{Pool, Sqlite}; 4 | 5 | pub async fn rename_tag_name( 6 | pool: &Pool, 7 | source_tag: String, 8 | target_tag: Tag, 9 | ) -> Result { 10 | let new_tag_name = target_tag.name(); 11 | 12 | let mut tx = pool 13 | .begin() 14 | .await 15 | .map_err(DBError::CouldntBeginTransaction)?; 16 | 17 | let result = { 18 | let maybe_original_tag_id = sqlx::query!( 19 | " 20 | SELECT 21 | id 22 | FROM 23 | tags 24 | WHERE 25 | name = ? 26 | ", 27 | source_tag 28 | ) 29 | .fetch_optional(&mut *tx) 30 | .await 31 | .map_err(|e| DBError::CouldntExecuteQuery("check if original tag exists".into(), e))? 32 | .map(|r| r.id); 33 | 34 | let original_tag_id = match maybe_original_tag_id { 35 | Some(id) => id, 36 | None => return Ok(0), 37 | }; 38 | 39 | let maybe_new_tag_id = sqlx::query!( 40 | " 41 | SELECT 42 | id 43 | FROM 44 | tags 45 | WHERE 46 | name = ? 47 | ", 48 | new_tag_name 49 | ) 50 | .fetch_optional(&mut *tx) 51 | .await 52 | .map_err(|e| DBError::CouldntExecuteQuery("check if new tag exists".into(), e))? 53 | .map(|r| r.id); 54 | 55 | match maybe_new_tag_id { 56 | Some(new_tag_id) => { 57 | sqlx::query!( 58 | " 59 | UPDATE 60 | bookmark_tags 61 | SET 62 | tag_id = ? 63 | WHERE 64 | tag_id = ? 65 | AND NOT EXISTS ( 66 | SELECT 1 67 | FROM bookmark_tags AS bt 68 | WHERE bt.bookmark_id = bookmark_tags.bookmark_id 69 | AND bt.tag_id = ? 70 | ) 71 | ", 72 | new_tag_id, 73 | original_tag_id, 74 | new_tag_id, 75 | ) 76 | .execute(&mut *tx) 77 | .await 78 | .map_err(|e| { 79 | DBError::CouldntExecuteQuery("replace tag id in bookmark_tags".to_string(), e) 80 | })?; 81 | 82 | sqlx::query!( 83 | " 84 | DELETE FROM 85 | tags 86 | WHERE 87 | id = ? 88 | ", 89 | original_tag_id, 90 | ) 91 | .execute(&mut *tx) 92 | .await 93 | .map_err(|e| DBError::CouldntExecuteQuery("delete original tag".to_string(), e))? 94 | } 95 | None => sqlx::query!( 96 | " 97 | UPDATE 98 | tags 99 | SET 100 | name = ? 101 | WHERE 102 | id = ? 103 | ", 104 | new_tag_name, 105 | original_tag_id, 106 | ) 107 | .execute(&mut *tx) 108 | .await 109 | .map_err(|e| DBError::CouldntExecuteQuery("rename tag".to_string(), e))?, 110 | } 111 | }; 112 | 113 | tx.commit() 114 | .await 115 | .map_err(DBError::CouldntCommitTransaction)?; 116 | 117 | Ok(result.rows_affected()) 118 | } 119 | 120 | #[cfg(test)] 121 | mod tests { 122 | 123 | use std::time::{SystemTime, UNIX_EPOCH}; 124 | 125 | use super::*; 126 | use crate::domain::{DraftBookmark, PotentialBookmark, Tag}; 127 | use crate::persistence::test_fixtures::DBPoolFixture; 128 | use crate::persistence::{ 129 | SaveBookmarkOptions, create_or_update_bookmark, get_bookmark_with_exact_uri, get_tags, 130 | }; 131 | use insta::assert_yaml_snapshot; 132 | 133 | //-------------// 134 | // SUCCESSES // 135 | //-------------// 136 | 137 | #[tokio::test] 138 | async fn renaming_tag_works_when_new_tag_doesnt_exist() { 139 | // GIVEN 140 | let fx = DBPoolFixture::new().await; 141 | let uri = "https://github.com/launchbadge/sqlx"; 142 | let draft_bookmark = DraftBookmark::try_from(PotentialBookmark::from(( 143 | uri, 144 | None, 145 | &vec!["old-tag-1", "old-tag-2"], 146 | ))) 147 | .expect("draft bookmark should be initialized"); 148 | 149 | let start = SystemTime::now(); 150 | let since_the_epoch = start.duration_since(UNIX_EPOCH).unwrap(); 151 | let now = since_the_epoch.as_secs() as i64; 152 | 153 | create_or_update_bookmark( 154 | &fx.pool, 155 | &draft_bookmark, 156 | now, 157 | SaveBookmarkOptions::default(), 158 | ) 159 | .await 160 | .expect("bookmark should be saved in db"); 161 | 162 | let new_tag = Tag::try_from("new-tag").expect("new tag should've been created"); 163 | 164 | // WHEN 165 | let rows_affected = rename_tag_name(&fx.pool, "old-tag-1".to_string(), new_tag) 166 | .await 167 | .expect("result should've been a success"); 168 | 169 | // THEN 170 | assert_eq!(rows_affected, 1); 171 | 172 | let tags = get_tags(&fx.pool) 173 | .await 174 | .expect("tags should've been fetched"); 175 | assert_yaml_snapshot!(tags, @r" 176 | - new-tag 177 | - old-tag-2 178 | "); 179 | } 180 | 181 | #[tokio::test] 182 | async fn renaming_tag_works_when_new_tag_already_exists() { 183 | // GIVEN 184 | let fx = DBPoolFixture::new().await; 185 | let uris = [ 186 | ("https://uri-one.com", None, vec!["tag1", "tag2"]), 187 | ("https://uri-two.com", None, vec!["tag2", "tag4"]), 188 | ("https://uri-three.com", None, vec!["tag1", "tag3"]), 189 | ("https://uri-four.com", None, vec!["tag1"]), 190 | ("https://uri-five.com", None, vec!["tag3"]), 191 | ]; 192 | 193 | let start = SystemTime::now(); 194 | let since_the_epoch = start.duration_since(UNIX_EPOCH).unwrap(); 195 | let now = since_the_epoch.as_secs() as i64; 196 | 197 | for (uri, title, tags) in uris { 198 | let draft_bookmark = 199 | DraftBookmark::try_from(PotentialBookmark::from((uri, title, &tags))) 200 | .expect("draft bookmark should be initialized"); 201 | create_or_update_bookmark( 202 | &fx.pool, 203 | &draft_bookmark, 204 | now, 205 | SaveBookmarkOptions::default(), 206 | ) 207 | .await 208 | .expect("bookmark should be saved in db"); 209 | } 210 | 211 | let new_tag = Tag::try_from("tag3").expect("new tag should've been created"); 212 | let tags_before = get_tags(&fx.pool) 213 | .await 214 | .expect("tags before should've been fetched"); 215 | assert_eq!(tags_before.len(), 4, "tags before wasn't what was expected"); 216 | 217 | // WHEN 218 | let rows_affected = rename_tag_name(&fx.pool, "tag1".to_string(), new_tag) 219 | .await 220 | .expect("result should've been a success"); 221 | 222 | // THEN 223 | assert_eq!(rows_affected, 1); 224 | 225 | let tags = get_tags(&fx.pool) 226 | .await 227 | .expect("tags should've been fetched"); 228 | assert_yaml_snapshot!(tags, @r" 229 | - tag2 230 | - tag3 231 | - tag4 232 | "); 233 | 234 | let bookmark_one = get_bookmark_with_exact_uri(&fx.pool, "https://uri-one.com") 235 | .await 236 | .expect("bookmark should've been fetched") 237 | .expect("bookmark should've been present"); 238 | assert_yaml_snapshot!(bookmark_one, @r#" 239 | uri: "https://uri-one.com" 240 | title: ~ 241 | tags: "tag2,tag3" 242 | "#); 243 | 244 | let bookmark_two = get_bookmark_with_exact_uri(&fx.pool, "https://uri-two.com") 245 | .await 246 | .expect("bookmark should've been fetched") 247 | .expect("bookmark should've been present"); 248 | assert_yaml_snapshot!(bookmark_two, @r#" 249 | uri: "https://uri-two.com" 250 | title: ~ 251 | tags: "tag2,tag4" 252 | "#); 253 | 254 | let bookmark_three = get_bookmark_with_exact_uri(&fx.pool, "https://uri-three.com") 255 | .await 256 | .expect("bookmark should've been fetched") 257 | .expect("bookmark should've been present"); 258 | assert_yaml_snapshot!(bookmark_three, @r#" 259 | uri: "https://uri-three.com" 260 | title: ~ 261 | tags: tag3 262 | "#); 263 | 264 | let bookmark_four = get_bookmark_with_exact_uri(&fx.pool, "https://uri-four.com") 265 | .await 266 | .expect("bookmark should've been fetched") 267 | .expect("bookmark should've been present"); 268 | assert_yaml_snapshot!(bookmark_four, @r#" 269 | uri: "https://uri-four.com" 270 | title: ~ 271 | tags: tag3 272 | "#); 273 | 274 | let bookmark_five = get_bookmark_with_exact_uri(&fx.pool, "https://uri-five.com") 275 | .await 276 | .expect("bookmark should've been fetched") 277 | .expect("bookmark should've been present"); 278 | assert_yaml_snapshot!(bookmark_five, @r#" 279 | uri: "https://uri-five.com" 280 | title: ~ 281 | tags: tag3 282 | "#); 283 | } 284 | 285 | #[tokio::test] 286 | async fn renaming_non_existent_tag_doesnt_fail() { 287 | // GIVEN 288 | let fx = DBPoolFixture::new().await; 289 | let uri = "https://github.com/launchbadge/sqlx"; 290 | let draft_bookmark = DraftBookmark::try_from(PotentialBookmark::from(( 291 | uri, 292 | None, 293 | &vec!["old-tag-1", "old-tag-2"], 294 | ))) 295 | .expect("draft bookmark should be initialized"); 296 | 297 | let start = SystemTime::now(); 298 | let since_the_epoch = start.duration_since(UNIX_EPOCH).unwrap(); 299 | let now = since_the_epoch.as_secs() as i64; 300 | 301 | create_or_update_bookmark( 302 | &fx.pool, 303 | &draft_bookmark, 304 | now, 305 | SaveBookmarkOptions::default(), 306 | ) 307 | .await 308 | .expect("bookmark should be saved in db"); 309 | let new_tag = Tag::try_from("new-tag").expect("new tag should've been created"); 310 | 311 | // WHEN 312 | let rows_affected = rename_tag_name(&fx.pool, "old-tag-3".to_string(), new_tag) 313 | .await 314 | .expect("result should've been a success"); 315 | 316 | // THEN 317 | assert_eq!(rows_affected, 0); 318 | } 319 | } 320 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | use crate::cli::{ 2 | CouldntGetDetailsViaEditorError, DeleteBookmarksError, DeleteTagsError, ImportError, 3 | ListBookmarksError, ListTagsError, ParsingTempFileContentError, RenameTagError, 4 | SaveBookmarkError, SaveBookmarksError, SearchBookmarksError, ShowBookmarkError, 5 | }; 6 | use crate::common::{ENV_VAR_BMM_EDITOR, ENV_VAR_EDITOR, IMPORT_FILE_FORMATS}; 7 | use crate::persistence::DBError; 8 | use crate::tui::AppTuiError; 9 | use crate::utils::DataDirError; 10 | use std::io::Error as IOError; 11 | 12 | const IMPORT_EXAMPLE_JSON: &str = include_str!("static/import-example.json"); 13 | const IGNORE_ERRORS_MESSAGE: &str = "Possible workaround: running with -i/--ignore-attribute-errors might fix some attribute errors. 14 | If a title is too long, it'll will be trimmed, and some invalid tags might be transformed to fit bmm's requirements."; 15 | 16 | #[derive(thiserror::Error, Debug)] 17 | pub enum AppError { 18 | // data related 19 | #[error(transparent)] 20 | CouldntGetDataDirectory(DataDirError), 21 | #[error("could not create data directory: {0}")] 22 | CouldntCreateDataDirectory(IOError), 23 | #[error("couldn't initialize bmm's database: {0}")] 24 | CouldntInitializeDatabase(#[from] DBError), 25 | #[error("database path is not valid string")] 26 | DBPathNotValidStr, 27 | 28 | // bookmarks related 29 | #[error("couldn't import bookmarks: {0}")] 30 | CouldntImportBookmarks(#[from] ImportError), 31 | #[error("couldn't list bookmarks: {0}")] 32 | CouldntListBookmarks(#[from] ListBookmarksError), 33 | #[error("couldn't search bookmarks: {0}")] 34 | CouldntSearchBookmarks(#[from] SearchBookmarksError), 35 | #[error("couldn't save bookmark: {0}")] 36 | CouldntSaveBookmark(#[from] SaveBookmarkError), 37 | #[error("couldn't save bookmarks: {0}")] 38 | CouldntSaveBookmarks(#[from] SaveBookmarksError), 39 | #[error("couldn't show bookmark details: {0}")] 40 | CouldntShowBookmark(#[from] ShowBookmarkError), 41 | #[error("couldn't delete bookmarks: {0}")] 42 | CouldntDeleteBookmarks(#[from] DeleteBookmarksError), 43 | 44 | // tags related 45 | #[error("couldn't list tags: {0}")] 46 | CouldntListTags(#[from] ListTagsError), 47 | #[error("couldn't rename tag: {0}")] 48 | CouldntRenameTag(#[from] RenameTagError), 49 | #[error("couldn't delete tag(s): {0}")] 50 | CouldntDeleteTag(#[from] DeleteTagsError), 51 | 52 | // tui related 53 | #[error("couldn't run bmm's TUI: {0}")] 54 | CouldntRunTui(#[from] AppTuiError), 55 | } 56 | 57 | impl AppError { 58 | pub fn code(&self) -> Option { 59 | match self { 60 | AppError::CouldntGetDataDirectory(e) => match e { 61 | #[cfg(target_family = "unix")] 62 | DataDirError::XDGDataHomeNotAbsolute => None, 63 | DataDirError::CouldntGetDataDir => Some(100), 64 | }, 65 | AppError::CouldntCreateDataDirectory(_) => Some(101), 66 | AppError::CouldntInitializeDatabase(_) => Some(102), 67 | AppError::DBPathNotValidStr => None, 68 | AppError::CouldntImportBookmarks(e) => match e { 69 | ImportError::FileHasNoExtension => None, 70 | ImportError::FileDoesntExist => None, 71 | ImportError::CouldntOpenFile(_) => None, 72 | ImportError::CouldntReadFile(_) => None, 73 | ImportError::CouldntDeserializeJSONInput(_) => None, 74 | ImportError::CouldntParseHTMLInput(_) => None, 75 | ImportError::FileFormatNotSupported(_) => None, 76 | ImportError::UnexpectedError(_) => Some(300), 77 | ImportError::TooManyBookmarks(_) => None, 78 | ImportError::ValidationError { .. } => None, 79 | ImportError::SaveError(_) => Some(301), 80 | }, 81 | AppError::CouldntListBookmarks(e) => match e { 82 | ListBookmarksError::CouldntGetBookmarksFromDB(_) => Some(400), 83 | ListBookmarksError::CouldntDisplayResults(_) => Some(401), 84 | }, 85 | AppError::CouldntSaveBookmark(e) => match e { 86 | SaveBookmarkError::CouldntCheckIfBookmarkExists(_) => Some(500), 87 | SaveBookmarkError::UriAlreadySaved => None, 88 | SaveBookmarkError::BookmarkDetailsAreInvalid(_) => None, 89 | SaveBookmarkError::CouldntSaveBookmark(_) => Some(501), 90 | SaveBookmarkError::CouldntUseTextEditor(se) => match se { 91 | CouldntGetDetailsViaEditorError::CreateTempFile(_) => Some(550), 92 | CouldntGetDetailsViaEditorError::OpenTempFile(_) => Some(551), 93 | CouldntGetDetailsViaEditorError::WriteToTempFile(_) => Some(552), 94 | CouldntGetDetailsViaEditorError::CouldntFindEditorExe(..) => None, 95 | CouldntGetDetailsViaEditorError::OpenTextEditor(_, _) => Some(553), 96 | CouldntGetDetailsViaEditorError::ReadTempFileContents(_) => Some(554), 97 | CouldntGetDetailsViaEditorError::InvalidEditorEnvVar(_) => None, 98 | CouldntGetDetailsViaEditorError::NoEditorConfigured => None, 99 | CouldntGetDetailsViaEditorError::ParsingEditorText(pe) => match pe { 100 | ParsingTempFileContentError::IncorrectRegexError(_) => Some(560), 101 | ParsingTempFileContentError::InputMissing => None, 102 | }, 103 | }, 104 | SaveBookmarkError::UnexpectedError(_) => Some(580), 105 | }, 106 | AppError::CouldntShowBookmark(e) => match e { 107 | ShowBookmarkError::CouldntGetBookmarkFromDB(_) => Some(600), 108 | ShowBookmarkError::BookmarkDoesntExist => None, 109 | }, 110 | AppError::CouldntListTags(e) => match e { 111 | ListTagsError::CouldntGetTagsFromDB(_) => Some(700), 112 | ListTagsError::CouldntDisplayResults(_) => Some(701), 113 | ListTagsError::CouldntRunTui(e) => Some(e.code()), 114 | }, 115 | AppError::CouldntDeleteBookmarks(e) => match e { 116 | DeleteBookmarksError::CouldntDeleteBookmarksInDB(_) => Some(800), 117 | DeleteBookmarksError::CouldntFlushStdout(_) => Some(801), 118 | DeleteBookmarksError::CouldntReadUserInput(_) => Some(802), 119 | }, 120 | AppError::CouldntRenameTag(e) => match e { 121 | RenameTagError::SourceAndTargetSame => None, 122 | RenameTagError::NoSuchTag => None, 123 | RenameTagError::CouldntRenameTag(_) => Some(900), 124 | RenameTagError::TagIsInvalid => None, 125 | }, 126 | AppError::CouldntRunTui(e) => Some(e.code()), 127 | AppError::CouldntDeleteTag(e) => match e { 128 | DeleteTagsError::CouldntFlushStdout(_) => Some(1000), 129 | DeleteTagsError::CouldntReadUserInput(_) => Some(1001), 130 | DeleteTagsError::CouldntCheckIfTagsExist(_) => Some(1002), 131 | DeleteTagsError::TagsDoNotExist(_) => None, 132 | DeleteTagsError::CouldntDeleteTags(_) => Some(1003), 133 | }, 134 | AppError::CouldntSaveBookmarks(e) => match e { 135 | SaveBookmarksError::CouldntReadStdin(_) => Some(2001), 136 | SaveBookmarksError::TooManyBookmarks(_) => None, 137 | SaveBookmarksError::ValidationError { .. } => None, 138 | SaveBookmarksError::SaveError(_) => Some(2002), 139 | SaveBookmarksError::UnexpectedError(_) => Some(2003), 140 | }, 141 | AppError::CouldntSearchBookmarks(e) => match e { 142 | SearchBookmarksError::SearchQueryInvalid(_) => None, 143 | SearchBookmarksError::CouldntGetBookmarksFromDB(_) => Some(3000), 144 | SearchBookmarksError::CouldntDisplayResults(_) => Some(3001), 145 | SearchBookmarksError::CouldntRunTui(e) => Some(e.code()), 146 | }, 147 | } 148 | } 149 | 150 | pub fn follow_up(&self) -> Option { 151 | match self { 152 | AppError::CouldntGetDataDirectory(e) => match e { 153 | #[cfg(target_family = "unix")] 154 | DataDirError::XDGDataHomeNotAbsolute => 155 | Some("Context: XDG specifications dictate that XDG_DATA_HOME must be an absolute path. 156 | Read more here: https://specifications.freedesktop.org/basedir-spec/latest/#basics".into()), 157 | DataDirError::CouldntGetDataDir => 158 | Some("Possible workaround: manually specify the path for bmm's database using --db-path".into()) 159 | }, 160 | AppError::CouldntImportBookmarks(e) => match e { 161 | ImportError::FileHasNoExtension => Some(format!("bmm can only import from files with one of these extensions: {IMPORT_FILE_FORMATS:?}")), 162 | ImportError::ValidationError { .. } => Some(IGNORE_ERRORS_MESSAGE.into()), 163 | ImportError::CouldntDeserializeJSONInput(_) => 164 | Some(format!("Suggestion: ensure the file is valid JSON and looks like the following: 165 | 166 | {IMPORT_EXAMPLE_JSON}" )), 167 | _ => None, 168 | }, 169 | AppError::CouldntSaveBookmark(e) => match e { 170 | SaveBookmarkError::BookmarkDetailsAreInvalid(_) => Some(IGNORE_ERRORS_MESSAGE.into()), 171 | SaveBookmarkError::CouldntUseTextEditor(se) => match se { 172 | CouldntGetDetailsViaEditorError::CouldntFindEditorExe(editor_exe, env_var_used, _) => 173 | Some(format!(r#"Context: bmm used the environment variable {env_var_used} to determine your text editor. 174 | Check if "{editor_exe}" actually points to your text editor's executable."#)), 175 | CouldntGetDetailsViaEditorError::NoEditorConfigured => 176 | Some(format!("Suggestion: set the environment variables {ENV_VAR_BMM_EDITOR} or {ENV_VAR_EDITOR} to use this feature")), 177 | CouldntGetDetailsViaEditorError::ParsingEditorText(ParsingTempFileContentError::InputMissing) => 178 | Some("Suggestion: enter the details between the >>>/<<< markers without changing the structure of the document".into()), 179 | _ => None, 180 | }, 181 | SaveBookmarkError::UnexpectedError(_) => None, 182 | _ => None, 183 | }, 184 | AppError::CouldntSaveBookmarks(SaveBookmarksError::ValidationError { .. }) => Some(IGNORE_ERRORS_MESSAGE.into()), 185 | _ => None, 186 | } 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |

bmm

3 |

4 | Build status 5 | crates.io 6 | Latest Release 7 | Commits Since Latest Release 8 |

9 |

10 | 11 | `bmm` (stands for "bookmarks manager") lets you get to your bookmarks in a 12 | flash. 13 | 14 | ![tui-2](https://github.com/user-attachments/assets/a3dc5fb7-d258-461e-86b5-f2498dfbd4dc) 15 | 16 | It does so by storing your bookmarks locally, allowing you to quickly access, 17 | manage, and search through them using various commands. `bmm` has a traditional 18 | command line interface that can be used standalone and/or integrated with other 19 | tools, and a textual user interface for easy browsing. 20 | 21 | 🤔 Motivation 22 | --- 23 | 24 | I'd been using [buku](https://github.com/jarun/buku) for managing my bookmarks 25 | via the command line. It's a fantastic tool, but I was noticing some slowdown 26 | after years of collecting bookmarks in it. I was curious if I could replicate 27 | the subset of its functionality that I used while improving search performance. 28 | Additionally, I missed having a TUI to browse bookmarks in. `bmm` started out as 29 | a way to fulfill both goals. Turns out, it runs quite a lot faster than `buku` 30 | (check out benchmarks 31 | [here](https://github.com/dhth/bmm/actions/workflows/bench.yml)). I've now moved 32 | my bookmark management completely to `bmm`, but `buku` remains an excellent 33 | tool, and those looking for a broader feature set should definitely check it 34 | out. 35 | 36 | 💾 Installation 37 | --- 38 | 39 | **homebrew**: 40 | 41 | ```sh 42 | brew install dhth/tap/bmm 43 | ``` 44 | 45 | **cargo**: 46 | 47 | ```sh 48 | cargo install bmm 49 | ``` 50 | 51 | Or get the binaries directly from a Github [release][1]. Read more about 52 | verifying the authenticity of released artifacts 53 | [here](#-verifying-release-artifacts). 54 | 55 | ⚡️ Usage 56 | --- 57 | 58 | ```text 59 | Usage: bmm [OPTIONS] 60 | 61 | Commands: 62 | import Import bookmarks from various sources 63 | delete Delete bookmarks 64 | list List bookmarks based on several kinds of queries 65 | save Save/update a bookmark 66 | save-all Save/update multiple bookmarks 67 | search Search bookmarks by matching over terms 68 | show Show bookmark details 69 | tags Interact with tags 70 | tui Open bmm's TUI 71 | help Print this message or the help of the given subcommand(s) 72 | 73 | Options: 74 | --db-path Override bmm's database location (default: /bmm/bmm.db) 75 | --debug Output debug information without doing anything 76 | -h, --help Print help (see more with '--help') 77 | ``` 78 | 79 | ⌨ CLI mode 80 | --- 81 | 82 | `bmm` allows every action it supports to be performed via its CLI. As such, it 83 | can be easily integrated with other search tools (eg. 84 | [Alfred](https://www.alfredapp.com/), [fzf](https://github.com/junegunn/fzf), 85 | etc.) 86 | 87 | ![cli](https://github.com/user-attachments/assets/f8493e7c-8286-4fa4-8d49-6f34b5c5044b) 88 | 89 | ### Importing existing bookmarks 90 | 91 | `bmm` allows importing bookmarks from various sources. It supports the following 92 | input formats: 93 | 94 | - HTML (These are bookmark files exported by browsers like Firefox, Chrome, etc, 95 | in the NETSCAPE-Bookmark-file-1 format.) 96 | - JSON 97 | - TXT 98 | 99 | ```bash 100 | bmm import firefox.html 101 | bmm import bookmarks.json --dry-run 102 | 103 | # overwrite already saved attributes (title and tags) while importing 104 | bmm import bookmarks.txt --reset-missing-details 105 | 106 | # ignore errors related to bookmark title and tags 107 | # if title is too long, it'll be trimmed, some invalid tags will be corrected 108 | bmm import bookmarks.txt --ignore-attribute-errors 109 | ``` 110 | 111 |
An example HTML file 112 | 113 | ```html 114 | 115 | 118 | 119 | 121 | Bookmarks 122 |

Bookmarks Menu

123 | 124 |

125 |

Bookmarks Toolbar

126 |

127 |

productivity

128 |

129 |

crates

130 |

131 |

sqlx - crates.io: Rust Package Registry 132 |

133 |

GitHub - dhth/omm: on-my-mind: a keyboard-driven task manager for the command line 134 |
GitHub - dhth/hours: A no-frills time tracking toolkit for command line nerds 135 |

136 |

GitHub - dhth/bmm: get to your bookmarks in a flash 137 |

138 |

139 | ``` 140 |
141 | 142 |
An example JSON file 143 | 144 | ```json 145 | [ 146 | { 147 | "uri": "https://github.com/dhth/bmm", 148 | "title": null, 149 | "tags": "tools,bookmarks" 150 | }, 151 | { 152 | "uri": "https://github.com/dhth/omm", 153 | "title": "on-my-mind: a keyboard-driven task manager for the command line", 154 | "tags": null 155 | } 156 | ] 157 | ``` 158 |
159 | 160 |
An example TXT file 161 | 162 | ```text 163 | https://github.com/dhth/bmm 164 | https://github.com/dhth/omm 165 | https://github.com/dhth/hours 166 | ``` 167 |
168 | 169 | ### Saving/updating a bookmark 170 | 171 | ```bash 172 | # save a new URI 173 | bmm save https://github.com/dhth/bmm 174 | 175 | # save a new URI with title and tags 176 | bmm save https://github.com/dhth/omm \ 177 | --title 'a keyboard-driven task manager for the command line' \ 178 | --tags 'tools,productivity' 179 | 180 | # update the title of a previously saved bookmark 181 | bmm save https://github.com/dhth/bmm \ 182 | --title 'yet another bookmarking tool' 183 | 184 | # append to the tags of a previously saved bookmark 185 | bmm save https://github.com/dhth/omm \ 186 | --tags 'task-manager' 187 | 188 | # use your editor to provide details 189 | bmm save https://github.com/dhth/bmm -e 190 | ``` 191 | 192 | ### Saving/updating several bookmarks at a time 193 | 194 | ```bash 195 | # save/update multiple bookmarks via arguments 196 | bmm save \ 197 | 'https://github.com/dhth/bmm' \ 198 | 'https://github.com/dhth/omm' \ 199 | --tags 'cli,bookmarks' 200 | 201 | # save/update multiple bookmarks via stdin 202 | cat << EOF | bmm save --tags tools --reset-missing-details -s 203 | https://github.com/dhth/bmm 204 | https://github.com/dhth/omm 205 | https://github.com/dhth/hours 206 | EOF 207 | ``` 208 | 209 | ### Listing bookmarks based on several queries 210 | 211 | `bmm` allows listing bookmarks based on queries on bookmark uri/title/tags. The 212 | first two are pattern matched, while the last is matched exactly. 213 | 214 | ```bash 215 | bmm list --uri 'github.com' \ 216 | --title 'command line' \ 217 | --tags 'tools,productivity' \ 218 | --format json 219 | ``` 220 | 221 | ### Searching bookmarks by terms 222 | 223 | Sometimes you want to search for bookmarks without being very granular. The 224 | `search` command allows you to do so. It accepts a list of terms, and will 225 | return bookmarks where all of the terms are matched over any attribute or tag 226 | belonging to a bookmark. You can also open the results in `bmm`'s TUI. 227 | 228 | ```bash 229 | # search bookmarks based on search terms 230 | bmm search cli rust tool bookmarks --format delimited 231 | 232 | # open search results in bmm's TUI 233 | bmm search cli rust tool bookmarks --tui 234 | ``` 235 | 236 | ### Show bookmark details 237 | 238 | ```bash 239 | bmm show 'https://github.com/dhth/bmm' 240 | ``` 241 | 242 | ### Interaction with tags 243 | 244 | ```bash 245 | # Show saved tags 246 | bmm tags list \ 247 | --format json \ 248 | --show-stats 249 | 250 | # open saved tags in bmm's TUI 251 | bmm tags list --tui 252 | 253 | # rename tag 254 | bmm tags rename old-tag new-tag 255 | 256 | # delete tags 257 | bmm tags delete tag1 tag2 tag3 258 | ``` 259 | 260 | ### Delete bookmarks 261 | 262 | ```bash 263 | bmm delete 'https://github.com/dhth/bmm' 'https://github.com/dhth/omm' 264 | 265 | # skip confirmation 266 | bmm delete --yes 'https://github.com/dhth/bmm' 267 | ``` 268 | 269 | 📟 TUI mode 270 | --- 271 | 272 | To allow for easy browsing, `bmm` ships with its own TUI. It can be launched 273 | either in a generic mode (via `bmm tui`) or in the context of a specific command 274 | (e.g., `bmm search tools --tui`). 275 | 276 | The TUI lets you do the following: 277 | 278 | - Search bookmarks based on terms 279 | - List all tags 280 | - View bookmarks that hold a tag 281 | 282 | Feature requests for the TUI can be submitted via `bmm`'s [issues 283 | page](https://github.com/dhth/bmm/issues). 284 | 285 | ![tui](https://github.com/user-attachments/assets/6ca63039-8872-4520-93da-1576cc0cf8ec) 286 | 287 | ### TUI Reference Manual 288 | 289 | ```text 290 | bmm has three views. 291 | 292 | - Bookmarks List View 293 | - Tags List View 294 | - Help View 295 | 296 | Keymaps 297 | --- 298 | 299 | General 300 | ? show/hide help view 301 | Esc / q go back/reset input/exit 302 | j / Down go down in a list 303 | k / Up go up in a list 304 | 305 | Bookmarks List View 306 | s show search input 307 | Enter submit search query 308 | t show Tags List View (when search is not active) 309 | o open URI in browser 310 | y copy URI under cursor to system clipboard 311 | Y copy all URIs to system clipboard 312 | 313 | Tags List View 314 | Enter show bookmarks that are tagged with the one under cursor 315 | ``` 316 | 317 | 🔐 Verifying release artifacts 318 | --- 319 | 320 | In case you get the `bmm` binary directly from a [release][1], you may want to 321 | verify its authenticity. Checksums are applied to all released artifacts, and 322 | the resulting checksum file is attested using [Github Attestations][2]. 323 | 324 | Steps to verify (replace `A.B.C` in the commands below with the version you 325 | want): 326 | 327 | 1. Download the sha256 checksum file for your platform from the release: 328 | 329 | ```shell 330 | curl -sSLO https://github.com/dhth/bmm/releases/download/vA.B.C/bmm-x86_64-unknown-linux-gnu.tar.xz.sha256 331 | ``` 332 | 333 | 2. Verify the integrity of the checksum file using [gh][3]. 334 | 335 | ```shell 336 | gh attestation verify bmm-x86_64-unknown-linux-gnu.tar.xz.sha256 --repo dhth/bmm 337 | ``` 338 | 339 | 3. Download the compressed archive you want, and validate its checksum: 340 | 341 | ```shell 342 | curl -sSLO https://github.com/dhth/bmm/releases/download/vA.B.C/bmm-x86_64-unknown-linux-gnu.tar.xz 343 | sha256sum --ignore-missing -c bmm-x86_64-unknown-linux-gnu.tar.xz.sha256 344 | ``` 345 | 346 | 3. If checksum validation goes through, uncompress the archive: 347 | 348 | ```shell 349 | tar -xzf bmm-x86_64-unknown-linux-gnu.tar.xz 350 | cd bmm-x86_64-unknown-linux-gnu 351 | ./bmm 352 | # profit! 353 | ``` 354 | 355 | 🙏 Acknowledgements 356 | --- 357 | 358 | `bmm` sits on the shoulders of the following crates: 359 | 360 | - [clap](https://crates.io/crates/clap) 361 | - [csv](https://crates.io/crates/csv) 362 | - [dirs](https://crates.io/crates/dirs) 363 | - [lazy_static](https://crates.io/crates/lazy_static) 364 | - [once_cell](https://crates.io/crates/once_cell) 365 | - [open](https://crates.io/crates/open) 366 | - [ratatui](https://crates.io/crates/ratatui) 367 | - [regex](https://crates.io/crates/regex) 368 | - [select](https://crates.io/crates/select) 369 | - [serde](https://crates.io/crates/serde) 370 | - [serde_json](https://crates.io/crates/serde_json) 371 | - [sqlx](https://crates.io/crates/sqlx) 372 | - [tempfile](https://crates.io/crates/tempfile) 373 | - [thiserror](https://crates.io/crates/thiserror) 374 | - [tokio](https://crates.io/crates/tokio) 375 | - [input](https://crates.io/crates/tui-input) 376 | - [url](https://crates.io/crates/url) 377 | - [which](https://crates.io/crates/which) 378 | 379 | [1]: https://github.com/dhth/bmm/releases 380 | [2]: https://github.blog/news-insights/product-news/introducing-artifact-attestations-now-in-public-beta/ 381 | [3]: https://github.com/cli/cli 382 | --------------------------------------------------------------------------------