diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000..110d9f7 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,12 @@ +[profile.default] +# Fail fast: stop after first test failure +fail-fast = false + +# Mark tests slow after 5s, fail after 30s +slow-timeout = { period = "5s", terminate-after = 6 } + +[[profile.default.overrides]] +# Network integration tests: allow retries and more time +filter = "test(test_fetch)" +retries = 2 +slow-timeout = { period = "10s", terminate-after = 3 } diff --git a/.env b/.env deleted file mode 100644 index 435b7db..0000000 --- a/.env +++ /dev/null @@ -1,6 +0,0 @@ - - -BASE_URL = "https://www.rust-ui.com/registry/index.json" -URL_CONFIG_SCHEMA_JSON = "https://www.rust-ui.com/schema.json" -URL_REGISTRY_STYLES_JSON = "https://www.rust-ui.com/registry/styles/index.json" -BASE_URL_STYLES_DEFAULT = "https://www.rust-ui.com/registry/styles/default" \ No newline at end of file diff --git a/.gitignore b/.gitignore index e629ca6..8ba7086 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,32 @@ # Generated by Cargo # will have compiled files and executables -/target/ +**/target/ pkg Cargo.lock # These are backup files generated by rustfmt **/*.rs.bk -# node e2e test tools and outputs -node_modules/ -test-results/ -end2end/playwright-report/ -playwright/.cache/ +# Init & Add +/node_modules/ +/pnpm-lock.yaml +style/ +tailwind.config.js +ui_config.toml +package.json +src/components/ui/* +src/components/demos/* -**/target/ + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# Apple +**/.DS_Store +**/.Icon? +**/.Icon + + + +start-leptos-ssr-workspace/ +_TMP/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..282b5d0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,122 @@ +# rust-ui CLI — Changelog + +## Unreleased + +--- + +## 0.3.11 + +### Added + +- `ui init` — interactive base color picker (Neutral / Stone / Zinc / Mauve / Olive / Mist / Taupe) and accent color picker (Default / Amber / Blue / … / Yellow); selections are saved to `ui_config.toml` and the generated `tailwind.css` uses the exact OKLCH values; `--yes` / `--force` skips prompts and uses defaults +- `ui add` — deprecated component warnings: warns and exits when a deprecated component is requested, pointing to the replacement (e.g. `toast` → use `sonner`) +- `ui init --yes` / `-y` and `--force` / `-f` — skip confirmation prompts and force overwrite existing files without prompting +- `ui init --reinstall` — re-download and overwrite all already-installed components after init; prompts automatically when existing components are detected on a re-run +- `ui init` — backup/restore on failure: `ui_config.toml` is backed up before writing and automatically restored if the init process fails (RAII `FileBackup` guard) + +--- + +## 0.3.10 + +### Fixed + +- MCP server now correctly advertises the `tools` capability in the initialize response, so Claude Code and other clients properly discover and register tools on session start + +--- + +## 0.3.9 + +### Added + +- `ui mcp` — starts a stdio MCP server exposing rust-ui registry tools to AI editors (Claude Code, Cursor, VS Code, OpenCode) +- `ui mcp init --client ` — writes the editor config file so your AI editor auto-connects to the MCP server on startup +- MCP tools available: `list_components`, `search_components`, `view_component`, `get_add_command`, `get_audit_checklist` +- `ui view ` — prints a component's source from the registry without installing it; supports `--json` +- `ui add --path ` — override the output directory for components, bypassing `base_path_components` from `ui_config.toml` +- `ui diff` — shows a line-by-line diff of installed components vs the registry; supports a single component (`ui diff button`) or all installed components at once; supports `--json` for machine-readable output + +--- + +## 0.3.8 + +### Added + +- `ui update` — checks all installed components against the registry; reports `up to date`, `outdated`, or `not in registry` per component; suggests the exact `ui add -y` command to fix each; supports `--json` +- `ui search ` — filters the registry by name (case-insensitive); supports `--json` for scripted output +- `ui list --json` — machine-readable JSON output for `ui list` (`{ total, categories: { ... } }`) +- `ui list` — lists all available components from the registry grouped by category (grep-friendly, one component per line) +- `ui info --json` — machine-readable JSON output for `ui info`, useful for scripting and AI tooling +- `ui docs` — opens `https://rust-ui.com` in the system default browser (cross-platform: `open` / `xdg-open` / `start`) +- `ui add --dry-run` / `-n` — resolves all dependencies and previews which files would be written, overwritten, or skipped without touching the filesystem; output is sorted for determinism + +--- + +## 0.3.7 + +### Added + +- `ui info` — prints project config (`ui_config.toml`), base color, base path, workspace detection, and all installed components with count +- `ui add --yes` / `-y` — skips the overwrite prompt and forces all files to be written +- Overwrite prompt on `add` — when a component file already exists, the user is asked before overwriting (requires a TTY; bypassed with `--yes`) +- Explicit summary after `add`: + - `✅ Added:` — newly written files + - `⏭ Skipped:` — existing files the user chose not to overwrite + - `📦 Dep already installed:` — auto-resolved dependency components already on disk (no silent skips) + +### Changed + +- Auto-resolved dependency components that are already installed no longer trigger the overwrite prompt; they are reported separately in the summary + +--- + +## 0.3.6 + +### Added + +- `ui add` reads tailwind input file path from `[package.metadata.leptos]` in `Cargo.toml` +- Workspace-aware Cargo dep injection: detects workspace root and uses `[workspace.dependencies]` when available + +### Changed + +- Removed deprecated starter templates +- Upgraded ratatui to 0.30 + +--- + +## 0.3.5 + +### Added + +- JS file dependency support in `add`: downloads JS files to `public/` alongside Rust components + +--- + +## 0.3.4 + +### Added + +- Interactive TUI picker (ratatui) with tabs: Components, Hooks, Blocks, Icons, Demos, Settings +- Installed components highlighted in TUI list +- Dependency detail panel in TUI +- Footer keyboard shortcuts (`Ctrl+letter` to jump between tabs) +- Unit tests for TUI logic + +--- + +## 0.1.5 + +### Added + +- `ui starters` — choose and clone starter templates (Tauri, Tauri Fullstack) +- Registry-based component fetching (`add` reads from remote registry) +- Automatic `mod.rs` registration on `add` +- Automatic `pub mod components` registration in `lib.rs` / `main.rs` +- Cargo dependency injection on `add` + +--- + +## 0.1.4 and earlier + +- Initial `ui init` command: scaffolds `ui_config.toml`, installs Tailwind CSS config, wires Leptos dependencies +- Initial `ui add ` command: fetches components from registry by name with dependency resolution +- Workspace detection and multi-crate support diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9fb3d01 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,49 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +### Building and Testing +```bash +# Run tests (preferred) +cargo nextest run + +# Run tests (fallback if nextest not installed) +cargo test + +# For manual end-to-end testing, use the _TMP/ directory (gitignored) as a throwaway project. + +# Run with specific commands (examples from main.rs) +cargo run --bin ui init +cargo run --bin ui add button demo_button demo_button_variants demo_button_sizes +cargo run --bin ui add demo_use_floating_placement +cargo run --bin ui starters +``` + + +### Project Structure +``` +crates/ui-cli/ +├── src/ +│ ├── command_add/ # Component installation logic +│ ├── command_diff/ # ui diff command +│ ├── command_docs/ # ui docs command +│ ├── command_info/ # ui info command +│ ├── command_init/ # Project initialization +│ ├── command_list/ # ui list command +│ ├── command_mcp/ # ui mcp server + mcp init +│ ├── command_search/ # ui search command +│ ├── command_starters/ # Starter template cloning +│ ├── command_update/ # ui update command +│ ├── command_view/ # ui view command +│ └── shared/ # Shared utilities +└── Cargo.toml # Binary configuration +``` + +## Workflow Rules + +- **CHANGELOG.md**: Update `CHANGELOG.md` every time a new feature, fix, or change is added. New work goes under `## Unreleased`. Keep entries concise and user-facing. +- **Version**: When bumping the crate version in `Cargo.toml`, move `## Unreleased` entries to the new version section in `CHANGELOG.md` at the same time. + - **DEFAULT**: Always bump the **minor** version only (e.g. `0.3.7` → `0.3.8`). Never bump major or minor segment (e.g. `0.4.0`) without explicitly asking the user first. + diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..47b65d1 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3574 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml", +] + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colored" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "console" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.61.2", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", +] + +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "shell-words", + "tempfile", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "euclid" +version = "0.22.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad9cdb4b747e485a12abb0e6566612956c7a1bafa3bdb8d682c5b6d403589e48" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + +[[package]] +name = "indexmap" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "indicatif" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88" +dependencies = [ + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" +dependencies = [ + "hashbrown", + "portable-atomic", + "thiserror 2.0.17", +] + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.179" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "line-clipping" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96051b46fc183dc9cd4a223960ef37b9af631b55191852a8274bfef064cda20f" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memmap2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +dependencies = [ + "libc", +] + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pastey" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "pest_meta" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags 2.10.0", + "compact_str", + "hashbrown", + "indoc", + "itertools 0.14.0", + "kasuari", + "lru", + "strum", + "thiserror 2.0.17", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.10.0", + "hashbrown", + "indoc", + "instability", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +dependencies = [ + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg", +] + +[[package]] +name = "rgb" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmcp" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2cb14cb9278a12eae884c9f3c0cfeca2cc28f361211206424a1d7abed95f090" +dependencies = [ + "async-trait", + "base64", + "chrono", + "futures", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02ea81d9482b07e1fe156ac7cf98b6823d51fb84531936a5e1cbb4eec31ad5" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.114", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.114", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo", + "siphasher", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.10.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.9.10+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "ui-cli" +version = "0.3.11" +dependencies = [ + "cargo_toml", + "clap", + "colored", + "crossterm", + "dialoguer", + "glob", + "heck", + "indicatif", + "ratatui", + "reqwest", + "resvg", + "rmcp", + "schemars", + "serde", + "serde_json", + "similar", + "strum", + "tempfile", + "thiserror 2.0.17", + "tokio", + "toml", + "toml_edit", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fbf03860ff438702f3910ca5f28f8dac63c1c11e7efb5012b8b175493606330" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +dependencies = [ + "atomic", + "getrandom 0.3.4", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.114", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fabae64378cb18147bb18bca364e63bdbe72a0ffe4adf0addfec8aa166b2c56" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9c2d862265a8bb4471d87e033e730f536e2a285cc7cb05dbce09a2a97075f90" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zmij" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 1882bcb..a17abce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,33 +1,47 @@ [package] name = "ui-cli" -version = "0.1.7-beta" +version = "0.3.11" edition = "2024" -authors = ["Everlabs"] +authors = ["Rustify"] +homepage = "https://rust-ui.com/docs/components/cli" description = "A CLI to add components to your app." keywords = ["cli", "ui", "components", "leptos", "tailwind"] +categories = ["gui", "web-programming", "development-tools", "accessibility", "wasm"] license = "MIT" readme = "./README.md" + repository = "https://github.com/rust-ui/cli" [dependencies] -clap = { version = "4", features = ["derive"] } -dotenv = "0.15" -tokio = { version = "1", features = ["full"] } -reqwest = { version = "0.12", features = ["json", "blocking"] } +cargo_toml = "0.22" +glob = "0.3" +clap = { version = "4", default-features = false, features = ["derive", "std"] } +colored = "3" +dialoguer = "0.12" +indicatif = "0.18" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +rmcp = { version = "1", features = ["server", "transport-io"] } +schemars = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" -colored = "2" -indicatif = "0.17" -toml = "0.8.22" +similar = "2" +strum = { version = "0.27", features = ["derive"] } +heck = "0.5" +thiserror = "2" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +toml = { version = "0.9", features = ["parse", "display", "serde"] } +toml_edit = "0.23" +crossterm = "0.29" +ratatui = { version = "0.30", features = ["crossterm"] } +resvg = "0.45" + +[dev-dependencies] +tempfile = "3" [[bin]] name = "ui" path = "src/main.rs" - - - - diff --git a/Components.toml b/Components.toml deleted file mode 100644 index ef87553..0000000 --- a/Components.toml +++ /dev/null @@ -1 +0,0 @@ -base_path_components = "src/components" diff --git a/FakeCargo.toml b/FakeCargo.toml deleted file mode 100644 index 7961080..0000000 --- a/FakeCargo.toml +++ /dev/null @@ -1,29 +0,0 @@ - -# * This file is designed to be used in Test to make sure we don't add cargo_dependencies twice. - -[package] -name = "cli_coming_soon" -version = "0.0.5" -edition = "2021" -description = "A CLI tool to add components to your app" -authors = ["Everlabs"] -license = "MIT" - -[dependencies] -clap = { version = "4.0", features = ["derive"] } -tokio = { version = "1", features = ["full"] } -reqwest = { version = "0.12.5", features = ["json", "blocking"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -colored = "2.1.0" -indicatif = "0.17.8" -codee = "0.3.0" -convert_case = "0.8.0" -ev = "0.1.0" -unic-langid = "0.9.5" - - - -[[bin]] -name = "cli_coming_soon" -path = "src/main.rs" diff --git a/LICENSE b/LICENSE index 99bbd00..a82543c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Rust UI +Copyright (c) 2025 Rustify Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 5bc40c4..19c0f64 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,41 @@ +# UI CLI +A CLI tool to add **rust-ui** components to your Leptos project. -# General purpose - -The goal of this project is to provide a **CLI tool** to add any number of components to your project. -It works for **Leptos** at the moment, but it will be extended to other frameworks in the future. - - -# Installation - +## Installation ```bash -cargo install ui-cli -# └─> Don't forget to regularly run: `cargo install-update -a` (frequent updates) +cargo install ui-cli --force ``` - -# Usage +## Commands ```bash -ui add button -# ui add demo_card demo_button -# └──> Works with any number of components +ui starters # clone a starter project +ui init # set up an existing project +ui add # interactive component picker +ui add button card # add components directly +ui add button --dry-run # preview without installing +ui add button -y # skip overwrite prompt +ui list # browse all components +ui search # search components by name +ui view # view a component's source +ui diff # compare installed vs registry +ui update # check for outdated components +ui mcp # start the MCP server (for AI editors) +ui mcp init --client claude # write editor config for MCP ``` +## MCP (AI Editor Integration) -# Contributions 💪 - -It works but it's very messy and there is a lot of room for improvements. - -Any contribution is welcome! +Run `ui mcp init --client ` once in your project. +Your editor will auto-connect and can then browse, search, and add components for you. +## Links +- Docs & components: [rust-ui.com](https://www.rust-ui.com) +- Changelog: [CHANGELOG.md](./CHANGELOG.md) -# License +## License -This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. +MIT — see [LICENSE](./LICENSE). diff --git a/TODOS.md b/TODOS.md index 1621536..0bbaacc 100644 --- a/TODOS.md +++ b/TODOS.md @@ -4,6 +4,7 @@ - INIT: Ask to override if Components.toml exists - ADD: Use base_components_path from Components.toml to write the demos/components at the right place - INIT: Check if deps in package.json exists and ask to overwrite +- CONFIG: Add is_workspace option diff --git a/__FEATURES.md b/__FEATURES.md new file mode 100644 index 0000000..bf5e36a --- /dev/null +++ b/__FEATURES.md @@ -0,0 +1,129 @@ +# Missing Features vs shadcn CLI + +Features that shadcn CLI has that our `ui-cli` currently lacks. + + + --- + shadcn CLI vs rust-ui CLI — Feature Comparison + + ┌─────────────────────────────────────────────────────────────────────────────┐ + │ COMMAND COMPARISON │ + ├─────────────────────┬───────────────────────┬────────────────────────────── │ + │ Feature │ shadcn (TS) │ rust-ui (Rust) │ + ├─────────────────────┼───────────────────────┼───────────────────────────────┤ + │ init │ ✅ Full │ ✅ Basic │ + │ add │ ✅ Full │ ✅ Full + TUI │ + │ starters │ ❌ │ ✅ (tauri, tauri-fullstack) │ + │ diff │ ✅ (deprecated) │ ✅ │ + │ search │ ✅ │ ✅ │ + │ list │ ❌ │ ✅ (rust-ui exclusive) │ + │ update │ ❌ │ ✅ (rust-ui exclusive) │ + │ info │ ✅ │ ✅ │ + │ docs │ ✅ │ ✅ │ + │ view │ ✅ │ ✅ │ + │ mcp │ ✅ │ ✅ │ + │ migrate │ ✅ │ ❌ (not needed) │ + │ build │ ✅ │ ❌ (not needed) │ + │ registry add │ ✅ │ ❌ (not needed) │ + └─────────────────────┴───────────────────────┴───────────────────────────────┘ + + --- + init — Both have it, shadcn's is far richer + + ┌─────────────────────────────────────────────┬────────────────────────────────────────────────┬───────────────────────┐ + │ Capability │ shadcn │ rust-ui │ + ├─────────────────────────────────────────────┼────────────────────────────────────────────────┼───────────────────────┤ + │ Framework auto-detection │ ✅ (Next, Vite, Astro, Laravel, React Router…) │ ❌ │ + ├─────────────────────────────────────────────┼────────────────────────────────────────────────┼───────────────────────┤ + │ Monorepo / workspace support │ ✅ │ ✅ (workspace_utils) │ + ├─────────────────────────────────────────────┼────────────────────────────────────────────────┼───────────────────────┤ + │ Multiple templates │ ✅ 7 templates │ ❌ │ + ├─────────────────────────────────────────────┼────────────────────────────────────────────────┼───────────────────────┤ + │ Theme presets / color base selection │ ✅ interactive │ ✅ interactive (7 base + 18 accent)│ + ├─────────────────────────────────────────────┼────────────────────────────────────────────────┼───────────────────────┤ + │ CSS variables toggle │ ✅ │ ❌ │ + ├─────────────────────────────────────────────┼────────────────────────────────────────────────┼───────────────────────┤ + │ RTL support │ ✅ │ ❌ │ + ├─────────────────────────────────────────────┼────────────────────────────────────────────────┼───────────────────────┤ + │ Overwrite config prompt │ ✅ │ ✅ │ + ├─────────────────────────────────────────────┼────────────────────────────────────────────────┼───────────────────────┤ + │ --yes / --force / --defaults flags │ ✅ │ ✅ (--yes, --force) │ + ├─────────────────────────────────────────────┼────────────────────────────────────────────────┼───────────────────────┤ + │ Re-install existing components │ ✅ │ ✅ (--reinstall) │ + ├─────────────────────────────────────────────┼────────────────────────────────────────────────┼───────────────────────┤ + │ Backup/restore on failure │ ✅ │ ✅ (RAII FileBackup) │ + ├─────────────────────────────────────────────┼────────────────────────────────────────────────┼───────────────────────┤ + │ Leptos dep check │ ❌ │ ✅ │ + ├─────────────────────────────────────────────┼────────────────────────────────────────────────┼───────────────────────┤ + │ Tailwind CSS file detection from Cargo.toml │ ❌ │ ✅ │ + ├─────────────────────────────────────────────┼────────────────────────────────────────────────┼───────────────────────┤ + │ merge package.json (preserves user deps) │ ❌ │ ✅ │ + └─────────────────────────────────────────────┴────────────────────────────────────────────────┴───────────────────────┘ + + --- + add — rust-ui has a unique interactive TUI (ratatui) + + ┌───────────────────────────────────┬────────────────────────────────┬──────────────────────────────────────────────────────────────────────────┐ + │ Capability │ shadcn │ rust-ui │ + ├───────────────────────────────────┼────────────────────────────────┼──────────────────────────────────────────────────────────────────────────┤ + │ Add by name │ ✅ │ ✅ │ + ├───────────────────────────────────┼────────────────────────────────┼──────────────────────────────────────────────────────────────────────────┤ + │ Dependency resolution │ ✅ │ ✅ (via tree_parser) │ + ├───────────────────────────────────┼────────────────────────────────┼──────────────────────────────────────────────────────────────────────────┤ + │ Interactive TUI picker │ ❌ (simple multiselect prompt) │ ✅ Ratatui with tabs (components, hooks, blocks, icons, demos, settings) │ + ├───────────────────────────────────┼────────────────────────────────┼──────────────────────────────────────────────────────────────────────────┤ + │ Overwrite prompt (interactive) │ ✅ │ ✅ │ + ├───────────────────────────────────┼────────────────────────────────┼──────────────────────────────────────────────────────────────────────────┤ + │ --yes / --overwrite flag │ ✅ │ ✅ │ + ├───────────────────────────────────┼────────────────────────────────┼──────────────────────────────────────────────────────────────────────────┤ + │ Deprecated component warnings │ ✅ │ ✅ │ + ├───────────────────────────────────┼────────────────────────────────┼──────────────────────────────────────────────────────────────────────────┤ + │ --all flag │ ✅ │ ❌ (not planned) │ + ├───────────────────────────────────┼────────────────────────────────┼──────────────────────────────────────────────────────────────────────────┤ + │ --dry-run │ ✅ │ ✅ │ + ├───────────────────────────────────┼────────────────────────────────┼──────────────────────────────────────────────────────────────────────────┤ + │ --diff / --view │ ✅ │ ✅ │ + ├───────────────────────────────────┼────────────────────────────────┼──────────────────────────────────────────────────────────────────────────┤ + │ --path override │ ✅ │ ✅ │ + ├───────────────────────────────────┼────────────────────────────────┼──────────────────────────────────────────────────────────────────────────┤ + │ --json output │ ✅ │ ✅ (list, search, diff, update, view, info) │ + ├───────────────────────────────────┼────────────────────────────────┼──────────────────────────────────────────────────────────────────────────┤ + │ JS file dependency download │ ❌ │ ✅ │ + ├───────────────────────────────────┼────────────────────────────────┼──────────────────────────────────────────────────────────────────────────┤ + │ Cargo dep injection │ ❌ │ ✅ │ + ├───────────────────────────────────┼────────────────────────────────┼──────────────────────────────────────────────────────────────────────────┤ + │ Register module in lib.rs/main.rs │ ❌ │ ✅ │ + ├───────────────────────────────────┼────────────────────────────────┼──────────────────────────────────────────────────────────────────────────┤ + │ Deprecated component warnings │ ✅ │ ❌ │ + └───────────────────────────────────┴────────────────────────────────┴──────────────────────────────────────────────────────────────────────────┘ + + --- + Commands only in shadcn (not needed / not planned for rust-ui) + + ┌──────────────┬───────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Command │ What it does │ + ├──────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────┤ + │ migrate │ Run migrations: icons, radix, rtl — auto-refactors source files (not needed) │ + ├──────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────┤ + │ build │ Build registry items from local source (for publishing custom registries) (not needed) │ + ├──────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────┤ + │ registry add │ Add a registry source to the project config (not needed) │ + └──────────────┴───────────────────────────────────────────────────────────────────────────────────────────────────┘ + + + +--- + +## Features Still Missing + +### `init` — interactive theme/color picker ✅ +Implemented: interactive base color (7 options) + accent color (18 options) prompts. +Selections stored in `ui_config.toml` as `base_color` + `color_theme`. +`--yes` / `--force` skips prompts and uses defaults (Neutral + Default). + +--- + +## Quality-of-Life Improvements + +- **Custom registry support** — allow users to point `add`/`search` at a non-default registry URL +- **`--silent` flag** — suppress all output (for scripting) diff --git a/a_arrow_up.svg b/a_arrow_up.svg new file mode 100644 index 0000000..493e5ae --- /dev/null +++ b/a_arrow_up.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/alarm_clock_check.svg b/alarm_clock_check.svg new file mode 100644 index 0000000..aec7bf7 --- /dev/null +++ b/alarm_clock_check.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/components.json b/components.json deleted file mode 100644 index 209b714..0000000 --- a/components.json +++ /dev/null @@ -1,18 +0,0 @@ - -{ - "$schema": "https://ever-ui.com/schema.json", - "style": "default", - "rsc": true, - "tsx": true, - "tailwind": { - "config": "tailwind.config.ts", - "css": "app/globals.css", - "baseColor": "neutral", - "cssVariables": true, - "prefix": "" - }, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils/cn" - } -} diff --git a/index.json b/index.json deleted file mode 100644 index 6b413f1..0000000 --- a/index.json +++ /dev/null @@ -1,3191 +0,0 @@ -[ - { - "cargo_dependencies": [], - "files": [ - "ui/_animations.rs" - ], - "name": "_animations", - "parent_dir": "ui", - "path": "components/ui/_animations.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [], - "files": [ - "ui/sticky_cursor_links.rs" - ], - "name": "sticky_cursor_links", - "parent_dir": "ui", - "path": "components/ui/sticky_cursor_links.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [], - "files": [ - "ui/badge.rs" - ], - "name": "badge", - "parent_dir": "ui", - "path": "components/ui/badge.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [], - "files": [ - "ui/containers.rs" - ], - "name": "containers", - "parent_dir": "ui", - "path": "components/ui/containers.rs", - "registry_dependencies": [ - "_styles" - ], - "type": "components:ui" - }, - { - "cargo_dependencies": [], - "files": [ - "ui/gradient.rs" - ], - "name": "gradient", - "parent_dir": "ui", - "path": "components/ui/gradient.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [], - "files": [ - "ui/_styles.rs" - ], - "name": "_styles", - "parent_dir": "ui", - "path": "components/ui/_styles.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [], - "files": [ - "ui/skeleton.rs" - ], - "name": "skeleton", - "parent_dir": "ui", - "path": "components/ui/skeleton.rs", - "registry_dependencies": [ - "_animations" - ], - "type": "components:ui" - }, - { - "cargo_dependencies": [], - "files": [ - "ui/radar.rs" - ], - "name": "radar", - "parent_dir": "ui", - "path": "components/ui/radar.rs", - "registry_dependencies": [ - "_animations" - ], - "type": "components:ui" - }, - { - "cargo_dependencies": [], - "files": [ - "ui/orbiting_circles.rs" - ], - "name": "orbiting_circles", - "parent_dir": "ui", - "path": "components/ui/orbiting_circles.rs", - "registry_dependencies": [ - "_animations" - ], - "type": "components:ui" - }, - { - "cargo_dependencies": [], - "files": [ - "ui/meteor_effect.rs" - ], - "name": "meteor_effect", - "parent_dir": "ui", - "path": "components/ui/meteor_effect.rs", - "registry_dependencies": [ - "_animations", - "_styles" - ], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/card_wobble.rs" - ], - "name": "card_wobble", - "parent_dir": "ui", - "path": "components/ui/card_wobble.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/beam_border.rs" - ], - "name": "beam_border", - "parent_dir": "ui", - "path": "components/ui/beam_border.rs", - "registry_dependencies": [ - "_animations" - ], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/command.rs" - ], - "name": "command", - "parent_dir": "ui", - "path": "components/ui/command.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/dialog.rs" - ], - "name": "dialog", - "parent_dir": "ui", - "path": "components/ui/dialog.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/tabs.rs" - ], - "name": "tabs", - "parent_dir": "ui", - "path": "components/ui/tabs.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/daisy_tabs.rs" - ], - "name": "daisy_tabs", - "parent_dir": "ui", - "path": "components/ui/daisy_tabs.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/sidenav.rs" - ], - "name": "sidenav", - "parent_dir": "ui", - "path": "components/ui/sidenav.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/marquee.rs" - ], - "name": "marquee", - "parent_dir": "ui", - "path": "components/ui/marquee.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/mask.rs" - ], - "name": "mask", - "parent_dir": "ui", - "path": "components/ui/mask.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/sparkles.rs" - ], - "name": "sparkles", - "parent_dir": "ui", - "path": "components/ui/sparkles.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/carousel_parallax.rs" - ], - "name": "carousel_parallax", - "parent_dir": "ui", - "path": "components/ui/carousel_parallax.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/separator.rs" - ], - "name": "separator", - "parent_dir": "ui", - "path": "components/ui/separator.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/animate.rs" - ], - "name": "animate", - "parent_dir": "ui", - "path": "components/ui/animate.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/headings.rs" - ], - "name": "headings", - "parent_dir": "ui", - "path": "components/ui/headings.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/textarea.rs" - ], - "name": "textarea", - "parent_dir": "ui", - "path": "components/ui/textarea.rs", - "registry_dependencies": [ - "_styles" - ], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/breadcrumb.rs" - ], - "name": "breadcrumb", - "parent_dir": "ui", - "path": "components/ui/breadcrumb.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/dropdown_menu.rs" - ], - "name": "dropdown_menu", - "parent_dir": "ui", - "path": "components/ui/dropdown_menu.rs", - "registry_dependencies": [ - "button" - ], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/grid.rs" - ], - "name": "grid", - "parent_dir": "ui", - "path": "components/ui/grid.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/spotlight.rs" - ], - "name": "spotlight", - "parent_dir": "ui", - "path": "components/ui/spotlight.rs", - "registry_dependencies": [ - "_animations" - ], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/announcement.rs" - ], - "name": "announcement", - "parent_dir": "ui", - "path": "components/ui/announcement.rs", - "registry_dependencies": [ - "_animations" - ], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/mod.rs" - ], - "name": "mod", - "parent_dir": "ui", - "path": "components/ui/mod.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/alert.rs" - ], - "name": "alert", - "parent_dir": "ui", - "path": "components/ui/alert.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/status.rs" - ], - "name": "status", - "parent_dir": "ui", - "path": "components/ui/status.rs", - "registry_dependencies": [ - "_styles", - "_animations" - ], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/checkbox.rs" - ], - "name": "checkbox", - "parent_dir": "ui", - "path": "components/ui/checkbox.rs", - "registry_dependencies": [ - "_styles" - ], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/expandable.rs" - ], - "name": "expandable", - "parent_dir": "ui", - "path": "components/ui/expandable.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/faq.rs" - ], - "name": "faq", - "parent_dir": "ui", - "path": "components/ui/faq.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/blurry_blob.rs" - ], - "name": "blurry_blob", - "parent_dir": "ui", - "path": "components/ui/blurry_blob.rs", - "registry_dependencies": [ - "_animations" - ], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/table.rs" - ], - "name": "table", - "parent_dir": "ui", - "path": "components/ui/table.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/cards_glowing.rs" - ], - "name": "cards_glowing", - "parent_dir": "ui", - "path": "components/ui/cards_glowing.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/background_retro.rs" - ], - "name": "background_retro", - "parent_dir": "ui", - "path": "components/ui/background_retro.rs", - "registry_dependencies": [ - "_animations" - ], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/typing_effect.rs" - ], - "name": "typing_effect", - "parent_dir": "ui", - "path": "components/ui/typing_effect.rs", - "registry_dependencies": [ - "_animations" - ], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/text_swiping.rs" - ], - "name": "text_swiping", - "parent_dir": "ui", - "path": "components/ui/text_swiping.rs", - "registry_dependencies": [ - "_animations" - ], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/label.rs" - ], - "name": "label", - "parent_dir": "ui", - "path": "components/ui/label.rs", - "registry_dependencies": [ - "_styles" - ], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/input.rs" - ], - "name": "input", - "parent_dir": "ui", - "path": "components/ui/input.rs", - "registry_dependencies": [ - "_styles" - ], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/radar_mini.rs" - ], - "name": "radar_mini", - "parent_dir": "ui", - "path": "components/ui/radar_mini.rs", - "registry_dependencies": [ - "_animations" - ], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/card3d_hover.rs" - ], - "name": "card3d_hover", - "parent_dir": "ui", - "path": "components/ui/card3d_hover.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/blockquote.rs" - ], - "name": "blockquote", - "parent_dir": "ui", - "path": "components/ui/blockquote.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/avatar_simple.rs" - ], - "name": "avatar_simple", - "parent_dir": "ui", - "path": "components/ui/avatar_simple.rs", - "registry_dependencies": [ - "_styles" - ], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/slider.rs" - ], - "name": "slider", - "parent_dir": "ui", - "path": "components/ui/slider.rs", - "registry_dependencies": [ - "_styles" - ], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/card.rs" - ], - "name": "card", - "parent_dir": "ui", - "path": "components/ui/card.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/bento_grid.rs" - ], - "name": "bento_grid", - "parent_dir": "ui", - "path": "components/ui/bento_grid.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/sheet.rs" - ], - "name": "sheet", - "parent_dir": "ui", - "path": "components/ui/sheet.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/form.rs" - ], - "name": "form", - "parent_dir": "ui", - "path": "components/ui/form.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [ - "std" - ], - "files": [ - "ui/button.rs" - ], - "name": "button", - "parent_dir": "ui", - "path": "components/ui/button.rs", - "registry_dependencies": [], - "type": "components:ui" - }, - { - "cargo_dependencies": [], - "files": [ - "hooks/use_lock_body_scroll.rs" - ], - "name": "use_lock_body_scroll", - "parent_dir": "hooks", - "path": "components/hooks/use_lock_body_scroll.rs", - "registry_dependencies": [], - "type": "components:hooks" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_navigation_menu.rs" - ], - "name": "demo_navigation_menu", - "parent_dir": "demos", - "path": "components/demos/demo_navigation_menu.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_sparkles_bottom.rs" - ], - "name": "demo_sparkles_bottom", - "parent_dir": "demos", - "path": "components/demos/demo_sparkles_bottom.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_grid.rs" - ], - "name": "demo_grid", - "parent_dir": "demos", - "path": "components/demos/demo_grid.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_mask.rs" - ], - "name": "demo_mask", - "parent_dir": "demos", - "path": "components/demos/demo_mask.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_button_reactive.rs" - ], - "name": "demo_button_reactive", - "parent_dir": "demos", - "path": "components/demos/demo_button_reactive.rs", - "registry_dependencies": [ - "button" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_orbiting_circles.rs" - ], - "name": "demo_orbiting_circles", - "parent_dir": "demos", - "path": "components/demos/demo_orbiting_circles.rs", - "registry_dependencies": [ - "orbiting_circles" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_button_sizes.rs" - ], - "name": "demo_button_sizes", - "parent_dir": "demos", - "path": "components/demos/demo_button_sizes.rs", - "registry_dependencies": [ - "button" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_command.rs" - ], - "name": "demo_command", - "parent_dir": "demos", - "path": "components/demos/demo_command.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_badge.rs" - ], - "name": "demo_badge", - "parent_dir": "demos", - "path": "components/demos/demo_badge.rs", - "registry_dependencies": [ - "badge" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_dropzone.rs" - ], - "name": "demo_dropzone", - "parent_dir": "demos", - "path": "components/demos/demo_dropzone.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_blurry_blob.rs" - ], - "name": "demo_blurry_blob", - "parent_dir": "demos", - "path": "components/demos/demo_blurry_blob.rs", - "registry_dependencies": [ - "blurry_blob" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_meteor_effect.rs" - ], - "name": "demo_meteor_effect", - "parent_dir": "demos", - "path": "components/demos/demo_meteor_effect.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_tabs.rs" - ], - "name": "demo_tabs", - "parent_dir": "demos", - "path": "components/demos/demo_tabs.rs", - "registry_dependencies": [ - "tabs" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_floating_button_menu.rs" - ], - "name": "demo_floating_button_menu", - "parent_dir": "demos", - "path": "components/demos/demo_floating_button_menu.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_button.rs" - ], - "name": "demo_button", - "parent_dir": "demos", - "path": "components/demos/demo_button.rs", - "registry_dependencies": [ - "button" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_mask_color.rs" - ], - "name": "demo_mask_color", - "parent_dir": "demos", - "path": "components/demos/demo_mask_color.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_textarea.rs" - ], - "name": "demo_textarea", - "parent_dir": "demos", - "path": "components/demos/demo_textarea.rs", - "registry_dependencies": [ - "textarea" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_sidenav.rs" - ], - "name": "demo_sidenav", - "parent_dir": "demos", - "path": "components/demos/demo_sidenav.rs", - "registry_dependencies": [ - "sidenav" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_radar.rs" - ], - "name": "demo_radar", - "parent_dir": "demos", - "path": "components/demos/demo_radar.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_marquee.rs" - ], - "name": "demo_marquee", - "parent_dir": "demos", - "path": "components/demos/demo_marquee.rs", - "registry_dependencies": [ - "marquee" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_command_dialog.rs" - ], - "name": "demo_command_dialog", - "parent_dir": "demos", - "path": "components/demos/demo_command_dialog.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_headings.rs" - ], - "name": "demo_headings", - "parent_dir": "demos", - "path": "components/demos/demo_headings.rs", - "registry_dependencies": [ - "headings" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_badge_variants.rs" - ], - "name": "demo_badge_variants", - "parent_dir": "demos", - "path": "components/demos/demo_badge_variants.rs", - "registry_dependencies": [ - "badge" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_skeleton_image.rs" - ], - "name": "demo_skeleton_image", - "parent_dir": "demos", - "path": "components/demos/demo_skeleton_image.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_navigation_menu_complex.rs" - ], - "name": "demo_navigation_menu_complex", - "parent_dir": "demos", - "path": "components/demos/demo_navigation_menu_complex.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_multi_select.rs" - ], - "name": "demo_multi_select", - "parent_dir": "demos", - "path": "components/demos/demo_multi_select.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_animate.rs" - ], - "name": "demo_animate", - "parent_dir": "demos", - "path": "components/demos/demo_animate.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_sparkles.rs" - ], - "name": "demo_sparkles", - "parent_dir": "demos", - "path": "components/demos/demo_sparkles.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_bento_grid4.rs" - ], - "name": "demo_bento_grid4", - "parent_dir": "demos", - "path": "components/demos/demo_bento_grid4.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_headings_variants.rs" - ], - "name": "demo_headings_variants", - "parent_dir": "demos", - "path": "components/demos/demo_headings_variants.rs", - "registry_dependencies": [ - "headings" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_tooltip.rs" - ], - "name": "demo_tooltip", - "parent_dir": "demos", - "path": "components/demos/demo_tooltip.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_button_with_clx.rs" - ], - "name": "demo_button_with_clx", - "parent_dir": "demos", - "path": "components/demos/demo_button_with_clx.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_status.rs" - ], - "name": "demo_status", - "parent_dir": "demos", - "path": "components/demos/demo_status.rs", - "registry_dependencies": [ - "status" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_button_variants.rs" - ], - "name": "demo_button_variants", - "parent_dir": "demos", - "path": "components/demos/demo_button_variants.rs", - "registry_dependencies": [ - "button" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_slider_hover.rs" - ], - "name": "demo_slider_hover", - "parent_dir": "demos", - "path": "components/demos/demo_slider_hover.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_parallax_simple.rs" - ], - "name": "demo_parallax_simple", - "parent_dir": "demos", - "path": "components/demos/demo_parallax_simple.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_sparkles_rounded.rs" - ], - "name": "demo_sparkles_rounded", - "parent_dir": "demos", - "path": "components/demos/demo_sparkles_rounded.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_spotlight.rs" - ], - "name": "demo_spotlight", - "parent_dir": "demos", - "path": "components/demos/demo_spotlight.rs", - "registry_dependencies": [ - "spotlight" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_separator.rs" - ], - "name": "demo_separator", - "parent_dir": "demos", - "path": "components/demos/demo_separator.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_breadcrumb.rs" - ], - "name": "demo_breadcrumb", - "parent_dir": "demos", - "path": "components/demos/demo_breadcrumb.rs", - "registry_dependencies": [ - "breadcrumb" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_skeleton.rs" - ], - "name": "demo_skeleton", - "parent_dir": "demos", - "path": "components/demos/demo_skeleton.rs", - "registry_dependencies": [ - "skeleton" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_select.rs" - ], - "name": "demo_select", - "parent_dir": "demos", - "path": "components/demos/demo_select.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_bento_grid5.rs" - ], - "name": "demo_bento_grid5", - "parent_dir": "demos", - "path": "components/demos/demo_bento_grid5.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_cards_direction_aware.rs" - ], - "name": "demo_cards_direction_aware", - "parent_dir": "demos", - "path": "components/demos/demo_cards_direction_aware.rs", - "registry_dependencies": [ - "button" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_gradient.rs" - ], - "name": "demo_gradient", - "parent_dir": "demos", - "path": "components/demos/demo_gradient.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_dialog.rs" - ], - "name": "demo_dialog", - "parent_dir": "demos", - "path": "components/demos/demo_dialog.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_carousel_parallax.rs" - ], - "name": "demo_carousel_parallax", - "parent_dir": "demos", - "path": "components/demos/demo_carousel_parallax.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_button_override.rs" - ], - "name": "demo_button_override", - "parent_dir": "demos", - "path": "components/demos/demo_button_override.rs", - "registry_dependencies": [ - "button" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_sheet_directions.rs" - ], - "name": "demo_sheet_directions", - "parent_dir": "demos", - "path": "components/demos/demo_sheet_directions.rs", - "registry_dependencies": [ - "sheet" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_background_retro.rs" - ], - "name": "demo_background_retro", - "parent_dir": "demos", - "path": "components/demos/demo_background_retro.rs", - "registry_dependencies": [ - "background_retro" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_alert.rs" - ], - "name": "demo_alert", - "parent_dir": "demos", - "path": "components/demos/demo_alert.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_bento_grid6.rs" - ], - "name": "demo_bento_grid6", - "parent_dir": "demos", - "path": "components/demos/demo_bento_grid6.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_card.rs" - ], - "name": "demo_card", - "parent_dir": "demos", - "path": "components/demos/demo_card.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_expandable.rs" - ], - "name": "demo_expandable", - "parent_dir": "demos", - "path": "components/demos/demo_expandable.rs", - "registry_dependencies": [ - "expandable" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_card_tilting.rs" - ], - "name": "demo_card_tilting", - "parent_dir": "demos", - "path": "components/demos/demo_card_tilting.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_mask_vertical.rs" - ], - "name": "demo_mask_vertical", - "parent_dir": "demos", - "path": "components/demos/demo_mask_vertical.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_headings_animate.rs" - ], - "name": "demo_headings_animate", - "parent_dir": "demos", - "path": "components/demos/demo_headings_animate.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_tailwind_scroll_only.rs" - ], - "name": "demo_tailwind_scroll_only", - "parent_dir": "demos", - "path": "components/demos/demo_tailwind_scroll_only.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_table.rs" - ], - "name": "demo_table", - "parent_dir": "demos", - "path": "components/demos/demo_table.rs", - "registry_dependencies": [ - "table" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_status_variants.rs" - ], - "name": "demo_status_variants", - "parent_dir": "demos", - "path": "components/demos/demo_status_variants.rs", - "registry_dependencies": [ - "status" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_card3d_hover.rs" - ], - "name": "demo_card3d_hover", - "parent_dir": "demos", - "path": "components/demos/demo_card3d_hover.rs", - "registry_dependencies": [ - "card3d_hover" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_headings_motion.rs" - ], - "name": "demo_headings_motion", - "parent_dir": "demos", - "path": "components/demos/demo_headings_motion.rs", - "registry_dependencies": [ - "button" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_beam_border.rs" - ], - "name": "demo_beam_border", - "parent_dir": "demos", - "path": "components/demos/demo_beam_border.rs", - "registry_dependencies": [ - "beam_border" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_input.rs" - ], - "name": "demo_input", - "parent_dir": "demos", - "path": "components/demos/demo_input.rs", - "registry_dependencies": [ - "input" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_checkbox.rs" - ], - "name": "demo_checkbox", - "parent_dir": "demos", - "path": "components/demos/demo_checkbox.rs", - "registry_dependencies": [ - "checkbox" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_label.rs" - ], - "name": "demo_label", - "parent_dir": "demos", - "path": "components/demos/demo_label.rs", - "registry_dependencies": [ - "label", - "checkbox" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_hamburger_menus.rs" - ], - "name": "demo_hamburger_menus", - "parent_dir": "demos", - "path": "components/demos/demo_hamburger_menus.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_text_swiping.rs" - ], - "name": "demo_text_swiping", - "parent_dir": "demos", - "path": "components/demos/demo_text_swiping.rs", - "registry_dependencies": [ - "text_swiping" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_faq.rs" - ], - "name": "demo_faq", - "parent_dir": "demos", - "path": "components/demos/demo_faq.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_blockquote.rs" - ], - "name": "demo_blockquote", - "parent_dir": "demos", - "path": "components/demos/demo_blockquote.rs", - "registry_dependencies": [ - "blockquote" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_parallax_zoom_words.rs" - ], - "name": "demo_parallax_zoom_words", - "parent_dir": "demos", - "path": "components/demos/demo_parallax_zoom_words.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_announcement.rs" - ], - "name": "demo_announcement", - "parent_dir": "demos", - "path": "components/demos/demo_announcement.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_animate_group.rs" - ], - "name": "demo_animate_group", - "parent_dir": "demos", - "path": "components/demos/demo_animate_group.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_sheet.rs" - ], - "name": "demo_sheet", - "parent_dir": "demos", - "path": "components/demos/demo_sheet.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_badge_custom.rs" - ], - "name": "demo_badge_custom", - "parent_dir": "demos", - "path": "components/demos/demo_badge_custom.rs", - "registry_dependencies": [ - "badge" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_use_not.rs" - ], - "name": "demo_use_not", - "parent_dir": "demos", - "path": "components/demos/demo_use_not.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_use_web_notification.rs" - ], - "name": "demo_use_web_notification", - "parent_dir": "demos", - "path": "components/demos/demo_use_web_notification.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee" - ], - "files": [ - "demos/demo_use_cookie.rs" - ], - "name": "demo_use_cookie", - "parent_dir": "demos", - "path": "components/demos/demo_use_cookie.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee" - ], - "files": [ - "demos/demo_use_user_media.rs" - ], - "name": "demo_use_user_media", - "parent_dir": "demos", - "path": "components/demos/demo_use_user_media.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee" - ], - "files": [ - "demos/demo_use_hover.rs" - ], - "name": "demo_use_hover", - "parent_dir": "demos", - "path": "components/demos/demo_use_hover.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee" - ], - "files": [ - "demos/demo_use_intl_number_format.rs" - ], - "name": "demo_use_intl_number_format", - "parent_dir": "demos", - "path": "components/demos/demo_use_intl_number_format.rs", - "registry_dependencies": [ - "slider" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee" - ], - "files": [ - "demos/demo_use_element_bounding.rs" - ], - "name": "demo_use_element_bounding", - "parent_dir": "demos", - "path": "components/demos/demo_use_element_bounding.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee" - ], - "files": [ - "demos/demo_use_throttle.rs" - ], - "name": "demo_use_throttle", - "parent_dir": "demos", - "path": "components/demos/demo_use_throttle.rs", - "registry_dependencies": [ - "input" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee" - ], - "files": [ - "demos/demo_use_watch_debounced.rs" - ], - "name": "demo_use_watch_debounced", - "parent_dir": "demos", - "path": "components/demos/demo_use_watch_debounced.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee" - ], - "files": [ - "demos/demo_use_clipboard.rs" - ], - "name": "demo_use_clipboard", - "parent_dir": "demos", - "path": "components/demos/demo_use_clipboard.rs", - "registry_dependencies": [ - "button", - "input" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee" - ], - "files": [ - "demos/demo_use_timestamp.rs" - ], - "name": "demo_use_timestamp", - "parent_dir": "demos", - "path": "components/demos/demo_use_timestamp.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee" - ], - "files": [ - "demos/demo_use_floating_flip.rs" - ], - "name": "demo_use_floating_flip", - "parent_dir": "demos", - "path": "components/demos/demo_use_floating_flip.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee" - ], - "files": [ - "demos/demo_use_debounce.rs" - ], - "name": "demo_use_debounce", - "parent_dir": "demos", - "path": "components/demos/demo_use_debounce.rs", - "registry_dependencies": [ - "input" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee" - ], - "files": [ - "demos/demo_use_raf_fn.rs" - ], - "name": "demo_use_raf_fn", - "parent_dir": "demos", - "path": "components/demos/demo_use_raf_fn.rs", - "registry_dependencies": [ - "button" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee" - ], - "files": [ - "demos/demo_use_watch_throttled.rs" - ], - "name": "demo_use_watch_throttled", - "parent_dir": "demos", - "path": "components/demos/demo_use_watch_throttled.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee" - ], - "files": [ - "demos/demo_use_timeout_fn.rs" - ], - "name": "demo_use_timeout_fn", - "parent_dir": "demos", - "path": "components/demos/demo_use_timeout_fn.rs", - "registry_dependencies": [ - "button" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee" - ], - "files": [ - "demos/demo_use_resize_observer.rs" - ], - "name": "demo_use_resize_observer", - "parent_dir": "demos", - "path": "components/demos/demo_use_resize_observer.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee", - "unic_langid" - ], - "files": [ - "demos/demo_use_locale.rs" - ], - "name": "demo_use_locale", - "parent_dir": "demos", - "path": "components/demos/demo_use_locale.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "unic_langid", - "codee" - ], - "files": [ - "demos/demo_use_color_mode.rs" - ], - "name": "demo_use_color_mode", - "parent_dir": "demos", - "path": "components/demos/demo_use_color_mode.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "unic_langid", - "codee" - ], - "files": [ - "demos/demo_use_click_outside.rs" - ], - "name": "demo_use_click_outside", - "parent_dir": "demos", - "path": "components/demos/demo_use_click_outside.rs", - "registry_dependencies": [ - "button" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee", - "unic_langid" - ], - "files": [ - "demos/demo_use_or.rs" - ], - "name": "demo_use_or", - "parent_dir": "demos", - "path": "components/demos/demo_use_or.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee", - "unic_langid" - ], - "files": [ - "demos/demo_use_infinite_scroll.rs" - ], - "name": "demo_use_infinite_scroll", - "parent_dir": "demos", - "path": "components/demos/demo_use_infinite_scroll.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "unic_langid", - "codee" - ], - "files": [ - "demos/demo_use_round.rs" - ], - "name": "demo_use_round", - "parent_dir": "demos", - "path": "components/demos/demo_use_round.rs", - "registry_dependencies": [ - "slider" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee", - "unic_langid" - ], - "files": [ - "demos/demo_use_broadcast_channel.rs" - ], - "name": "demo_use_broadcast_channel", - "parent_dir": "demos", - "path": "components/demos/demo_use_broadcast_channel.rs", - "registry_dependencies": [ - "button" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "unic_langid", - "codee" - ], - "files": [ - "demos/demo_use_sorted.rs" - ], - "name": "demo_use_sorted", - "parent_dir": "demos", - "path": "components/demos/demo_use_sorted.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "unic_langid", - "codee" - ], - "files": [ - "demos/demo_use_lock_body_scroll.rs" - ], - "name": "demo_use_lock_body_scroll", - "parent_dir": "demos", - "path": "components/demos/demo_use_lock_body_scroll.rs", - "registry_dependencies": [ - "button" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "unic_langid", - "codee" - ], - "files": [ - "demos/demo_use_display_media.rs" - ], - "name": "demo_use_display_media", - "parent_dir": "demos", - "path": "components/demos/demo_use_display_media.rs", - "registry_dependencies": [ - "button" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee", - "unic_langid" - ], - "files": [ - "demos/demo_use_mouse_in_element.rs" - ], - "name": "demo_use_mouse_in_element", - "parent_dir": "demos", - "path": "components/demos/demo_use_mouse_in_element.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee", - "unic_langid" - ], - "files": [ - "demos/demo_use_geolocation.rs" - ], - "name": "demo_use_geolocation", - "parent_dir": "demos", - "path": "components/demos/demo_use_geolocation.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee", - "unic_langid" - ], - "files": [ - "demos/demo_use_drop_zone.rs" - ], - "name": "demo_use_drop_zone", - "parent_dir": "demos", - "path": "components/demos/demo_use_drop_zone.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "unic_langid", - "codee" - ], - "files": [ - "demos/demo_use_web_lock.rs" - ], - "name": "demo_use_web_lock", - "parent_dir": "demos", - "path": "components/demos/demo_use_web_lock.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee", - "unic_langid" - ], - "files": [ - "demos/demo_use_mouse.rs" - ], - "name": "demo_use_mouse", - "parent_dir": "demos", - "path": "components/demos/demo_use_mouse.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee", - "unic_langid" - ], - "files": [ - "demos/demo_use_and.rs" - ], - "name": "demo_use_and", - "parent_dir": "demos", - "path": "components/demos/demo_use_and.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee", - "unic_langid" - ], - "files": [ - "demos/demo_use_debounce_fn.rs" - ], - "name": "demo_use_debounce_fn", - "parent_dir": "demos", - "path": "components/demos/demo_use_debounce_fn.rs", - "registry_dependencies": [ - "button" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee", - "unic_langid" - ], - "files": [ - "demos/demo_use_breakpoints.rs" - ], - "name": "demo_use_breakpoints", - "parent_dir": "demos", - "path": "components/demos/demo_use_breakpoints.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "ev", - "unic_langid", - "codee" - ], - "files": [ - "demos/demo_use_toggle.rs" - ], - "name": "demo_use_toggle", - "parent_dir": "demos", - "path": "components/demos/demo_use_toggle.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "unic_langid", - "codee", - "ev" - ], - "files": [ - "demos/demo_use_element_size.rs" - ], - "name": "demo_use_element_size", - "parent_dir": "demos", - "path": "components/demos/demo_use_element_size.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "unic_langid", - "ev", - "codee" - ], - "files": [ - "demos/demo_use_media_query.rs" - ], - "name": "demo_use_media_query", - "parent_dir": "demos", - "path": "components/demos/demo_use_media_query.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "unic_langid", - "ev", - "codee" - ], - "files": [ - "demos/demo_use_idle.rs" - ], - "name": "demo_use_idle", - "parent_dir": "demos", - "path": "components/demos/demo_use_idle.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "ev", - "codee", - "unic_langid", - "std" - ], - "files": [ - "demos/demo_use_mutation_observer.rs" - ], - "name": "demo_use_mutation_observer", - "parent_dir": "demos", - "path": "components/demos/demo_use_mutation_observer.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "unic_langid", - "ev", - "codee", - "std" - ], - "files": [ - "demos/demo_use_throttle_fn.rs" - ], - "name": "demo_use_throttle_fn", - "parent_dir": "demos", - "path": "components/demos/demo_use_throttle_fn.rs", - "registry_dependencies": [ - "button" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "unic_langid", - "codee", - "std", - "ev" - ], - "files": [ - "demos/demo_use_permission.rs" - ], - "name": "demo_use_permission", - "parent_dir": "demos", - "path": "components/demos/demo_use_permission.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee", - "unic_langid", - "std", - "ev" - ], - "files": [ - "demos/demo_use_window_size.rs" - ], - "name": "demo_use_window_size", - "parent_dir": "demos", - "path": "components/demos/demo_use_window_size.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "unic_langid", - "codee", - "std", - "ev" - ], - "files": [ - "demos/demo_use_interval_fn.rs" - ], - "name": "demo_use_interval_fn", - "parent_dir": "demos", - "path": "components/demos/demo_use_interval_fn.rs", - "registry_dependencies": [ - "button" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "std", - "ev", - "codee", - "unic_langid" - ], - "files": [ - "demos/demo_use_floating_tooltip.rs" - ], - "name": "demo_use_floating_tooltip", - "parent_dir": "demos", - "path": "components/demos/demo_use_floating_tooltip.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "ev", - "unic_langid", - "codee", - "std" - ], - "files": [ - "demos/demo_use_device_pixel_ratio.rs" - ], - "name": "demo_use_device_pixel_ratio", - "parent_dir": "demos", - "path": "components/demos/demo_use_device_pixel_ratio.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "unic_langid", - "ev", - "codee", - "std" - ], - "files": [ - "demos/demo_use_event_listener.rs" - ], - "name": "demo_use_event_listener", - "parent_dir": "demos", - "path": "components/demos/demo_use_event_listener.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee", - "unic_langid", - "ev", - "std" - ], - "files": [ - "demos/demo_use_window_scroll.rs" - ], - "name": "demo_use_window_scroll", - "parent_dir": "demos", - "path": "components/demos/demo_use_window_scroll.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "std", - "ev", - "unic_langid", - "codee" - ], - "files": [ - "demos/demo_use_floor.rs" - ], - "name": "demo_use_floor", - "parent_dir": "demos", - "path": "components/demos/demo_use_floor.rs", - "registry_dependencies": [ - "slider" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "ev", - "unic_langid", - "std", - "codee" - ], - "files": [ - "demos/demo_use_key_press.rs" - ], - "name": "demo_use_key_press", - "parent_dir": "demos", - "path": "components/demos/demo_use_key_press.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "std", - "ev", - "codee", - "unic_langid" - ], - "files": [ - "demos/demo_use_interval.rs" - ], - "name": "demo_use_interval", - "parent_dir": "demos", - "path": "components/demos/demo_use_interval.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "serde", - "ev", - "std", - "unic_langid", - "codee" - ], - "files": [ - "demos/demo_use_storage.rs" - ], - "name": "demo_use_storage", - "parent_dir": "demos", - "path": "components/demos/demo_use_storage.rs", - "registry_dependencies": [ - "button", - "input" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "unic_langid", - "ev", - "std", - "serde", - "codee" - ], - "files": [ - "demos/demo_use_autosize.rs" - ], - "name": "demo_use_autosize", - "parent_dir": "demos", - "path": "components/demos/demo_use_autosize.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "serde", - "codee", - "std", - "convert_case", - "ev", - "unic_langid" - ], - "files": [ - "demos/demo_use_floating_placement.rs" - ], - "name": "demo_use_floating_placement", - "parent_dir": "demos", - "path": "components/demos/demo_use_floating_placement.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "convert_case", - "codee", - "serde", - "unic_langid", - "ev", - "std" - ], - "files": [ - "demos/demo_use_watch_pausable.rs" - ], - "name": "demo_use_watch_pausable", - "parent_dir": "demos", - "path": "components/demos/demo_use_watch_pausable.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee", - "std", - "serde", - "unic_langid", - "ev", - "convert_case" - ], - "files": [ - "demos/demo_use_floating_shift.rs" - ], - "name": "demo_use_floating_shift", - "parent_dir": "demos", - "path": "components/demos/demo_use_floating_shift.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "serde", - "codee", - "std", - "unic_langid", - "convert_case", - "ev" - ], - "files": [ - "demos/demo_use_interval_local_storage.rs" - ], - "name": "demo_use_interval_local_storage", - "parent_dir": "demos", - "path": "components/demos/demo_use_interval_local_storage.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "convert_case", - "ev", - "serde", - "codee", - "unic_langid", - "std" - ], - "files": [ - "demos/demo_use_prefers_reduced_motion.rs" - ], - "name": "demo_use_prefers_reduced_motion", - "parent_dir": "demos", - "path": "components/demos/demo_use_prefers_reduced_motion.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "std", - "serde", - "unic_langid", - "convert_case", - "codee", - "ev" - ], - "files": [ - "demos/demo_use_sync_signal.rs" - ], - "name": "demo_use_sync_signal", - "parent_dir": "demos", - "path": "components/demos/demo_use_sync_signal.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "unic_langid", - "serde", - "codee", - "ev", - "std", - "convert_case" - ], - "files": [ - "demos/demo_use_locales.rs" - ], - "name": "demo_use_locales", - "parent_dir": "demos", - "path": "components/demos/demo_use_locales.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "unic_langid", - "convert_case", - "codee", - "std", - "serde", - "ev" - ], - "files": [ - "demos/demo_use_window_focus.rs" - ], - "name": "demo_use_window_focus", - "parent_dir": "demos", - "path": "components/demos/demo_use_window_focus.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "ev", - "serde", - "std", - "convert_case", - "codee", - "unic_langid" - ], - "files": [ - "demos/demo_use_floating_size.rs" - ], - "name": "demo_use_floating_size", - "parent_dir": "demos", - "path": "components/demos/demo_use_floating_size.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "unic_langid", - "ev", - "codee", - "serde", - "std", - "convert_case" - ], - "files": [ - "demos/demo_use_intersection_observer.rs" - ], - "name": "demo_use_intersection_observer", - "parent_dir": "demos", - "path": "components/demos/demo_use_intersection_observer.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "convert_case", - "codee", - "serde", - "ev", - "unic_langid", - "std" - ], - "files": [ - "demos/demo_use_abs.rs" - ], - "name": "demo_use_abs", - "parent_dir": "demos", - "path": "components/demos/demo_use_abs.rs", - "registry_dependencies": [ - "slider" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "convert_case", - "serde", - "ev", - "codee", - "unic_langid", - "std" - ], - "files": [ - "demos/demo_use_ceil.rs" - ], - "name": "demo_use_ceil", - "parent_dir": "demos", - "path": "components/demos/demo_use_ceil.rs", - "registry_dependencies": [ - "slider" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "codee", - "std", - "serde", - "ev", - "unic_langid", - "convert_case" - ], - "files": [ - "demos/demo_use_css_var.rs" - ], - "name": "demo_use_css_var", - "parent_dir": "demos", - "path": "components/demos/demo_use_css_var.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "ev", - "codee", - "serde", - "convert_case", - "std", - "unic_langid" - ], - "files": [ - "demos/demo_use_cycle_list.rs" - ], - "name": "demo_use_cycle_list", - "parent_dir": "demos", - "path": "components/demos/demo_use_cycle_list.rs", - "registry_dependencies": [ - "button" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "charts_rs" - ], - "files": [ - "demos/demo_chart_pie.rs" - ], - "name": "demo_chart_pie", - "parent_dir": "demos", - "path": "components/demos/demo_chart_pie.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "charts_rs" - ], - "files": [ - "demos/demo_chart_line.rs" - ], - "name": "demo_chart_line", - "parent_dir": "demos", - "path": "components/demos/demo_chart_line.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "charts_rs" - ], - "files": [ - "demos/demo_chart_heatmap.rs" - ], - "name": "demo_chart_heatmap", - "parent_dir": "demos", - "path": "components/demos/demo_chart_heatmap.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "charts_rs" - ], - "files": [ - "demos/demo_chart_radar.rs" - ], - "name": "demo_chart_radar", - "parent_dir": "demos", - "path": "components/demos/demo_chart_radar.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "charts_rs" - ], - "files": [ - "demos/demo_chart_candlesticks.rs" - ], - "name": "demo_chart_candlesticks", - "parent_dir": "demos", - "path": "components/demos/demo_chart_candlesticks.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "charts_rs" - ], - "files": [ - "demos/demo_chart_scatter.rs" - ], - "name": "demo_chart_scatter", - "parent_dir": "demos", - "path": "components/demos/demo_chart_scatter.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "charts_rs" - ], - "files": [ - "demos/demo_chart_bar_horizontal.rs" - ], - "name": "demo_chart_bar_horizontal", - "parent_dir": "demos", - "path": "components/demos/demo_chart_bar_horizontal.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "charts_rs" - ], - "files": [ - "demos/demo_chart_bar_json.rs" - ], - "name": "demo_chart_bar_json", - "parent_dir": "demos", - "path": "components/demos/demo_chart_bar_json.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [ - "charts_rs", - "serde" - ], - "files": [ - "demos/demo_chart_bar.rs" - ], - "name": "demo_chart_bar", - "parent_dir": "demos", - "path": "components/demos/demo_chart_bar.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_tabs_shadow.rs" - ], - "name": "demo_tabs_shadow", - "parent_dir": "demos", - "path": "components/demos/demo_tabs_shadow.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_select_price_table.rs" - ], - "name": "demo_select_price_table", - "parent_dir": "demos", - "path": "components/demos/demo_select_price_table.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_masonry.rs" - ], - "name": "demo_masonry", - "parent_dir": "demos", - "path": "components/demos/demo_masonry.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_steps_indicator.rs" - ], - "name": "demo_steps_indicator", - "parent_dir": "demos", - "path": "components/demos/demo_steps_indicator.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_carousel_snap_scroll.rs" - ], - "name": "demo_carousel_snap_scroll", - "parent_dir": "demos", - "path": "components/demos/demo_carousel_snap_scroll.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_sticky_cursor_links.rs" - ], - "name": "demo_sticky_cursor_links", - "parent_dir": "demos", - "path": "components/demos/demo_sticky_cursor_links.rs", - "registry_dependencies": [ - "sticky_cursor_links" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_css_pill_ocean.rs" - ], - "name": "demo_css_pill_ocean", - "parent_dir": "demos", - "path": "components/demos/demo_css_pill_ocean.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_wheel_headings.rs" - ], - "name": "demo_wheel_headings", - "parent_dir": "demos", - "path": "components/demos/demo_wheel_headings.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_css_pill_mountain.rs" - ], - "name": "demo_css_pill_mountain", - "parent_dir": "demos", - "path": "components/demos/demo_css_pill_mountain.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_ferris_animate.rs" - ], - "name": "demo_ferris_animate", - "parent_dir": "demos", - "path": "components/demos/demo_ferris_animate.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_cards_follow_shadow.rs" - ], - "name": "demo_cards_follow_shadow", - "parent_dir": "demos", - "path": "components/demos/demo_cards_follow_shadow.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_css_pill_lighthouse.rs" - ], - "name": "demo_css_pill_lighthouse", - "parent_dir": "demos", - "path": "components/demos/demo_css_pill_lighthouse.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_cards_stacking.rs" - ], - "name": "demo_cards_stacking", - "parent_dir": "demos", - "path": "components/demos/demo_cards_stacking.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_carousel3d_rotating.rs" - ], - "name": "demo_carousel3d_rotating", - "parent_dir": "demos", - "path": "components/demos/demo_carousel3d_rotating.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_triggered_highlight.rs" - ], - "name": "demo_triggered_highlight", - "parent_dir": "demos", - "path": "components/demos/demo_triggered_highlight.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_card3d_flip_rotation.rs" - ], - "name": "demo_card3d_flip_rotation", - "parent_dir": "demos", - "path": "components/demos/demo_card3d_flip_rotation.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_gallery_clickable_transitions.rs" - ], - "name": "demo_gallery_clickable_transitions", - "parent_dir": "demos", - "path": "components/demos/demo_gallery_clickable_transitions.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_cards_repushing.rs" - ], - "name": "demo_cards_repushing", - "parent_dir": "demos", - "path": "components/demos/demo_cards_repushing.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_card_focus.rs" - ], - "name": "demo_card_focus", - "parent_dir": "demos", - "path": "components/demos/demo_card_focus.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_universe_rotating.rs" - ], - "name": "demo_universe_rotating", - "parent_dir": "demos", - "path": "components/demos/demo_universe_rotating.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_cards_slider.rs" - ], - "name": "demo_cards_slider", - "parent_dir": "demos", - "path": "components/demos/demo_cards_slider.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_rain_letters.rs" - ], - "name": "demo_rain_letters", - "parent_dir": "demos", - "path": "components/demos/demo_rain_letters.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_gallery_zoom.rs" - ], - "name": "demo_gallery_zoom", - "parent_dir": "demos", - "path": "components/demos/demo_gallery_zoom.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_always_great_grid.rs" - ], - "name": "demo_always_great_grid", - "parent_dir": "demos", - "path": "components/demos/demo_always_great_grid.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_cards_glowing.rs" - ], - "name": "demo_cards_glowing", - "parent_dir": "demos", - "path": "components/demos/demo_cards_glowing.rs", - "registry_dependencies": [ - "cards_glowing" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_cta_animation_on_hover.rs" - ], - "name": "demo_cta_animation_on_hover", - "parent_dir": "demos", - "path": "components/demos/demo_cta_animation_on_hover.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_typing_effect.rs" - ], - "name": "demo_typing_effect", - "parent_dir": "demos", - "path": "components/demos/demo_typing_effect.rs", - "registry_dependencies": [ - "typing_effect" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_card_wobble.rs" - ], - "name": "demo_card_wobble", - "parent_dir": "demos", - "path": "components/demos/demo_card_wobble.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_cursor_multi_color.rs" - ], - "name": "demo_cursor_multi_color", - "parent_dir": "demos", - "path": "components/demos/demo_cursor_multi_color.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_docker.rs" - ], - "name": "demo_docker", - "parent_dir": "demos", - "path": "components/demos/demo_docker.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_particles_vercel.rs" - ], - "name": "demo_particles_vercel", - "parent_dir": "demos", - "path": "components/demos/demo_particles_vercel.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_radar_mini.rs" - ], - "name": "demo_radar_mini", - "parent_dir": "demos", - "path": "components/demos/demo_radar_mini.rs", - "registry_dependencies": [ - "radar_mini" - ], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_scroll_light.rs" - ], - "name": "demo_scroll_light", - "parent_dir": "demos", - "path": "components/demos/demo_scroll_light.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_hamburger_menu.rs" - ], - "name": "demo_hamburger_menu", - "parent_dir": "demos", - "path": "components/demos/demo_hamburger_menu.rs", - "registry_dependencies": [], - "type": "components:demos" - }, - { - "cargo_dependencies": [], - "files": [ - "demos/demo_parallax1.rs" - ], - "name": "demo_parallax1", - "parent_dir": "demos", - "path": "components/demos/demo_parallax1.rs", - "registry_dependencies": [], - "type": "components:demos" - } -] \ No newline at end of file diff --git a/package.json b/package.json deleted file mode 100644 index cf1f532..0000000 --- a/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "type": "module", - "dependencies": { - "@tailwindcss/cli": "^4.1.4", - "tailwindcss": "^4.1.4", - "tw-animate-css": "^1.2.5" - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 7849601..0000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,555 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@tailwindcss/cli': - specifier: ^4.1.4 - version: 4.1.4 - tailwindcss: - specifier: ^4.1.4 - version: 4.1.4 - tw-animate-css: - specifier: ^1.2.5 - version: 1.2.5 - -packages: - - '@parcel/watcher-android-arm64@2.5.1': - resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [android] - - '@parcel/watcher-darwin-arm64@2.5.1': - resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [darwin] - - '@parcel/watcher-darwin-x64@2.5.1': - resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [darwin] - - '@parcel/watcher-freebsd-x64@2.5.1': - resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [freebsd] - - '@parcel/watcher-linux-arm-glibc@2.5.1': - resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==} - engines: {node: '>= 10.0.0'} - cpu: [arm] - os: [linux] - - '@parcel/watcher-linux-arm-musl@2.5.1': - resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} - engines: {node: '>= 10.0.0'} - cpu: [arm] - os: [linux] - - '@parcel/watcher-linux-arm64-glibc@2.5.1': - resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [linux] - - '@parcel/watcher-linux-arm64-musl@2.5.1': - resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [linux] - - '@parcel/watcher-linux-x64-glibc@2.5.1': - resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [linux] - - '@parcel/watcher-linux-x64-musl@2.5.1': - resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [linux] - - '@parcel/watcher-win32-arm64@2.5.1': - resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [win32] - - '@parcel/watcher-win32-ia32@2.5.1': - resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==} - engines: {node: '>= 10.0.0'} - cpu: [ia32] - os: [win32] - - '@parcel/watcher-win32-x64@2.5.1': - resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [win32] - - '@parcel/watcher@2.5.1': - resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} - engines: {node: '>= 10.0.0'} - - '@tailwindcss/cli@4.1.4': - resolution: {integrity: sha512-gP05Qihh+cZ2FqD5fa0WJXx3KEk2YWUYv/RBKAyiOg0V4vYVDr/xlLc0sacpnVEXM45BVUR9U2hsESufYs6YTA==} - hasBin: true - - '@tailwindcss/node@4.1.4': - resolution: {integrity: sha512-MT5118zaiO6x6hNA04OWInuAiP1YISXql8Z+/Y8iisV5nuhM8VXlyhRuqc2PEviPszcXI66W44bCIk500Oolhw==} - - '@tailwindcss/oxide-android-arm64@4.1.4': - resolution: {integrity: sha512-xMMAe/SaCN/vHfQYui3fqaBDEXMu22BVwQ33veLc8ep+DNy7CWN52L+TTG9y1K397w9nkzv+Mw+mZWISiqhmlA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [android] - - '@tailwindcss/oxide-darwin-arm64@4.1.4': - resolution: {integrity: sha512-JGRj0SYFuDuAGilWFBlshcexev2hOKfNkoX+0QTksKYq2zgF9VY/vVMq9m8IObYnLna0Xlg+ytCi2FN2rOL0Sg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@tailwindcss/oxide-darwin-x64@4.1.4': - resolution: {integrity: sha512-sdDeLNvs3cYeWsEJ4H1DvjOzaGios4QbBTNLVLVs0XQ0V95bffT3+scptzYGPMjm7xv4+qMhCDrkHwhnUySEzA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@tailwindcss/oxide-freebsd-x64@4.1.4': - resolution: {integrity: sha512-VHxAqxqdghM83HslPhRsNhHo91McsxRJaEnShJOMu8mHmEj9Ig7ToHJtDukkuLWLzLboh2XSjq/0zO6wgvykNA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [freebsd] - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.4': - resolution: {integrity: sha512-OTU/m/eV4gQKxy9r5acuesqaymyeSCnsx1cFto/I1WhPmi5HDxX1nkzb8KYBiwkHIGg7CTfo/AcGzoXAJBxLfg==} - engines: {node: '>= 10'} - cpu: [arm] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-gnu@4.1.4': - resolution: {integrity: sha512-hKlLNvbmUC6z5g/J4H+Zx7f7w15whSVImokLPmP6ff1QqTVE+TxUM9PGuNsjHvkvlHUtGTdDnOvGNSEUiXI1Ww==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-musl@4.1.4': - resolution: {integrity: sha512-X3As2xhtgPTY/m5edUtddmZ8rCruvBvtxYLMw9OsZdH01L2gS2icsHRwxdU0dMItNfVmrBezueXZCHxVeeb7Aw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@tailwindcss/oxide-linux-x64-gnu@4.1.4': - resolution: {integrity: sha512-2VG4DqhGaDSmYIu6C4ua2vSLXnJsb/C9liej7TuSO04NK+JJJgJucDUgmX6sn7Gw3Cs5ZJ9ZLrnI0QRDOjLfNQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@tailwindcss/oxide-linux-x64-musl@4.1.4': - resolution: {integrity: sha512-v+mxVgH2kmur/X5Mdrz9m7TsoVjbdYQT0b4Z+dr+I4RvreCNXyCFELZL/DO0M1RsidZTrm6O1eMnV6zlgEzTMQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@tailwindcss/oxide-wasm32-wasi@4.1.4': - resolution: {integrity: sha512-2TLe9ir+9esCf6Wm+lLWTMbgklIjiF0pbmDnwmhR9MksVOq+e8aP3TSsXySnBDDvTTVd/vKu1aNttEGj3P6l8Q==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - bundledDependencies: - - '@napi-rs/wasm-runtime' - - '@emnapi/core' - - '@emnapi/runtime' - - '@tybys/wasm-util' - - '@emnapi/wasi-threads' - - tslib - - '@tailwindcss/oxide-win32-arm64-msvc@4.1.4': - resolution: {integrity: sha512-VlnhfilPlO0ltxW9/BgfLI5547PYzqBMPIzRrk4W7uupgCt8z6Trw/tAj6QUtF2om+1MH281Pg+HHUJoLesmng==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@tailwindcss/oxide-win32-x64-msvc@4.1.4': - resolution: {integrity: sha512-+7S63t5zhYjslUGb8NcgLpFXD+Kq1F/zt5Xv5qTv7HaFTG/DHyHD9GA6ieNAxhgyA4IcKa/zy7Xx4Oad2/wuhw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@tailwindcss/oxide@4.1.4': - resolution: {integrity: sha512-p5wOpXyOJx7mKh5MXh5oKk+kqcz8T+bA3z/5VWWeQwFrmuBItGwz8Y2CHk/sJ+dNb9B0nYFfn0rj/cKHZyjahQ==} - engines: {node: '>= 10'} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - detect-libc@1.0.3: - resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} - engines: {node: '>=0.10'} - hasBin: true - - detect-libc@2.0.3: - resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} - engines: {node: '>=8'} - - enhanced-resolve@5.18.1: - resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==} - engines: {node: '>=10.13.0'} - - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - jiti@2.4.2: - resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} - hasBin: true - - lightningcss-darwin-arm64@1.29.2: - resolution: {integrity: sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - - lightningcss-darwin-x64@1.29.2: - resolution: {integrity: sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - - lightningcss-freebsd-x64@1.29.2: - resolution: {integrity: sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - - lightningcss-linux-arm-gnueabihf@1.29.2: - resolution: {integrity: sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - - lightningcss-linux-arm64-gnu@1.29.2: - resolution: {integrity: sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - - lightningcss-linux-arm64-musl@1.29.2: - resolution: {integrity: sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - - lightningcss-linux-x64-gnu@1.29.2: - resolution: {integrity: sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - - lightningcss-linux-x64-musl@1.29.2: - resolution: {integrity: sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - - lightningcss-win32-arm64-msvc@1.29.2: - resolution: {integrity: sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - - lightningcss-win32-x64-msvc@1.29.2: - resolution: {integrity: sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - - lightningcss@1.29.2: - resolution: {integrity: sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==} - engines: {node: '>= 12.0.0'} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - - mri@1.2.0: - resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} - engines: {node: '>=4'} - - node-addon-api@7.1.1: - resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - - tailwindcss@4.1.4: - resolution: {integrity: sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==} - - tapable@2.2.1: - resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} - engines: {node: '>=6'} - - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - tw-animate-css@1.2.5: - resolution: {integrity: sha512-ABzjfgVo+fDbhRREGL4KQZUqqdPgvc5zVrLyeW9/6mVqvaDepXc7EvedA+pYmMnIOsUAQMwcWzNvom26J2qYvQ==} - -snapshots: - - '@parcel/watcher-android-arm64@2.5.1': - optional: true - - '@parcel/watcher-darwin-arm64@2.5.1': - optional: true - - '@parcel/watcher-darwin-x64@2.5.1': - optional: true - - '@parcel/watcher-freebsd-x64@2.5.1': - optional: true - - '@parcel/watcher-linux-arm-glibc@2.5.1': - optional: true - - '@parcel/watcher-linux-arm-musl@2.5.1': - optional: true - - '@parcel/watcher-linux-arm64-glibc@2.5.1': - optional: true - - '@parcel/watcher-linux-arm64-musl@2.5.1': - optional: true - - '@parcel/watcher-linux-x64-glibc@2.5.1': - optional: true - - '@parcel/watcher-linux-x64-musl@2.5.1': - optional: true - - '@parcel/watcher-win32-arm64@2.5.1': - optional: true - - '@parcel/watcher-win32-ia32@2.5.1': - optional: true - - '@parcel/watcher-win32-x64@2.5.1': - optional: true - - '@parcel/watcher@2.5.1': - dependencies: - detect-libc: 1.0.3 - is-glob: 4.0.3 - micromatch: 4.0.8 - node-addon-api: 7.1.1 - optionalDependencies: - '@parcel/watcher-android-arm64': 2.5.1 - '@parcel/watcher-darwin-arm64': 2.5.1 - '@parcel/watcher-darwin-x64': 2.5.1 - '@parcel/watcher-freebsd-x64': 2.5.1 - '@parcel/watcher-linux-arm-glibc': 2.5.1 - '@parcel/watcher-linux-arm-musl': 2.5.1 - '@parcel/watcher-linux-arm64-glibc': 2.5.1 - '@parcel/watcher-linux-arm64-musl': 2.5.1 - '@parcel/watcher-linux-x64-glibc': 2.5.1 - '@parcel/watcher-linux-x64-musl': 2.5.1 - '@parcel/watcher-win32-arm64': 2.5.1 - '@parcel/watcher-win32-ia32': 2.5.1 - '@parcel/watcher-win32-x64': 2.5.1 - - '@tailwindcss/cli@4.1.4': - dependencies: - '@parcel/watcher': 2.5.1 - '@tailwindcss/node': 4.1.4 - '@tailwindcss/oxide': 4.1.4 - enhanced-resolve: 5.18.1 - mri: 1.2.0 - picocolors: 1.1.1 - tailwindcss: 4.1.4 - - '@tailwindcss/node@4.1.4': - dependencies: - enhanced-resolve: 5.18.1 - jiti: 2.4.2 - lightningcss: 1.29.2 - tailwindcss: 4.1.4 - - '@tailwindcss/oxide-android-arm64@4.1.4': - optional: true - - '@tailwindcss/oxide-darwin-arm64@4.1.4': - optional: true - - '@tailwindcss/oxide-darwin-x64@4.1.4': - optional: true - - '@tailwindcss/oxide-freebsd-x64@4.1.4': - optional: true - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.4': - optional: true - - '@tailwindcss/oxide-linux-arm64-gnu@4.1.4': - optional: true - - '@tailwindcss/oxide-linux-arm64-musl@4.1.4': - optional: true - - '@tailwindcss/oxide-linux-x64-gnu@4.1.4': - optional: true - - '@tailwindcss/oxide-linux-x64-musl@4.1.4': - optional: true - - '@tailwindcss/oxide-wasm32-wasi@4.1.4': - optional: true - - '@tailwindcss/oxide-win32-arm64-msvc@4.1.4': - optional: true - - '@tailwindcss/oxide-win32-x64-msvc@4.1.4': - optional: true - - '@tailwindcss/oxide@4.1.4': - optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.1.4 - '@tailwindcss/oxide-darwin-arm64': 4.1.4 - '@tailwindcss/oxide-darwin-x64': 4.1.4 - '@tailwindcss/oxide-freebsd-x64': 4.1.4 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.4 - '@tailwindcss/oxide-linux-arm64-gnu': 4.1.4 - '@tailwindcss/oxide-linux-arm64-musl': 4.1.4 - '@tailwindcss/oxide-linux-x64-gnu': 4.1.4 - '@tailwindcss/oxide-linux-x64-musl': 4.1.4 - '@tailwindcss/oxide-wasm32-wasi': 4.1.4 - '@tailwindcss/oxide-win32-arm64-msvc': 4.1.4 - '@tailwindcss/oxide-win32-x64-msvc': 4.1.4 - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - detect-libc@1.0.3: {} - - detect-libc@2.0.3: {} - - enhanced-resolve@5.18.1: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.2.1 - - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - graceful-fs@4.2.11: {} - - is-extglob@2.1.1: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-number@7.0.0: {} - - jiti@2.4.2: {} - - lightningcss-darwin-arm64@1.29.2: - optional: true - - lightningcss-darwin-x64@1.29.2: - optional: true - - lightningcss-freebsd-x64@1.29.2: - optional: true - - lightningcss-linux-arm-gnueabihf@1.29.2: - optional: true - - lightningcss-linux-arm64-gnu@1.29.2: - optional: true - - lightningcss-linux-arm64-musl@1.29.2: - optional: true - - lightningcss-linux-x64-gnu@1.29.2: - optional: true - - lightningcss-linux-x64-musl@1.29.2: - optional: true - - lightningcss-win32-arm64-msvc@1.29.2: - optional: true - - lightningcss-win32-x64-msvc@1.29.2: - optional: true - - lightningcss@1.29.2: - dependencies: - detect-libc: 2.0.3 - optionalDependencies: - lightningcss-darwin-arm64: 1.29.2 - lightningcss-darwin-x64: 1.29.2 - lightningcss-freebsd-x64: 1.29.2 - lightningcss-linux-arm-gnueabihf: 1.29.2 - lightningcss-linux-arm64-gnu: 1.29.2 - lightningcss-linux-arm64-musl: 1.29.2 - lightningcss-linux-x64-gnu: 1.29.2 - lightningcss-linux-x64-musl: 1.29.2 - lightningcss-win32-arm64-msvc: 1.29.2 - lightningcss-win32-x64-msvc: 1.29.2 - - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.1 - - mri@1.2.0: {} - - node-addon-api@7.1.1: {} - - picocolors@1.1.1: {} - - picomatch@2.3.1: {} - - tailwindcss@4.1.4: {} - - tapable@2.2.1: {} - - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - - tw-animate-css@1.2.5: {} diff --git a/rustfmt.toml b/rustfmt.toml index 866c756..9b8847e 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1 +1,6 @@ -max_width = 120 \ No newline at end of file +edition = "2024" +max_width = 110 +imports_granularity = "Module" +group_imports = "StdExternalCrate" +struct_field_align_threshold = 0 +use_small_heuristics = "Max" \ No newline at end of file diff --git a/src/command_add/_add.rs b/src/command_add/_add.rs index 32a6c50..6a96fb8 100644 --- a/src/command_add/_add.rs +++ b/src/command_add/_add.rs @@ -1,80 +1,670 @@ -use clap::{Arg, ArgMatches, Command}; -// use dotenv::dotenv; -// use std::env; +use std::collections::HashSet; +use std::path::Path; use std::vec::Vec; -use super::components::{Components, MyComponent}; -// use crate::constants::env::ENV; -use super::components_toml::ComponentsToml; -use super::dependencies::Dependencies; -use super::registry::{Registry, RegistryComponent}; -use crate::constants::commands::{ADD, COMMAND}; -use crate::constants::url::URL; +const UI_CONFIG_TOML: &str = "ui_config.toml"; + +struct DeprecatedComponent { + name: &'static str, + replacement: &'static str, +} + +const DEPRECATED_COMPONENTS: &[DeprecatedComponent] = &[DeprecatedComponent { + name: "toast", + replacement: "sonner", +}]; + +use clap::{Arg, ArgMatches, Command}; + +use super::components::Components; +use super::installed::get_installed_components; +use super::registry::RegistryComponent; +use super::tree_parser::TreeParser; +use crate::command_diff::_diff::{diff_components, format_diff_human}; +use crate::command_init::config::UiConfig; +use crate::command_view::_view::view_components; +use crate::shared::cli_error::{CliError, CliResult}; +use crate::shared::rust_ui_client::RustUIClient; pub fn command_add() -> Command { - Command::new(COMMAND::ADD) - .about(ADD::ABOUT) - .arg(Arg::new(ADD::COMPONENTS).help(ADD::HELP).required(false).num_args(1..)) + Command::new("add") + .about("Add components and dependencies to your project") + .arg(Arg::new("components").help("The components to add (space-separated)").required(false).num_args(1..)) + .arg( + Arg::new("yes") + .short('y') + .long("yes") + .help("Overwrite existing files without prompting") + .action(clap::ArgAction::SetTrue), + ) + .arg( + Arg::new("dry-run") + .short('n') + .long("dry-run") + .help("Preview which files would be written without making any changes") + .action(clap::ArgAction::SetTrue), + ) + .arg( + Arg::new("path") + .long("path") + .help("Override the output directory for components (default: base_path_components from ui_config.toml)") + .value_name("PATH"), + ) + .arg( + Arg::new("view") + .long("view") + .help("View registry source for each component without installing") + .action(clap::ArgAction::SetTrue), + ) + .arg( + Arg::new("diff") + .long("diff") + .help("Show a diff of what would change for each component without installing") + .action(clap::ArgAction::SetTrue), + ) } -/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ -/* 🦀 MAIN 🦀 */ -/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ +/* ========================================================== */ +/* 🦀 MAIN 🦀 */ +/* ========================================================== */ + +/// Install a specific list of components into `base_path`, always overwriting. +/// Used by `ui init --reinstall` to re-download existing components. +pub async fn process_add_components(components: Vec, base_path: &str) -> CliResult<()> { + if components.is_empty() { + return Ok(()); + } + + let tree_content = RustUIClient::fetch_tree_md().await?; + let tree_parser = TreeParser::parse_tree_md(&tree_content)?; + + let resolved_set = tree_parser.resolve_dependencies(&components)?; + let all_resolved_components: Vec = resolved_set.components.into_iter().collect(); + let all_resolved_parent_dirs: Vec = resolved_set.parent_dirs.into_iter().collect(); + let all_resolved_cargo_dependencies: Vec = resolved_set.cargo_deps.into_iter().collect(); + let all_resolved_js_files: HashSet = resolved_set.js_files; + let user_requested: HashSet = components.into_iter().collect(); + + Components::create_components_mod_if_not_exists_with_pub_mods( + base_path.to_string(), + all_resolved_parent_dirs, + )?; + + let components_path = Path::new(base_path); + let parent_path = components_path + .parent() + .ok_or_else(|| CliError::invalid_path(base_path, "no parent directory"))?; + let entry_file_path = if parent_path.join("lib.rs").exists() { + parent_path.join("lib.rs") + } else { + parent_path.join("main.rs") + }; + Components::register_components_in_application_entry( + entry_file_path.to_string_lossy().as_ref(), + )?; + + let installed = get_installed_components(base_path); + let mut written: Vec = Vec::new(); + let mut skipped: Vec = Vec::new(); + let mut already_installed: Vec = Vec::new(); + + for component_name in all_resolved_components { + if installed.contains(&component_name) && !user_requested.contains(&component_name) { + already_installed.push(component_name); + continue; + } + + let outcome = RegistryComponent::fetch_from_registry(component_name.clone()) + .await? + .then_write_to_file_to(true, base_path) // force = always overwrite on reinstall + .await?; + + match outcome { + super::registry::WriteOutcome::Written => written.push(component_name), + super::registry::WriteOutcome::Skipped => skipped.push(component_name), + } + } + + print_add_summary(&written, &skipped, &already_installed); + + if !all_resolved_cargo_dependencies.is_empty() { + super::dependencies::process_cargo_deps(&all_resolved_cargo_dependencies)?; + } + if !all_resolved_js_files.is_empty() { + process_js_files(&all_resolved_js_files).await?; + } + + Ok(()) +} // -pub async fn process_add(matches: &ArgMatches) -> Result<(), Box> { - // dotenv().ok(); +pub async fn process_add(matches: &ArgMatches) -> CliResult<()> { + let user_components: Vec = + matches.get_many::("components").unwrap_or_default().cloned().collect(); + let force = matches.get_flag("yes"); + let dry_run = matches.get_flag("dry-run"); + let view_flag = matches.get_flag("view"); + let diff_flag = matches.get_flag("diff"); + let path_override: Option = matches.get_one::("path").cloned(); + let has_path_override = path_override.is_some(); + + // Fetch and parse tree.md + let tree_content = RustUIClient::fetch_tree_md().await?; + let tree_parser = TreeParser::parse_tree_md(&tree_content)?; - let base_path_components = ComponentsToml::try_extract_base_path_components(); + // Get base path for components: --path flag takes priority over ui_config.toml + let base_path = path_override.unwrap_or_else(|| { + UiConfig::try_reading_ui_config(UI_CONFIG_TOML) + .map(|c| c.base_path_components) + .unwrap_or_else(|_| "src/components".to_string()) + }); - if base_path_components.is_err() { - eprintln!("{}", base_path_components.unwrap_err()); - return Ok(()); // Early return + // Detect already installed components + let installed = get_installed_components(&base_path); + + // If no components provided, launch TUI + let user_components = if user_components.is_empty() { + let component_names: Vec = tree_parser.get_all_component_names(); + let dependencies = tree_parser.get_dependencies_map(); + let selected = super::ratatui::run_tui(component_names, installed.clone(), dependencies)?; + if selected.is_empty() { + println!("No components selected."); + return Ok(()); + } + selected + } else { + user_components + }; + + // Warn and exit if any requested component is deprecated + for component in &user_components { + if let Some(dep) = DEPRECATED_COMPONENTS.iter().find(|d| d.name == component.as_str()) { + eprintln!( + "Warning: '{}' is deprecated. Use '{}' instead.", + dep.name, dep.replacement + ); + return Err(CliError::validation(&format!( + "'{}' is deprecated. Use '{}' instead.", + dep.name, dep.replacement + ))); + } } - // let base_url = env::var(ENV::BASE_URL).unwrap_or_default(); - let url_registry_index_json = URL::URL_REGISTRY_INDEX_JSON; + // Resolve dependencies using the new tree-based system + let resolved_set = tree_parser.resolve_dependencies(&user_components)?; - let user_components: Vec = matches - .get_many::(ADD::COMPONENTS) - .unwrap_or_default() - .cloned() - .collect(); + // Convert HashSets to Vecs for compatibility with existing functions + let all_resolved_components: Vec = resolved_set.components.into_iter().collect(); + let all_resolved_parent_dirs: Vec = resolved_set.parent_dirs.into_iter().collect(); + let all_resolved_cargo_dependencies: Vec = resolved_set.cargo_deps.into_iter().collect(); + let all_resolved_js_files: HashSet = resolved_set.js_files; - let index_content_from_url = Registry::fetch_index_content(&url_registry_index_json).await?; + // Track which components the user explicitly requested for prompt decisions + let user_requested: HashSet = user_components.iter().cloned().collect(); - let vec_components_from_index: Vec = serde_json::from_str(&index_content_from_url).unwrap(); + // --view: print registry source for each resolved component, then exit + if view_flag { + let mut names = all_resolved_components.clone(); + names.sort(); + return view_components(&names).await; + } - let all_tree_resolved = Dependencies::all_tree_resolved(user_components, &vec_components_from_index); - Dependencies::print_dependency_tree(&all_tree_resolved); // Can be commented out - let all_resolved_components = Dependencies::get_all_resolved_components(&all_tree_resolved); - let all_resolved_parent_dirs = Dependencies::get_all_resolved_parent_dirs(&all_tree_resolved); - let all_resolved_cargo_dependencies = Dependencies::get_all_resolved_cargo_dependencies(&all_tree_resolved); + // --diff: show diff vs local files for each resolved component, then exit + if diff_flag { + let mut names = all_resolved_components.clone(); + names.sort(); + let diffs = diff_components(&names, &base_path).await?; + println!("{}", format_diff_human(&diffs)); + return Ok(()); + } - // println!("--------------------------------"); - // println!("All resolved components: {:?}", all_resolved_components); - // println!("All resolved parent dirs: {:?}", all_resolved_parent_dirs); - // println!("All resolved cargo dependencies: {:?}", all_resolved_cargo_dependencies); + // Dry-run: show what would happen without touching the filesystem + if dry_run { + let summary = compute_dry_run_summary( + &all_resolved_components, + &installed, + &user_requested, + &all_resolved_cargo_dependencies, + &all_resolved_js_files, + ); + println!("{}", format_dry_run_summary(&summary)); + return Ok(()); + } // Create components/mod.rs if it does not exist - let user_config_path_toml = ComponentsToml::get_base_path().unwrap_or_default(); Components::create_components_mod_if_not_exists_with_pub_mods( - user_config_path_toml, - all_resolved_parent_dirs.clone(), - ); + base_path.clone(), + all_resolved_parent_dirs, + )?; + + // Register `components` module in lib.rs/main.rs — skip when --path overrides the directory + // because the custom path may not correspond to any Rust entry file. + if !has_path_override { + let components_path = Path::new(&base_path); + let parent_path = components_path + .parent() + .ok_or_else(|| CliError::invalid_path(&base_path, "no parent directory"))?; + + let entry_file_path = if parent_path.join("lib.rs").exists() { + parent_path.join("lib.rs") + } else { + parent_path.join("main.rs") + }; + + let entry_file_path = entry_file_path.to_string_lossy().to_string(); + Components::register_components_in_application_entry(entry_file_path.as_str())?; + } // Components to add - for component_name_json in all_resolved_components { - RegistryComponent::fetch_from_registry(component_name_json) + let mut written: Vec = Vec::new(); + let mut skipped: Vec = Vec::new(); + let mut already_installed: Vec = Vec::new(); + + for component_name in all_resolved_components { + // Auto-resolved dep already on disk — skip fetch, report it separately + if installed.contains(&component_name) && !user_requested.contains(&component_name) { + already_installed.push(component_name); + continue; + } + + let outcome = RegistryComponent::fetch_from_registry(component_name.clone()) .await? - .then_write_to_file() + .then_write_to_file_to(force, &base_path) .await?; + + match outcome { + super::registry::WriteOutcome::Written => written.push(component_name), + super::registry::WriteOutcome::Skipped => skipped.push(component_name), + } } + print_add_summary(&written, &skipped, &already_installed); + // Handle cargo dependencies if any exist if !all_resolved_cargo_dependencies.is_empty() { - Dependencies::add_cargo_dep_to_toml(&all_resolved_cargo_dependencies)?; + super::dependencies::process_cargo_deps(&all_resolved_cargo_dependencies)?; + } + + // Handle JS file dependencies if any exist + if !all_resolved_js_files.is_empty() { + process_js_files(&all_resolved_js_files).await?; + } + + Ok(()) +} + +/* ========================================================== */ +/* 🔍 DRY-RUN SUMMARY 🔍 */ +/* ========================================================== */ + +struct DryRunSummary { + would_add: Vec, + would_overwrite: Vec, + already_installed: Vec, + cargo_deps: Vec, + js_files: Vec, +} + +fn compute_dry_run_summary( + resolved: &[String], + installed: &HashSet, + user_requested: &HashSet, + cargo_deps: &[String], + js_files: &HashSet, +) -> DryRunSummary { + let mut would_add = Vec::new(); + let mut would_overwrite = Vec::new(); + let mut already_installed = Vec::new(); + + for name in resolved { + if installed.contains(name) && !user_requested.contains(name) { + already_installed.push(name.clone()); + } else if installed.contains(name) { + would_overwrite.push(name.clone()); + } else { + would_add.push(name.clone()); + } + } + + // Sort for deterministic output + would_add.sort(); + would_overwrite.sort(); + already_installed.sort(); + + let mut cargo_deps = cargo_deps.to_vec(); + cargo_deps.sort(); + + let mut js_files: Vec = js_files.iter().cloned().collect(); + js_files.sort(); + + DryRunSummary { would_add, would_overwrite, already_installed, cargo_deps, js_files } +} + +fn format_dry_run_summary(s: &DryRunSummary) -> String { + let mut lines: Vec = Vec::new(); + + if !s.would_add.is_empty() { + lines.push(format!("[dry-run] Would add: {}", s.would_add.join(", "))); + } + if !s.would_overwrite.is_empty() { + lines.push(format!("[dry-run] Would overwrite: {}", s.would_overwrite.join(", "))); + } + if !s.already_installed.is_empty() { + lines.push(format!("[dry-run] Dep already installed: {}", s.already_installed.join(", "))); + } + if !s.cargo_deps.is_empty() { + lines.push(format!("[dry-run] Would add cargo deps: {}", s.cargo_deps.join(", "))); + } + if !s.js_files.is_empty() { + lines.push(format!("[dry-run] Would install JS files: {}", s.js_files.join(", "))); + } + + if lines.is_empty() { "[dry-run] Nothing to add.".to_string() } else { lines.join("\n") } +} + +/* ========================================================== */ +/* ✨ SUMMARY ✨ */ +/* ========================================================== */ + +fn print_add_summary(written: &[String], skipped: &[String], already_installed: &[String]) { + let summary = format_add_summary(written, skipped, already_installed); + if !summary.is_empty() { + println!("{summary}"); + } +} + +pub fn format_add_summary( + written: &[String], + skipped: &[String], + already_installed: &[String], +) -> String { + let mut lines: Vec = Vec::new(); + + if !written.is_empty() { + lines.push(format!("✅ Added: {}", written.join(", "))); + } + if !skipped.is_empty() { + lines.push(format!("⏭ Skipped: {} (already exist)", skipped.join(", "))); + } + if !already_installed.is_empty() { + lines.push(format!("📦 Dep already installed: {}", already_installed.join(", "))); + } + + lines.join("\n") +} + +/* ========================================================== */ +/* 🧪 TESTS 🧪 */ +/* ========================================================== */ + +#[cfg(test)] +mod tests { + use super::*; + + fn s(v: &[&str]) -> Vec { + v.iter().map(|s| s.to_string()).collect() + } + + // --- format_add_summary --- + + #[test] + fn summary_all_written() { + let result = format_add_summary(&s(&["button", "badge"]), &[], &[]); + assert_eq!(result, "✅ Added: button, badge"); + } + + #[test] + fn summary_all_skipped() { + let result = format_add_summary(&[], &s(&["card"]), &[]); + assert_eq!(result, "⏭ Skipped: card (already exist)"); + } + + #[test] + fn summary_all_already_installed() { + let result = format_add_summary(&[], &[], &s(&["button"])); + assert_eq!(result, "📦 Dep already installed: button"); + } + + #[test] + fn summary_mixed_all_three() { + let result = format_add_summary(&s(&["badge"]), &s(&["card"]), &s(&["button"])); + assert_eq!( + result, + "✅ Added: badge\n⏭ Skipped: card (already exist)\n📦 Dep already installed: button" + ); + } + + #[test] + fn summary_written_and_already_installed() { + let result = format_add_summary(&s(&["badge"]), &[], &s(&["button"])); + assert_eq!(result, "✅ Added: badge\n📦 Dep already installed: button"); + } + + #[test] + fn summary_empty() { + let result = format_add_summary(&[], &[], &[]); + assert!(result.is_empty()); + } + + #[test] + fn summary_single_written() { + let result = format_add_summary(&s(&["badge"]), &[], &[]); + assert_eq!(result, "✅ Added: badge"); + } + + #[test] + fn summary_multiple_already_installed() { + let result = format_add_summary(&[], &[], &s(&["button", "card", "badge"])); + assert_eq!(result, "📦 Dep already installed: button, card, badge"); + } + + // --- dep-skip logic --- + + #[test] + fn dep_already_installed_not_requested_is_skipped() { + let installed: HashSet = ["button"].iter().map(|s| s.to_string()).collect(); + let user_requested: HashSet = ["badge"].iter().map(|s| s.to_string()).collect(); + // button is installed but not requested → should be put in already_installed + assert!(installed.contains("button") && !user_requested.contains("button")); + } + + #[test] + fn dep_already_installed_but_explicitly_requested_is_not_skipped() { + let installed: HashSet = ["button"].iter().map(|s| s.to_string()).collect(); + let user_requested: HashSet = ["button"].iter().map(|s| s.to_string()).collect(); + // button is installed AND requested → should go through normal write path + assert!(!(installed.contains("button") && !user_requested.contains("button"))); + } + + #[test] + fn new_dep_not_installed_is_not_skipped() { + let installed: HashSet = HashSet::new(); + let user_requested: HashSet = ["badge"].iter().map(|s| s.to_string()).collect(); + // button is not installed → never skipped regardless of requested + assert!(!(installed.contains("button") && !user_requested.contains("button"))); + } + + // --- compute_dry_run_summary / format_dry_run_summary --- + + fn make_set(items: &[&str]) -> HashSet { + items.iter().map(|s| s.to_string()).collect() + } + + fn dry_run( + resolved: &[&str], + installed: &[&str], + requested: &[&str], + cargo: &[&str], + js: &[&str], + ) -> DryRunSummary { + compute_dry_run_summary( + &resolved.iter().map(|s| s.to_string()).collect::>(), + &make_set(installed), + &make_set(requested), + &cargo.iter().map(|s| s.to_string()).collect::>(), + &make_set(js), + ) + } + + #[test] + fn dry_run_nothing_to_add_when_all_empty() { + let s = dry_run(&[], &[], &[], &[], &[]); + assert_eq!(format_dry_run_summary(&s), "[dry-run] Nothing to add."); + } + + #[test] + fn dry_run_new_component_goes_to_would_add() { + let s = dry_run(&["badge"], &[], &["badge"], &[], &[]); + assert!(s.would_add.contains(&"badge".to_string())); + assert!(s.would_overwrite.is_empty()); + assert!(s.already_installed.is_empty()); + } + + #[test] + fn dry_run_installed_dep_not_requested_goes_to_already_installed() { + let s = dry_run(&["button"], &["button"], &["badge"], &[], &[]); + assert!(s.already_installed.contains(&"button".to_string())); + assert!(s.would_add.is_empty()); + assert!(s.would_overwrite.is_empty()); + } + + #[test] + fn dry_run_installed_and_requested_goes_to_would_overwrite() { + let s = dry_run(&["button"], &["button"], &["button"], &[], &[]); + assert!(s.would_overwrite.contains(&"button".to_string())); + assert!(s.would_add.is_empty()); + assert!(s.already_installed.is_empty()); + } + + #[test] + fn dry_run_cargo_deps_shown_in_summary() { + let s = dry_run(&[], &[], &[], &["lucide-leptos"], &[]); + assert_eq!(s.cargo_deps, vec!["lucide-leptos"]); + assert!(format_dry_run_summary(&s).contains("Would add cargo deps")); + } + + #[test] + fn dry_run_js_files_shown_in_summary() { + let s = dry_run(&[], &[], &[], &[], &["floating-ui.js"]); + assert!(format_dry_run_summary(&s).contains("Would install JS files")); + } + + #[test] + fn dry_run_mixed_all_categories() { + let s = dry_run( + &["badge", "button", "card"], + &["button", "card"], + &["badge", "button"], + &["lucide"], + &["fp.js"], + ); + assert_eq!(s.would_add, vec!["badge"]); + assert_eq!(s.would_overwrite, vec!["button"]); + assert_eq!(s.already_installed, vec!["card"]); + assert_eq!(s.cargo_deps, vec!["lucide"]); + assert_eq!(s.js_files, vec!["fp.js"]); + } + + #[test] + fn dry_run_output_is_sorted() { + let s = dry_run(&["card", "alert", "badge"], &[], &["card", "alert", "badge"], &[], &[]); + assert_eq!(s.would_add, vec!["alert", "badge", "card"]); + } + + #[test] + fn dry_run_format_shows_all_sections() { + let s = dry_run( + &["badge", "button"], + &["button"], + &["badge", "button"], + &["dep-a"], + &["file.js"], + ); + let out = format_dry_run_summary(&s); + assert!(out.contains("Would add")); + assert!(out.contains("Would overwrite")); + assert!(out.contains("Would add cargo deps")); + assert!(out.contains("Would install JS files")); + } + + // --- process_add_components --- + + #[test] + fn process_add_components_returns_ok_for_empty_list() { + // Empty input must short-circuit without hitting the network + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(process_add_components(vec![], "src/components")); + assert!(result.is_ok()); } + // --- deprecated component warnings --- + + #[test] + fn toast_is_in_deprecated_list() { + assert!(DEPRECATED_COMPONENTS.iter().any(|d| d.name == "toast")); + } + + #[test] + fn deprecated_toast_points_to_sonner() { + let dep = DEPRECATED_COMPONENTS.iter().find(|d| d.name == "toast").unwrap(); + assert_eq!(dep.replacement, "sonner"); + } + + #[test] + fn non_deprecated_component_not_in_list() { + assert!(!DEPRECATED_COMPONENTS.iter().any(|d| d.name == "button")); + assert!(!DEPRECATED_COMPONENTS.iter().any(|d| d.name == "badge")); + } + + // --- command_add flag wiring --- + + #[test] + fn command_add_diff_flag_is_registered() { + let m = command_add().try_get_matches_from(["add", "button", "--diff"]).unwrap(); + assert!(m.get_flag("diff")); + assert!(!m.get_flag("view")); + } + + #[test] + fn command_add_view_flag_is_registered() { + let m = command_add().try_get_matches_from(["add", "button", "--view"]).unwrap(); + assert!(m.get_flag("view")); + assert!(!m.get_flag("diff")); + } +} + +/// Download and install JS files to the user's public directory +async fn process_js_files(js_files: &HashSet) -> CliResult<()> { + use crate::shared::task_spinner::TaskSpinner; + + let spinner = TaskSpinner::new("Installing JS files..."); + + for js_path in js_files { + spinner.set_message(&format!("📜 Downloading {js_path}")); + + // Fetch the JS file content + let content = RustUIClient::fetch_js_file(js_path).await?; + + // Determine the output path (public/ + js_path) + let output_path = Path::new("public").join(js_path.trim_start_matches('/')); + + // Create parent directories if they don't exist + if let Some(parent) = output_path.parent() { + std::fs::create_dir_all(parent).map_err(|_| CliError::directory_create_failed())?; + } + + // Check if file already exists + if output_path.exists() { + spinner.set_message(&format!("⏭️ Skipping {js_path} (already exists)")); + continue; + } + + // Write the file + std::fs::write(&output_path, content).map_err(|_| CliError::file_write_failed())?; + } + + let files_str = js_files.iter().cloned().collect::>().join(", "); + spinner.finish_success(&format!("JS files installed: [{files_str}]")); + Ok(()) } diff --git a/src/command_add/component_type.rs b/src/command_add/component_type.rs new file mode 100644 index 0000000..6c79882 --- /dev/null +++ b/src/command_add/component_type.rs @@ -0,0 +1,80 @@ +use heck::ToSnakeCase; +use strum::{Display, EnumIter, EnumString}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Display, EnumString, EnumIter)] +#[strum(serialize_all = "lowercase")] +pub enum ComponentType { + Ui, + Demos, + Hooks, + Extensions, +} + +impl ComponentType { + /// Get the directory path for this component type + pub fn to_path(&self) -> String { + self.to_string().to_snake_case() + } + + /// Determine component type from component name patterns + pub fn from_component_name(component_name: &str) -> Self { + if component_name.starts_with("demo_") { + Self::Demos + } else if component_name.starts_with("use_") { + Self::Hooks + } else if component_name.contains("extension") { + Self::Extensions + } else { + Self::Ui + } + } +} + +/* ========================================================== */ +/* 🧪 TESTS 🧪 */ +/* ========================================================== */ + +#[cfg(test)] +mod tests { + use super::*; + + // --- from_component_name --- + + #[test] + fn demo_prefix_maps_to_demos() { + assert_eq!(ComponentType::from_component_name("demo_button"), ComponentType::Demos); + } + + #[test] + fn use_prefix_maps_to_hooks() { + assert_eq!(ComponentType::from_component_name("use_floating"), ComponentType::Hooks); + } + + #[test] + fn extension_substring_maps_to_extensions() { + assert_eq!(ComponentType::from_component_name("my_extension"), ComponentType::Extensions); + } + + #[test] + fn plain_name_maps_to_ui() { + assert_eq!(ComponentType::from_component_name("button"), ComponentType::Ui); + assert_eq!(ComponentType::from_component_name("badge"), ComponentType::Ui); + assert_eq!(ComponentType::from_component_name("card"), ComponentType::Ui); + } + + // demo_ takes priority over extension substring + #[test] + fn demo_prefix_takes_priority_over_extension() { + assert_eq!(ComponentType::from_component_name("demo_extension"), ComponentType::Demos); + } + + // --- to_path --- + + #[test] + fn to_path_returns_lowercase_string() { + assert_eq!(ComponentType::Ui.to_path(), "ui"); + assert_eq!(ComponentType::Demos.to_path(), "demos"); + assert_eq!(ComponentType::Hooks.to_path(), "hooks"); + assert_eq!(ComponentType::Extensions.to_path(), "extensions"); + } +} diff --git a/src/command_add/components.rs b/src/command_add/components.rs index 8cd4431..b5ea66a 100644 --- a/src/command_add/components.rs +++ b/src/command_add/components.rs @@ -1,67 +1,171 @@ use std::io::Write; -use std::collections::HashSet; - -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct MyComponent { - pub name: String, - pub registry_dependencies: Vec, - pub cargo_dependencies: Vec, - #[serde(rename = "type")] - pub component_type: String, - #[serde(rename = "parent_dir")] - pub parent_dir: String, -} - -#[derive(Debug)] -pub struct ResolvedComponent { - pub component: MyComponent, - pub resolved_registry_dependencies: HashSet, // All dependencies including transitive ones - pub resolved_cargo_dependencies: HashSet, // All cargo dependencies including those from transitive dependencies -} +use crate::shared::cli_error::{CliError, CliResult}; -/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ +/* ========================================================== */ /* ✨ FUNCTIONS ✨ */ -/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ +/* ========================================================== */ pub struct Components {} impl Components { // TODO. Have instead all_resolved_parent_dirs instead of compomnents. - pub fn create_components_mod_if_not_exists_with_pub_mods(user_config_path: String, parent_dirs: Vec) { - let components_mod_path = format!("{}/mod.rs", user_config_path); + pub fn create_components_mod_if_not_exists_with_pub_mods( + user_config_path: String, + parent_dirs: Vec, + ) -> CliResult<()> { + let components_mod_path = std::path::Path::new(&user_config_path).join("mod.rs"); // println!("Parent directories to add to components/mod.rs: {:?}", parent_dirs); // Create the directory if it doesn't exist - let dir = std::path::Path::new(&components_mod_path) + let dir = components_mod_path .parent() - .expect("Failed to get parent directory"); - std::fs::create_dir_all(dir).expect("Failed to create directories"); + .ok_or_else(|| CliError::file_operation("Failed to get parent directory"))?; + std::fs::create_dir_all(dir).map_err(|_| CliError::directory_create_failed())?; // Initialize mod_content let mut mod_content = String::new(); // Check if the mod.rs file already exists - if std::path::Path::new(&components_mod_path).exists() { - mod_content = std::fs::read_to_string(&components_mod_path).expect("Failed to read mod.rs"); + if components_mod_path.exists() { + mod_content = + std::fs::read_to_string(&components_mod_path).map_err(|_| CliError::file_read_failed())?; } // Create or open the mod.rs file for writing let mut mod_rs_file = std::fs::OpenOptions::new() - .write(true) .append(true) .create(true) - .open(components_mod_path) - .expect("Failed to open mod.rs"); + .open(&components_mod_path) + .map_err(|_| CliError::file_operation("Failed to open mod.rs file"))?; // Add each parent directory as a module if it doesn't already exist for parent_dir in parent_dirs { - if !mod_content.contains(&format!("pub mod {};", parent_dir)) { - writeln!(mod_rs_file, "pub mod {};", parent_dir).expect("🔸 Failed to write to mod.rs"); + if !mod_content.contains(&format!("pub mod {parent_dir};")) { + writeln!(mod_rs_file, "pub mod {parent_dir};").map_err(|_| CliError::file_write_failed())?; } } + Ok(()) + } + + pub fn register_components_in_application_entry(entry_file_path: &str) -> CliResult<()> { + let file_content = + std::fs::read_to_string(entry_file_path).map_err(|_| CliError::file_read_failed())?; + + const MOD_COMPONENTS: &str = "mod components;"; + + if file_content.contains(MOD_COMPONENTS) { + return Ok(()); + } + let mod_components_import = format!("{MOD_COMPONENTS}\n{file_content}"); + std::fs::write(entry_file_path, mod_components_import.as_bytes()) + .map_err(|_| CliError::file_write_failed())?; + Ok(()) + } +} + +/* ========================================================== */ +/* 🧪 TESTS 🧪 */ +/* ========================================================== */ + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + + // --- register_components_in_application_entry --- + + #[test] + fn prepends_mod_components_when_missing() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("main.rs"); + std::fs::write(&path, "fn main() {}").unwrap(); + + Components::register_components_in_application_entry(path.to_str().unwrap()).unwrap(); + + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.starts_with("mod components;")); + assert!(content.contains("fn main() {}")); + } + + #[test] + fn skips_when_mod_components_already_present() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("main.rs"); + std::fs::write(&path, "mod components;\nfn main() {}").unwrap(); + + Components::register_components_in_application_entry(path.to_str().unwrap()).unwrap(); + + let content = std::fs::read_to_string(&path).unwrap(); + assert_eq!(content.matches("mod components;").count(), 1); + } + + #[test] + fn is_idempotent() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("lib.rs"); + std::fs::write(&path, "pub fn foo() {}").unwrap(); + + Components::register_components_in_application_entry(path.to_str().unwrap()).unwrap(); + Components::register_components_in_application_entry(path.to_str().unwrap()).unwrap(); + + let content = std::fs::read_to_string(&path).unwrap(); + assert_eq!(content.matches("mod components;").count(), 1); + } + + // --- create_components_mod_if_not_exists_with_pub_mods --- + + #[test] + fn creates_mod_rs_with_pub_mods() { + let tmp = TempDir::new().unwrap(); + let components_dir = tmp.path().join("components"); + let dirs = vec!["ui".to_string(), "hooks".to_string()]; + + Components::create_components_mod_if_not_exists_with_pub_mods( + components_dir.to_str().unwrap().to_string(), + dirs, + ) + .unwrap(); + + let mod_rs = std::fs::read_to_string(components_dir.join("mod.rs")).unwrap(); + assert!(mod_rs.contains("pub mod ui;")); + assert!(mod_rs.contains("pub mod hooks;")); + } + + #[test] + fn does_not_duplicate_existing_pub_mod() { + let tmp = TempDir::new().unwrap(); + let components_dir = tmp.path().join("components"); + std::fs::create_dir_all(&components_dir).unwrap(); + std::fs::write(components_dir.join("mod.rs"), "pub mod ui;\n").unwrap(); + + Components::create_components_mod_if_not_exists_with_pub_mods( + components_dir.to_str().unwrap().to_string(), + vec!["ui".to_string()], + ) + .unwrap(); + + let mod_rs = std::fs::read_to_string(components_dir.join("mod.rs")).unwrap(); + assert_eq!(mod_rs.matches("pub mod ui;").count(), 1); + } + + #[test] + fn appends_new_mods_to_existing_mod_rs() { + let tmp = TempDir::new().unwrap(); + let components_dir = tmp.path().join("components"); + std::fs::create_dir_all(&components_dir).unwrap(); + std::fs::write(components_dir.join("mod.rs"), "pub mod ui;\n").unwrap(); + + Components::create_components_mod_if_not_exists_with_pub_mods( + components_dir.to_str().unwrap().to_string(), + vec!["ui".to_string(), "hooks".to_string()], + ) + .unwrap(); + + let mod_rs = std::fs::read_to_string(components_dir.join("mod.rs")).unwrap(); + assert_eq!(mod_rs.matches("pub mod ui;").count(), 1); + assert!(mod_rs.contains("pub mod hooks;")); } } diff --git a/src/command_add/components_toml.rs b/src/command_add/components_toml.rs deleted file mode 100644 index 57c7774..0000000 --- a/src/command_add/components_toml.rs +++ /dev/null @@ -1,52 +0,0 @@ -use std::fs; - -use crate::constants::file_name::FILE_NAME; - -pub struct ComponentsToml {} - -impl ComponentsToml { - pub fn try_extract_base_path_components() -> Result { - let file_path = FILE_NAME::COMPONENTS_TOML; - let contents = fs::read_to_string(file_path).unwrap(); - - // TODO. There is this line: - // base_path_components = "src/components" - // Extract the value after the = sign and remove any quotes - let parts: Vec<&str> = contents.split("base_path_components =").collect(); - if parts.len() > 1 { - let value = parts[1].trim(); - if value.starts_with("\"") && value.ends_with("\"") { - return Ok(value.replace("\"", "")); - } - } - Err( - "🔸 Error: 'base_path_components' not found in Components.toml. Please add it to your Components.toml." - .to_string(), - ) - } - - pub fn get_base_path() -> Result { - // Read the Components.toml file - let config_str = match std::fs::read_to_string(FILE_NAME::COMPONENTS_TOML) { - Ok(content) => content, - Err(e) => { - println!("Error reading Components.toml: {}", e); - return Ok("components".to_string()); // Default to "components" - } - }; - - let mut base_path = "components".to_string(); // Default value - - // Split the lines and find the base_path - for line in config_str.lines() { - if line.starts_with("path = ") { - // Extract the path value - let path_value = line.split('=').nth(1).unwrap_or("").trim().trim_matches('"'); - base_path = path_value.to_string(); - break; // Exit the loop once we find the path - } - } - - Ok(base_path) - } -} diff --git a/src/command_add/dependencies.rs b/src/command_add/dependencies.rs index ee0506a..449a758 100644 --- a/src/command_add/dependencies.rs +++ b/src/command_add/dependencies.rs @@ -1,383 +1,641 @@ -use indicatif::ProgressBar; -use std::collections::{HashMap, HashSet}; -use std::{fs, time::Duration}; +use std::collections::HashSet; +use std::fs; +use std::path::Path; -use crate::constants::others::SPINNER_UPDATE_DURATION; +use cargo_toml::Manifest; +use toml_edit::{DocumentMut, Item, Value}; -use super::components::{MyComponent, ResolvedComponent}; +use crate::command_init::workspace_utils::{WorkspaceInfo, analyze_workspace}; +use crate::shared::cli_error::{CliError, CliResult}; +use crate::shared::task_spinner::TaskSpinner; -// TODO. Should distinguish clearly between cargo dependencies and registry dependencies. +pub fn process_cargo_deps(cargo_deps: &[String]) -> CliResult<()> { + let spinner = TaskSpinner::new("Checking dependencies..."); -pub struct Dependencies {} + // Detect workspace to determine how to add dependencies + let workspace_info = analyze_workspace().ok(); -impl Dependencies { - pub fn all_tree_resolved( - user_components: Vec, - vec_components_from_index: &[MyComponent], - ) -> HashMap { - let component_map: HashMap = vec_components_from_index - .iter() - .map(|c| (c.name.clone(), c.clone())) - .collect(); + // Get existing dependencies from the target Cargo.toml + let existing_deps = get_existing_dependencies(&workspace_info)?; - let resolved = resolve_all_dependencies(&component_map, &user_components).unwrap(); + // Filter out dependencies that already exist + let (new_deps, existing_deps_found): (Vec<_>, Vec<_>) = + cargo_deps.iter().partition(|dep| !existing_deps.contains(*dep)); - resolved + if !existing_deps_found.is_empty() { + let existing_str = existing_deps_found.iter().map(|s| s.as_str()).collect::>().join(", "); + spinner.set_message(&format!("⏭️ Skipping existing dependencies: [{existing_str}]")); } - pub fn get_all_resolved_components(resolved: &HashMap) -> Vec { - let mut all_components = HashSet::new(); + if new_deps.is_empty() { + spinner.finish_with_message("All dependencies already exist in Cargo.toml"); + return Ok(()); + } - // Add all the resolved components - for name in resolved.keys() { - all_components.insert(name.clone()); - } + spinner.set_message("Adding new crates to Cargo.toml..."); - // Add all their dependencies - for (_, component) in resolved { - for dep in &component.resolved_registry_dependencies { - all_components.insert(dep.clone()); - } - } + // Check if we should use workspace dependencies + let use_workspace_deps = should_use_workspace_deps(&workspace_info); - // Convert to sorted vector for consistent output - let mut result: Vec = all_components.into_iter().collect(); - result.sort(); - result - } + let mut added_deps = Vec::new(); - pub fn get_all_resolved_parent_dirs(resolved: &HashMap) -> Vec { - let mut all_parent_dirs = HashSet::new(); + for dep in &new_deps { + spinner.set_message(&format!("📦 Adding crate: {dep}")); - // Add all the resolved component types - for (_, component) in resolved { - all_parent_dirs.insert(component.component.parent_dir.clone()); - } + let result = if use_workspace_deps { + // Safe: use_workspace_deps is only true when workspace_info is Some with valid data + let Some(info) = workspace_info.as_ref() else { + return Err(CliError::cargo_operation("Workspace info unavailable")); + }; + add_workspace_dependency(dep, info) + } else { + add_dependency_with_cargo(dep, &workspace_info) + }; - // Convert to sorted vector for consistent output - let mut result: Vec = all_parent_dirs.into_iter().collect(); - result.sort(); - result + match result { + Ok(()) => added_deps.push(dep.as_str()), + Err(e) => return Err(e), + } } - pub fn get_all_resolved_cargo_dependencies(resolved: &HashMap) -> Vec { - let mut all_cargo_deps = HashSet::new(); + let dependencies_str = added_deps.join(", "); + let finish_message = format!("Successfully added to Cargo.toml: [{dependencies_str}] !"); + spinner.finish_success(&finish_message); - // Add all cargo dependencies from all components - for (_, component) in resolved { - for dep in &component.resolved_cargo_dependencies { - all_cargo_deps.insert(dep.clone()); - } - } + Ok(()) +} + +/* ========================================================== */ +/* ✨ FUNCTIONS ✨ */ +/* ========================================================== */ + +/// Check if we should use workspace dependencies pattern +fn should_use_workspace_deps(workspace_info: &Option) -> bool { + let Some(info) = workspace_info else { + return false; + }; - // Convert to sorted vector for consistent output - let mut result: Vec = all_cargo_deps.into_iter().collect(); - result.sort(); - result + if !info.is_workspace { + return false; } - // + let Some(workspace_root) = &info.workspace_root else { + return false; + }; - pub fn print_dependency_tree(resolved: &HashMap) { - println!("Dependency Tree Resolution:"); + // Check if workspace root has [workspace.dependencies] section + let root_cargo_toml = workspace_root.join("Cargo.toml"); + if !root_cargo_toml.exists() { + return false; + } - // Find components that are direct targets (not dependencies of other resolved components) - let mut dependent_components = HashSet::new(); - for (_, resolved_comp) in resolved { - for dep in &resolved_comp.resolved_registry_dependencies { - dependent_components.insert(dep.clone()); - } - } + let Ok(contents) = fs::read_to_string(&root_cargo_toml) else { + return false; + }; - // Print each target component's tree - for (name, _) in resolved { - // Only print the top-level components (not dependencies of other resolved components) - // Or, remove this condition to print all resolved components at top level - if !dependent_components.contains(name) { - print_component_tree(name, resolved, resolved, 0); - } - } + let Ok(doc) = contents.parse::() else { + return false; + }; + + // Check if [workspace.dependencies] exists + doc.get("workspace").and_then(|w| w.get("dependencies")).is_some() +} + +/// Add dependency using workspace pattern: +/// 1. Add to [workspace.dependencies] in root Cargo.toml +/// 2. Add dep.workspace = true to member Cargo.toml +fn add_workspace_dependency(dep: &str, info: &WorkspaceInfo) -> CliResult<()> { + let workspace_root = + info.workspace_root.as_ref().ok_or_else(|| CliError::cargo_operation("Workspace root not found"))?; + + let member_path = info + .target_crate_path + .as_ref() + .ok_or_else(|| CliError::cargo_operation("Target crate path not found"))?; + + // First, get the latest version from crates.io + let version = fetch_latest_version(dep)?; + + // Add to workspace root [workspace.dependencies] + let root_cargo_toml = workspace_root.join("Cargo.toml"); + add_to_workspace_dependencies(&root_cargo_toml, dep, &version)?; + + // Add to member [dependencies] with workspace = true + let member_cargo_toml = member_path.join("Cargo.toml"); + add_workspace_ref_to_member(&member_cargo_toml, dep)?; + + Ok(()) +} + +/// Add dependency to [workspace.dependencies] in root Cargo.toml +fn add_to_workspace_dependencies(cargo_toml_path: &Path, dep: &str, version: &str) -> CliResult<()> { + let contents = fs::read_to_string(cargo_toml_path)?; + let mut doc: DocumentMut = contents + .parse() + .map_err(|e| CliError::cargo_operation(&format!("Failed to parse Cargo.toml: {e}")))?; + + // Get or create [workspace.dependencies] + let workspace = doc.entry("workspace").or_insert(Item::Table(toml_edit::Table::new())); + + let workspace_table = + workspace.as_table_mut().ok_or_else(|| CliError::cargo_operation("[workspace] is not a table"))?; + + let deps = workspace_table.entry("dependencies").or_insert(Item::Table(toml_edit::Table::new())); + + let deps_table = deps + .as_table_mut() + .ok_or_else(|| CliError::cargo_operation("[workspace.dependencies] is not a table"))?; + + // Check if already exists + if deps_table.contains_key(dep) { + return Ok(()); } - // + // Add the dependency with version + deps_table.insert(dep, Item::Value(Value::String(toml_edit::Formatted::new(version.to_string())))); + + // Write back + fs::write(cargo_toml_path, doc.to_string())?; - pub fn add_cargo_dep_to_toml(cargo_deps: &[String]) -> Result<(), Box> { - // Find Cargo.toml file in the current directory or parent directories - let cargo_toml_path = find_cargo_toml()?; + Ok(()) +} - let spinner = ProgressBar::new_spinner(); - spinner.set_message("Adding crates to Cargo.toml..."); - spinner.enable_steady_tick(Duration::from_millis(SPINNER_UPDATE_DURATION)); +/// Add dep.workspace = true to member's [dependencies] +fn add_workspace_ref_to_member(cargo_toml_path: &Path, dep: &str) -> CliResult<()> { + let contents = fs::read_to_string(cargo_toml_path)?; + let mut doc: DocumentMut = contents + .parse() + .map_err(|e| CliError::cargo_operation(&format!("Failed to parse member Cargo.toml: {e}")))?; - // Read the current Cargo.toml content - let mut cargo_toml_content = fs::read_to_string(&cargo_toml_path)?; + // Get or create [dependencies] + let deps = doc.entry("dependencies").or_insert(Item::Table(toml_edit::Table::new())); - // Check if dependencies section exists - if !cargo_toml_content.contains("[dependencies]") { - cargo_toml_content.push_str("\n[dependencies]\n"); - } + let deps_table = + deps.as_table_mut().ok_or_else(|| CliError::cargo_operation("[dependencies] is not a table"))?; - // Add each dependency using the CLI command - let mut added_deps = Vec::new(); - for dep in cargo_deps { - // Skip "std" as it's a standard library and not a dependency to add - if dep == "std" { - continue; - } + // Check if already exists + if deps_table.contains_key(dep) { + return Ok(()); + } + + // Add dep.workspace = true using dotted key format + let mut dep_table = toml_edit::Table::new(); + dep_table.set_dotted(true); + dep_table.insert("workspace", Item::Value(Value::Boolean(toml_edit::Formatted::new(true)))); + deps_table.insert(dep, Item::Table(dep_table)); + + // Write back + fs::write(cargo_toml_path, doc.to_string())?; - // Update the spinner message to show the current crate being installed - spinner.set_message(format!("📦 Adding crate: {}", dep)); + Ok(()) +} + +/// Fetch the latest version of a crate from crates.io +fn fetch_latest_version(crate_name: &str) -> CliResult { + // Use cargo search to get the latest version + let output = std::process::Command::new("cargo") + .args(["search", crate_name, "--limit", "1"]) + .output() + .map_err(|_| CliError::cargo_operation("Failed to execute cargo search"))?; - // Execute the CLI command to add the dependency - let output = std::process::Command::new("cargo").arg("add").arg(dep).output()?; + if !output.status.success() { + return Err(CliError::cargo_operation(&format!("Failed to search for crate '{crate_name}'"))); + } - if output.status.success() { - added_deps.push(dep); - } else { - eprintln!( - "Failed to add dependency {}: {}", - dep, - String::from_utf8_lossy(&output.stderr) - ); + let stdout = String::from_utf8_lossy(&output.stdout); + + // Parse output like: serde = "1.0.219" # A generic serialization/deserialization framework + for line in stdout.lines() { + if line.starts_with(crate_name) { + // Extract version from format: crate_name = "version" + if let Some(version_part) = line.split('=').nth(1) { + let version = version_part + .trim() + .trim_matches('"') + .split_whitespace() + .next() + .unwrap_or("") + .trim_matches('"'); + + if !version.is_empty() { + return Ok(version.to_string()); + } } } + } - // Only write to the file if we've added new dependencies - if !added_deps.is_empty() { - let dependencies_str = added_deps - .iter() - .map(|dep| dep.as_str()) - .collect::>() - .join(", "); - let finish_message = format!("✔️ Successfully added to Cargo.toml: [{}] !", dependencies_str); - spinner.finish_with_message(finish_message); - } else { - spinner.finish_with_message("No new crates to add"); - } + // Fallback: use "*" if we can't determine version + Ok("*".to_string()) +} + +/// Fallback: use cargo add command +fn add_dependency_with_cargo(dep: &str, workspace_info: &Option) -> CliResult<()> { + let args = build_cargo_add_args(dep, workspace_info); + + let output = std::process::Command::new("cargo") + .args(&args) + .output() + .map_err(|_| CliError::cargo_operation("Failed to execute cargo add"))?; + if output.status.success() { Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(CliError::cargo_operation(&format!("Failed to add dependency '{dep}': {stderr}"))) } } -/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ -/* ✨ FUNCTIONS ✨ */ -/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ - -fn resolve_all_dependencies( - component_map: &HashMap, - user_components: &[String], -) -> Result, Box> { - // Map to store resolved components - let mut resolved_components: HashMap = HashMap::new(); - - // Process only the selected components - for component_name in user_components { - if !component_map.contains_key(component_name) { - return Err(format!("Target component '{}' not found in index", component_name).into()); - } +/// Build cargo add arguments, adding --package flag for workspaces +fn build_cargo_add_args(dep: &str, workspace_info: &Option) -> Vec { + let mut args = vec!["add".to_string(), dep.to_string()]; - resolve_component_recursive( - component_name, - component_map, - &mut resolved_components, - &mut HashSet::new(), - )?; + if let Some(info) = workspace_info.as_ref().filter(|i| i.is_workspace) + && let Some(crate_name) = &info.target_crate + { + args.push("--package".to_string()); + args.push(crate_name.clone()); } - Ok(resolved_components) + args } -fn resolve_component_recursive( - component_name: &str, - component_map: &HashMap, - resolved_components: &mut HashMap, - visited: &mut HashSet, -) -> Result<(HashSet, HashSet), Box> { - // Return cached result if already processed - if let Some(resolved) = resolved_components.get(component_name) { - return Ok(( - resolved.resolved_registry_dependencies.clone(), - resolved.resolved_cargo_dependencies.clone(), - )); - } +/// Check if a crate is already in Cargo.toml dependencies +fn get_existing_dependencies(workspace_info: &Option) -> CliResult> { + // Determine which Cargo.toml to check + let cargo_toml_path = if let Some(info) = workspace_info { + if let Some(crate_path) = &info.target_crate_path { + crate_path.join("Cargo.toml") + } else { + Path::new("Cargo.toml").to_path_buf() + } + } else { + Path::new("Cargo.toml").to_path_buf() + }; - // Prevent infinite recursion - if !visited.insert(component_name.to_string()) { - return Err(format!("Circular dependency detected involving '{}'", component_name).into()); + if !cargo_toml_path.exists() { + return Ok(HashSet::new()); } - // Get component or return error if not found - let component = match component_map.get(component_name) { - Some(c) => c, - None => return Err(format!("Component '{}' not found", component_name).into()), - }; + // Read the file directly to avoid workspace resolution issues + let contents = fs::read_to_string(&cargo_toml_path)?; + let manifest = Manifest::from_slice(contents.as_bytes())?; - // Collect all dependencies recursively - let mut resolved_registry_dependencies = HashSet::new(); - let mut resolved_cargo_dependencies = HashSet::new(); + let mut existing_deps = HashSet::new(); - // Add direct cargo dependencies - for cargo_dep in &component.cargo_dependencies { - resolved_cargo_dependencies.insert(cargo_dep.clone()); + // Check [dependencies] section + for dep_name in manifest.dependencies.keys() { + existing_deps.insert(dep_name.clone()); } - // Add direct registry dependencies and their transitive dependencies - for dep_name in &component.registry_dependencies { - resolved_registry_dependencies.insert(dep_name.clone()); + // Check [dev-dependencies] section + for dep_name in manifest.dev_dependencies.keys() { + existing_deps.insert(dep_name.clone()); + } - // Add transitive dependencies (both registry and cargo) - let (transitive_registry_deps, transitive_cargo_deps) = - resolve_component_recursive(dep_name, component_map, resolved_components, visited)?; + Ok(existing_deps) +} - for trans_dep in transitive_registry_deps { - resolved_registry_dependencies.insert(trans_dep); - } +/* ========================================================== */ +/* 🧪 TESTS 🧪 */ +/* ========================================================== */ - for cargo_dep in transitive_cargo_deps { - resolved_cargo_dependencies.insert(cargo_dep); - } +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use tempfile::TempDir; + + use super::*; + + #[test] + fn test_build_cargo_add_args_no_workspace() { + let args = build_cargo_add_args("serde", &None); + assert_eq!(args, vec!["add", "serde"]); } - // Remove component from visited set as we're done with it - visited.remove(component_name); + #[test] + fn test_build_cargo_add_args_single_crate() { + let info = WorkspaceInfo { + is_workspace: false, + workspace_root: None, + target_crate: Some("my-app".to_string()), + target_crate_path: None, + components_base_path: "src/components".to_string(), + }; + + let args = build_cargo_add_args("serde", &Some(info)); + assert_eq!(args, vec!["add", "serde"]); + } - // Store the resolved component - resolved_components.insert( - component_name.to_string(), - ResolvedComponent { - component: component.clone(), - resolved_registry_dependencies: resolved_registry_dependencies.clone(), - resolved_cargo_dependencies: resolved_cargo_dependencies.clone(), - }, - ); + #[test] + fn test_build_cargo_add_args_workspace_with_target() { + let info = WorkspaceInfo { + is_workspace: true, + workspace_root: Some(PathBuf::from("/project")), + target_crate: Some("frontend".to_string()), + target_crate_path: Some(PathBuf::from("/project/frontend")), + components_base_path: "frontend/src/components".to_string(), + }; + + let args = build_cargo_add_args("serde", &Some(info)); + assert_eq!(args, vec!["add", "serde", "--package", "frontend"]); + } - Ok((resolved_registry_dependencies, resolved_cargo_dependencies)) -} + #[test] + fn test_build_cargo_add_args_workspace_no_target() { + let info = WorkspaceInfo { + is_workspace: true, + workspace_root: Some(PathBuf::from("/project")), + target_crate: None, + target_crate_path: None, + components_base_path: "src/components".to_string(), + }; + + let args = build_cargo_add_args("serde", &Some(info)); + assert_eq!(args, vec!["add", "serde"]); + } -/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ -/* ✨ FUNCTIONS ✨ */ -/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ - -fn print_component_tree( - component_name: &str, - all_resolved: &HashMap, - current_branch: &HashMap, - depth: usize, -) { - if let Some(component) = current_branch.get(component_name) { - let indent = " ".repeat(depth); - println!("{}└─ {} ({})", indent, component_name, component.component.parent_dir); - - // TODO. Shortfix to remove std. I don't know where it comes from. - let filtered_cargo_deps: Vec<&String> = component - .component - .cargo_dependencies - .iter() - .filter(|&dep| dep != "std") - .collect(); - - if !filtered_cargo_deps.is_empty() { - let cargo_indent = " ".repeat(depth + 1); - println!("{}└─ Cargo Dependencies:", cargo_indent); - - // Sort cargo dependencies for consistent output - let mut cargo_deps = filtered_cargo_deps; - cargo_deps.sort(); - - for cargo_dep in cargo_deps { - let cargo_dep_indent = " ".repeat(depth + 2); - println!("{}└─ {}", cargo_dep_indent, cargo_dep); - } - } + #[test] + fn test_should_use_workspace_deps_no_workspace() { + assert!(!should_use_workspace_deps(&None)); + } - // Sort registry dependencies for consistent output - let mut deps: Vec<&String> = component.component.registry_dependencies.iter().collect(); - deps.sort(); - - for dep_name in deps { - // Only print dependency if it's in our resolved set - if all_resolved.contains_key(dep_name) { - print_component_tree(dep_name, all_resolved, all_resolved, depth + 1); - } else { - // This is a dependency that wasn't fully resolved (part of another branch) - let indent = " ".repeat(depth + 1); - println!("{}└─ {} (external)", indent, dep_name); - } - } + #[test] + fn test_should_use_workspace_deps_not_workspace() { + let info = WorkspaceInfo { + is_workspace: false, + workspace_root: None, + target_crate: Some("app".to_string()), + target_crate_path: None, + components_base_path: "src/components".to_string(), + }; + assert!(!should_use_workspace_deps(&Some(info))); } -} -/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ -/* ✨ FUNCTIONS ✨ */ -/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + #[test] + fn test_should_use_workspace_deps_with_workspace_dependencies() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + // Create workspace Cargo.toml with [workspace.dependencies] + fs::write( + root.join("Cargo.toml"), + r#" +[workspace] +members = ["app"] + +[workspace.dependencies] +leptos = "0.7" +"#, + ) + .unwrap(); + + let info = WorkspaceInfo { + is_workspace: true, + workspace_root: Some(root.to_path_buf()), + target_crate: Some("app".to_string()), + target_crate_path: Some(root.join("app")), + components_base_path: "app/src/components".to_string(), + }; + + assert!(should_use_workspace_deps(&Some(info))); + } + + #[test] + fn test_should_use_workspace_deps_without_workspace_dependencies() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + // Create workspace Cargo.toml WITHOUT [workspace.dependencies] + fs::write( + root.join("Cargo.toml"), + r#" +[workspace] +members = ["app"] +"#, + ) + .unwrap(); + + let info = WorkspaceInfo { + is_workspace: true, + workspace_root: Some(root.to_path_buf()), + target_crate: Some("app".to_string()), + target_crate_path: Some(root.join("app")), + components_base_path: "app/src/components".to_string(), + }; + + assert!(!should_use_workspace_deps(&Some(info))); + } -fn find_cargo_toml() -> Result> { - // Start with the current directory - let mut current_dir = std::env::current_dir()?; + #[test] + fn test_add_to_workspace_dependencies() { + let temp = TempDir::new().unwrap(); + let cargo_toml = temp.path().join("Cargo.toml"); + + // Create initial Cargo.toml + fs::write( + &cargo_toml, + r#"[workspace] +members = ["app"] + +[workspace.dependencies] +leptos = "0.7" +"#, + ) + .unwrap(); + + // Add serde + add_to_workspace_dependencies(&cargo_toml, "serde", "1.0").unwrap(); + + // Verify + let contents = fs::read_to_string(&cargo_toml).unwrap(); + assert!(contents.contains("serde = \"1.0\""), "Should contain serde dependency: {contents}"); + assert!(contents.contains("leptos = \"0.7\""), "Should preserve existing deps: {contents}"); + } - loop { - let cargo_toml_path = current_dir.join("Cargo.toml"); + #[test] + fn test_add_workspace_ref_to_member() { + let temp = TempDir::new().unwrap(); + let cargo_toml = temp.path().join("Cargo.toml"); + + // Create initial member Cargo.toml + fs::write( + &cargo_toml, + r#"[package] +name = "app" +version = "0.1.0" + +[dependencies] +leptos.workspace = true +"#, + ) + .unwrap(); + + // Add serde.workspace = true + add_workspace_ref_to_member(&cargo_toml, "serde").unwrap(); + + // Verify + let contents = fs::read_to_string(&cargo_toml).unwrap(); + assert!(contents.contains("serde"), "Should contain serde: {contents}"); + assert!( + contents.contains("workspace = true") || contents.contains("workspace=true"), + "Should have workspace = true: {contents}" + ); + } - if cargo_toml_path.exists() { - return Ok(cargo_toml_path.to_string_lossy().to_string()); - } + #[test] + fn test_add_workspace_ref_uses_dotted_format() { + let temp = TempDir::new().unwrap(); + let cargo_toml = temp.path().join("Cargo.toml"); + + // Create initial member Cargo.toml + fs::write( + &cargo_toml, + r#"[package] +name = "app" +version = "0.1.0" + +[dependencies] +"#, + ) + .unwrap(); + + // Add validator.workspace = true + add_workspace_ref_to_member(&cargo_toml, "validator").unwrap(); + + // Verify it uses dotted format (validator.workspace = true) not inline ({ workspace = true }) + let contents = fs::read_to_string(&cargo_toml).unwrap(); + assert!( + contents.contains("validator.workspace = true"), + "Should use dotted format 'validator.workspace = true', got: {contents}" + ); + assert!( + !contents.contains("{ workspace = true }"), + "Should NOT use inline table format, got: {contents}" + ); + } - // Move to the parent directory - if !current_dir.pop() { - // No parent directory (we're at the root) - break; - } + #[test] + fn get_existing_deps_returns_empty_when_no_cargo_toml() { + let temp = TempDir::new().unwrap(); + let info = WorkspaceInfo { + is_workspace: false, + workspace_root: None, + target_crate: None, + target_crate_path: Some(temp.path().to_path_buf()), + components_base_path: "src/components".to_string(), + }; + let result = get_existing_dependencies(&Some(info)).unwrap(); + assert!(result.is_empty()); } - Err("Could not find Cargo.toml in the current directory or any parent directories".into()) -} + #[test] + fn get_existing_deps_reads_dependencies_section() { + let temp = TempDir::new().unwrap(); + fs::write( + temp.path().join("Cargo.toml"), + r#"[package] +name = "app" +version = "0.1.0" + +[dependencies] +serde = "1.0" +tokio = "1.0" +"#, + ) + .unwrap(); + let info = WorkspaceInfo { + is_workspace: false, + workspace_root: None, + target_crate: None, + target_crate_path: Some(temp.path().to_path_buf()), + components_base_path: "src/components".to_string(), + }; + let result = get_existing_dependencies(&Some(info)).unwrap(); + assert!(result.contains("serde")); + assert!(result.contains("tokio")); + assert_eq!(result.len(), 2); + } -/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ -/* 🧪 TESTS 🧪 */ -/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ - -// #[cfg(test)] -// mod tests { -// use super::*; -// use std::fs; - -// #[test] -// fn make_sure_we_do_not_add_registry_dependencies_twice() { -// // Setup: Use the existing FakeCargo.toml file for testing -// let test_cargo_toml_path = "FakeCargo.toml"; - -// // Read the initial content of FakeCargo.toml to check existing dependencies -// let initial_content = fs::read_to_string(test_cargo_toml_path).expect("Unable to read test Cargo.toml"); -// let existing_dependencies: Vec<&str> = initial_content -// .lines() -// .filter_map(|line| { -// if line.trim().starts_with('[') || line.trim().is_empty() { -// None -// } else { -// Some(line.trim().split_whitespace().next().unwrap()) -// } -// }) -// .collect(); - -// // Test: Add a new dependency -// let new_dependencies = vec!["serde".to_string(), "reqwest".to_string()]; -// add_cargo_dep_to_toml(&new_dependencies).expect("Failed to update Cargo.toml"); - -// // Verify: Check if the dependencies were added -// let updated_content = fs::read_to_string(test_cargo_toml_path).expect("Unable to read test Cargo.toml"); - -// // Assert that new dependencies are added and not duplicated -// for dep in new_dependencies { -// assert!(updated_content.contains(&dep), "Dependency {} was not added", dep); -// } - -// // Assert that existing dependencies are not duplicated -// for dep in existing_dependencies { -// assert!( -// updated_content.matches(dep).count() == 1, -// "Dependency {} was added twice", -// dep -// ); -// } -// } -// } + #[test] + fn get_existing_deps_includes_dev_dependencies() { + let temp = TempDir::new().unwrap(); + fs::write( + temp.path().join("Cargo.toml"), + r#"[package] +name = "app" +version = "0.1.0" + +[dependencies] +serde = "1.0" + +[dev-dependencies] +tempfile = "3.0" +"#, + ) + .unwrap(); + let info = WorkspaceInfo { + is_workspace: false, + workspace_root: None, + target_crate: None, + target_crate_path: Some(temp.path().to_path_buf()), + components_base_path: "src/components".to_string(), + }; + let result = get_existing_dependencies(&Some(info)).unwrap(); + assert!(result.contains("serde")); + assert!(result.contains("tempfile")); + } + + #[test] + fn test_add_workspace_dependency_full_flow() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + // Create workspace root Cargo.toml + fs::write( + root.join("Cargo.toml"), + r#"[workspace] +members = ["app"] + +[workspace.dependencies] +leptos = "0.7" +"#, + ) + .unwrap(); + + // Create app directory and Cargo.toml + let app_dir = root.join("app"); + fs::create_dir_all(&app_dir).unwrap(); + fs::write( + app_dir.join("Cargo.toml"), + r#"[package] +name = "app" +version = "0.1.0" + +[dependencies] +leptos.workspace = true +"#, + ) + .unwrap(); + + let _info = WorkspaceInfo { + is_workspace: true, + workspace_root: Some(root.to_path_buf()), + target_crate: Some("app".to_string()), + target_crate_path: Some(app_dir.clone()), + components_base_path: "app/src/components".to_string(), + }; + + // Test the individual functions since fetch_latest_version requires network + add_to_workspace_dependencies(&root.join("Cargo.toml"), "serde", "1.0").unwrap(); + add_workspace_ref_to_member(&app_dir.join("Cargo.toml"), "serde").unwrap(); + + // Verify root Cargo.toml + let root_contents = fs::read_to_string(root.join("Cargo.toml")).unwrap(); + assert!(root_contents.contains("serde = \"1.0\""), "Root should have serde: {root_contents}"); + + // Verify app Cargo.toml + let app_contents = fs::read_to_string(app_dir.join("Cargo.toml")).unwrap(); + assert!(app_contents.contains("serde"), "App should have serde ref: {app_contents}"); + } +} diff --git a/src/command_add/installed.rs b/src/command_add/installed.rs new file mode 100644 index 0000000..34724b3 --- /dev/null +++ b/src/command_add/installed.rs @@ -0,0 +1,135 @@ +use std::collections::HashSet; +use std::path::Path; + +/// Scan the components directory and return a set of installed component names +pub fn get_installed_components(base_path: &str) -> HashSet { + let mut installed = HashSet::new(); + let base = Path::new(base_path); + + if !base.exists() { + return installed; + } + + // Scan subdirectories: ui/, demos/, hooks/, extensions/ + let subdirs = ["ui", "demos", "hooks", "extensions"]; + + for subdir in subdirs { + let dir_path = base.join(subdir); + if let Ok(entries) = std::fs::read_dir(&dir_path) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() + && path.extension().is_some_and(|ext| ext == "rs") + && let Some(stem) = path.file_stem() + { + let name = stem.to_string_lossy().to_string(); + // Skip mod.rs + if name != "mod" { + installed.insert(name); + } + } + } + } + } + + installed +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::sync::atomic::{AtomicU64, Ordering}; + + static TEST_COUNTER: AtomicU64 = AtomicU64::new(0); + + fn create_temp_dir(test_name: &str) -> std::path::PathBuf { + let id = TEST_COUNTER.fetch_add(1, Ordering::SeqCst); + let dir = std::env::temp_dir().join(format!("ui_cli_{}_{}", test_name, id)); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn returns_empty_for_nonexistent_path() { + let result = get_installed_components("/nonexistent/path/12345"); + assert!(result.is_empty()); + } + + #[test] + fn returns_empty_for_empty_directory() { + let temp_dir = create_temp_dir("empty"); + let result = get_installed_components(temp_dir.to_str().unwrap()); + assert!(result.is_empty()); + let _ = fs::remove_dir_all(&temp_dir); + } + + #[test] + fn finds_components_in_ui_subdir() { + let temp_dir = create_temp_dir("ui_subdir"); + let ui_dir = temp_dir.join("ui"); + fs::create_dir_all(&ui_dir).unwrap(); + fs::write(ui_dir.join("button.rs"), "// button").unwrap(); + fs::write(ui_dir.join("card.rs"), "// card").unwrap(); + + let result = get_installed_components(temp_dir.to_str().unwrap()); + assert!(result.contains("button")); + assert!(result.contains("card")); + assert_eq!(result.len(), 2); + + let _ = fs::remove_dir_all(&temp_dir); + } + + #[test] + fn skips_mod_rs() { + let temp_dir = create_temp_dir("mod_rs"); + let ui_dir = temp_dir.join("ui"); + fs::create_dir_all(&ui_dir).unwrap(); + fs::write(ui_dir.join("mod.rs"), "// mod").unwrap(); + fs::write(ui_dir.join("button.rs"), "// button").unwrap(); + + let result = get_installed_components(temp_dir.to_str().unwrap()); + assert!(!result.contains("mod")); + assert!(result.contains("button")); + assert_eq!(result.len(), 1); + + let _ = fs::remove_dir_all(&temp_dir); + } + + #[test] + fn finds_components_across_subdirs() { + let temp_dir = create_temp_dir("across_subdirs"); + fs::create_dir_all(temp_dir.join("ui")).unwrap(); + fs::create_dir_all(temp_dir.join("demos")).unwrap(); + fs::create_dir_all(temp_dir.join("hooks")).unwrap(); + + fs::write(temp_dir.join("ui/button.rs"), "").unwrap(); + fs::write(temp_dir.join("demos/demo_button.rs"), "").unwrap(); + fs::write(temp_dir.join("hooks/use_click.rs"), "").unwrap(); + + let result = get_installed_components(temp_dir.to_str().unwrap()); + assert!(result.contains("button")); + assert!(result.contains("demo_button")); + assert!(result.contains("use_click")); + assert_eq!(result.len(), 3); + + let _ = fs::remove_dir_all(&temp_dir); + } + + #[test] + fn ignores_non_rs_files() { + let temp_dir = create_temp_dir("non_rs"); + let ui_dir = temp_dir.join("ui"); + fs::create_dir_all(&ui_dir).unwrap(); + fs::write(ui_dir.join("button.rs"), "").unwrap(); + fs::write(ui_dir.join("readme.md"), "").unwrap(); + fs::write(ui_dir.join("style.css"), "").unwrap(); + + let result = get_installed_components(temp_dir.to_str().unwrap()); + assert_eq!(result.len(), 1); + assert!(result.contains("button")); + + let _ = fs::remove_dir_all(&temp_dir); + } +} diff --git a/src/command_add/mod.rs b/src/command_add/mod.rs index 7aa5d84..7935dda 100644 --- a/src/command_add/mod.rs +++ b/src/command_add/mod.rs @@ -1,7 +1,9 @@ pub mod _add; -pub mod models; +pub mod component_type; pub mod components; -pub mod components_toml; pub mod dependencies; +pub mod installed; +pub mod ratatui; pub mod registry; +pub mod tree_parser; diff --git a/src/command_add/models.rs b/src/command_add/models.rs deleted file mode 100644 index 8b13789..0000000 --- a/src/command_add/models.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/command_add/ratatui/app.rs b/src/command_add/ratatui/app.rs new file mode 100644 index 0000000..99c0826 --- /dev/null +++ b/src/command_add/ratatui/app.rs @@ -0,0 +1,395 @@ +use std::collections::HashSet; +use std::time::Instant; + +use ratatui::widgets::{ListState, ScrollbarState}; + +use super::DependencyMap; +use super::header::{Header, Tab}; + +pub struct App<'a> { + pub should_quit: bool, + pub header: Header<'a>, + // Installed components (already in project) + pub installed: HashSet, + // Dependencies map (component -> its dependencies) + pub dependencies: DependencyMap, + // Components (non-demo items) + pub components: Vec, + pub components_scroll: usize, + pub components_scroll_state: ScrollbarState, + pub components_list_state: ListState, + pub components_search_query: String, + pub components_search_active: bool, + pub components_checked: HashSet, + // Demos (demo_* items) + pub demos: Vec, + pub demos_scroll: usize, + pub demos_scroll_state: ScrollbarState, + pub demos_list_state: ListState, + pub demos_search_query: String, + pub demos_search_active: bool, + pub demos_checked: HashSet, + // Hooks + pub hooks_scroll: usize, + pub hooks_scroll_state: ScrollbarState, + pub hooks_list_state: ListState, + pub hooks_search_query: String, + pub hooks_search_active: bool, + pub hooks_checked: HashSet, + // Other + pub terminal_width: u16, + pub icons_selected: usize, + pub show_popup: bool, + pub popup_confirm_focused: bool, // true = Confirm button, false = Cancel button + pub show_help_popup: bool, + pub last_click_time: Option, + pub last_click_pos: Option<(u16, u16)>, + pub last_escape_time: Option, +} + +impl<'a> App<'a> { + pub fn new( + title: &'a str, + all_items: Vec, + installed: HashSet, + dependencies: DependencyMap, + ) -> Self { + // Separate demos from components + let (demos, components): (Vec<_>, Vec<_>) = + all_items.into_iter().partition(|s| s.starts_with("demo_")); + + App { + should_quit: false, + header: Header::new(title), + // Installed + installed, + // Dependencies + dependencies, + // Components + components, + components_scroll: 0, + components_scroll_state: ScrollbarState::default(), + components_list_state: ListState::default(), + components_search_query: String::new(), + components_search_active: false, + components_checked: HashSet::new(), + // Demos + demos, + demos_scroll: 0, + demos_scroll_state: ScrollbarState::default(), + demos_list_state: ListState::default(), + demos_search_query: String::new(), + demos_search_active: false, + demos_checked: HashSet::new(), + // Hooks + hooks_scroll: 0, + hooks_scroll_state: ScrollbarState::default(), + hooks_list_state: ListState::default(), + hooks_search_query: String::new(), + hooks_search_active: false, + hooks_checked: HashSet::new(), + // Other + terminal_width: 0, + icons_selected: 0, + show_popup: false, + popup_confirm_focused: true, // Default to Confirm button + show_help_popup: false, + last_click_time: None, + last_click_pos: None, + last_escape_time: None, + } + } + + pub fn on_up(&mut self) { + match self.header.tabs.current { + Tab::Components => { + self.components_scroll = self.components_scroll.saturating_sub(1); + self.components_scroll_state = self.components_scroll_state.position(self.components_scroll); + } + Tab::Demos => { + self.demos_scroll = self.demos_scroll.saturating_sub(1); + self.demos_scroll_state = self.demos_scroll_state.position(self.demos_scroll); + } + Tab::Hooks => { + self.hooks_scroll = self.hooks_scroll.saturating_sub(1); + self.hooks_scroll_state = self.hooks_scroll_state.position(self.hooks_scroll); + } + Tab::Icons => { + self.icons_selected = self.icons_selected.saturating_sub(1); + } + Tab::Blocks | Tab::Settings => {} + } + } + + pub fn on_down(&mut self) { + match self.header.tabs.current { + Tab::Components => { + self.components_scroll = self.components_scroll.saturating_add(1); + self.components_scroll_state = self.components_scroll_state.position(self.components_scroll); + } + Tab::Demos => { + self.demos_scroll = self.demos_scroll.saturating_add(1); + self.demos_scroll_state = self.demos_scroll_state.position(self.demos_scroll); + } + Tab::Hooks => { + self.hooks_scroll = self.hooks_scroll.saturating_add(1); + self.hooks_scroll_state = self.hooks_scroll_state.position(self.hooks_scroll); + } + Tab::Icons => { + if self.icons_selected < 1 { + self.icons_selected += 1; + } + } + Tab::Blocks | Tab::Settings => {} + } + } + + pub fn on_right(&mut self) { + self.header.tabs.next(); + } + + pub fn on_left(&mut self) { + self.header.tabs.previous(); + } + + pub fn on_key(&mut self, c: char) { + if c == 'q' { + self.should_quit = true; + } + } + + pub fn on_tick(&mut self) {} + + pub fn on_mouse_click(&mut self, column: u16, row: u16, terminal_width: u16) { + self.terminal_width = terminal_width; + + // Tab area is the first 3 lines (Constraint::Length(3) in _render.rs) + if row < 3 { + self.header.handle_click(column, terminal_width); + } + } + + pub fn toggle_components_search(&mut self) { + self.components_search_active = !self.components_search_active; + if !self.components_search_active { + self.components_search_query.clear(); + self.components_scroll = 0; + } + } + + pub fn components_search_input(&mut self, c: char) { + if self.components_search_active { + self.components_search_query.push(c); + self.components_scroll = 0; + } + } + + pub fn components_search_backspace(&mut self) { + if self.components_search_active { + self.components_search_query.pop(); + self.components_scroll = 0; + } + } + + pub fn toggle_component_checkbox(&mut self, component: &str) { + if self.components_checked.contains(component) { + self.components_checked.remove(component); + } else { + self.components_checked.insert(component.to_string()); + } + } + + pub fn toggle_popup(&mut self) { + self.show_popup = !self.show_popup; + if self.show_popup { + self.popup_confirm_focused = true; // Reset to Confirm when opening + } + } + + pub fn toggle_popup_button_focus(&mut self) { + self.popup_confirm_focused = !self.popup_confirm_focused; + } + + pub fn toggle_help_popup(&mut self) { + self.show_help_popup = !self.show_help_popup; + } + + pub fn deselect_all_components(&mut self) { + self.components_checked.clear(); + } + + // Demos methods + pub fn toggle_demos_search(&mut self) { + self.demos_search_active = !self.demos_search_active; + if !self.demos_search_active { + self.demos_search_query.clear(); + self.demos_scroll = 0; + } + } + + pub fn demos_search_input(&mut self, c: char) { + if self.demos_search_active { + self.demos_search_query.push(c); + self.demos_scroll = 0; + } + } + + pub fn demos_search_backspace(&mut self) { + if self.demos_search_active { + self.demos_search_query.pop(); + self.demos_scroll = 0; + } + } + + pub fn toggle_demo_checkbox(&mut self, demo: &str) { + if self.demos_checked.contains(demo) { + self.demos_checked.remove(demo); + } else { + self.demos_checked.insert(demo.to_string()); + } + } + + pub fn deselect_all_demos(&mut self) { + self.demos_checked.clear(); + } + + pub fn get_demos_double_click_info(&self, column: u16, row: u16, terminal_width: u16) -> Option { + if matches!(self.header.tabs.current, Tab::Demos) && !self.show_popup { + let left_panel_width = (terminal_width as f32 * 0.35) as u16; + + if column <= left_panel_width && row > 6 { + let visual_row = (row - 7) as usize; + let viewport_offset = self.demos_list_state.offset(); + let item_index = visual_row + viewport_offset; + return Some(item_index); + } + } + None + } + + pub fn get_components_double_click_info( + &self, + column: u16, + row: u16, + terminal_width: u16, + ) -> Option { + // Check if double-click is in Components tab left panel + if matches!(self.header.tabs.current, Tab::Components) && !self.show_popup { + // Check if click is in left panel (35% of width) + let left_panel_width = (terminal_width as f32 * 0.35) as u16; + + if column <= left_panel_width && row > 6 { + // Calculate which item was clicked (accounting for header and search) + // Row 0-2: Header/tabs, Row 3-5: Search box, Row 6: List border top, Row 7+: List items + let visual_row = (row - 7) as usize; + // Add the viewport offset from the ListState to get the actual item index + let viewport_offset = self.components_list_state.offset(); + let item_index = visual_row + viewport_offset; + return Some(item_index); + } + } + None + } + + pub fn toggle_hooks_search(&mut self) { + self.hooks_search_active = !self.hooks_search_active; + if !self.hooks_search_active { + self.hooks_search_query.clear(); + self.hooks_scroll = 0; + } + } + + pub fn hooks_search_input(&mut self, c: char) { + if self.hooks_search_active { + self.hooks_search_query.push(c); + self.hooks_scroll = 0; + } + } + + pub fn hooks_search_backspace(&mut self) { + if self.hooks_search_active { + self.hooks_search_query.pop(); + self.hooks_scroll = 0; + } + } + + pub fn toggle_hook_checkbox(&mut self, hook: &str) { + if self.hooks_checked.contains(hook) { + self.hooks_checked.remove(hook); + } else { + self.hooks_checked.insert(hook.to_string()); + } + } + + pub fn deselect_all_hooks(&mut self) { + self.hooks_checked.clear(); + } + + pub fn get_hooks_double_click_info(&self, column: u16, row: u16, terminal_width: u16) -> Option { + // Check if double-click is in Hooks tab left panel + if matches!(self.header.tabs.current, Tab::Hooks) && !self.show_popup { + // Check if click is in left panel (35% of width) + let left_panel_width = (terminal_width as f32 * 0.35) as u16; + + if column <= left_panel_width && row > 6 { + // Calculate which item was clicked (accounting for header and search) + // Row 0-2: Header/tabs, Row 3-5: Search box, Row 6: List border top, Row 7+: List items + let visual_row = (row - 7) as usize; + // Add the viewport offset from the ListState to get the actual item index + let viewport_offset = self.hooks_list_state.offset(); + let item_index = visual_row + viewport_offset; + return Some(item_index); + } + } + None + } + + pub fn get_dependencies(&self, component: &str) -> Option<&Vec> { + self.dependencies.get(component) + } + + pub fn jump_to_letter_components(&mut self, letter: char) { + use super::widgets::helpers::filter_items; + let components_refs: Vec<&str> = self.components.iter().map(|s| s.as_str()).collect(); + let filtered = filter_items(&components_refs, &self.components_search_query); + let lower = letter.to_ascii_lowercase(); + if let Some(idx) = filtered.iter().position(|c| c.to_lowercase().starts_with(lower)) { + self.components_scroll = idx; + self.components_scroll_state = self.components_scroll_state.position(idx); + } + } + + pub fn jump_to_letter_demos(&mut self, letter: char) { + use super::widgets::helpers::filter_items; + let demos_refs: Vec<&str> = self.demos.iter().map(|s| s.as_str()).collect(); + let filtered = filter_items(&demos_refs, &self.demos_search_query); + let lower = letter.to_ascii_lowercase(); + // For demos, skip "demo_" prefix when matching + if let Some(idx) = filtered.iter().position(|d| { + d.strip_prefix("demo_") + .unwrap_or(d) + .to_lowercase() + .starts_with(lower) + }) { + self.demos_scroll = idx; + self.demos_scroll_state = self.demos_scroll_state.position(idx); + } + } + + pub fn jump_to_letter_hooks(&mut self, letter: char) { + use super::tabs::tab2_hooks::HOOKS; + use super::widgets::helpers::filter_items; + let filtered = filter_items(HOOKS, &self.hooks_search_query); + let lower = letter.to_ascii_lowercase(); + // For hooks, skip "Use " prefix when matching + if let Some(idx) = filtered.iter().position(|h| { + h.strip_prefix("Use ") + .unwrap_or(h) + .to_lowercase() + .starts_with(lower) + }) { + self.hooks_scroll = idx; + self.hooks_scroll_state = self.hooks_scroll_state.position(idx); + } + } +} diff --git a/src/command_add/ratatui/crossterm.rs b/src/command_add/ratatui/crossterm.rs new file mode 100644 index 0000000..10fce60 --- /dev/null +++ b/src/command_add/ratatui/crossterm.rs @@ -0,0 +1,359 @@ +use std::collections::HashSet; +use std::error::Error; +use std::io; +use std::time::{Duration, Instant}; + +use crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, KeyCode, KeyModifiers, MouseEventKind}; +use crossterm::execute; +use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}; +use ratatui::Terminal; +use ratatui::backend::{Backend, CrosstermBackend}; + +use super::DependencyMap; +use super::app::App; +use super::header::Tab; +use super::tabs::{_render, tab1_components, tab2_hooks, tab5_demos}; + +pub fn run( + tick_rate: Duration, + components: Vec, + installed: HashSet, + dependencies: DependencyMap, +) -> Result, Box> { + // Setup terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // Create app and run it + let app = App::new("Rust/UI CLI", components, installed, dependencies); + let app_result = run_app(&mut terminal, app, tick_rate); + + // Restore terminal + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?; + terminal.show_cursor()?; + + app_result +} + +/* ========================================================== */ +/* ✨ FUNCTIONS ✨ */ +/* ========================================================== */ + +fn run_app( + terminal: &mut Terminal, + mut app: App, + tick_rate: Duration, +) -> Result, Box> +where + ::Error: 'static, +{ + let mut last_tick = Instant::now(); + loop { + terminal.draw(|frame| _render::render(frame, &mut app))?; + + let timeout = tick_rate.saturating_sub(last_tick.elapsed()); + if !event::poll(timeout)? { + app.on_tick(); + last_tick = Instant::now(); + continue; + } + match event::read()? { + event::Event::Key(key) if key.kind == event::KeyEventKind::Press => { + // Handle search mode in Components tab + if app.components_search_active && matches!(app.header.tabs.current, Tab::Components) { + match key.code { + KeyCode::Char('j') | KeyCode::Down => app.on_down(), + KeyCode::Char('k') | KeyCode::Up => app.on_up(), + KeyCode::Char(c) => app.components_search_input(c), + KeyCode::Backspace => app.components_search_backspace(), + KeyCode::Esc => app.toggle_components_search(), + _ => {} + } + // Handle search mode in Demos tab + } else if app.demos_search_active && matches!(app.header.tabs.current, Tab::Demos) { + match key.code { + KeyCode::Char('j') | KeyCode::Down => app.on_down(), + KeyCode::Char('k') | KeyCode::Up => app.on_up(), + KeyCode::Char(c) => app.demos_search_input(c), + KeyCode::Backspace => app.demos_search_backspace(), + KeyCode::Esc => app.toggle_demos_search(), + _ => {} + } + // Handle search mode in Hooks tab + } else if app.hooks_search_active && matches!(app.header.tabs.current, Tab::Hooks) { + match key.code { + KeyCode::Char('j') | KeyCode::Down => app.on_down(), + KeyCode::Char('k') | KeyCode::Up => app.on_up(), + KeyCode::Char(c) => app.hooks_search_input(c), + KeyCode::Backspace => app.hooks_search_backspace(), + KeyCode::Esc => app.toggle_hooks_search(), + _ => {} + } + } else { + match key.code { + KeyCode::Char('?') => { + app.toggle_help_popup(); + } + KeyCode::Char('/') if matches!(app.header.tabs.current, Tab::Components) => { + app.toggle_components_search(); + } + KeyCode::Char('/') if matches!(app.header.tabs.current, Tab::Demos) => { + app.toggle_demos_search(); + } + KeyCode::Char('/') if matches!(app.header.tabs.current, Tab::Hooks) => { + app.toggle_hooks_search(); + } + KeyCode::Char(' ') if matches!(app.header.tabs.current, Tab::Components) => { + if let Some(component) = tab1_components::get_selected_component(&app) { + app.toggle_component_checkbox(&component); + } + } + KeyCode::Char(' ') if matches!(app.header.tabs.current, Tab::Demos) => { + if let Some(demo) = tab5_demos::get_selected_demo(&app) { + app.toggle_demo_checkbox(&demo); + } + } + KeyCode::Char(' ') if matches!(app.header.tabs.current, Tab::Hooks) => { + if let Some(hook) = tab2_hooks::get_selected_hook(&app) { + app.toggle_hook_checkbox(hook); + } + } + // Ctrl+letter to jump to first item starting with that letter + KeyCode::Char(c) + if key.modifiers.contains(KeyModifiers::CONTROL) + && c.is_ascii_alphabetic() => + { + match app.header.tabs.current { + Tab::Components => app.jump_to_letter_components(c), + Tab::Demos => app.jump_to_letter_demos(c), + Tab::Hooks => app.jump_to_letter_hooks(c), + _ => {} + } + } + KeyCode::Enter + if matches!(app.header.tabs.current, Tab::Components) + && !app.show_popup + && !app.components_checked.is_empty() => + { + app.toggle_popup(); + } + // Handle Enter in popup - confirm or cancel based on button focus + KeyCode::Enter + if matches!(app.header.tabs.current, Tab::Components) + && app.show_popup + && !app.components_checked.is_empty() => + { + if app.popup_confirm_focused { + let selected: Vec = app.components_checked.into_iter().collect(); + return Ok(selected); + } else { + app.toggle_popup(); // Cancel - close popup + } + } + KeyCode::Enter + if matches!(app.header.tabs.current, Tab::Demos) + && !app.show_popup + && !app.demos_checked.is_empty() => + { + app.toggle_popup(); + } + KeyCode::Enter + if matches!(app.header.tabs.current, Tab::Demos) + && app.show_popup + && !app.demos_checked.is_empty() => + { + if app.popup_confirm_focused { + let selected: Vec = app.demos_checked.into_iter().collect(); + return Ok(selected); + } else { + app.toggle_popup(); // Cancel - close popup + } + } + KeyCode::Enter + if matches!(app.header.tabs.current, Tab::Hooks) + && !app.show_popup + && !app.hooks_checked.is_empty() => + { + app.toggle_popup(); + } + KeyCode::Enter + if matches!(app.header.tabs.current, Tab::Hooks) + && app.show_popup + && !app.hooks_checked.is_empty() => + { + if app.popup_confirm_focused { + let selected: Vec = app.hooks_checked.into_iter().collect(); + return Ok(selected); + } else { + app.toggle_popup(); // Cancel - close popup + } + } + KeyCode::Esc if app.show_help_popup => { + app.toggle_help_popup(); + } + KeyCode::Esc + if matches!(app.header.tabs.current, Tab::Components) && !app.show_popup => + { + // Handle double-tap Escape to deselect all components + let now = Instant::now(); + let is_double_tap = if let Some(last_time) = app.last_escape_time { + now.duration_since(last_time).as_millis() < 500 + } else { + false + }; + + if is_double_tap && !app.components_checked.is_empty() { + app.deselect_all_components(); + app.last_escape_time = None; + } else { + app.last_escape_time = Some(now); + } + } + KeyCode::Esc + if matches!(app.header.tabs.current, Tab::Components) && app.show_popup => + { + app.toggle_popup(); + } + KeyCode::Esc if matches!(app.header.tabs.current, Tab::Demos) && !app.show_popup => { + // Handle double-tap Escape to deselect all demos + let now = Instant::now(); + let is_double_tap = if let Some(last_time) = app.last_escape_time { + now.duration_since(last_time).as_millis() < 500 + } else { + false + }; + + if is_double_tap && !app.demos_checked.is_empty() { + app.deselect_all_demos(); + app.last_escape_time = None; + } else { + app.last_escape_time = Some(now); + } + } + KeyCode::Esc if matches!(app.header.tabs.current, Tab::Demos) && app.show_popup => { + app.toggle_popup(); + } + KeyCode::Esc if matches!(app.header.tabs.current, Tab::Hooks) && !app.show_popup => { + // Handle double-tap Escape to deselect all hooks + let now = Instant::now(); + let is_double_tap = if let Some(last_time) = app.last_escape_time { + now.duration_since(last_time).as_millis() < 500 + } else { + false + }; + + if is_double_tap && !app.hooks_checked.is_empty() { + app.deselect_all_hooks(); + app.last_escape_time = None; + } else { + app.last_escape_time = Some(now); + } + } + KeyCode::Esc if matches!(app.header.tabs.current, Tab::Hooks) && app.show_popup => { + app.toggle_popup(); + } + KeyCode::Char('h') | KeyCode::Left => { + if app.show_popup { + app.toggle_popup_button_focus(); + } else if !app.show_help_popup { + app.on_left(); + } + } + KeyCode::Char('j') | KeyCode::Down => { + if !app.show_popup && !app.show_help_popup { + app.on_down(); + } + } + KeyCode::Char('k') | KeyCode::Up => { + if !app.show_popup && !app.show_help_popup { + app.on_up(); + } + } + KeyCode::Char('l') | KeyCode::Right => { + if app.show_popup { + app.toggle_popup_button_focus(); + } else if !app.show_help_popup { + app.on_right(); + } + } + KeyCode::Tab if app.show_popup => { + app.toggle_popup_button_focus(); + } + KeyCode::Char(c) => app.on_key(c), + _ => {} + } + } + } + event::Event::Mouse(mouse) => { + match mouse.kind { + MouseEventKind::Down(_) => { + let terminal_width = terminal.size()?.width; + let now = std::time::Instant::now(); + let current_pos = (mouse.column, mouse.row); + + // Check for double-click (within 500ms and same position) + let is_double_click = if let (Some(last_time), Some(last_pos)) = + (app.last_click_time, app.last_click_pos) + { + now.duration_since(last_time).as_millis() < 500 && last_pos == current_pos + } else { + false + }; + + if is_double_click { + // Handle double-click on component list items + if let Some(visual_index) = + app.get_components_double_click_info(mouse.column, mouse.row, terminal_width) + && let Some(component) = + tab1_components::get_component_at_visual_index(&app, visual_index) + { + app.toggle_component_checkbox(&component); + } + // Handle double-click on demo list items + if let Some(visual_index) = + app.get_demos_double_click_info(mouse.column, mouse.row, terminal_width) + && let Some(demo) = tab5_demos::get_demo_at_visual_index(&app, visual_index) + { + app.toggle_demo_checkbox(&demo); + } + // Handle double-click on hook list items + if let Some(visual_index) = + app.get_hooks_double_click_info(mouse.column, mouse.row, terminal_width) + && let Some(hook) = tab2_hooks::get_hook_at_visual_index(&app, visual_index) + { + app.toggle_hook_checkbox(hook); + } + // Reset click tracking after double-click + app.last_click_time = None; + app.last_click_pos = None; + } else { + // Single click - update tracking + app.on_mouse_click(mouse.column, mouse.row, terminal_width); + app.last_click_time = Some(now); + app.last_click_pos = Some(current_pos); + } + } + MouseEventKind::ScrollUp => { + if !app.show_popup && !app.show_help_popup { + app.on_up(); + } + } + MouseEventKind::ScrollDown => { + if !app.show_popup && !app.show_help_popup { + app.on_down(); + } + } + _ => {} + } + } + _ => {} + } + if app.should_quit { + return Ok(Vec::new()); + } + } +} diff --git a/src/command_add/ratatui/header.rs b/src/command_add/ratatui/header.rs new file mode 100644 index 0000000..9c67e42 --- /dev/null +++ b/src/command_add/ratatui/header.rs @@ -0,0 +1,163 @@ +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph}; + +const SEPARATOR_LEN: usize = 3; // " │ " = 3 chars +const HORIZONTAL_PADDING: usize = 1; // Padding on left and right + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, strum::AsRefStr)] +pub enum Tab { + #[default] + Components, + Demos, + Hooks, + Blocks, + Icons, + Settings, +} + +impl Tab { + pub const ALL: [Tab; 6] = + [Tab::Components, Tab::Demos, Tab::Hooks, Tab::Blocks, Tab::Icons, Tab::Settings]; + + fn from_index(index: usize) -> Self { + Self::ALL.get(index).copied().unwrap_or(Tab::Components) + } + + fn to_index(self) -> usize { + Self::ALL.iter().position(|&t| t == self).unwrap_or(0) + } +} + +#[derive(Default)] +pub struct TabsState { + pub current: Tab, +} + +impl TabsState { + pub fn next(&mut self) { + let index = (self.current.to_index() + 1) % Tab::ALL.len(); + self.current = Tab::from_index(index); + } + + pub fn previous(&mut self) { + let index = self.current.to_index(); + let new_index = if index > 0 { index - 1 } else { Tab::ALL.len() - 1 }; + self.current = Tab::from_index(new_index); + } +} + +pub struct Header<'a> { + pub tabs: TabsState, + pub title: &'a str, +} + +impl<'a> Header<'a> { + pub fn new(title: &'a str) -> Self { + Self { tabs: TabsState::default(), title } + } + + pub fn render(&self, frame: &mut Frame, area: Rect) { + let block = Block::bordered().title(self.title); + let inner = block.inner(area); + frame.render_widget(block, area); + + // Build tab spans dynamically + let mut spans = Vec::new(); + let mut left_tabs_length = 0; + + // Add left padding + spans.push(Span::raw(" ".repeat(HORIZONTAL_PADDING))); + left_tabs_length += HORIZONTAL_PADDING; + + for (index, tab) in Tab::ALL.iter().enumerate() { + let tab_name = tab.as_ref(); + // Determine style based on whether this tab is active + let style = if self.tabs.current == *tab { + Style::default().fg(Color::White) + } else { + Style::default().fg(Color::DarkGray) + }; + + if index == Tab::ALL.len() - 1 { + // Last tab (Settings) - will be added after spacer + // Calculate spacer: inner_width - left_tabs_length - settings_length - right_padding + let spacer_len = (inner.width as usize) + .saturating_sub(left_tabs_length) + .saturating_sub(tab_name.len()) + .saturating_sub(HORIZONTAL_PADDING); + + spans.push(Span::raw(" ".repeat(spacer_len))); + spans.push(Span::styled(tab_name, style)); + } else { + // Left-aligned tabs + spans.push(Span::styled(tab_name, style)); + left_tabs_length += tab_name.len(); + + // Add separator if not the second-to-last tab + if index < Tab::ALL.len() - 2 { + spans.push(separator_span()); + left_tabs_length += SEPARATOR_LEN; + } + } + } + + let tabs_line = Line::from(spans); + let tabs_paragraph = Paragraph::new(tabs_line); + frame.render_widget(tabs_paragraph, inner); + } + + pub fn handle_click(&mut self, column: u16, terminal_width: u16) { + let boundaries = self.calculate_tab_boundaries(terminal_width); + + for (index, &(start, end)) in boundaries.iter().enumerate() { + if column >= start && column < end { + self.tabs.current = Tab::from_index(index); + return; + } + } + } + + fn calculate_tab_boundaries(&self, terminal_width: u16) -> Vec<(u16, u16)> { + // Custom flex layout: [pad][Components][ │ ][Hooks][ │ ][Blocks][ │ ][Icons][Spacer][Settings][pad] + // Returns (start, end) tuples for each tab + // All tabs are left-aligned except the last one (Settings) which is right-aligned + + let inner_width = terminal_width.saturating_sub(2); // Account for borders + let border_offset = 1; // Add 1 to convert from inner area positions to absolute terminal columns + + let mut boundaries = Vec::new(); + let mut current_pos = HORIZONTAL_PADDING as u16; // Start after left padding + + // Calculate boundaries for all tabs except the last one (Settings) + // Settings is right-aligned, so we handle it separately + for (index, tab) in Tab::ALL.iter().enumerate() { + let tab_name = tab.as_ref(); + if index == Tab::ALL.len() - 1 { + // Last tab (Settings) - right-aligned with right padding + let tab_len = tab_name.len() as u16; + let start = inner_width.saturating_sub(tab_len).saturating_sub(HORIZONTAL_PADDING as u16) + + border_offset; + let end = inner_width.saturating_sub(HORIZONTAL_PADDING as u16) + border_offset; + boundaries.push((start, end)); + } else { + // Left-aligned tabs + let tab_len = tab_name.len() as u16; + let start = current_pos + border_offset; + let end = current_pos + tab_len + border_offset; + boundaries.push((start, end)); + + // Move position forward: tab length + separator length + current_pos += tab_len + SEPARATOR_LEN as u16; + } + } + + boundaries + } +} + +fn separator_span() -> Span<'static> { + Span::styled(" │ ", Style::default().fg(Color::DarkGray)) +} diff --git a/src/command_add/ratatui/mod.rs b/src/command_add/ratatui/mod.rs new file mode 100644 index 0000000..749d73b --- /dev/null +++ b/src/command_add/ratatui/mod.rs @@ -0,0 +1,25 @@ +mod app; +mod crossterm; +mod header; +mod tabs; +mod widgets; + +use std::collections::{HashMap, HashSet}; +use std::time::Duration; + +use crate::shared::cli_error::{CliError, CliResult}; + +/// Map of component name to its dependencies +pub type DependencyMap = HashMap>; + +/// Run the ratatui TUI for adding components +/// Returns the selected components when user confirms +pub fn run_tui( + components: Vec, + installed: HashSet, + dependencies: DependencyMap, +) -> CliResult> { + let tick_rate = Duration::from_millis(250); + crossterm::run(tick_rate, components, installed, dependencies) + .map_err(|err| CliError::Io { source: std::io::Error::other(err.to_string()) }) +} diff --git a/src/command_add/ratatui/tabs/_render.rs b/src/command_add/ratatui/tabs/_render.rs new file mode 100644 index 0000000..eb706ac --- /dev/null +++ b/src/command_add/ratatui/tabs/_render.rs @@ -0,0 +1,143 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Clear, Paragraph, Wrap}; + +use super::super::app::App; +use super::super::header::Tab; +use super::super::widgets::popup::popup_area; +use super::{tab1_components, tab2_hooks, tab3_blocks, tab4_icons, tab5_demos, tab9_settings}; + +pub fn render(frame: &mut Frame, app: &mut App) { + let chunks = + Layout::vertical([Constraint::Length(3), Constraint::Min(0), Constraint::Length(1)]).split(frame.area()); + + let (Some(header_area), Some(content_area), Some(footer_area)) = + (chunks.first(), chunks.get(1), chunks.get(2)) + else { + return; + }; + + // Render header with tabs + app.header.render(frame, *header_area); + + match app.header.tabs.current { + Tab::Components => tab1_components::draw_tab_components(frame, app, *content_area), + Tab::Demos => tab5_demos::draw_tab_demos(frame, app, *content_area), + Tab::Hooks => tab2_hooks::draw_tab_hooks(frame, app, *content_area), + Tab::Blocks => tab3_blocks::draw_tab_blocks(frame, app, *content_area), + Tab::Icons => tab4_icons::draw_tab_icons(frame, app, *content_area), + Tab::Settings => tab9_settings::draw_tab_settings(frame, app, *content_area), + }; + + // Render footer with shortcuts + draw_footer(frame, app, *footer_area); + + // Render help popup on top of everything + if app.show_help_popup { + draw_help_popup(frame, frame.area()); + } +} + +fn draw_footer(frame: &mut Frame, app: &App, area: Rect) { + let key_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD); + let sep_style = Style::default().fg(Color::DarkGray); + let text_style = Style::default().fg(Color::Gray); + + let shortcuts = match app.header.tabs.current { + Tab::Components | Tab::Demos | Tab::Hooks => { + if app.show_popup { + vec![ + Span::styled("←/→", key_style), + Span::styled(" Switch ", text_style), + Span::styled("│", sep_style), + Span::styled(" Enter", key_style), + Span::styled(" Confirm ", text_style), + Span::styled("│", sep_style), + Span::styled(" Esc", key_style), + Span::styled(" Cancel", text_style), + ] + } else { + vec![ + Span::styled("Space", key_style), + Span::styled(" Select ", text_style), + Span::styled("│", sep_style), + Span::styled(" /", key_style), + Span::styled(" Search ", text_style), + Span::styled("│", sep_style), + Span::styled(" Ctrl+a-z", key_style), + Span::styled(" Jump ", text_style), + Span::styled("│", sep_style), + Span::styled(" ?", key_style), + Span::styled(" Help ", text_style), + Span::styled("│", sep_style), + Span::styled(" Enter", key_style), + Span::styled(" Confirm ", text_style), + Span::styled("│", sep_style), + Span::styled(" q", key_style), + Span::styled(" Quit", text_style), + ] + } + } + _ => { + vec![ + Span::styled("←/→", key_style), + Span::styled(" Tabs ", text_style), + Span::styled("│", sep_style), + Span::styled(" ?", key_style), + Span::styled(" Help ", text_style), + Span::styled("│", sep_style), + Span::styled(" q", key_style), + Span::styled(" Quit", text_style), + ] + } + }; + + let footer = Paragraph::new(Line::from(shortcuts)).style(Style::default().bg(Color::DarkGray)); + frame.render_widget(footer, area); +} + +fn draw_help_popup(frame: &mut Frame, area: Rect) { + let popup_block = + Block::bordered().title("⌨️ Keyboard Shortcuts").style(Style::default().fg(Color::Cyan)); + let popup_area = popup_area(area, 75, 85); + + // Clear the background + frame.render_widget(Clear, popup_area); + + let help_text = r#" +Global Commands: + ? Show this help menu + q Quit the application + h / ← Navigate to previous tab + l / → Navigate to next tab + j / ↓ Scroll down + k / ↑ Scroll up + +Components Tab: + / Activate search mode + Space Toggle checkbox for selected component + Enter View checked components (when components are selected) + Esc Esc Double-tap Escape to deselect all components + Double-click Toggle checkbox on clicked component + Mouse Wheel Scroll up/down + +Search Mode (Components Tab): + j / ↓ Navigate down while searching + k / ↑ Navigate up while searching + Esc Exit search mode + Backspace Delete character + Any char Add to search query + + +Press ESC to close this help menu +"#; + + let popup_paragraph = Paragraph::new(help_text) + .block(popup_block) + .wrap(Wrap { trim: true }) + .style(Style::default().fg(Color::White)); + + frame.render_widget(popup_paragraph, popup_area); +} diff --git a/src/command_add/ratatui/tabs/mod.rs b/src/command_add/ratatui/tabs/mod.rs new file mode 100644 index 0000000..3ab2bdf --- /dev/null +++ b/src/command_add/ratatui/tabs/mod.rs @@ -0,0 +1,7 @@ +pub mod _render; +pub mod tab1_components; +pub mod tab2_hooks; +pub mod tab3_blocks; +pub mod tab4_icons; +pub mod tab5_demos; +pub mod tab9_settings; diff --git a/src/command_add/ratatui/tabs/tab1_components.rs b/src/command_add/ratatui/tabs/tab1_components.rs new file mode 100644 index 0000000..13da58b --- /dev/null +++ b/src/command_add/ratatui/tabs/tab1_components.rs @@ -0,0 +1,138 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Style}; +use ratatui::text::Span; +use ratatui::widgets::{Block, List, ListItem, Scrollbar, ScrollbarOrientation}; + +use super::super::app::App; +use super::super::widgets::checked_popup::draw_confirm_dialog; +use super::super::widgets::detail_panel::draw_detail_panel; +use super::super::widgets::helpers::{filter_items, get_item_at_visual_index, get_selected_item}; +use super::super::widgets::search_input::draw_search_input; + +pub fn draw_tab_components(frame: &mut Frame, app: &mut App, area: Rect) { + // Horizontal split: sidenav on left, detail on right + let horizontal_chunks = + Layout::horizontal([Constraint::Percentage(35), Constraint::Percentage(65)]).split(area); + + let (Some(&left_panel), Some(&right_panel)) = (horizontal_chunks.first(), horizontal_chunks.get(1)) + else { + return; + }; + + // Split left panel vertically: search input at top, list below + let left_chunks = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).split(left_panel); + + let (Some(&search_area), Some(&list_area)) = (left_chunks.first(), left_chunks.get(1)) else { + return; + }; + + // Filter components based on search query (prefix matching) + let components_refs: Vec<&str> = app.components.iter().map(|s| s.as_str()).collect(); + let filtered_components = filter_items(&components_refs, &app.components_search_query); + + // Ensure scroll doesn't exceed filtered list bounds + if !filtered_components.is_empty() && app.components_scroll >= filtered_components.len() { + app.components_scroll = filtered_components.len().saturating_sub(1); + } + + // Update scrollbar state with filtered content length + app.components_scroll_state = app.components_scroll_state.content_length(filtered_components.len()); + + // Left side: Component list + let items: Vec = filtered_components + .iter() + .map(|component| { + let is_installed = app.installed.contains(*component); + let is_checked = app.components_checked.contains(*component); + + let (icon, color) = if is_checked { + ("☑", Color::Green) // Selected + } else if is_installed { + ("✓", Color::Cyan) // Already installed (not selected) + } else { + ("☐", Color::DarkGray) // Not selected + }; + + let suffix = if is_installed { " (installed)" } else { "" }; + ListItem::new(Span::styled(format!(" {icon} {component}{suffix}"), Style::default().fg(color))) + }) + .collect(); + + let checked_count = app.components_checked.len(); + let installed_count = app.components.iter().filter(|c| app.installed.contains(*c)).count(); + + let title = { + let base = if app.components_search_query.is_empty() { + format!("Components ({})", app.components.len()) + } else { + format!("Components ({}/{})", filtered_components.len(), app.components.len()) + }; + + let mut parts = vec![base]; + if installed_count > 0 { + parts.push(format!("{installed_count} installed")); + } + if checked_count > 0 { + parts.push(format!("{checked_count} selected")); + } + parts.join(" · ") + }; + + let list = List::new(items) + .block(Block::bordered().title(title)) + .highlight_style(Style::default().bg(Color::DarkGray)); + + // Update list state + if !filtered_components.is_empty() { + app.components_list_state.select(Some(app.components_scroll)); + } + + // Draw search input in left panel + draw_search_input(frame, &app.components_search_query, app.components_search_active, search_area); + + // Render list in left panel + frame.render_stateful_widget(list, list_area, &mut app.components_list_state); + + // Render scrollbar in left panel + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight).begin_symbol(Some("↑")).end_symbol(Some("↓")), + list_area, + &mut app.components_scroll_state, + ); + + // Right side: Detail panel + let selected_component = filtered_components.get(app.components_scroll).copied(); + let dependencies = selected_component.and_then(|c| app.get_dependencies(c)); + draw_detail_panel(frame, selected_component, app.components_checked.len(), "component", dependencies, right_panel); + + // Render confirmation dialog if show_popup is true and there are checked components + if app.show_popup && !app.components_checked.is_empty() { + let mut checked_list: Vec = app.components_checked.iter().cloned().collect(); + checked_list.sort(); + draw_confirm_dialog( + frame, + &checked_list, + " Add Components ", + "component", + app.popup_confirm_focused, + area, + ); + } +} + +/* ========================================================== */ +/* ✨ FUNCTIONS ✨ */ +/* ========================================================== */ + +pub fn get_selected_component(app: &App) -> Option { + let components_refs: Vec<&str> = app.components.iter().map(|s| s.as_str()).collect(); + get_selected_item(&components_refs, app.components_scroll, &app.components_search_query) + .map(|s| s.to_string()) +} + +pub fn get_component_at_visual_index(app: &App, visual_index: usize) -> Option { + let components_refs: Vec<&str> = app.components.iter().map(|s| s.as_str()).collect(); + get_item_at_visual_index(&components_refs, visual_index, &app.components_search_query) + .map(|s| s.to_string()) +} diff --git a/src/command_add/ratatui/tabs/tab2_hooks.rs b/src/command_add/ratatui/tabs/tab2_hooks.rs new file mode 100644 index 0000000..b254560 --- /dev/null +++ b/src/command_add/ratatui/tabs/tab2_hooks.rs @@ -0,0 +1,123 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Style}; +use ratatui::text::Span; +use ratatui::widgets::{Block, List, ListItem, Scrollbar, ScrollbarOrientation}; + +use super::super::app::App; +use super::super::widgets::checked_popup::draw_confirm_dialog; +use super::super::widgets::detail_panel::draw_detail_panel; +use super::super::widgets::helpers::{filter_items, get_item_at_visual_index, get_selected_item}; +use super::super::widgets::search_input::draw_search_input; + +pub fn draw_tab_hooks(frame: &mut Frame, app: &mut App, area: Rect) { + // Horizontal split: sidenav on left, detail on right + let horizontal_chunks = + Layout::horizontal([Constraint::Percentage(35), Constraint::Percentage(65)]).split(area); + + let (Some(&left_panel), Some(&right_panel)) = (horizontal_chunks.first(), horizontal_chunks.get(1)) + else { + return; + }; + + // Split left panel vertically: search input at top, list below + let left_chunks = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).split(left_panel); + + let (Some(&search_area), Some(&list_area)) = (left_chunks.first(), left_chunks.get(1)) else { + return; + }; + + // Filter hooks based on search query (prefix matching) + let filtered_hooks = filter_items(HOOKS, &app.hooks_search_query); + + // Ensure scroll doesn't exceed filtered list bounds + if !filtered_hooks.is_empty() && app.hooks_scroll >= filtered_hooks.len() { + app.hooks_scroll = filtered_hooks.len().saturating_sub(1); + } + + // Update scrollbar state with filtered content length + app.hooks_scroll_state = app.hooks_scroll_state.content_length(filtered_hooks.len()); + + // Left side: Hook list + let items: Vec = filtered_hooks + .iter() + .map(|hook| { + let is_checked = app.hooks_checked.contains(*hook); + let (checkbox, color) = if is_checked { ("☑", Color::Green) } else { ("☐", Color::DarkGray) }; + ListItem::new(Span::styled(format!(" {} {}", checkbox, hook), Style::default().fg(color))) + }) + .collect(); + + let checked_count = app.hooks_checked.len(); + let title = if app.hooks_search_query.is_empty() { + if checked_count > 0 { + format!("Hooks ({}) - {} Selected", HOOKS.len(), checked_count) + } else { + format!("Hooks ({})", HOOKS.len()) + } + } else if checked_count > 0 { + format!("Hooks ({}/{}) - {} Selected", filtered_hooks.len(), HOOKS.len(), checked_count) + } else { + format!("Hooks ({}/{})", filtered_hooks.len(), HOOKS.len()) + }; + + let list = List::new(items) + .block(Block::bordered().title(title)) + .highlight_style(Style::default().bg(Color::DarkGray)); + + // Update list state + if !filtered_hooks.is_empty() { + app.hooks_list_state.select(Some(app.hooks_scroll)); + } + + // Draw search input in left panel + draw_search_input(frame, &app.hooks_search_query, app.hooks_search_active, search_area); + + // Render list in left panel + frame.render_stateful_widget(list, list_area, &mut app.hooks_list_state); + + // Render scrollbar in left panel + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight).begin_symbol(Some("↑")).end_symbol(Some("↓")), + list_area, + &mut app.hooks_scroll_state, + ); + + // Right side: Detail panel (hooks don't have registry dependencies) + let selected_hook = filtered_hooks.get(app.hooks_scroll).copied(); + draw_detail_panel(frame, selected_hook, app.hooks_checked.len(), "hook", None, right_panel); + + // Render confirmation dialog if show_popup is true and there are checked hooks + if app.show_popup && !app.hooks_checked.is_empty() { + let mut checked_list: Vec = app.hooks_checked.iter().cloned().collect(); + checked_list.sort(); + draw_confirm_dialog(frame, &checked_list, " Add Hooks ", "hook", app.popup_confirm_focused, area); + } +} + +/* ========================================================== */ +/* ✨ FUNCTIONS ✨ */ +/* ========================================================== */ + +pub fn get_selected_hook(app: &App) -> Option<&'static str> { + get_selected_item(HOOKS, app.hooks_scroll, &app.hooks_search_query) +} + +pub fn get_hook_at_visual_index(app: &App, visual_index: usize) -> Option<&'static str> { + get_item_at_visual_index(HOOKS, visual_index, &app.hooks_search_query) +} + +/* ========================================================== */ +/* ✨ CONST ✨ */ +/* ========================================================== */ + +pub const HOOKS: &[&str] = &[ + "Use Lock Body Scroll", + "Use Horizontal Scroll", + "Use Media Query", + "Use Local Storage", + "Use Debounce", + "Use Throttle", + "Use Click Outside", + "Use Intersection Observer", +]; diff --git a/src/command_add/ratatui/tabs/tab3_blocks.rs b/src/command_add/ratatui/tabs/tab3_blocks.rs new file mode 100644 index 0000000..88063ed --- /dev/null +++ b/src/command_add/ratatui/tabs/tab3_blocks.rs @@ -0,0 +1,25 @@ +use ratatui::Frame; +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::style::{Color, Style}; +use ratatui::widgets::{Block, Paragraph}; + +use super::super::app::App; + +pub fn draw_tab_blocks(frame: &mut Frame, _app: &mut App, area: Rect) { + let block = Block::bordered().title("Blocks").style(Style::default().fg(Color::White)); + + let inner_area = block.inner(area); + frame.render_widget(block, area); + + let layout = Layout::vertical([Constraint::Percentage(50)]).split(inner_area); + + let Some(&content_area) = layout.first() else { + return; + }; + + let coming_soon = Paragraph::new("Coming soon") + .alignment(Alignment::Center) + .style(Style::default().fg(Color::DarkGray)); + + frame.render_widget(coming_soon, content_area); +} diff --git a/src/command_add/ratatui/tabs/tab4_icons.rs b/src/command_add/ratatui/tabs/tab4_icons.rs new file mode 100644 index 0000000..3c6a706 --- /dev/null +++ b/src/command_add/ratatui/tabs/tab4_icons.rs @@ -0,0 +1,111 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Style}; +use ratatui::text::Span; +use ratatui::widgets::{Block, List, ListItem, ListState, Paragraph}; + +use super::super::app::App; + +const ICON_ITEMS: &[&str] = &["A Arrow Up", "Alarm Clock Check"]; + +pub fn draw_tab_icons(frame: &mut Frame, app: &mut App, area: Rect) { + // Horizontal flex layout: list on left, content on right + let chunks = Layout::horizontal([Constraint::Percentage(30), Constraint::Percentage(70)]).split(area); + + let (Some(&left_panel), Some(&right_panel)) = (chunks.first(), chunks.get(1)) else { + return; + }; + + // Left panel: list of icons + let items: Vec = ICON_ITEMS + .iter() + .enumerate() + .map(|(i, item)| { + let style = if i == app.icons_selected { + Style::default().fg(Color::White) + } else { + Style::default().fg(Color::DarkGray) + }; + ListItem::new(Span::styled(format!(" • {item}"), style)) + }) + .collect(); + + let list = List::new(items) + .block(Block::bordered().title("Icons")) + .highlight_style(Style::default().fg(Color::White)); + + let mut state = ListState::default(); + state.select(Some(app.icons_selected)); + + frame.render_stateful_widget(list, left_panel, &mut state); + + // Right panel: icon preview + let content_block = Block::bordered().title("Preview"); + let inner_area = content_block.inner(right_panel); + frame.render_widget(content_block, right_panel); + + let filename = match app.icons_selected { + 0 => "a_arrow_up.svg", + 1 => "alarm_clock_check.svg", + _ => "", + }; + + if !filename.is_empty() { + match svg_to_halfblocks(filename) { + Some(text) => { + let paragraph = Paragraph::new(text).style(Style::default().fg(Color::Rgb(255, 165, 0))); + frame.render_widget(paragraph, inner_area); + } + None => { + let paragraph = Paragraph::new(format!("Failed to load {filename}")) + .style(Style::default().fg(Color::Red)); + frame.render_widget(paragraph, inner_area); + } + } + } else { + let paragraph = Paragraph::new("Select an icon").style(Style::default().fg(Color::Gray)); + frame.render_widget(paragraph, inner_area); + } +} + +fn svg_to_halfblocks(filename: &str) -> Option { + let svg_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(filename); + let svg_data = std::fs::read(&svg_path).ok()?; + + let tree = resvg::usvg::Tree::from_data(&svg_data, &resvg::usvg::Options::default()).ok()?; + let size = tree.size(); + + // Scale for visibility (24x24 SVG -> 36x36 pixels) + let scale = 1.5_f32; + let width = (size.width() * scale) as u32; + let height = (size.height() * scale) as u32; + + let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height)?; + let transform = resvg::tiny_skia::Transform::from_scale(scale, scale); + resvg::render(&tree, transform, &mut pixmap.as_mut()); + + // Convert to half-block characters (2 pixels per character vertically) + let mut result = String::new(); + let pixels = pixmap.pixels(); + + for y in (0..height).step_by(2) { + for x in 0..width { + let top_idx = (y * width + x) as usize; + let bot_idx = ((y + 1) * width + x) as usize; + + let top = pixels.get(top_idx).map(|p| p.alpha() > 128).unwrap_or(false); + let bot = pixels.get(bot_idx).map(|p| p.alpha() > 128).unwrap_or(false); + + let ch = match (top, bot) { + (true, true) => '█', + (true, false) => '▀', + (false, true) => '▄', + (false, false) => ' ', + }; + result.push(ch); + } + result.push('\n'); + } + + Some(result) +} diff --git a/src/command_add/ratatui/tabs/tab5_demos.rs b/src/command_add/ratatui/tabs/tab5_demos.rs new file mode 100644 index 0000000..837d252 --- /dev/null +++ b/src/command_add/ratatui/tabs/tab5_demos.rs @@ -0,0 +1,129 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Style}; +use ratatui::text::Span; +use ratatui::widgets::{Block, List, ListItem, Scrollbar, ScrollbarOrientation}; + +use super::super::app::App; +use super::super::widgets::checked_popup::draw_confirm_dialog; +use super::super::widgets::detail_panel::draw_detail_panel; +use super::super::widgets::helpers::{filter_items, get_item_at_visual_index, get_selected_item}; +use super::super::widgets::search_input::draw_search_input; + +pub fn draw_tab_demos(frame: &mut Frame, app: &mut App, area: Rect) { + // Horizontal split: sidenav on left, detail on right + let horizontal_chunks = + Layout::horizontal([Constraint::Percentage(35), Constraint::Percentage(65)]).split(area); + + let (Some(&left_panel), Some(&right_panel)) = (horizontal_chunks.first(), horizontal_chunks.get(1)) + else { + return; + }; + + // Split left panel vertically: search input at top, list below + let left_chunks = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).split(left_panel); + + let (Some(&search_area), Some(&list_area)) = (left_chunks.first(), left_chunks.get(1)) else { + return; + }; + + // Filter demos based on search query (prefix matching) + let demos_refs: Vec<&str> = app.demos.iter().map(|s| s.as_str()).collect(); + let filtered_demos = filter_items(&demos_refs, &app.demos_search_query); + + // Ensure scroll doesn't exceed filtered list bounds + if !filtered_demos.is_empty() && app.demos_scroll >= filtered_demos.len() { + app.demos_scroll = filtered_demos.len().saturating_sub(1); + } + + // Update scrollbar state with filtered content length + app.demos_scroll_state = app.demos_scroll_state.content_length(filtered_demos.len()); + + // Left side: Demo list + let items: Vec = filtered_demos + .iter() + .map(|demo| { + let is_installed = app.installed.contains(*demo); + let is_checked = app.demos_checked.contains(*demo); + + let (icon, color) = if is_checked { + ("☑", Color::Green) // Selected + } else if is_installed { + ("✓", Color::Cyan) // Already installed (not selected) + } else { + ("☐", Color::DarkGray) // Not selected + }; + + let suffix = if is_installed { " (installed)" } else { "" }; + ListItem::new(Span::styled(format!(" {icon} {demo}{suffix}"), Style::default().fg(color))) + }) + .collect(); + + let checked_count = app.demos_checked.len(); + let installed_count = app.demos.iter().filter(|d| app.installed.contains(*d)).count(); + + let title = { + let base = if app.demos_search_query.is_empty() { + format!("Demos ({})", app.demos.len()) + } else { + format!("Demos ({}/{})", filtered_demos.len(), app.demos.len()) + }; + + let mut parts = vec![base]; + if installed_count > 0 { + parts.push(format!("{installed_count} installed")); + } + if checked_count > 0 { + parts.push(format!("{checked_count} selected")); + } + parts.join(" · ") + }; + + let list = List::new(items) + .block(Block::bordered().title(title)) + .highlight_style(Style::default().bg(Color::DarkGray)); + + // Update list state + if !filtered_demos.is_empty() { + app.demos_list_state.select(Some(app.demos_scroll)); + } + + // Draw search input in left panel + draw_search_input(frame, &app.demos_search_query, app.demos_search_active, search_area); + + // Render list in left panel + frame.render_stateful_widget(list, list_area, &mut app.demos_list_state); + + // Render scrollbar in left panel + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight).begin_symbol(Some("↑")).end_symbol(Some("↓")), + list_area, + &mut app.demos_scroll_state, + ); + + // Right side: Detail panel + let selected_demo = filtered_demos.get(app.demos_scroll).copied(); + let dependencies = selected_demo.and_then(|d| app.get_dependencies(d)); + draw_detail_panel(frame, selected_demo, app.demos_checked.len(), "demo", dependencies, right_panel); + + // Render confirmation dialog if show_popup is true and there are checked demos + if app.show_popup && !app.demos_checked.is_empty() { + let mut checked_list: Vec = app.demos_checked.iter().cloned().collect(); + checked_list.sort(); + draw_confirm_dialog(frame, &checked_list, " Add Demos ", "demo", app.popup_confirm_focused, area); + } +} + +/* ========================================================== */ +/* ✨ FUNCTIONS ✨ */ +/* ========================================================== */ + +pub fn get_selected_demo(app: &App) -> Option { + let demos_refs: Vec<&str> = app.demos.iter().map(|s| s.as_str()).collect(); + get_selected_item(&demos_refs, app.demos_scroll, &app.demos_search_query).map(|s| s.to_string()) +} + +pub fn get_demo_at_visual_index(app: &App, visual_index: usize) -> Option { + let demos_refs: Vec<&str> = app.demos.iter().map(|s| s.as_str()).collect(); + get_item_at_visual_index(&demos_refs, visual_index, &app.demos_search_query).map(|s| s.to_string()) +} diff --git a/src/command_add/ratatui/tabs/tab9_settings.rs b/src/command_add/ratatui/tabs/tab9_settings.rs new file mode 100644 index 0000000..96b6737 --- /dev/null +++ b/src/command_add/ratatui/tabs/tab9_settings.rs @@ -0,0 +1,25 @@ +use ratatui::Frame; +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::style::{Color, Style}; +use ratatui::widgets::{Block, Paragraph}; + +use super::super::app::App; + +pub fn draw_tab_settings(frame: &mut Frame, _app: &mut App, area: Rect) { + let block = Block::bordered().title("Settings").style(Style::default().fg(Color::White)); + + let inner_area = block.inner(area); + frame.render_widget(block, area); + + let layout = Layout::vertical([Constraint::Percentage(50)]).split(inner_area); + + let Some(&content_area) = layout.first() else { + return; + }; + + let coming_soon = Paragraph::new("Coming soon") + .alignment(Alignment::Center) + .style(Style::default().fg(Color::DarkGray)); + + frame.render_widget(coming_soon, content_area); +} diff --git a/src/command_add/ratatui/widgets/checked_popup.rs b/src/command_add/ratatui/widgets/checked_popup.rs new file mode 100644 index 0000000..7471b14 --- /dev/null +++ b/src/command_add/ratatui/widgets/checked_popup.rs @@ -0,0 +1,88 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Clear, Paragraph, Wrap}; + +use super::popup::popup_area; + +/// Renders a confirmation dialog with Cancel and Confirm buttons +pub fn draw_confirm_dialog( + frame: &mut Frame, + items: &[String], + title: &str, + item_type: &str, + confirm_focused: bool, + area: Rect, +) { + let popup_rect = popup_area(area, 50, 60); + + // Clear the background + frame.render_widget(Clear, popup_rect); + + // Main block with title + let block = Block::bordered().title(title).style(Style::default().fg(Color::White)); + let inner = block.inner(popup_rect); + frame.render_widget(block, popup_rect); + + // Split inner area: content on top, buttons at bottom + let chunks = Layout::vertical([Constraint::Min(3), Constraint::Length(3)]).split(inner); + + let (Some(&content_area), Some(&button_area)) = (chunks.first(), chunks.get(1)) else { + return; + }; + + // Content: list of items + let item_count = items.len(); + let item_type_plural = if item_count == 1 { item_type } else { &format!("{item_type}s") }; + + let mut lines: Vec = vec![ + Line::from(Span::styled( + format!("Add {item_count} {item_type_plural}?"), + Style::default().fg(Color::Yellow), + )), + Line::from(""), + ]; + + // Add items (limit display if too many) + let max_display = 12; + for (i, item) in items.iter().enumerate() { + if i >= max_display { + lines.push(Line::from(Span::styled( + format!(" ... and {} more", item_count - max_display), + Style::default().fg(Color::DarkGray), + ))); + break; + } + lines.push(Line::from(Span::styled(format!(" • {item}"), Style::default().fg(Color::White)))); + } + + let content = Paragraph::new(lines).wrap(Wrap { trim: true }); + frame.render_widget(content, content_area); + + // Buttons - styled as bordered buttons + let cancel_style = if !confirm_focused { + Style::default().fg(Color::Black).bg(Color::White).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Gray) + }; + + let confirm_style = if confirm_focused { + Style::default().fg(Color::Black).bg(Color::Green).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Gray) + }; + + let button_line = Line::from(vec![ + Span::styled(if !confirm_focused { "│" } else { " " }, cancel_style), + Span::styled(" Cancel ", cancel_style), + Span::styled(if !confirm_focused { "│" } else { " " }, cancel_style), + Span::raw(" "), + Span::styled(if confirm_focused { "│" } else { " " }, confirm_style), + Span::styled(" Confirm ", confirm_style), + Span::styled(if confirm_focused { "│" } else { " " }, confirm_style), + ]); + + let buttons = Paragraph::new(button_line).centered(); + frame.render_widget(buttons, button_area); +} diff --git a/src/command_add/ratatui/widgets/detail_panel.rs b/src/command_add/ratatui/widgets/detail_panel.rs new file mode 100644 index 0000000..e86b52a --- /dev/null +++ b/src/command_add/ratatui/widgets/detail_panel.rs @@ -0,0 +1,51 @@ +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Color, Style}; +use ratatui::widgets::{Block, Paragraph}; + +/// Renders a detail panel showing the selected item and instructions +pub fn draw_detail_panel( + frame: &mut Frame, + selected_item: Option<&str>, + checked_count: usize, + item_type: &str, + dependencies: Option<&Vec>, + area: Rect, +) { + let content_block = Block::bordered().title("Detail"); + let inner_area = content_block.inner(area); + frame.render_widget(content_block, area); + + if let Some(item) = selected_item { + // Build dependencies section + let deps_section = if let Some(deps) = dependencies { + if deps.is_empty() { + "\n\nDependencies: None".to_string() + } else { + let deps_list: Vec = deps.iter().map(|d| format!(" - {d}")).collect(); + format!("\n\nDependencies ({}):\n{}", deps.len(), deps_list.join("\n")) + } + } else { + String::new() + }; + + let instruction = if checked_count > 0 { + let item_type_display = + if checked_count == 1 { item_type.to_string() } else { format!("{}s", item_type) }; + format!( + "\n\n({} {} checked)\nPress ENTER to view checked {}", + checked_count, item_type_display, item_type_display + ) + } else { + String::new() + }; + + let text = format!("Selected: {}{}{}", item, deps_section, instruction); + let paragraph = Paragraph::new(text).style(Style::default().fg(Color::White)); + frame.render_widget(paragraph, inner_area); + } else { + let paragraph = + Paragraph::new(format!("Select a {}", item_type)).style(Style::default().fg(Color::Gray)); + frame.render_widget(paragraph, inner_area); + } +} diff --git a/src/command_add/ratatui/widgets/helpers.rs b/src/command_add/ratatui/widgets/helpers.rs new file mode 100644 index 0000000..3d49b47 --- /dev/null +++ b/src/command_add/ratatui/widgets/helpers.rs @@ -0,0 +1,95 @@ +/// Filter items based on search query (prefix matching) +pub fn filter_items<'a>(items: &[&'a str], search_query: &str) -> Vec<&'a str> { + if search_query.is_empty() { + items.to_vec() + } else { + items + .iter() + .filter(|item| item.to_lowercase().starts_with(&search_query.to_lowercase())) + .copied() + .collect() + } +} + +/// Get the currently selected item based on scroll position and search query +pub fn get_selected_item<'a>(items: &[&'a str], scroll: usize, search_query: &str) -> Option<&'a str> { + let filtered_items = filter_items(items, search_query); + filtered_items.get(scroll).copied() +} + +/// Get item at a specific visual index in the filtered list +pub fn get_item_at_visual_index<'a>( + items: &[&'a str], + visual_index: usize, + search_query: &str, +) -> Option<&'a str> { + let filtered_items = filter_items(items, search_query); + filtered_items.get(visual_index).copied() +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_ITEMS: &[&str] = &["button", "badge", "card", "checkbox", "dialog", "dropdown"]; + + #[test] + fn filter_items_empty_query_returns_all() { + let result = filter_items(TEST_ITEMS, ""); + assert_eq!(result, TEST_ITEMS); + } + + #[test] + fn filter_items_matches_prefix() { + let result = filter_items(TEST_ITEMS, "b"); + assert_eq!(result, vec!["button", "badge"]); + } + + #[test] + fn filter_items_case_insensitive() { + let result = filter_items(TEST_ITEMS, "B"); + assert_eq!(result, vec!["button", "badge"]); + } + + #[test] + fn filter_items_no_match_returns_empty() { + let result = filter_items(TEST_ITEMS, "xyz"); + assert!(result.is_empty()); + } + + #[test] + fn filter_items_exact_match() { + let result = filter_items(TEST_ITEMS, "button"); + assert_eq!(result, vec!["button"]); + } + + #[test] + fn get_selected_item_valid_index() { + let result = get_selected_item(TEST_ITEMS, 2, ""); + assert_eq!(result, Some("card")); + } + + #[test] + fn get_selected_item_with_filter() { + let result = get_selected_item(TEST_ITEMS, 1, "b"); + assert_eq!(result, Some("badge")); + } + + #[test] + fn get_selected_item_out_of_bounds() { + let result = get_selected_item(TEST_ITEMS, 100, ""); + assert_eq!(result, None); + } + + #[test] + fn get_item_at_visual_index_valid() { + let result = get_item_at_visual_index(TEST_ITEMS, 0, "c"); + assert_eq!(result, Some("card")); + } + + #[test] + fn get_item_at_visual_index_out_of_bounds() { + let result = get_item_at_visual_index(TEST_ITEMS, 10, "c"); + assert_eq!(result, None); + } +} diff --git a/src/command_add/ratatui/widgets/mod.rs b/src/command_add/ratatui/widgets/mod.rs new file mode 100644 index 0000000..663b7a6 --- /dev/null +++ b/src/command_add/ratatui/widgets/mod.rs @@ -0,0 +1,5 @@ +pub mod checked_popup; +pub mod detail_panel; +pub mod helpers; +pub mod popup; +pub mod search_input; diff --git a/src/command_add/ratatui/widgets/popup.rs b/src/command_add/ratatui/widgets/popup.rs new file mode 100644 index 0000000..f9870ec --- /dev/null +++ b/src/command_add/ratatui/widgets/popup.rs @@ -0,0 +1,57 @@ +use ratatui::layout::{Constraint, Flex, Layout, Rect}; + +/// Helper function to create a centered rect using up certain percentage of the available rect +pub fn popup_area(area: Rect, percent_x: u16, percent_y: u16) -> Rect { + let vertical = Layout::vertical([Constraint::Percentage(percent_y)]).flex(Flex::Center); + let horizontal = Layout::horizontal([Constraint::Percentage(percent_x)]).flex(Flex::Center); + let [area] = vertical.areas(area); + let [area] = horizontal.areas(area); + area +} + +/* ========================================================== */ +/* 🧪 TESTS 🧪 */ +/* ========================================================== */ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn popup_area_output_contained_within_input() { + let area = Rect::new(0, 0, 100, 50); + let result = popup_area(area, 80, 60); + assert!(result.x >= area.x); + assert!(result.y >= area.y); + assert!(result.right() <= area.right()); + assert!(result.bottom() <= area.bottom()); + } + + #[test] + fn popup_area_at_100_percent_equals_input() { + let area = Rect::new(0, 0, 100, 50); + let result = popup_area(area, 100, 100); + assert_eq!(result.width, area.width); + assert_eq!(result.height, area.height); + } + + #[test] + fn popup_area_reduces_dimensions() { + let area = Rect::new(0, 0, 100, 50); + let result = popup_area(area, 50, 50); + assert!(result.width < area.width); + assert!(result.height < area.height); + } + + #[test] + fn popup_area_is_centered() { + let area = Rect::new(0, 0, 100, 50); + let result = popup_area(area, 50, 50); + let area_center_x = area.x + area.width / 2; + let area_center_y = area.y + area.height / 2; + let result_center_x = result.x + result.width / 2; + let result_center_y = result.y + result.height / 2; + assert!((result_center_x as i32 - area_center_x as i32).abs() <= 1); + assert!((result_center_y as i32 - area_center_y as i32).abs() <= 1); + } +} diff --git a/src/command_add/ratatui/widgets/search_input.rs b/src/command_add/ratatui/widgets/search_input.rs new file mode 100644 index 0000000..b22e973 --- /dev/null +++ b/src/command_add/ratatui/widgets/search_input.rs @@ -0,0 +1,29 @@ +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph}; + +/// Renders a search input widget with icon and cursor +pub fn draw_search_input(frame: &mut Frame, search_query: &str, search_active: bool, area: Rect) { + let search_style = if search_active { + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + + let cursor = if search_active { "│" } else { "" }; + let text = if search_query.is_empty() && !search_active { + "Press / to search...".to_string() + } else { + format!("{}{}", search_query, cursor) + }; + + let input = Paragraph::new(Line::from(vec![ + Span::styled("🔍 ", Style::default().fg(Color::Gray)), + Span::styled(text, search_style), + ])) + .block(Block::bordered().title("Search")); + + frame.render_widget(input, area); +} diff --git a/src/command_add/registry.rs b/src/command_add/registry.rs index f720a84..c947558 100644 --- a/src/command_add/registry.rs +++ b/src/command_add/registry.rs @@ -1,139 +1,255 @@ -// use dotenv::dotenv; -// use std::env; use std::io::Write; +use std::path::Path; -// use crate::constants::env::ENV; -use crate::constants::url::URL; - -use super::components_toml::ComponentsToml; -use serde_json; - -pub struct Registry {} - -impl Registry { - pub async fn fetch_index_content(url: &str) -> Result> { - // Attempt to fetch the content from the URL - let response = reqwest::get(url).await; - - // Check if the request was successful - let index_content_from_url = match response { - Ok(resp) => { - if resp.status().is_success() { - resp.text().await? - } else { - let error_message = format!("🔸 Failed to fetch data: Server returned status {}", resp.status()); - println!("{}", error_message); // Print the error message - return Err(error_message.into()); - } - } - Err(e) => { - let error_message = format!("🔸 Failed to fetch data: {}", e); - println!("{}", error_message); // Print the error message - return Err(error_message.into()); - } - }; - - // Check if the fetched content is empty - if index_content_from_url.is_empty() { - let error_message = "🔸 Failed to fetch data: The server returned an empty response."; - println!("{}", error_message); // Print the error message - return Err(error_message.into()); - } +use dialoguer::Confirm; +use dialoguer::theme::ColorfulTheme; - Ok(index_content_from_url) - } +use super::component_type::ComponentType; +use crate::shared::cli_error::{CliError, CliResult}; +use crate::shared::rust_ui_client::RustUIClient; + +/* ========================================================== */ +/* 📦 TYPES 📦 */ +/* ========================================================== */ + +#[derive(Debug, PartialEq)] +pub enum WriteOutcome { + Written, + Skipped, } -/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ +/* ========================================================== */ /* ✨ FUNCTIONS ✨ */ -/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ +/* ========================================================== */ pub struct RegistryComponent { - pub registry_json_path: String, - pub registry_json_content: String, - pub component_name_json: String, + pub registry_md_path: String, + pub registry_md_content: String, + pub component_name: String, } impl RegistryComponent { - pub async fn fetch_from_registry( - component_name_json: String, - ) -> Result> { - let base_url_styles_default = URL::BASE_URL_STYLES_DEFAULT; - let formatted_url_json = format!("{}/{}.json", base_url_styles_default, component_name_json); - - let response = reqwest::get(&formatted_url_json).await?; - let json_content: serde_json::Value = response.json().await?; - - let registry_json_path = json_content["path"].as_str().ok_or("Path not found")?.to_string(); - let registry_json_content = json_content["files"][0]["content"] - .as_str() - .ok_or("Content not found")? - .to_string(); + pub async fn fetch_from_registry(component_name: String) -> CliResult { + let registry_md_content = RustUIClient::fetch_styles_default(&component_name).await?; + let component_type = ComponentType::from_component_name(&component_name); + let registry_md_path = format!("{}/{}.rs", component_type.to_path(), component_name); - Ok(RegistryComponent { - registry_json_path, - registry_json_content, - component_name_json, - }) + Ok(RegistryComponent { registry_md_path, registry_md_content, component_name }) } - pub async fn then_write_to_file(self) -> Result<(), Box> { - let user_config_path = ComponentsToml::get_base_path().unwrap_or_default(); - let full_path_component = format!("{}/{}", user_config_path, self.registry_json_path); + pub async fn then_write_to_file_to(self, force: bool, base_path: &str) -> CliResult { + let components_base_path = base_path.to_string(); + let full_path_component = std::path::Path::new(&components_base_path).join(&self.registry_md_path); - let full_path_component_without_name_rs = std::path::Path::new(&full_path_component) + let full_path_component_without_name_rs = full_path_component .parent() - .ok_or("Failed to get parent directory")? + .ok_or_else(|| CliError::file_operation("Failed to get parent directory"))? .to_str() - .ok_or("Failed to convert path to string")? + .ok_or_else(|| CliError::file_operation("Failed to convert path to string"))? .to_string(); - write_component_name_in_mod_rs_if_not_exists(self.component_name_json, full_path_component_without_name_rs); + let outcome = write_component_file(&full_path_component, &self.registry_md_content, force)?; - let dir = std::path::Path::new(&full_path_component) - .parent() - .ok_or("Failed to get parent directory")?; - std::fs::create_dir_all(dir)?; + if outcome == WriteOutcome::Skipped { + return Ok(WriteOutcome::Skipped); + } - std::fs::write(full_path_component, self.registry_json_content)?; + write_component_name_in_mod_rs_if_not_exists( + self.component_name, + full_path_component_without_name_rs, + )?; - Ok(()) + Ok(WriteOutcome::Written) } } -/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ +/* ========================================================== */ +/* ✨ HELPERS ✨ */ +/* ========================================================== */ + +/// Write a component file to disk. If the file already exists and `force` is +/// false, prompt the user. Returns whether the file was written or skipped. +pub fn write_component_file(path: &Path, content: &str, force: bool) -> CliResult { + if path.exists() && !force { + let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("file"); + let overwrite = Confirm::with_theme(&ColorfulTheme::default()) + .with_prompt(format!("{file_name} already exists. Overwrite?")) + .default(false) + .interact() + .map_err(|err| CliError::validation(&format!("Failed to get user input: {err}")))?; + + if !overwrite { + return Ok(WriteOutcome::Skipped); + } + } + + let dir = path.parent().ok_or_else(|| CliError::file_operation("Failed to get parent directory"))?; + std::fs::create_dir_all(dir).map_err(|_| CliError::directory_create_failed())?; + std::fs::write(path, content).map_err(|_| CliError::file_write_failed())?; + + Ok(WriteOutcome::Written) +} + +/* ========================================================== */ /* ✨ FUNCTIONS ✨ */ -/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ +/* ========================================================== */ + +/* ========================================================== */ +/* 🧪 TESTS 🧪 */ +/* ========================================================== */ + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use super::*; + + fn temp_file(dir: &TempDir, name: &str) -> std::path::PathBuf { + dir.path().join(name) + } + + #[test] + fn write_new_file_returns_written() { + let dir = TempDir::new().unwrap(); + let path = temp_file(&dir, "button.rs"); + + let outcome = write_component_file(&path, "// button", true).unwrap(); + + assert_eq!(outcome, WriteOutcome::Written); + assert!(path.exists()); + } + + #[test] + fn written_content_is_correct() { + let dir = TempDir::new().unwrap(); + let path = temp_file(&dir, "button.rs"); + + write_component_file(&path, "// button content", true).unwrap(); + + assert_eq!(fs::read_to_string(&path).unwrap(), "// button content"); + } + + #[test] + fn force_true_overwrites_existing_file() { + let dir = TempDir::new().unwrap(); + let path = temp_file(&dir, "button.rs"); + fs::write(&path, "// old").unwrap(); + + let outcome = write_component_file(&path, "// new", true).unwrap(); + + assert_eq!(outcome, WriteOutcome::Written); + assert_eq!(fs::read_to_string(&path).unwrap(), "// new"); + } + + #[test] + fn write_creates_nested_parent_dirs() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("ui").join("nested").join("button.rs"); + + let outcome = write_component_file(&path, "// button", true).unwrap(); + + assert_eq!(outcome, WriteOutcome::Written); + assert!(path.exists()); + } + + #[test] + fn new_file_with_force_false_returns_written() { + // force=false on a non-existing file: no prompt needed, just writes + let dir = TempDir::new().unwrap(); + let path = temp_file(&dir, "badge.rs"); + + let outcome = write_component_file(&path, "// badge", false).unwrap(); + + assert_eq!(outcome, WriteOutcome::Written); + assert!(path.exists()); + } + + // --- write_component_name_in_mod_rs_if_not_exists --- + + #[test] + fn creates_mod_rs_with_pub_mod_entry() { + let dir = TempDir::new().unwrap(); + let subdir = dir.path().join("ui"); + fs::create_dir_all(&subdir).unwrap(); + + write_component_name_in_mod_rs_if_not_exists( + "button".to_string(), + subdir.to_str().unwrap().to_string(), + ) + .unwrap(); + + let mod_rs = fs::read_to_string(subdir.join("mod.rs")).unwrap(); + assert!(mod_rs.contains("pub mod button;")); + } + + #[test] + fn skips_if_component_already_in_mod_rs() { + let dir = TempDir::new().unwrap(); + let subdir = dir.path().join("ui"); + fs::create_dir_all(&subdir).unwrap(); + fs::write(subdir.join("mod.rs"), "pub mod button;\n").unwrap(); + + write_component_name_in_mod_rs_if_not_exists( + "button".to_string(), + subdir.to_str().unwrap().to_string(), + ) + .unwrap(); + + let mod_rs = fs::read_to_string(subdir.join("mod.rs")).unwrap(); + assert_eq!(mod_rs.matches("pub mod button;").count(), 1); + } + + #[test] + fn appends_new_component_to_existing_mod_rs() { + let dir = TempDir::new().unwrap(); + let subdir = dir.path().join("ui"); + fs::create_dir_all(&subdir).unwrap(); + fs::write(subdir.join("mod.rs"), "pub mod button;\n").unwrap(); + + write_component_name_in_mod_rs_if_not_exists( + "badge".to_string(), + subdir.to_str().unwrap().to_string(), + ) + .unwrap(); + + let mod_rs = fs::read_to_string(subdir.join("mod.rs")).unwrap(); + assert!(mod_rs.contains("pub mod button;")); + assert!(mod_rs.contains("pub mod badge;")); + } +} -fn write_component_name_in_mod_rs_if_not_exists(component_name: String, full_path_component_without_name_rs: String) { - let mod_rs_path = format!("{}/mod.rs", full_path_component_without_name_rs); +fn write_component_name_in_mod_rs_if_not_exists( + component_name: String, + full_path_component_without_name_rs: String, +) -> CliResult<()> { + let mod_rs_path = std::path::Path::new(&full_path_component_without_name_rs).join("mod.rs"); // Create the directory if it doesn't exist - let dir = std::path::Path::new(&mod_rs_path) - .parent() - .expect("Failed to get parent directory"); - std::fs::create_dir_all(dir).expect("Failed to create directories"); + let dir = + mod_rs_path.parent().ok_or_else(|| CliError::file_operation("Failed to get parent directory"))?; + std::fs::create_dir_all(dir).map_err(|_| CliError::directory_create_failed())?; // Check if the mod.rs file already exists let mut mod_rs_content = String::new(); - if std::path::Path::new(&mod_rs_path).exists() { - mod_rs_content = std::fs::read_to_string(&mod_rs_path).expect("Failed to read mod.rs"); + if mod_rs_path.exists() { + mod_rs_content = std::fs::read_to_string(&mod_rs_path).map_err(|_| CliError::file_read_failed())?; } // Check if the component already exists if mod_rs_content.contains(&component_name) { - println!("Component {} already exists in mod.rs", component_name); - return; // Exit the function if the component already exists + return Ok(()); } // Append the component name to mod.rs let mut mod_rs_file = std::fs::OpenOptions::new() - .write(true) .append(true) .create(true) - .open(mod_rs_path) - .expect("Failed to open mod.rs"); + .open(&mod_rs_path) + .map_err(|_| CliError::file_operation("Failed to open mod.rs file"))?; // Write the new component name - writeln!(mod_rs_file, "pub mod {};", component_name).expect("Failed to write to mod.rs"); + writeln!(mod_rs_file, "pub mod {component_name};").map_err(|_| CliError::file_write_failed())?; + Ok(()) } diff --git a/src/command_add/tree_parser.rs b/src/command_add/tree_parser.rs new file mode 100644 index 0000000..c19e1ae --- /dev/null +++ b/src/command_add/tree_parser.rs @@ -0,0 +1,366 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; + +use crate::shared::cli_error::CliResult; + +#[derive(Debug, Clone)] +pub struct TreeParser { + components: HashMap, +} + +#[derive(Debug, Clone)] +pub struct ComponentEntry { + pub name: String, + pub category: String, + pub dependencies: Vec, + pub cargo_deps: Vec, + pub js_files: Vec, +} + +#[derive(Debug, Clone)] +pub struct ResolvedSet { + pub components: HashSet, + pub cargo_deps: HashSet, + pub parent_dirs: HashSet, + pub js_files: HashSet, +} + +impl TreeParser { + pub fn parse_tree_md(content: &str) -> CliResult { + let mut components = HashMap::new(); + let mut current_component: Option = None; + let mut dependency_stack: Vec = Vec::new(); + + for line in content.lines() { + let line = line.trim(); + + // Skip empty lines and code block markers + if line.is_empty() || line.starts_with("```") { + continue; + } + + // Parse component lines (*) + if let Some(line_content) = line.strip_prefix("* ") { + // Save previous component if exists + if let Some(component) = current_component.take() { + components.insert(component.name.clone(), component); + } + + if let Some((name_part, category_part)) = line_content.rsplit_once(" (") { + let name = name_part.trim().to_string(); + let category = category_part.trim_end_matches(')').to_string(); + + current_component = Some(ComponentEntry { + name: name.clone(), + category, + dependencies: Vec::new(), + cargo_deps: Vec::new(), + js_files: Vec::new(), + }); + + dependency_stack.clear(); + dependency_stack.push(name); + } + } + // Parse dependency lines (**) + else if let Some(dep_content) = line.strip_prefix("** ") { + if let Some(cargo_dep_name) = dep_content.strip_prefix("cargo: ") { + // Cargo dependency + let cargo_dep = cargo_dep_name.trim().to_string(); + if let Some(ref mut component) = current_component { + component.cargo_deps.push(cargo_dep); + } + } else if let Some(js_path) = dep_content.strip_prefix("js: ") { + // JS file dependency + let js_file = js_path.trim().to_string(); + if let Some(ref mut component) = current_component { + component.js_files.push(js_file); + } + } else if let Some((dep_name, _)) = dep_content.rsplit_once(" (") { + // Registry dependency + let dep_name = dep_name.trim().to_string(); + if let Some(ref mut component) = current_component { + component.dependencies.push(dep_name.clone()); + } + + // Update dependency stack for nested dependencies + dependency_stack.truncate(1); // Keep only root component + dependency_stack.push(dep_name); + } + } + // Parse nested dependency lines (***) + else if let Some(dep_content) = line.strip_prefix("*** ") { + if let Some(cargo_dep_name) = dep_content.strip_prefix("cargo: ") { + // Nested cargo dependency - add to root component + let cargo_dep = cargo_dep_name.trim().to_string(); + if let Some(ref mut component) = current_component { + component.cargo_deps.push(cargo_dep); + } + } else if let Some(js_path) = dep_content.strip_prefix("js: ") { + // Nested JS file dependency - add to root component + let js_file = js_path.trim().to_string(); + if let Some(ref mut component) = current_component { + component.js_files.push(js_file); + } + } else if let Some((dep_name, _)) = dep_content.rsplit_once(" (") { + // Nested registry dependency - add to root component + let dep_name = dep_name.trim().to_string(); + if let Some(ref mut component) = current_component { + component.dependencies.push(dep_name); + } + } + } + } + + // Save last component + if let Some(component) = current_component { + components.insert(component.name.clone(), component); + } + + Ok(TreeParser { components }) + } + + pub fn get_all_component_names(&self) -> Vec { + let mut names: Vec = self.components.keys().cloned().collect(); + names.sort(); + names + } + + /// Returns components grouped by category, with both categories and names sorted. + pub fn get_components_by_category(&self) -> BTreeMap> { + let mut map: BTreeMap> = BTreeMap::new(); + for entry in self.components.values() { + map.entry(entry.category.clone()).or_default().push(entry.name.clone()); + } + for names in map.values_mut() { + names.sort(); + } + map + } + + pub fn get_dependencies_map(&self) -> HashMap> { + self.components + .iter() + .map(|(name, entry)| (name.clone(), entry.dependencies.clone())) + .collect() + } + + pub fn resolve_dependencies(&self, user_components: &[String]) -> CliResult { + let mut resolved_components = HashSet::new(); + let mut resolved_cargo_deps = HashSet::new(); + let mut resolved_parent_dirs = HashSet::new(); + let mut resolved_js_files = HashSet::new(); + + // Process each user component + for component_name in user_components { + if let Some(component_entry) = self.components.get(component_name) { + // Add the component itself + resolved_components.insert(component_name.clone()); + resolved_parent_dirs.insert(component_entry.category.clone()); + + // Add its direct dependencies + for dep in &component_entry.dependencies { + resolved_components.insert(dep.clone()); + + // Add parent dir for dependency + if let Some(dep_entry) = self.components.get(dep) { + resolved_parent_dirs.insert(dep_entry.category.clone()); + } + } + + // Add cargo dependencies + for cargo_dep in &component_entry.cargo_deps { + resolved_cargo_deps.insert(cargo_dep.clone()); + } + + // Add JS file dependencies + for js_file in &component_entry.js_files { + resolved_js_files.insert(js_file.clone()); + } + } else { + println!("⚠️ Component '{component_name}' not found in registry. Skipping..."); + } + } + + // Debug output — intentionally kept for visibility during development + println!("📦 Final set of resolved components: {resolved_components:?}"); + println!("📦 Final set of cargo dependencies: {resolved_cargo_deps:?}"); + if !resolved_js_files.is_empty() { + println!("📦 Final set of JS files: {resolved_js_files:?}"); + } + + Ok(ResolvedSet { + components: resolved_components, + cargo_deps: resolved_cargo_deps, + parent_dirs: resolved_parent_dirs, + js_files: resolved_js_files, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_TREE: &str = r#" +* button (ui) +** badge (ui) +** cargo: some-crate + +* badge (ui) + +* card (ui) +** button (ui) +*** badge (ui) + +* demo_button (demos) +** button (ui) + +* select (ui) +** cargo: strum +** js: /hooks/lock_scroll.js + +* sheet (ui) +** js: /hooks/lock_scroll.js +** button (ui) +"#; + + #[test] + fn parse_tree_md_extracts_components() { + let parser = TreeParser::parse_tree_md(SAMPLE_TREE).unwrap(); + let names = parser.get_all_component_names(); + assert!(names.contains(&"button".to_string())); + assert!(names.contains(&"badge".to_string())); + assert!(names.contains(&"card".to_string())); + assert!(names.contains(&"demo_button".to_string())); + } + + #[test] + fn parse_tree_md_extracts_dependencies() { + let parser = TreeParser::parse_tree_md(SAMPLE_TREE).unwrap(); + let deps_map = parser.get_dependencies_map(); + + assert_eq!(deps_map.get("button").unwrap(), &vec!["badge".to_string()]); + assert!(deps_map.get("badge").unwrap().is_empty()); + assert_eq!(deps_map.get("card").unwrap(), &vec!["button".to_string(), "badge".to_string()]); + } + + #[test] + fn parse_tree_md_extracts_cargo_deps() { + let parser = TreeParser::parse_tree_md(SAMPLE_TREE).unwrap(); + let entry = parser.components.get("button").unwrap(); + assert!(entry.cargo_deps.contains(&"some-crate".to_string())); + } + + #[test] + fn parse_tree_md_extracts_category() { + let parser = TreeParser::parse_tree_md(SAMPLE_TREE).unwrap(); + assert_eq!(parser.components.get("button").unwrap().category, "ui"); + assert_eq!(parser.components.get("demo_button").unwrap().category, "demos"); + } + + #[test] + fn get_all_component_names_sorted() { + let parser = TreeParser::parse_tree_md(SAMPLE_TREE).unwrap(); + let names = parser.get_all_component_names(); + let mut sorted = names.clone(); + sorted.sort(); + assert_eq!(names, sorted); + } + + #[test] + fn resolve_dependencies_collects_cargo_deps() { + let parser = TreeParser::parse_tree_md(SAMPLE_TREE).unwrap(); + let resolved = parser.resolve_dependencies(&["button".to_string()]).unwrap(); + assert!(resolved.cargo_deps.contains("some-crate")); + } + + #[test] + fn resolve_dependencies_includes_transitive() { + let parser = TreeParser::parse_tree_md(SAMPLE_TREE).unwrap(); + let resolved = parser.resolve_dependencies(&["card".to_string()]).unwrap(); + + assert!(resolved.components.contains("card")); + assert!(resolved.components.contains("button")); + assert!(resolved.components.contains("badge")); + } + + #[test] + fn resolve_dependencies_collects_parent_dirs() { + let parser = TreeParser::parse_tree_md(SAMPLE_TREE).unwrap(); + let resolved = parser.resolve_dependencies(&["demo_button".to_string()]).unwrap(); + + assert!(resolved.parent_dirs.contains("demos")); + assert!(resolved.parent_dirs.contains("ui")); + } + + #[test] + fn resolve_dependencies_missing_component_skipped() { + let parser = TreeParser::parse_tree_md(SAMPLE_TREE).unwrap(); + let resolved = parser.resolve_dependencies(&["nonexistent".to_string()]).unwrap(); + assert!(resolved.components.is_empty()); + } + + #[test] + fn parse_tree_md_extracts_js_files() { + let parser = TreeParser::parse_tree_md(SAMPLE_TREE).unwrap(); + let select = parser.components.get("select").unwrap(); + assert!(select.js_files.contains(&"/hooks/lock_scroll.js".to_string())); + } + + #[test] + fn resolve_dependencies_collects_js_files() { + let parser = TreeParser::parse_tree_md(SAMPLE_TREE).unwrap(); + let resolved = parser.resolve_dependencies(&["select".to_string()]).unwrap(); + assert!(resolved.js_files.contains("/hooks/lock_scroll.js")); + } + + #[test] + fn resolve_dependencies_js_files_deduped() { + let parser = TreeParser::parse_tree_md(SAMPLE_TREE).unwrap(); + // Both select and sheet use the same JS file + let resolved = parser.resolve_dependencies(&["select".to_string(), "sheet".to_string()]).unwrap(); + // Should only contain one instance + assert_eq!(resolved.js_files.len(), 1); + assert!(resolved.js_files.contains("/hooks/lock_scroll.js")); + } + + #[test] + fn component_without_js_has_empty_js_files() { + let parser = TreeParser::parse_tree_md(SAMPLE_TREE).unwrap(); + let button = parser.components.get("button").unwrap(); + assert!(button.js_files.is_empty()); + } + + #[test] + fn get_components_by_category_groups_correctly() { + let parser = TreeParser::parse_tree_md(SAMPLE_TREE).unwrap(); + let by_cat = parser.get_components_by_category(); + + assert!(by_cat.contains_key("ui")); + assert!(by_cat.contains_key("demos")); + assert!(by_cat["ui"].contains(&"button".to_string())); + assert!(by_cat["demos"].contains(&"demo_button".to_string())); + } + + #[test] + fn get_components_by_category_names_are_sorted() { + let parser = TreeParser::parse_tree_md(SAMPLE_TREE).unwrap(); + let by_cat = parser.get_components_by_category(); + + let ui = &by_cat["ui"]; + let mut sorted = ui.clone(); + sorted.sort(); + assert_eq!(ui, &sorted); + } + + #[test] + fn get_components_by_category_categories_are_sorted() { + let parser = TreeParser::parse_tree_md(SAMPLE_TREE).unwrap(); + let by_cat = parser.get_components_by_category(); + + let keys: Vec<&String> = by_cat.keys().collect(); + let mut sorted = keys.clone(); + sorted.sort(); + assert_eq!(keys, sorted); + } +} diff --git a/src/command_diff/_diff.rs b/src/command_diff/_diff.rs new file mode 100644 index 0000000..7da9dce --- /dev/null +++ b/src/command_diff/_diff.rs @@ -0,0 +1,317 @@ +use std::path::Path; + +use clap::{Arg, ArgMatches, Command}; +use colored::Colorize; +use serde::Serialize; +use similar::{ChangeTag, TextDiff}; + +use crate::command_add::component_type::ComponentType; +use crate::command_add::installed::get_installed_components; +use crate::command_init::config::UiConfig; +use crate::shared::cli_error::CliResult; +use crate::shared::rust_ui_client::RustUIClient; + +const UI_CONFIG_TOML: &str = "ui_config.toml"; +const CONTEXT_LINES: usize = 3; + +/* ========================================================== */ +/* 📦 TYPES 📦 */ +/* ========================================================== */ + +#[derive(Debug, PartialEq, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DiffStatus { + UpToDate, + Changed, + NotInRegistry, +} + +#[derive(Debug, Clone)] +pub struct ComponentDiff { + pub name: String, + pub status: DiffStatus, + pub local: String, + pub remote: String, +} + +/* ========================================================== */ +/* 🔧 COMMAND 🔧 */ +/* ========================================================== */ + +pub fn command_diff() -> Command { + Command::new("diff") + .about("Show line-by-line diff of installed components vs the registry") + .arg(Arg::new("component").help("Component name to diff (omit to diff all installed)").required(false)) + .arg(Arg::new("json").long("json").help("Output as JSON").action(clap::ArgAction::SetTrue)) +} + +/* ========================================================== */ +/* 🦀 MAIN 🦀 */ +/* ========================================================== */ + +/// Fetch registry content and compute diffs for a list of component names. +/// Names are processed in the order given; sort before calling if needed. +pub async fn diff_components(names: &[String], base_path: &str) -> CliResult> { + let mut diffs: Vec = Vec::new(); + for name in names { + let component_type = ComponentType::from_component_name(name); + let local_path = Path::new(base_path).join(component_type.to_path()).join(format!("{name}.rs")); + match RustUIClient::fetch_styles_default(name).await { + Ok(remote) => { + let local = std::fs::read_to_string(&local_path).unwrap_or_default(); + let status = if local == remote { DiffStatus::UpToDate } else { DiffStatus::Changed }; + diffs.push(ComponentDiff { name: name.clone(), status, local, remote }); + } + Err(_) => { + diffs.push(ComponentDiff { + name: name.clone(), + status: DiffStatus::NotInRegistry, + local: String::new(), + remote: String::new(), + }); + } + } + } + Ok(diffs) +} + +pub async fn process_diff(matches: &ArgMatches) -> CliResult<()> { + let json = matches.get_flag("json"); + let component_arg: Option<&String> = matches.get_one("component"); + + let config = UiConfig::try_reading_ui_config(UI_CONFIG_TOML)?; + let base_path = config.base_path_components; + + let names: Vec = if let Some(name) = component_arg { + vec![name.clone()] + } else { + let mut installed: Vec = get_installed_components(&base_path).into_iter().collect(); + installed.sort(); + installed + }; + + if names.is_empty() { + println!("No components installed."); + return Ok(()); + } + + if component_arg.is_none() { + println!("Checking {} installed component{}...\n", names.len(), if names.len() == 1 { "" } else { "s" }); + } + + let diffs = diff_components(&names, &base_path).await?; + + let output = if json { format_diff_json(&diffs)? } else { format_diff_human(&diffs) }; + println!("{output}"); + + Ok(()) +} + +/* ========================================================== */ +/* 🖨 FORMATTERS 🖨 */ +/* ========================================================== */ + +/// Human-readable diff output with context lines. +pub fn format_diff_human(diffs: &[ComponentDiff]) -> String { + let name_width = diffs.iter().map(|d| d.name.len()).max().unwrap_or(0); + let mut output = String::new(); + + // When showing multiple components, show a summary line for each + let multi = diffs.len() > 1; + + let mut changed_count = 0; + + for diff in diffs { + match diff.status { + DiffStatus::UpToDate => { + if multi { + let padded = format!("{: { + let padded = format!("{: { + changed_count += 1; + let td = TextDiff::from_lines(&diff.local, &diff.remote); + let change_count = td.iter_all_changes().filter(|c| c.tag() != ChangeTag::Equal).count(); + + if multi { + let padded = format!("{: output.push_str(&format!("{}\n", format!(" {line}").dimmed())), + ChangeTag::Delete => output.push_str(&format!("{}\n", format!("- {line}").red())), + ChangeTag::Insert => output.push_str(&format!("{}\n", format!("+ {line}").green())), + } + } + } + } + output.push('\n'); + } + } + } + + if multi { + output.push('\n'); + if changed_count == 0 { + output.push_str("All components are up to date."); + } else { + output.push_str(&format!( + "{} component{} changed. Run `ui diff ` to inspect.", + changed_count, + if changed_count == 1 { " has" } else { "s have" } + )); + } + } + + output +} + +/// Machine-readable JSON output. +pub fn format_diff_json(diffs: &[ComponentDiff]) -> CliResult { + let json_diffs: Vec = diffs + .iter() + .map(|d| { + let td = TextDiff::from_lines(&d.local, &d.remote); + let hunks: Vec = td + .grouped_ops(0) + .into_iter() + .map(|group| { + let (mut removed, mut added) = (Vec::new(), Vec::new()); + for op in &group { + for change in td.iter_changes(op) { + let line = change.value().trim_end_matches('\n').to_string(); + match change.tag() { + ChangeTag::Delete => removed.push(line), + ChangeTag::Insert => added.push(line), + ChangeTag::Equal => {} + } + } + } + serde_json::json!({ "removed": removed, "added": added }) + }) + .collect(); + let status = serde_json::to_value(&d.status).unwrap_or_default(); + serde_json::json!({ "name": d.name, "status": status, "hunks": hunks }) + }) + .collect(); + + serde_json::to_string_pretty(&json_diffs).map_err(Into::into) +} + +/* ========================================================== */ +/* 🧪 TESTS 🧪 */ +/* ========================================================== */ + +#[cfg(test)] +mod tests { + use super::*; + + fn make_diff(name: &str, status: DiffStatus, local: &str, remote: &str) -> ComponentDiff { + ComponentDiff { name: name.to_string(), status, local: local.to_string(), remote: remote.to_string() } + } + + // --- format_diff_human --- + + #[test] + fn up_to_date_single_component_shows_no_diff_block() { + let diff = make_diff("button", DiffStatus::UpToDate, "fn foo() {}", "fn foo() {}"); + let out = format_diff_human(&[diff]); + assert!(!out.contains("---")); + assert!(!out.contains("+++")); + } + + #[test] + fn changed_component_shows_diff_headers() { + let diff = make_diff("button", DiffStatus::Changed, "let x = 1;", "let x = 2;"); + let out = format_diff_human(&[diff]); + assert!(out.contains("--- button (local)")); + assert!(out.contains("+++ button (registry)")); + } + + #[test] + fn multi_up_to_date_shows_all_up_to_date_message() { + let diffs = vec![ + make_diff("badge", DiffStatus::UpToDate, "x", "x"), + make_diff("card", DiffStatus::UpToDate, "x", "x"), + ]; + let out = format_diff_human(&diffs); + assert!(out.contains("All components are up to date.")); + } + + #[test] + fn multi_changed_shows_changed_count() { + let diffs = vec![ + make_diff("button", DiffStatus::Changed, "old", "new"), + make_diff("badge", DiffStatus::UpToDate, "x", "x"), + ]; + let out = format_diff_human(&diffs); + assert!(out.contains("1 component has changed")); + } + + #[test] + fn not_in_registry_shows_question_mark_label() { + let diffs = vec![make_diff("my_custom", DiffStatus::NotInRegistry, "", "")]; + let out = format_diff_human(&diffs); + assert!(out.contains("not in registry")); + } + + // --- format_diff_json --- + + #[test] + fn json_output_is_valid_array() { + let diffs = vec![make_diff("button", DiffStatus::UpToDate, "x", "x")]; + let json = format_diff_json(&diffs).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert!(parsed.is_array()); + } + + #[test] + fn json_status_serialized_correctly() { + let diffs = vec![ + make_diff("a", DiffStatus::UpToDate, "x", "x"), + make_diff("b", DiffStatus::Changed, "old", "new"), + make_diff("c", DiffStatus::NotInRegistry, "", ""), + ]; + let json = format_diff_json(&diffs).unwrap(); + assert!(json.contains("up_to_date")); + assert!(json.contains("changed")); + assert!(json.contains("not_in_registry")); + } + + #[test] + fn json_contains_hunks_for_changed_component() { + let diffs = vec![make_diff("button", DiffStatus::Changed, "fn foo() {}\nold\n", "fn foo() {}\nnew\n")]; + let json = format_diff_json(&diffs).unwrap(); + assert!(json.contains("hunks")); + assert!(json.contains("old")); + assert!(json.contains("new")); + } + + // --- diff_components --- + + #[tokio::test] + async fn diff_components_empty_names_returns_empty_vec() { + let result = diff_components(&[], "any/path").await.unwrap(); + assert!(result.is_empty()); + } +} diff --git a/src/command_diff/mod.rs b/src/command_diff/mod.rs new file mode 100644 index 0000000..fe71016 --- /dev/null +++ b/src/command_diff/mod.rs @@ -0,0 +1 @@ +pub mod _diff; diff --git a/src/command_docs/_docs.rs b/src/command_docs/_docs.rs new file mode 100644 index 0000000..a1b2867 --- /dev/null +++ b/src/command_docs/_docs.rs @@ -0,0 +1,94 @@ +use std::process::Command as ProcessCommand; + +use clap::Command; + +use crate::shared::cli_error::{CliError, CliResult}; + +const DOCS_URL: &str = "https://rust-ui.com"; + +/* ========================================================== */ +/* 🦀 MAIN 🦀 */ +/* ========================================================== */ + +pub fn command_docs() -> Command { + Command::new("docs").about("Open the rust-ui documentation in your browser") +} + +pub fn process_docs() -> CliResult<()> { + println!("Opening {DOCS_URL} ..."); + open_url(DOCS_URL) +} + +/* ========================================================== */ +/* ✨ HELPERS ✨ */ +/* ========================================================== */ + +/// Opens the given URL in the system default browser. +/// Returns the platform-specific command and arguments used — extracted for testability. +pub fn open_url(url: &str) -> CliResult<()> { + let (program, args) = browser_command(url); + + let status = ProcessCommand::new(program) + .args(&args) + .status() + .map_err(|_| CliError::validation("Failed to launch browser"))?; + + if status.success() { + Ok(()) + } else { + Err(CliError::validation("Browser command exited with a non-zero status")) + } +} + +/// Returns the platform command and args needed to open `url`. +/// Separated from `open_url` so it can be unit-tested without side effects. +pub fn browser_command(url: &str) -> (&'static str, Vec) { + #[cfg(target_os = "macos")] + return ("open", vec![url.to_string()]); + + #[cfg(target_os = "linux")] + return ("xdg-open", vec![url.to_string()]); + + #[cfg(target_os = "windows")] + return ("cmd", vec!["/c".to_string(), "start".to_string(), url.to_string()]); + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + return ("xdg-open", vec![url.to_string()]); +} + +/* ========================================================== */ +/* 🧪 TESTS 🧪 */ +/* ========================================================== */ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn docs_url_is_https() { + assert!(DOCS_URL.starts_with("https://")); + } + + #[test] + fn docs_url_has_no_trailing_slash() { + assert!(!DOCS_URL.ends_with('/')); + } + + #[test] + fn browser_command_includes_url() { + let (_, args) = browser_command("https://example.com"); + assert!(args.iter().any(|a| a.contains("https://example.com"))); + } + + #[test] + fn browser_command_program_is_non_empty() { + let (program, _) = browser_command(DOCS_URL); + assert!(!program.is_empty()); + } + + #[test] + fn browser_command_args_are_non_empty() { + let (_, args) = browser_command(DOCS_URL); + assert!(!args.is_empty()); + } +} diff --git a/src/command_docs/mod.rs b/src/command_docs/mod.rs new file mode 100644 index 0000000..2f467f9 --- /dev/null +++ b/src/command_docs/mod.rs @@ -0,0 +1 @@ +pub mod _docs; diff --git a/src/command_info/_info.rs b/src/command_info/_info.rs new file mode 100644 index 0000000..36ff632 --- /dev/null +++ b/src/command_info/_info.rs @@ -0,0 +1,273 @@ +use clap::{Arg, ArgMatches, Command}; +use serde::Serialize; + +use crate::command_add::installed::get_installed_components; +use crate::command_init::config::UiConfig; +use crate::command_init::workspace_utils::analyze_workspace; +use crate::shared::cli_error::CliResult; + +const UI_CONFIG_TOML: &str = "ui_config.toml"; + +/* ========================================================== */ +/* 📦 TYPES 📦 */ +/* ========================================================== */ + +#[derive(Serialize)] +pub struct InfoData { + pub config_file: String, + pub base_color: String, + pub base_path: String, + pub workspace: Option, + pub target_crate: Option, + pub installed: Vec, +} + +/* ========================================================== */ +/* 🦀 MAIN 🦀 */ +/* ========================================================== */ + +pub fn command_info() -> Command { + Command::new("info") + .about("Show project configuration and installed components") + .arg( + Arg::new("json") + .long("json") + .help("Output as JSON") + .action(clap::ArgAction::SetTrue), + ) +} + +pub fn process_info(matches: &ArgMatches) -> CliResult<()> { + let json = matches.get_flag("json"); + + let config = UiConfig::try_reading_ui_config(UI_CONFIG_TOML)?; + let installed = get_installed_components(&config.base_path_components); + let workspace = analyze_workspace().ok(); + + let data = build_info_data(&config.base_color, &config.base_path_components, &installed, workspace.as_ref()); + + let output = if json { format_info_json(&data)? } else { format_info(&data) }; + println!("{output}"); + Ok(()) +} + +/* ========================================================== */ +/* ✨ HELPERS ✨ */ +/* ========================================================== */ + +pub fn build_info_data( + base_color: &str, + base_path: &str, + installed: &std::collections::HashSet, + workspace: Option<&crate::command_init::workspace_utils::WorkspaceInfo>, +) -> InfoData { + let mut sorted_installed: Vec = installed.iter().cloned().collect(); + sorted_installed.sort(); + + let (ws_flag, target_crate) = match workspace { + Some(ws) => (Some(ws.is_workspace), ws.target_crate.clone()), + None => (None, None), + }; + + InfoData { + config_file: UI_CONFIG_TOML.to_string(), + base_color: base_color.to_string(), + base_path: base_path.to_string(), + workspace: ws_flag, + target_crate, + installed: sorted_installed, + } +} + +/// Human-readable formatter. +pub fn format_info(data: &InfoData) -> String { + let mut lines: Vec = Vec::new(); + + lines.push(format!(" Config file {}", data.config_file)); + lines.push(format!(" Base color {}", data.base_color)); + lines.push(format!(" Base path {}", data.base_path)); + + if let Some(is_workspace) = data.workspace { + lines.push(format!(" Workspace {}", if is_workspace { "yes" } else { "no" })); + } + if let Some(ref crate_name) = data.target_crate { + lines.push(format!(" Target crate {crate_name}")); + } + + let count = data.installed.len(); + if count == 0 { + lines.push(" Installed none".to_string()); + } else { + lines.push(format!(" Installed ({count}) {}", data.installed.join(", "))); + } + + lines.join("\n") +} + +/// Machine-readable JSON formatter. +pub fn format_info_json(data: &InfoData) -> CliResult { + serde_json::to_string_pretty(data).map_err(Into::into) +} + +/* ========================================================== */ +/* ✨ HELPERS ✨ */ +/* ========================================================== */ + +/* ========================================================== */ +/* 🧪 TESTS 🧪 */ +/* ========================================================== */ + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::*; + use crate::command_init::workspace_utils::WorkspaceInfo; + + fn installed(names: &[&str]) -> HashSet { + names.iter().map(|s| s.to_string()).collect() + } + + fn no_workspace() -> Option { + None + } + + fn single_crate_workspace() -> Option { + Some(WorkspaceInfo { + is_workspace: false, + workspace_root: None, + target_crate: Some("my-app".to_string()), + target_crate_path: None, + components_base_path: "src/components".to_string(), + }) + } + + fn full_workspace() -> Option { + Some(WorkspaceInfo { + is_workspace: true, + workspace_root: Some(std::path::PathBuf::from("/project")), + target_crate: Some("frontend".to_string()), + target_crate_path: None, + components_base_path: "frontend/src/components".to_string(), + }) + } + + fn data(color: &str, path: &str, names: &[&str], ws: Option) -> InfoData { + build_info_data(color, path, &installed(names), ws.as_ref()) + } + + // --- format_info (human-readable) --- + + #[test] + fn shows_config_fields() { + let result = format_info(&data("neutral", "src/components", &[], no_workspace())); + assert!(result.contains("ui_config.toml")); + assert!(result.contains("neutral")); + assert!(result.contains("src/components")); + } + + #[test] + fn shows_none_when_no_components_installed() { + let result = format_info(&data("neutral", "src/components", &[], no_workspace())); + assert!(result.contains("none")); + } + + #[test] + fn shows_installed_components_sorted() { + let result = format_info(&data("neutral", "src/components", &["card", "button", "badge"], no_workspace())); + assert!(result.contains("badge, button, card")); + } + + #[test] + fn shows_installed_count() { + let result = format_info(&data("neutral", "src/components", &["button", "badge"], no_workspace())); + assert!(result.contains("(2)")); + } + + #[test] + fn shows_workspace_no_when_single_crate() { + let result = format_info(&data("neutral", "src/components", &[], single_crate_workspace())); + assert!(result.contains("no")); + } + + #[test] + fn shows_workspace_yes_when_in_workspace() { + let result = format_info(&data("neutral", "src/components", &[], full_workspace())); + assert!(result.contains("yes")); + assert!(result.contains("frontend")); + } + + #[test] + fn shows_target_crate_when_available() { + let result = format_info(&data("neutral", "src/components", &[], single_crate_workspace())); + assert!(result.contains("my-app")); + } + + #[test] + fn no_workspace_info_omits_workspace_line() { + let result = format_info(&data("neutral", "src/components", &[], no_workspace())); + assert!(!result.contains("Workspace")); + assert!(!result.contains("Target crate")); + } + + #[test] + fn single_installed_component() { + let result = format_info(&data("neutral", "src/components", &["button"], no_workspace())); + assert!(result.contains("(1)")); + assert!(result.contains("button")); + } + + // --- format_info_json --- + + #[test] + fn json_output_is_valid_json() { + let d = data("neutral", "src/components", &["button"], no_workspace()); + let json = format_info_json(&d).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert!(parsed.is_object()); + } + + #[test] + fn json_contains_all_fields() { + let d = data("neutral", "src/components", &["button"], no_workspace()); + let json = format_info_json(&d).unwrap(); + assert!(json.contains("base_color")); + assert!(json.contains("base_path")); + assert!(json.contains("config_file")); + assert!(json.contains("installed")); + } + + #[test] + fn json_installed_is_array() { + let d = data("neutral", "src/components", &["badge", "button"], no_workspace()); + let json = format_info_json(&d).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert!(parsed["installed"].is_array()); + assert_eq!(parsed["installed"].as_array().unwrap().len(), 2); + } + + #[test] + fn json_workspace_null_when_no_workspace() { + let d = data("neutral", "src/components", &[], no_workspace()); + let json = format_info_json(&d).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert!(parsed["workspace"].is_null()); + } + + #[test] + fn json_workspace_true_when_in_workspace() { + let d = data("neutral", "src/components", &[], full_workspace()); + let json = format_info_json(&d).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["workspace"], true); + } + + #[test] + fn json_installed_sorted() { + let d = data("neutral", "src/components", &["card", "alert", "badge"], no_workspace()); + let json = format_info_json(&d).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + let names: Vec<&str> = parsed["installed"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect(); + assert_eq!(names, vec!["alert", "badge", "card"]); + } +} diff --git a/src/command_info/mod.rs b/src/command_info/mod.rs new file mode 100644 index 0000000..42193a8 --- /dev/null +++ b/src/command_info/mod.rs @@ -0,0 +1 @@ +pub mod _info; diff --git a/src/command_init/_init.rs b/src/command_init/_init.rs index f367b79..bfb7182 100644 --- a/src/command_init/_init.rs +++ b/src/command_init/_init.rs @@ -1,75 +1,428 @@ +use std::fs::{self, File}; +use std::io::{self, Write}; +use std::path::Path; + use clap::{Arg, Command}; -use indicatif::ProgressBar; -use std::time::Duration; +use dialoguer::theme::ColorfulTheme; +use dialoguer::{Confirm, Select}; + +const UI_CONFIG_TOML: &str = "ui_config.toml"; +const PACKAGE_JSON: &str = "package.json"; -use super::config::{add_init_dependencies, AppConfig}; -use super::{install::Install, user_input::UserInput}; -use crate::constants::commands::{COMMAND, INIT}; -use crate::constants::file_name::FILE_NAME; -use crate::constants::template::TEMPLATE; -use crate::constants::{others::SPINNER_UPDATE_DURATION, paths::RELATIVE_PATH_PROJECT_DIR}; -use crate::shared::shared_write_template_file::shared_write_template_file; +use super::backup::FileBackup; +use super::colors::{AccentColor, BaseColor}; +use super::config::{UiConfig, add_init_crates}; +use super::install::InstallType; +use super::workspace_utils::{check_leptos_dependency, get_tailwind_input_file}; +use crate::command_add::installed::get_installed_components; +use crate::command_init::install::install_dependencies; +use crate::command_init::template::MyTemplate; +use crate::shared::cli_error::{CliError, CliResult}; +use crate::shared::task_spinner::TaskSpinner; + +/// Returned by `process_init`. Non-empty `to_reinstall` means the caller +/// should re-download those components (e.g. via `process_add_components`). +pub struct InitOutcome { + pub to_reinstall: Vec, + pub base_path: String, +} -/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ -/* 🦀 MAIN 🦀 */ -/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ +/* ========================================================== */ +/* 🦀 MAIN 🦀 */ +/* ========================================================== */ pub fn command_init() -> Command { - Command::new(COMMAND::INIT) - .about(INIT::ABOUT) - .arg(Arg::new(INIT::PROJECT_NAME).help(INIT::HELP).required(false)) + Command::new("init") + .about("Initialize the project") + .arg(Arg::new("project_name").help("The name of the project to initialize").required(false)) + .arg( + Arg::new("yes") + .short('y') + .long("yes") + .help("Skip confirmation prompts and accept defaults") + .action(clap::ArgAction::SetTrue), + ) + .arg( + Arg::new("force") + .short('f') + .long("force") + .help("Force overwrite existing files without prompting") + .action(clap::ArgAction::SetTrue), + ) + .arg( + Arg::new("reinstall") + .long("reinstall") + .help("Re-download and overwrite all already-installed components") + .action(clap::ArgAction::SetTrue), + ) .subcommand(Command::new("run").about("Run the initialization logic")) } -/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ +/* ========================================================== */ /* ✨ FUNCTIONS ✨ */ -/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ +/* ========================================================== */ + +/// Run project initialisation. +/// +/// - `force` – overwrite existing files without prompting (`--yes` / `--force`) +/// - `reinstall` – `Some(true)` = always reinstall components, `Some(false)` = never, +/// `None` = prompt when existing components are detected +pub async fn process_init(force: bool, reinstall: Option) -> CliResult { + // Check if Leptos is installed before proceeding + if !check_leptos_dependency()? { + return Err(CliError::config( + "Leptos dependency not found in Cargo.toml. Please install Leptos first.", + )); + } + + // Get tailwind input file from Cargo.toml metadata + let tailwind_input_file = get_tailwind_input_file()?; + + // Read the existing config (if any) so we can detect installed components + // and derive the base_path *before* we overwrite ui_config.toml. + let existing_config = UiConfig::try_reading_ui_config(UI_CONFIG_TOML).ok(); + let base_path = existing_config + .as_ref() + .map(|c| c.base_path_components.clone()) + .unwrap_or_else(|| "src/components".to_string()); + + // Detect components installed in the current project (empty on first run) + let installed: Vec = get_installed_components(&base_path).into_iter().collect(); + + // Prompt for base + accent colors (or use defaults when --yes/--force) + let (base_color, accent_color) = if force { + (BaseColor::default(), AccentColor::default()) + } else { + (prompt_base_color()?, prompt_accent_color()?) + }; + + // Back up ui_config.toml — restored automatically on Drop if we error out + let mut config_backup = FileBackup::new(Path::new(UI_CONFIG_TOML)) + .map_err(|e| CliError::file_operation(&e.to_string()))?; + + let ui_config = UiConfig { + base_color: base_color.label().to_lowercase(), + color_theme: accent_color.label().to_lowercase(), + ..UiConfig::default() + }; + let ui_config_toml = toml::to_string_pretty(&ui_config)?; + + // ui_config.toml - always write (config file) + write_template_file(UI_CONFIG_TOML, &ui_config_toml).await?; + + // package.json - merge with existing to preserve user dependencies + merge_package_json(PACKAGE_JSON, MyTemplate::PACKAGE_JSON).await?; + + // tailwind.css - ask before overwriting if exists (skipped when --yes or --force) + let css = MyTemplate::build_css(base_color, accent_color); + write_template_with_confirmation(&tailwind_input_file, &css, force).await?; + + add_init_crates().await?; + + install_dependencies(&[InstallType::Tailwind]).await?; -#[allow(dead_code)] -pub async fn init_project() { - process_init().await; + // All writes succeeded — disarm the backup + if let Some(ref mut backup) = config_backup { + backup.disarm(); + } + + // Determine which components to reinstall + let to_reinstall = if installed.is_empty() { + vec![] + } else { + let should_reinstall = match reinstall { + Some(v) => v, + None if force => true, + None => { + Confirm::with_theme(&ColorfulTheme::default()) + .with_prompt(format!( + "{} existing component(s) found. Re-install them?", + installed.len() + )) + .default(false) + .interact() + .map_err(|e| CliError::validation(&e.to_string()))? + } + }; + if should_reinstall { installed } else { vec![] } + }; + + Ok(InitOutcome { to_reinstall, base_path }) } -/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ +/* ========================================================== */ /* ✨ FUNCTIONS ✨ */ -/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ +/* ========================================================== */ -pub async fn process_init() { - // Create app_config.toml file with default values in it - let app_config = AppConfig::default(); +fn prompt_base_color() -> CliResult { + let labels = BaseColor::all_labels(); + let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt("Base color") + .default(0) + .items(&labels) + .interact() + .map_err(|e| CliError::validation(&e.to_string()))?; + Ok(BaseColor::from_index(selection)) +} - INIT_TEMPLATE_FILE(FILE_NAME::APP_CONFIG_TOML, &toml::to_string_pretty(&app_config).unwrap()).await; - INIT_TEMPLATE_FILE(FILE_NAME::PACKAGE_JSON, TEMPLATE::PACKAGE_JSON).await; - INIT_TEMPLATE_FILE(&app_config.tailwind_input_file, TEMPLATE::STYLE_TAILWIND_CSS).await; - INIT_TEMPLATE_FILE(FILE_NAME::TAILWIND_CONFIG_JS, TEMPLATE::TAILWIND_CONFIG).await; +fn prompt_accent_color() -> CliResult { + let labels = AccentColor::all_labels(); + let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt("Accent color") + .default(0) + .items(&labels) + .interact() + .map_err(|e| CliError::validation(&e.to_string()))?; + Ok(AccentColor::from_index(selection)) +} - add_init_dependencies().await; +/// Write template file (always writes, no confirmation) +async fn write_template_file(file_name: &str, template: &str) -> CliResult<()> { + let file_path = Path::new(".").join(file_name); + let spinner = TaskSpinner::new(&format!("Writing {file_name}...")); - UserInput::handle_index_styles().await; + write_file_content(&file_path, template)?; - Install::tailwind_with_pnpm().await; + spinner.finish_success(&format!("{file_name} written.")); + Ok(()) } -// -/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ -/* ✨ FUNCTIONS ✨ */ -/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ +/// Merge package.json with existing file to preserve user dependencies +async fn merge_package_json(file_name: &str, template: &str) -> CliResult<()> { + let file_path = Path::new(".").join(file_name); + let file_exists = file_path.exists(); + let spinner = TaskSpinner::new(&format!("Writing {file_name}...")); + + let content = if file_exists { + let existing_content = fs::read_to_string(&file_path)?; + merge_json_objects(&existing_content, template)? + } else { + template.to_string() + }; + + write_file_content(&file_path, &content)?; + + let action = if file_exists { "merged" } else { "written" }; + spinner.finish_success(&format!("{file_name} {action}.")); + Ok(()) +} + +/// Write template file with confirmation if file already exists. +/// When `force` is true, overwrites without prompting. +async fn write_template_with_confirmation( + file_name: &str, + template: &str, + force: bool, +) -> CliResult<()> { + let file_path = Path::new(".").join(file_name); + + if file_path.exists() && !force { + let should_overwrite = Confirm::with_theme(&ColorfulTheme::default()) + .with_prompt(format!("{file_name} already exists. Overwrite?")) + .default(false) + .interact() + .map_err(|err| CliError::validation(&format!("Failed to get user input: {err}")))?; + + if !should_overwrite { + println!("⏭️ Skipping {file_name}"); + return Ok(()); + } + } + + let spinner = TaskSpinner::new(&format!("Writing {file_name}...")); + write_file_content(&file_path, template)?; + spinner.finish_success(&format!("{file_name} written.")); + Ok(()) +} + +/* ========================================================== */ +/* ✨ HELPERS ✨ */ +/* ========================================================== */ + +/// Write content to a file, creating parent directories if needed +fn write_file_content(file_path: &Path, content: &str) -> io::Result<()> { + // Create the directory if it doesn't exist + if let Some(dir) = file_path.parent() { + fs::create_dir_all(dir)?; + } + + let mut file = File::create(file_path)?; + file.write_all(content.as_bytes())?; + Ok(()) +} + +/// Merge JSON objects: template values are added to existing, preserving existing fields +fn merge_json_objects(existing: &str, template: &str) -> CliResult { + let mut existing_json: serde_json::Value = serde_json::from_str(existing) + .map_err(|err| CliError::file_operation(&format!("Failed to parse existing JSON: {err}")))?; + + let template_json: serde_json::Value = serde_json::from_str(template) + .map_err(|err| CliError::file_operation(&format!("Failed to parse template JSON: {err}")))?; + + if let (Some(existing_obj), Some(template_obj)) = + (existing_json.as_object_mut(), template_json.as_object()) + { + for (key, value) in template_obj { + existing_obj.insert(key.clone(), value.clone()); + } + } + + serde_json::to_string_pretty(&existing_json) + .map_err(|err| CliError::file_operation(&format!("Failed to serialize JSON: {err}"))) +} + +/* ========================================================== */ +/* 🧪 TESTS 🧪 */ +/* ========================================================== */ + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + + // --- command_init flags --- + + #[test] + fn command_init_yes_flag_is_registered() { + let m = command_init().try_get_matches_from(["init", "--yes"]).unwrap(); + assert!(m.get_flag("yes")); + assert!(!m.get_flag("force")); + } + + #[test] + fn command_init_yes_short_flag_is_registered() { + let m = command_init().try_get_matches_from(["init", "-y"]).unwrap(); + assert!(m.get_flag("yes")); + } + + #[test] + fn command_init_force_flag_is_registered() { + let m = command_init().try_get_matches_from(["init", "--force"]).unwrap(); + assert!(m.get_flag("force")); + assert!(!m.get_flag("yes")); + } + + #[test] + fn command_init_force_short_flag_is_registered() { + let m = command_init().try_get_matches_from(["init", "-f"]).unwrap(); + assert!(m.get_flag("force")); + } + + #[test] + fn command_init_both_flags_can_be_combined() { + let m = command_init().try_get_matches_from(["init", "--yes", "--force"]).unwrap(); + assert!(m.get_flag("yes")); + assert!(m.get_flag("force")); + } + + #[test] + fn command_init_reinstall_flag_is_registered() { + let m = command_init().try_get_matches_from(["init", "--reinstall"]).unwrap(); + assert!(m.get_flag("reinstall")); + } + + #[test] + fn command_init_reinstall_is_false_by_default() { + let m = command_init().try_get_matches_from(["init"]).unwrap(); + assert!(!m.get_flag("reinstall")); + } + + #[test] + fn test_merge_json_preserves_existing_dependencies() { + let existing = r#"{ + "name": "my-app", + "dependencies": { + "axios": "^1.0.0", + "react": "^18.0.0" + } +}"#; + let template = r#"{"type": "module"}"#; + + let result = merge_json_objects(existing, template).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + + // Template field added + assert_eq!(parsed["type"], "module"); + // Existing fields preserved + assert_eq!(parsed["name"], "my-app"); + assert_eq!(parsed["dependencies"]["axios"], "^1.0.0"); + assert_eq!(parsed["dependencies"]["react"], "^18.0.0"); + } + + #[test] + fn test_merge_json_template_takes_precedence() { + let existing = r#"{"type": "commonjs", "name": "app"}"#; + let template = r#"{"type": "module"}"#; + + let result = merge_json_objects(existing, template).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + + // Template value overwrites existing + assert_eq!(parsed["type"], "module"); + // Other existing fields preserved + assert_eq!(parsed["name"], "app"); + } + + #[test] + fn test_merge_json_empty_existing() { + let existing = r#"{}"#; + let template = r#"{"type": "module"}"#; + + let result = merge_json_objects(existing, template).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + + assert_eq!(parsed["type"], "module"); + } + + #[test] + fn test_merge_json_complex_existing() { + let existing = r#"{ + "name": "my-leptos-app", + "private": true, + "scripts": { + "dev": "trunk serve" + }, + "devDependencies": { + "tailwindcss": "^4.0.0", + "tw-animate-css": "^1.0.0" + } +}"#; + let template = r#"{"type": "module"}"#; + + let result = merge_json_objects(existing, template).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + + // Template field added + assert_eq!(parsed["type"], "module"); + // All existing fields preserved + assert_eq!(parsed["name"], "my-leptos-app"); + assert_eq!(parsed["private"], true); + assert_eq!(parsed["scripts"]["dev"], "trunk serve"); + assert_eq!(parsed["devDependencies"]["tailwindcss"], "^4.0.0"); + } + + #[test] + fn test_write_file_content_creates_directories() { + let temp = TempDir::new().unwrap(); + let file_path = temp.path().join("nested").join("dir").join("file.txt"); -/// INIT TEMPLATE FILE -#[allow(non_snake_case)] -async fn INIT_TEMPLATE_FILE(file_name: &str, template: &str) { - let file_path = format!("{}/{}", RELATIVE_PATH_PROJECT_DIR, file_name); + write_file_content(&file_path, "test content").unwrap(); - // if !shared_check_file_exist_and_ask_overwrite(&file_path, file_name_ext).await { - // return; - // } + assert!(file_path.exists()); + let content = fs::read_to_string(&file_path).unwrap(); + assert_eq!(content, "test content"); + } - let spinner: ProgressBar = ProgressBar::new_spinner(); - spinner.set_message("Writing to file..."); - spinner.enable_steady_tick(Duration::from_millis(SPINNER_UPDATE_DURATION)); + #[test] + fn test_write_file_content_overwrites() { + let temp = TempDir::new().unwrap(); + let file_path = temp.path().join("file.txt"); - let _ = shared_write_template_file(&file_path, &spinner, template).await; + write_file_content(&file_path, "first").unwrap(); + write_file_content(&file_path, "second").unwrap(); - let finish_message = format!("✔️ Writing {} complete.", file_name); - spinner.finish_with_message(finish_message); + let content = fs::read_to_string(&file_path).unwrap(); + assert_eq!(content, "second"); + } } diff --git a/src/command_init/backup.rs b/src/command_init/backup.rs new file mode 100644 index 0000000..6111bfc --- /dev/null +++ b/src/command_init/backup.rs @@ -0,0 +1,134 @@ +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +/// RAII backup guard. Copies `path` to `path.bak` on construction. +/// +/// If the guard is dropped while still armed (i.e. the operation failed), +/// the original file is restored from the backup. Call `disarm()` on success +/// to delete the backup and prevent any restore. +pub struct FileBackup { + original: PathBuf, + backup: PathBuf, + armed: bool, +} + +impl FileBackup { + /// Back up `path` to `.bak`. + /// Returns `None` if the file does not exist (nothing to back up). + pub fn new(path: &Path) -> io::Result> { + if !path.exists() { + return Ok(None); + } + let backup = PathBuf::from(format!("{}.bak", path.display())); + fs::copy(path, &backup)?; + Ok(Some(Self { original: path.to_path_buf(), backup, armed: true })) + } + + /// Disarm the guard: delete the backup file and prevent restore on drop. + /// Call this after a successful write. + pub fn disarm(&mut self) { + self.armed = false; + let _ = fs::remove_file(&self.backup); + } + + /// Path of the backup file (`.bak`). + #[cfg(test)] + pub fn backup_path(&self) -> &Path { + &self.backup + } +} + +impl Drop for FileBackup { + fn drop(&mut self) { + if self.armed { + let _ = fs::copy(&self.backup, &self.original); + let _ = fs::remove_file(&self.backup); + } + } +} + +/* ========================================================== */ +/* 🧪 TESTS 🧪 */ +/* ========================================================== */ + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn write(dir: &TempDir, name: &str, content: &str) -> PathBuf { + let p = dir.path().join(name); + fs::write(&p, content).unwrap(); + p + } + + #[test] + fn returns_none_when_file_does_not_exist() { + let dir = TempDir::new().unwrap(); + let result = FileBackup::new(&dir.path().join("missing.toml")).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn creates_bak_file_when_original_exists() { + let dir = TempDir::new().unwrap(); + let original = write(&dir, "config.toml", "original"); + let guard = FileBackup::new(&original).unwrap().unwrap(); + assert!(guard.backup_path().exists()); + } + + #[test] + fn disarm_deletes_backup_file() { + let dir = TempDir::new().unwrap(); + let original = write(&dir, "config.toml", "original"); + let mut guard = FileBackup::new(&original).unwrap().unwrap(); + let backup_path = guard.backup_path().to_path_buf(); + guard.disarm(); + assert!(!backup_path.exists()); + } + + #[test] + fn disarmed_guard_does_not_restore_on_drop() { + let dir = TempDir::new().unwrap(); + let original = write(&dir, "config.toml", "original"); + let mut guard = FileBackup::new(&original).unwrap().unwrap(); + guard.disarm(); + fs::write(&original, "modified").unwrap(); + drop(guard); + let content = fs::read_to_string(&original).unwrap(); + assert_eq!(content, "modified"); + } + + #[test] + fn armed_drop_restores_original_content() { + let dir = TempDir::new().unwrap(); + let original = write(&dir, "config.toml", "original"); + let guard = FileBackup::new(&original).unwrap().unwrap(); + // Simulate a failed write — overwrite the file then drop (still armed) + fs::write(&original, "corrupted").unwrap(); + drop(guard); + let content = fs::read_to_string(&original).unwrap(); + assert_eq!(content, "original"); + } + + #[test] + fn armed_drop_removes_backup_file_after_restore() { + let dir = TempDir::new().unwrap(); + let original = write(&dir, "config.toml", "original"); + let guard = FileBackup::new(&original).unwrap().unwrap(); + let backup_path = guard.backup_path().to_path_buf(); + drop(guard); + assert!(!backup_path.exists()); + } + + #[test] + fn backup_content_matches_original() { + let dir = TempDir::new().unwrap(); + let original = write(&dir, "config.toml", "important data"); + let guard = FileBackup::new(&original).unwrap().unwrap(); + let backup_content = fs::read_to_string(guard.backup_path()).unwrap(); + assert_eq!(backup_content, "important data"); + } +} diff --git a/src/command_init/colors.rs b/src/command_init/colors.rs new file mode 100644 index 0000000..4633492 --- /dev/null +++ b/src/command_init/colors.rs @@ -0,0 +1,1083 @@ +/// Base color (gray scale) — controls background/foreground/border/muted/accent vars. +/// OKLCH values mirror the /create page's theme_picker.rs. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum BaseColor { + #[default] + Neutral, + Stone, + Zinc, + Mauve, + Olive, + Mist, + Taupe, +} + +impl BaseColor { + pub const ALL: &'static [BaseColor] = &[ + BaseColor::Neutral, + BaseColor::Stone, + BaseColor::Zinc, + BaseColor::Mauve, + BaseColor::Olive, + BaseColor::Mist, + BaseColor::Taupe, + ]; + + pub fn label(&self) -> &'static str { + match self { + BaseColor::Neutral => "Neutral", + BaseColor::Stone => "Stone", + BaseColor::Zinc => "Zinc", + BaseColor::Mauve => "Mauve", + BaseColor::Olive => "Olive", + BaseColor::Mist => "Mist", + BaseColor::Taupe => "Taupe", + } + } + + pub fn all_labels() -> Vec<&'static str> { + Self::ALL.iter().map(|c| c.label()).collect() + } + + #[allow(dead_code)] + pub fn from_str(s: &str) -> Option { + Self::ALL.iter().copied().find(|c| c.label().eq_ignore_ascii_case(s)) + } + + pub fn from_index(idx: usize) -> Self { + Self::ALL.get(idx).copied().unwrap_or_default() + } + + pub fn light_vars(&self) -> &'static [(&'static str, &'static str)] { + match self { + BaseColor::Neutral => NEUTRAL_LIGHT, + BaseColor::Stone => STONE_LIGHT, + BaseColor::Zinc => ZINC_LIGHT, + BaseColor::Mauve => MAUVE_LIGHT, + BaseColor::Olive => OLIVE_LIGHT, + BaseColor::Mist => MIST_LIGHT, + BaseColor::Taupe => TAUPE_LIGHT, + } + } + + pub fn dark_vars(&self) -> &'static [(&'static str, &'static str)] { + match self { + BaseColor::Neutral => NEUTRAL_DARK, + BaseColor::Stone => STONE_DARK, + BaseColor::Zinc => ZINC_DARK, + BaseColor::Mauve => MAUVE_DARK, + BaseColor::Olive => OLIVE_DARK, + BaseColor::Mist => MIST_DARK, + BaseColor::Taupe => TAUPE_DARK, + } + } +} + +/// Accent color — overrides primary/secondary/chart/sidebar vars on top of the base. +/// OKLCH values mirror the /create page's color_theme_picker.rs. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum AccentColor { + #[default] + Default, + Amber, + Blue, + Cyan, + Emerald, + Fuchsia, + Green, + Indigo, + Lime, + Orange, + Pink, + Purple, + Red, + Rose, + Sky, + Teal, + Violet, + Yellow, +} + +impl AccentColor { + pub const ALL: &'static [AccentColor] = &[ + AccentColor::Default, + AccentColor::Amber, + AccentColor::Blue, + AccentColor::Cyan, + AccentColor::Emerald, + AccentColor::Fuchsia, + AccentColor::Green, + AccentColor::Indigo, + AccentColor::Lime, + AccentColor::Orange, + AccentColor::Pink, + AccentColor::Purple, + AccentColor::Red, + AccentColor::Rose, + AccentColor::Sky, + AccentColor::Teal, + AccentColor::Violet, + AccentColor::Yellow, + ]; + + pub fn label(&self) -> &'static str { + match self { + AccentColor::Default => "Default", + AccentColor::Amber => "Amber", + AccentColor::Blue => "Blue", + AccentColor::Cyan => "Cyan", + AccentColor::Emerald => "Emerald", + AccentColor::Fuchsia => "Fuchsia", + AccentColor::Green => "Green", + AccentColor::Indigo => "Indigo", + AccentColor::Lime => "Lime", + AccentColor::Orange => "Orange", + AccentColor::Pink => "Pink", + AccentColor::Purple => "Purple", + AccentColor::Red => "Red", + AccentColor::Rose => "Rose", + AccentColor::Sky => "Sky", + AccentColor::Teal => "Teal", + AccentColor::Violet => "Violet", + AccentColor::Yellow => "Yellow", + } + } + + pub fn all_labels() -> Vec<&'static str> { + Self::ALL.iter().map(|c| c.label()).collect() + } + + #[allow(dead_code)] + pub fn from_str(s: &str) -> Option { + Self::ALL.iter().copied().find(|c| c.label().eq_ignore_ascii_case(s)) + } + + pub fn from_index(idx: usize) -> Self { + Self::ALL.get(idx).copied().unwrap_or_default() + } + + pub fn light_vars(&self) -> &'static [(&'static str, &'static str)] { + match self { + AccentColor::Default => &[], + AccentColor::Amber => AMBER_LIGHT, + AccentColor::Blue => BLUE_LIGHT, + AccentColor::Cyan => CYAN_LIGHT, + AccentColor::Emerald => EMERALD_LIGHT, + AccentColor::Fuchsia => FUCHSIA_LIGHT, + AccentColor::Green => GREEN_LIGHT, + AccentColor::Indigo => INDIGO_LIGHT, + AccentColor::Lime => LIME_LIGHT, + AccentColor::Orange => ORANGE_LIGHT, + AccentColor::Pink => PINK_LIGHT, + AccentColor::Purple => PURPLE_LIGHT, + AccentColor::Red => RED_LIGHT, + AccentColor::Rose => ROSE_LIGHT, + AccentColor::Sky => SKY_LIGHT, + AccentColor::Teal => TEAL_LIGHT, + AccentColor::Violet => VIOLET_LIGHT, + AccentColor::Yellow => YELLOW_LIGHT, + } + } + + pub fn dark_vars(&self) -> &'static [(&'static str, &'static str)] { + match self { + AccentColor::Default => &[], + AccentColor::Amber => AMBER_DARK, + AccentColor::Blue => BLUE_DARK, + AccentColor::Cyan => CYAN_DARK, + AccentColor::Emerald => EMERALD_DARK, + AccentColor::Fuchsia => FUCHSIA_DARK, + AccentColor::Green => GREEN_DARK, + AccentColor::Indigo => INDIGO_DARK, + AccentColor::Lime => LIME_DARK, + AccentColor::Orange => ORANGE_DARK, + AccentColor::Pink => PINK_DARK, + AccentColor::Purple => PURPLE_DARK, + AccentColor::Red => RED_DARK, + AccentColor::Rose => ROSE_DARK, + AccentColor::Sky => SKY_DARK, + AccentColor::Teal => TEAL_DARK, + AccentColor::Violet => VIOLET_DARK, + AccentColor::Yellow => YELLOW_DARK, + } + } +} + +/* ========================================================== */ +/* ✨ CSS GENERATION ✨ */ +/* ========================================================== */ + +/// Build the :root + .dark CSS variable blocks from a base color + accent overlay. +/// The accent vars are written after base vars, overriding primary/secondary/chart/sidebar. +pub fn generate_theme_vars(base: BaseColor, accent: AccentColor) -> String { + let mut out = String::from(":root {\n --radius: 0.625rem;\n"); + + for (k, v) in base.light_vars() { + out.push_str(&format!(" {k}: {v};\n")); + } + for (k, v) in accent.light_vars() { + out.push_str(&format!(" {k}: {v};\n")); + } + out.push_str(" --destructive: oklch(0.577 0.245 27.325);\n"); + out.push_str("}\n"); + + out.push_str("\n.dark {\n"); + for (k, v) in base.dark_vars() { + out.push_str(&format!(" {k}: {v};\n")); + } + for (k, v) in accent.dark_vars() { + out.push_str(&format!(" {k}: {v};\n")); + } + out.push_str(" --destructive: oklch(0.704 0.191 22.216);\n"); + out.push_str("}\n"); + + out +} + +/* ========================================================== */ +/* ✨ BASE COLOR DATA ✨ */ +/* ========================================================== */ + +// Exact OKLCH values from shadcn/ui v4 registry/themes.ts (mirrored from /create page) + +static NEUTRAL_LIGHT: &[(&str, &str)] = &[ + ("--background", "oklch(1 0 0)"), + ("--foreground", "oklch(0.145 0 0)"), + ("--card", "oklch(1 0 0)"), + ("--card-foreground", "oklch(0.145 0 0)"), + ("--popover", "oklch(1 0 0)"), + ("--popover-foreground", "oklch(0.145 0 0)"), + ("--primary", "oklch(0.205 0 0)"), + ("--primary-foreground", "oklch(0.985 0 0)"), + ("--secondary", "oklch(0.97 0 0)"), + ("--secondary-foreground", "oklch(0.205 0 0)"), + ("--muted", "oklch(0.97 0 0)"), + ("--muted-foreground", "oklch(0.556 0 0)"), + ("--accent", "oklch(0.97 0 0)"), + ("--accent-foreground", "oklch(0.205 0 0)"), + ("--border", "oklch(0.922 0 0)"), + ("--input", "oklch(0.922 0 0)"), + ("--ring", "oklch(0.708 0 0)"), +]; + +static NEUTRAL_DARK: &[(&str, &str)] = &[ + ("--background", "oklch(0.145 0 0)"), + ("--foreground", "oklch(0.985 0 0)"), + ("--card", "oklch(0.205 0 0)"), + ("--card-foreground", "oklch(0.985 0 0)"), + ("--popover", "oklch(0.205 0 0)"), + ("--popover-foreground", "oklch(0.985 0 0)"), + ("--primary", "oklch(0.87 0.00 0)"), + ("--primary-foreground", "oklch(0.205 0 0)"), + ("--secondary", "oklch(0.269 0 0)"), + ("--secondary-foreground", "oklch(0.985 0 0)"), + ("--muted", "oklch(0.269 0 0)"), + ("--muted-foreground", "oklch(0.708 0 0)"), + ("--accent", "oklch(0.371 0 0)"), + ("--accent-foreground", "oklch(0.985 0 0)"), + ("--border", "oklch(1 0 0 / 10%)"), + ("--input", "oklch(1 0 0 / 15%)"), + ("--ring", "oklch(0.556 0 0)"), +]; + +static STONE_LIGHT: &[(&str, &str)] = &[ + ("--background", "oklch(1 0 0)"), + ("--foreground", "oklch(0.147 0.004 49.25)"), + ("--card", "oklch(1 0 0)"), + ("--card-foreground", "oklch(0.147 0.004 49.25)"), + ("--popover", "oklch(1 0 0)"), + ("--popover-foreground", "oklch(0.147 0.004 49.25)"), + ("--primary", "oklch(0.216 0.006 56.043)"), + ("--primary-foreground", "oklch(0.985 0.001 106.423)"), + ("--secondary", "oklch(0.97 0.001 106.424)"), + ("--secondary-foreground", "oklch(0.216 0.006 56.043)"), + ("--muted", "oklch(0.97 0.001 106.424)"), + ("--muted-foreground", "oklch(0.553 0.013 58.071)"), + ("--accent", "oklch(0.97 0.001 106.424)"), + ("--accent-foreground", "oklch(0.216 0.006 56.043)"), + ("--border", "oklch(0.923 0.003 48.717)"), + ("--input", "oklch(0.923 0.003 48.717)"), + ("--ring", "oklch(0.709 0.01 56.259)"), +]; + +static STONE_DARK: &[(&str, &str)] = &[ + ("--background", "oklch(0.147 0.004 49.25)"), + ("--foreground", "oklch(0.985 0.001 106.423)"), + ("--card", "oklch(0.216 0.006 56.043)"), + ("--card-foreground", "oklch(0.985 0.001 106.423)"), + ("--popover", "oklch(0.216 0.006 56.043)"), + ("--popover-foreground", "oklch(0.985 0.001 106.423)"), + ("--primary", "oklch(0.923 0.003 48.717)"), + ("--primary-foreground", "oklch(0.216 0.006 56.043)"), + ("--secondary", "oklch(0.268 0.007 34.298)"), + ("--secondary-foreground", "oklch(0.985 0.001 106.423)"), + ("--muted", "oklch(0.268 0.007 34.298)"), + ("--muted-foreground", "oklch(0.709 0.01 56.259)"), + ("--accent", "oklch(0.268 0.007 34.298)"), + ("--accent-foreground", "oklch(0.985 0.001 106.423)"), + ("--border", "oklch(1 0 0 / 10%)"), + ("--input", "oklch(1 0 0 / 15%)"), + ("--ring", "oklch(0.553 0.013 58.071)"), +]; + +static ZINC_LIGHT: &[(&str, &str)] = &[ + ("--background", "oklch(1 0 0)"), + ("--foreground", "oklch(0.141 0.005 285.823)"), + ("--card", "oklch(1 0 0)"), + ("--card-foreground", "oklch(0.141 0.005 285.823)"), + ("--popover", "oklch(1 0 0)"), + ("--popover-foreground", "oklch(0.141 0.005 285.823)"), + ("--primary", "oklch(0.21 0.006 285.885)"), + ("--primary-foreground", "oklch(0.985 0 0)"), + ("--secondary", "oklch(0.967 0.001 286.375)"), + ("--secondary-foreground", "oklch(0.21 0.006 285.885)"), + ("--muted", "oklch(0.967 0.001 286.375)"), + ("--muted-foreground", "oklch(0.552 0.016 285.938)"), + ("--accent", "oklch(0.967 0.001 286.375)"), + ("--accent-foreground", "oklch(0.21 0.006 285.885)"), + ("--border", "oklch(0.92 0.004 286.32)"), + ("--input", "oklch(0.92 0.004 286.32)"), + ("--ring", "oklch(0.705 0.015 286.067)"), +]; + +static ZINC_DARK: &[(&str, &str)] = &[ + ("--background", "oklch(0.141 0.005 285.823)"), + ("--foreground", "oklch(0.985 0 0)"), + ("--card", "oklch(0.21 0.006 285.885)"), + ("--card-foreground", "oklch(0.985 0 0)"), + ("--popover", "oklch(0.21 0.006 285.885)"), + ("--popover-foreground", "oklch(0.985 0 0)"), + ("--primary", "oklch(0.92 0.004 286.32)"), + ("--primary-foreground", "oklch(0.21 0.006 285.885)"), + ("--secondary", "oklch(0.274 0.006 286.033)"), + ("--secondary-foreground", "oklch(0.985 0 0)"), + ("--muted", "oklch(0.274 0.006 286.033)"), + ("--muted-foreground", "oklch(0.705 0.015 286.067)"), + ("--accent", "oklch(0.274 0.006 286.033)"), + ("--accent-foreground", "oklch(0.985 0 0)"), + ("--border", "oklch(1 0 0 / 10%)"), + ("--input", "oklch(1 0 0 / 15%)"), + ("--ring", "oklch(0.552 0.016 285.938)"), +]; + +static MAUVE_LIGHT: &[(&str, &str)] = &[ + ("--background", "oklch(1 0 0)"), + ("--foreground", "oklch(0.145 0.008 326)"), + ("--card", "oklch(1 0 0)"), + ("--card-foreground", "oklch(0.145 0.008 326)"), + ("--popover", "oklch(1 0 0)"), + ("--popover-foreground", "oklch(0.145 0.008 326)"), + ("--primary", "oklch(0.212 0.019 322.12)"), + ("--primary-foreground", "oklch(0.985 0 0)"), + ("--secondary", "oklch(0.96 0.003 325.6)"), + ("--secondary-foreground", "oklch(0.212 0.019 322.12)"), + ("--muted", "oklch(0.96 0.003 325.6)"), + ("--muted-foreground", "oklch(0.542 0.034 322.5)"), + ("--accent", "oklch(0.96 0.003 325.6)"), + ("--accent-foreground", "oklch(0.212 0.019 322.12)"), + ("--border", "oklch(0.922 0.005 325.62)"), + ("--input", "oklch(0.922 0.005 325.62)"), + ("--ring", "oklch(0.711 0.019 323.02)"), +]; + +static MAUVE_DARK: &[(&str, &str)] = &[ + ("--background", "oklch(0.145 0.008 326)"), + ("--foreground", "oklch(0.985 0 0)"), + ("--card", "oklch(0.212 0.019 322.12)"), + ("--card-foreground", "oklch(0.985 0 0)"), + ("--popover", "oklch(0.212 0.019 322.12)"), + ("--popover-foreground", "oklch(0.985 0 0)"), + ("--primary", "oklch(0.922 0.005 325.62)"), + ("--primary-foreground", "oklch(0.212 0.019 322.12)"), + ("--secondary", "oklch(0.263 0.024 320.12)"), + ("--secondary-foreground", "oklch(0.985 0 0)"), + ("--muted", "oklch(0.263 0.024 320.12)"), + ("--muted-foreground", "oklch(0.711 0.019 323.02)"), + ("--accent", "oklch(0.263 0.024 320.12)"), + ("--accent-foreground", "oklch(0.985 0 0)"), + ("--border", "oklch(1 0 0 / 10%)"), + ("--input", "oklch(1 0 0 / 15%)"), + ("--ring", "oklch(0.542 0.034 322.5)"), +]; + +static OLIVE_LIGHT: &[(&str, &str)] = &[ + ("--background", "oklch(1 0 0)"), + ("--foreground", "oklch(0.153 0.006 107.1)"), + ("--card", "oklch(1 0 0)"), + ("--card-foreground", "oklch(0.153 0.006 107.1)"), + ("--popover", "oklch(1 0 0)"), + ("--popover-foreground", "oklch(0.153 0.006 107.1)"), + ("--primary", "oklch(0.228 0.013 107.4)"), + ("--primary-foreground", "oklch(0.988 0.003 106.5)"), + ("--secondary", "oklch(0.966 0.005 106.5)"), + ("--secondary-foreground", "oklch(0.228 0.013 107.4)"), + ("--muted", "oklch(0.966 0.005 106.5)"), + ("--muted-foreground", "oklch(0.58 0.031 107.3)"), + ("--accent", "oklch(0.966 0.005 106.5)"), + ("--accent-foreground", "oklch(0.228 0.013 107.4)"), + ("--border", "oklch(0.93 0.007 106.5)"), + ("--input", "oklch(0.93 0.007 106.5)"), + ("--ring", "oklch(0.737 0.021 106.9)"), +]; + +static OLIVE_DARK: &[(&str, &str)] = &[ + ("--background", "oklch(0.153 0.006 107.1)"), + ("--foreground", "oklch(0.988 0.003 106.5)"), + ("--card", "oklch(0.228 0.013 107.4)"), + ("--card-foreground", "oklch(0.988 0.003 106.5)"), + ("--popover", "oklch(0.228 0.013 107.4)"), + ("--popover-foreground", "oklch(0.988 0.003 106.5)"), + ("--primary", "oklch(0.93 0.007 106.5)"), + ("--primary-foreground", "oklch(0.228 0.013 107.4)"), + ("--secondary", "oklch(0.286 0.016 107.4)"), + ("--secondary-foreground", "oklch(0.988 0.003 106.5)"), + ("--muted", "oklch(0.286 0.016 107.4)"), + ("--muted-foreground", "oklch(0.737 0.021 106.9)"), + ("--accent", "oklch(0.286 0.016 107.4)"), + ("--accent-foreground", "oklch(0.988 0.003 106.5)"), + ("--border", "oklch(1 0 0 / 10%)"), + ("--input", "oklch(1 0 0 / 15%)"), + ("--ring", "oklch(0.58 0.031 107.3)"), +]; + +static MIST_LIGHT: &[(&str, &str)] = &[ + ("--background", "oklch(1 0 0)"), + ("--foreground", "oklch(0.148 0.004 228.8)"), + ("--card", "oklch(1 0 0)"), + ("--card-foreground", "oklch(0.148 0.004 228.8)"), + ("--popover", "oklch(1 0 0)"), + ("--popover-foreground", "oklch(0.148 0.004 228.8)"), + ("--primary", "oklch(0.218 0.008 223.9)"), + ("--primary-foreground", "oklch(0.987 0.002 197.1)"), + ("--secondary", "oklch(0.963 0.002 197.1)"), + ("--secondary-foreground", "oklch(0.218 0.008 223.9)"), + ("--muted", "oklch(0.963 0.002 197.1)"), + ("--muted-foreground", "oklch(0.56 0.021 213.5)"), + ("--accent", "oklch(0.963 0.002 197.1)"), + ("--accent-foreground", "oklch(0.218 0.008 223.9)"), + ("--border", "oklch(0.925 0.005 214.3)"), + ("--input", "oklch(0.925 0.005 214.3)"), + ("--ring", "oklch(0.723 0.014 214.4)"), +]; + +static MIST_DARK: &[(&str, &str)] = &[ + ("--background", "oklch(0.148 0.004 228.8)"), + ("--foreground", "oklch(0.987 0.002 197.1)"), + ("--card", "oklch(0.218 0.008 223.9)"), + ("--card-foreground", "oklch(0.987 0.002 197.1)"), + ("--popover", "oklch(0.218 0.008 223.9)"), + ("--popover-foreground", "oklch(0.987 0.002 197.1)"), + ("--primary", "oklch(0.925 0.005 214.3)"), + ("--primary-foreground", "oklch(0.218 0.008 223.9)"), + ("--secondary", "oklch(0.275 0.011 216.9)"), + ("--secondary-foreground", "oklch(0.987 0.002 197.1)"), + ("--muted", "oklch(0.275 0.011 216.9)"), + ("--muted-foreground", "oklch(0.723 0.014 214.4)"), + ("--accent", "oklch(0.275 0.011 216.9)"), + ("--accent-foreground", "oklch(0.987 0.002 197.1)"), + ("--border", "oklch(1 0 0 / 10%)"), + ("--input", "oklch(1 0 0 / 15%)"), + ("--ring", "oklch(0.56 0.021 213.5)"), +]; + +static TAUPE_LIGHT: &[(&str, &str)] = &[ + ("--background", "oklch(1 0 0)"), + ("--foreground", "oklch(0.147 0.004 49.3)"), + ("--card", "oklch(1 0 0)"), + ("--card-foreground", "oklch(0.147 0.004 49.3)"), + ("--popover", "oklch(1 0 0)"), + ("--popover-foreground", "oklch(0.147 0.004 49.3)"), + ("--primary", "oklch(0.214 0.009 43.1)"), + ("--primary-foreground", "oklch(0.986 0.002 67.8)"), + ("--secondary", "oklch(0.96 0.002 17.2)"), + ("--secondary-foreground", "oklch(0.214 0.009 43.1)"), + ("--muted", "oklch(0.96 0.002 17.2)"), + ("--muted-foreground", "oklch(0.547 0.021 43.1)"), + ("--accent", "oklch(0.96 0.002 17.2)"), + ("--accent-foreground", "oklch(0.214 0.009 43.1)"), + ("--border", "oklch(0.922 0.005 34.3)"), + ("--input", "oklch(0.922 0.005 34.3)"), + ("--ring", "oklch(0.714 0.014 41.2)"), +]; + +static TAUPE_DARK: &[(&str, &str)] = &[ + ("--background", "oklch(0.147 0.004 49.3)"), + ("--foreground", "oklch(0.986 0.002 67.8)"), + ("--card", "oklch(0.214 0.009 43.1)"), + ("--card-foreground", "oklch(0.986 0.002 67.8)"), + ("--popover", "oklch(0.214 0.009 43.1)"), + ("--popover-foreground", "oklch(0.986 0.002 67.8)"), + ("--primary", "oklch(0.922 0.005 34.3)"), + ("--primary-foreground", "oklch(0.214 0.009 43.1)"), + ("--secondary", "oklch(0.268 0.011 36.5)"), + ("--secondary-foreground", "oklch(0.986 0.002 67.8)"), + ("--muted", "oklch(0.268 0.011 36.5)"), + ("--muted-foreground", "oklch(0.714 0.014 41.2)"), + ("--accent", "oklch(0.268 0.011 36.5)"), + ("--accent-foreground", "oklch(0.986 0.002 67.8)"), + ("--border", "oklch(1 0 0 / 10%)"), + ("--input", "oklch(1 0 0 / 15%)"), + ("--ring", "oklch(0.547 0.021 43.1)"), +]; + +/* ========================================================== */ +/* ✨ ACCENT COLOR DATA ✨ */ +/* ========================================================== */ + +// Accent colors only override: primary, primary-foreground, secondary, +// secondary-foreground, chart-1..5, sidebar-primary, sidebar-primary-foreground + +static AMBER_LIGHT: &[(&str, &str)] = &[ + ("--primary", "oklch(0.555 0.163 48.998)"), + ("--primary-foreground", "oklch(0.987 0.022 95.277)"), + ("--secondary", "oklch(0.967 0.001 286.375)"), + ("--secondary-foreground", "oklch(0.21 0.006 285.885)"), + ("--chart-1", "oklch(0.879 0.169 91.605)"), + ("--chart-2", "oklch(0.769 0.188 70.08)"), + ("--chart-3", "oklch(0.666 0.179 58.318)"), + ("--chart-4", "oklch(0.555 0.163 48.998)"), + ("--chart-5", "oklch(0.473 0.137 46.201)"), + ("--sidebar-primary", "oklch(0.666 0.179 58.318)"), + ("--sidebar-primary-foreground", "oklch(0.987 0.022 95.277)"), +]; +static AMBER_DARK: &[(&str, &str)] = &[ + ("--primary", "oklch(0.473 0.137 46.201)"), + ("--primary-foreground", "oklch(0.987 0.022 95.277)"), + ("--secondary", "oklch(0.274 0.006 286.033)"), + ("--secondary-foreground", "oklch(0.985 0 0)"), + ("--chart-1", "oklch(0.879 0.169 91.605)"), + ("--chart-2", "oklch(0.769 0.188 70.08)"), + ("--chart-3", "oklch(0.666 0.179 58.318)"), + ("--chart-4", "oklch(0.555 0.163 48.998)"), + ("--chart-5", "oklch(0.473 0.137 46.201)"), + ("--sidebar-primary", "oklch(0.769 0.188 70.08)"), + ("--sidebar-primary-foreground", "oklch(0.279 0.077 45.635)"), +]; + +static BLUE_LIGHT: &[(&str, &str)] = &[ + ("--primary", "oklch(0.488 0.243 264.376)"), + ("--primary-foreground", "oklch(0.97 0.014 254.604)"), + ("--secondary", "oklch(0.967 0.001 286.375)"), + ("--secondary-foreground", "oklch(0.21 0.006 285.885)"), + ("--chart-1", "oklch(0.809 0.105 251.813)"), + ("--chart-2", "oklch(0.623 0.214 259.815)"), + ("--chart-3", "oklch(0.546 0.245 262.881)"), + ("--chart-4", "oklch(0.488 0.243 264.376)"), + ("--chart-5", "oklch(0.424 0.199 265.638)"), + ("--sidebar-primary", "oklch(0.546 0.245 262.881)"), + ("--sidebar-primary-foreground", "oklch(0.97 0.014 254.604)"), +]; +static BLUE_DARK: &[(&str, &str)] = &[ + ("--primary", "oklch(0.424 0.199 265.638)"), + ("--primary-foreground", "oklch(0.97 0.014 254.604)"), + ("--secondary", "oklch(0.274 0.006 286.033)"), + ("--secondary-foreground", "oklch(0.985 0 0)"), + ("--chart-1", "oklch(0.809 0.105 251.813)"), + ("--chart-2", "oklch(0.623 0.214 259.815)"), + ("--chart-3", "oklch(0.546 0.245 262.881)"), + ("--chart-4", "oklch(0.488 0.243 264.376)"), + ("--chart-5", "oklch(0.424 0.199 265.638)"), + ("--sidebar-primary", "oklch(0.623 0.214 259.815)"), + ("--sidebar-primary-foreground", "oklch(0.97 0.014 254.604)"), +]; + +static CYAN_LIGHT: &[(&str, &str)] = &[ + ("--primary", "oklch(0.52 0.105 223.128)"), + ("--primary-foreground", "oklch(0.984 0.019 200.873)"), + ("--secondary", "oklch(0.967 0.001 286.375)"), + ("--secondary-foreground", "oklch(0.21 0.006 285.885)"), + ("--chart-1", "oklch(0.865 0.127 207.078)"), + ("--chart-2", "oklch(0.715 0.143 215.221)"), + ("--chart-3", "oklch(0.609 0.126 221.723)"), + ("--chart-4", "oklch(0.52 0.105 223.128)"), + ("--chart-5", "oklch(0.45 0.085 224.283)"), + ("--sidebar-primary", "oklch(0.609 0.126 221.723)"), + ("--sidebar-primary-foreground", "oklch(0.984 0.019 200.873)"), +]; +static CYAN_DARK: &[(&str, &str)] = &[ + ("--primary", "oklch(0.45 0.085 224.283)"), + ("--primary-foreground", "oklch(0.984 0.019 200.873)"), + ("--secondary", "oklch(0.274 0.006 286.033)"), + ("--secondary-foreground", "oklch(0.985 0 0)"), + ("--chart-1", "oklch(0.865 0.127 207.078)"), + ("--chart-2", "oklch(0.715 0.143 215.221)"), + ("--chart-3", "oklch(0.609 0.126 221.723)"), + ("--chart-4", "oklch(0.52 0.105 223.128)"), + ("--chart-5", "oklch(0.45 0.085 224.283)"), + ("--sidebar-primary", "oklch(0.715 0.143 215.221)"), + ("--sidebar-primary-foreground", "oklch(0.302 0.056 229.695)"), +]; + +static EMERALD_LIGHT: &[(&str, &str)] = &[ + ("--primary", "oklch(0.508 0.118 165.612)"), + ("--primary-foreground", "oklch(0.979 0.021 166.113)"), + ("--secondary", "oklch(0.967 0.001 286.375)"), + ("--secondary-foreground", "oklch(0.21 0.006 285.885)"), + ("--chart-1", "oklch(0.845 0.143 164.978)"), + ("--chart-2", "oklch(0.696 0.17 162.48)"), + ("--chart-3", "oklch(0.596 0.145 163.225)"), + ("--chart-4", "oklch(0.508 0.118 165.612)"), + ("--chart-5", "oklch(0.432 0.095 166.913)"), + ("--sidebar-primary", "oklch(0.596 0.145 163.225)"), + ("--sidebar-primary-foreground", "oklch(0.979 0.021 166.113)"), +]; +static EMERALD_DARK: &[(&str, &str)] = &[ + ("--primary", "oklch(0.432 0.095 166.913)"), + ("--primary-foreground", "oklch(0.979 0.021 166.113)"), + ("--secondary", "oklch(0.274 0.006 286.033)"), + ("--secondary-foreground", "oklch(0.985 0 0)"), + ("--chart-1", "oklch(0.845 0.143 164.978)"), + ("--chart-2", "oklch(0.696 0.17 162.48)"), + ("--chart-3", "oklch(0.596 0.145 163.225)"), + ("--chart-4", "oklch(0.508 0.118 165.612)"), + ("--chart-5", "oklch(0.432 0.095 166.913)"), + ("--sidebar-primary", "oklch(0.696 0.17 162.48)"), + ("--sidebar-primary-foreground", "oklch(0.262 0.051 172.552)"), +]; + +static FUCHSIA_LIGHT: &[(&str, &str)] = &[ + ("--primary", "oklch(0.518 0.253 323.949)"), + ("--primary-foreground", "oklch(0.977 0.017 320.058)"), + ("--secondary", "oklch(0.967 0.001 286.375)"), + ("--secondary-foreground", "oklch(0.21 0.006 285.885)"), + ("--chart-1", "oklch(0.833 0.145 321.434)"), + ("--chart-2", "oklch(0.667 0.295 322.15)"), + ("--chart-3", "oklch(0.591 0.293 322.896)"), + ("--chart-4", "oklch(0.518 0.253 323.949)"), + ("--chart-5", "oklch(0.452 0.211 324.591)"), + ("--sidebar-primary", "oklch(0.591 0.293 322.896)"), + ("--sidebar-primary-foreground", "oklch(0.977 0.017 320.058)"), +]; +static FUCHSIA_DARK: &[(&str, &str)] = &[ + ("--primary", "oklch(0.452 0.211 324.591)"), + ("--primary-foreground", "oklch(0.977 0.017 320.058)"), + ("--secondary", "oklch(0.274 0.006 286.033)"), + ("--secondary-foreground", "oklch(0.985 0 0)"), + ("--chart-1", "oklch(0.833 0.145 321.434)"), + ("--chart-2", "oklch(0.667 0.295 322.15)"), + ("--chart-3", "oklch(0.591 0.293 322.896)"), + ("--chart-4", "oklch(0.518 0.253 323.949)"), + ("--chart-5", "oklch(0.452 0.211 324.591)"), + ("--sidebar-primary", "oklch(0.667 0.295 322.15)"), + ("--sidebar-primary-foreground", "oklch(0.977 0.017 320.058)"), +]; + +static GREEN_LIGHT: &[(&str, &str)] = &[ + ("--primary", "oklch(0.532 0.157 131.589)"), + ("--primary-foreground", "oklch(0.986 0.031 120.757)"), + ("--secondary", "oklch(0.967 0.001 286.375)"), + ("--secondary-foreground", "oklch(0.21 0.006 285.885)"), + ("--chart-1", "oklch(0.871 0.15 154.449)"), + ("--chart-2", "oklch(0.723 0.219 149.579)"), + ("--chart-3", "oklch(0.627 0.194 149.214)"), + ("--chart-4", "oklch(0.527 0.154 150.069)"), + ("--chart-5", "oklch(0.448 0.119 151.328)"), + ("--sidebar-primary", "oklch(0.648 0.2 131.684)"), + ("--sidebar-primary-foreground", "oklch(0.986 0.031 120.757)"), +]; +static GREEN_DARK: &[(&str, &str)] = &[ + ("--primary", "oklch(0.453 0.124 130.933)"), + ("--primary-foreground", "oklch(0.986 0.031 120.757)"), + ("--secondary", "oklch(0.274 0.006 286.033)"), + ("--secondary-foreground", "oklch(0.985 0 0)"), + ("--chart-1", "oklch(0.871 0.15 154.449)"), + ("--chart-2", "oklch(0.723 0.219 149.579)"), + ("--chart-3", "oklch(0.627 0.194 149.214)"), + ("--chart-4", "oklch(0.527 0.154 150.069)"), + ("--chart-5", "oklch(0.448 0.119 151.328)"), + ("--sidebar-primary", "oklch(0.768 0.233 130.85)"), + ("--sidebar-primary-foreground", "oklch(0.986 0.031 120.757)"), +]; + +static INDIGO_LIGHT: &[(&str, &str)] = &[ + ("--primary", "oklch(0.457 0.24 277.023)"), + ("--primary-foreground", "oklch(0.962 0.018 272.314)"), + ("--secondary", "oklch(0.967 0.001 286.375)"), + ("--secondary-foreground", "oklch(0.21 0.006 285.885)"), + ("--chart-1", "oklch(0.785 0.115 274.713)"), + ("--chart-2", "oklch(0.585 0.233 277.117)"), + ("--chart-3", "oklch(0.511 0.262 276.966)"), + ("--chart-4", "oklch(0.457 0.24 277.023)"), + ("--chart-5", "oklch(0.398 0.195 277.366)"), + ("--sidebar-primary", "oklch(0.511 0.262 276.966)"), + ("--sidebar-primary-foreground", "oklch(0.962 0.018 272.314)"), +]; +static INDIGO_DARK: &[(&str, &str)] = &[ + ("--primary", "oklch(0.398 0.195 277.366)"), + ("--primary-foreground", "oklch(0.962 0.018 272.314)"), + ("--secondary", "oklch(0.274 0.006 286.033)"), + ("--secondary-foreground", "oklch(0.985 0 0)"), + ("--chart-1", "oklch(0.785 0.115 274.713)"), + ("--chart-2", "oklch(0.585 0.233 277.117)"), + ("--chart-3", "oklch(0.511 0.262 276.966)"), + ("--chart-4", "oklch(0.457 0.24 277.023)"), + ("--chart-5", "oklch(0.398 0.195 277.366)"), + ("--sidebar-primary", "oklch(0.585 0.233 277.117)"), + ("--sidebar-primary-foreground", "oklch(0.962 0.018 272.314)"), +]; + +static LIME_LIGHT: &[(&str, &str)] = &[ + ("--primary", "oklch(0.532 0.157 131.589)"), + ("--primary-foreground", "oklch(0.986 0.031 120.757)"), + ("--secondary", "oklch(0.967 0.001 286.375)"), + ("--secondary-foreground", "oklch(0.21 0.006 285.885)"), + ("--chart-1", "oklch(0.897 0.196 126.665)"), + ("--chart-2", "oklch(0.768 0.233 130.85)"), + ("--chart-3", "oklch(0.648 0.2 131.684)"), + ("--chart-4", "oklch(0.532 0.157 131.589)"), + ("--chart-5", "oklch(0.453 0.124 130.933)"), + ("--sidebar-primary", "oklch(0.648 0.2 131.684)"), + ("--sidebar-primary-foreground", "oklch(0.986 0.031 120.757)"), +]; +static LIME_DARK: &[(&str, &str)] = &[ + ("--primary", "oklch(0.453 0.124 130.933)"), + ("--primary-foreground", "oklch(0.986 0.031 120.757)"), + ("--secondary", "oklch(0.274 0.006 286.033)"), + ("--secondary-foreground", "oklch(0.985 0 0)"), + ("--chart-1", "oklch(0.897 0.196 126.665)"), + ("--chart-2", "oklch(0.768 0.233 130.85)"), + ("--chart-3", "oklch(0.648 0.2 131.684)"), + ("--chart-4", "oklch(0.532 0.157 131.589)"), + ("--chart-5", "oklch(0.453 0.124 130.933)"), + ("--sidebar-primary", "oklch(0.768 0.233 130.85)"), + ("--sidebar-primary-foreground", "oklch(0.274 0.072 132.109)"), +]; + +static ORANGE_LIGHT: &[(&str, &str)] = &[ + ("--primary", "oklch(0.553 0.195 38.402)"), + ("--primary-foreground", "oklch(0.98 0.016 73.684)"), + ("--secondary", "oklch(0.967 0.001 286.375)"), + ("--secondary-foreground", "oklch(0.21 0.006 285.885)"), + ("--chart-1", "oklch(0.837 0.128 66.29)"), + ("--chart-2", "oklch(0.705 0.213 47.604)"), + ("--chart-3", "oklch(0.646 0.222 41.116)"), + ("--chart-4", "oklch(0.553 0.195 38.402)"), + ("--chart-5", "oklch(0.47 0.157 37.304)"), + ("--sidebar-primary", "oklch(0.646 0.222 41.116)"), + ("--sidebar-primary-foreground", "oklch(0.98 0.016 73.684)"), +]; +static ORANGE_DARK: &[(&str, &str)] = &[ + ("--primary", "oklch(0.47 0.157 37.304)"), + ("--primary-foreground", "oklch(0.98 0.016 73.684)"), + ("--secondary", "oklch(0.274 0.006 286.033)"), + ("--secondary-foreground", "oklch(0.985 0 0)"), + ("--chart-1", "oklch(0.837 0.128 66.29)"), + ("--chart-2", "oklch(0.705 0.213 47.604)"), + ("--chart-3", "oklch(0.646 0.222 41.116)"), + ("--chart-4", "oklch(0.553 0.195 38.402)"), + ("--chart-5", "oklch(0.47 0.157 37.304)"), + ("--sidebar-primary", "oklch(0.705 0.213 47.604)"), + ("--sidebar-primary-foreground", "oklch(0.98 0.016 73.684)"), +]; + +static PINK_LIGHT: &[(&str, &str)] = &[ + ("--primary", "oklch(0.525 0.223 3.958)"), + ("--primary-foreground", "oklch(0.971 0.014 343.198)"), + ("--secondary", "oklch(0.967 0.001 286.375)"), + ("--secondary-foreground", "oklch(0.21 0.006 285.885)"), + ("--chart-1", "oklch(0.823 0.12 346.018)"), + ("--chart-2", "oklch(0.656 0.241 354.308)"), + ("--chart-3", "oklch(0.592 0.249 0.584)"), + ("--chart-4", "oklch(0.525 0.223 3.958)"), + ("--chart-5", "oklch(0.459 0.187 3.815)"), + ("--sidebar-primary", "oklch(0.592 0.249 0.584)"), + ("--sidebar-primary-foreground", "oklch(0.971 0.014 343.198)"), +]; +static PINK_DARK: &[(&str, &str)] = &[ + ("--primary", "oklch(0.459 0.187 3.815)"), + ("--primary-foreground", "oklch(0.971 0.014 343.198)"), + ("--secondary", "oklch(0.274 0.006 286.033)"), + ("--secondary-foreground", "oklch(0.985 0 0)"), + ("--chart-1", "oklch(0.823 0.12 346.018)"), + ("--chart-2", "oklch(0.656 0.241 354.308)"), + ("--chart-3", "oklch(0.592 0.249 0.584)"), + ("--chart-4", "oklch(0.525 0.223 3.958)"), + ("--chart-5", "oklch(0.459 0.187 3.815)"), + ("--sidebar-primary", "oklch(0.656 0.241 354.308)"), + ("--sidebar-primary-foreground", "oklch(0.971 0.014 343.198)"), +]; + +static PURPLE_LIGHT: &[(&str, &str)] = &[ + ("--primary", "oklch(0.496 0.265 301.924)"), + ("--primary-foreground", "oklch(0.977 0.014 308.299)"), + ("--secondary", "oklch(0.967 0.001 286.375)"), + ("--secondary-foreground", "oklch(0.21 0.006 285.885)"), + ("--chart-1", "oklch(0.827 0.119 306.383)"), + ("--chart-2", "oklch(0.627 0.265 303.9)"), + ("--chart-3", "oklch(0.558 0.288 302.321)"), + ("--chart-4", "oklch(0.496 0.265 301.924)"), + ("--chart-5", "oklch(0.438 0.218 303.724)"), + ("--sidebar-primary", "oklch(0.558 0.288 302.321)"), + ("--sidebar-primary-foreground", "oklch(0.977 0.014 308.299)"), +]; +static PURPLE_DARK: &[(&str, &str)] = &[ + ("--primary", "oklch(0.438 0.218 303.724)"), + ("--primary-foreground", "oklch(0.977 0.014 308.299)"), + ("--secondary", "oklch(0.274 0.006 286.033)"), + ("--secondary-foreground", "oklch(0.985 0 0)"), + ("--chart-1", "oklch(0.827 0.119 306.383)"), + ("--chart-2", "oklch(0.627 0.265 303.9)"), + ("--chart-3", "oklch(0.558 0.288 302.321)"), + ("--chart-4", "oklch(0.496 0.265 301.924)"), + ("--chart-5", "oklch(0.438 0.218 303.724)"), + ("--sidebar-primary", "oklch(0.627 0.265 303.9)"), + ("--sidebar-primary-foreground", "oklch(0.977 0.014 308.299)"), +]; + +static RED_LIGHT: &[(&str, &str)] = &[ + ("--primary", "oklch(0.505 0.213 27.518)"), + ("--primary-foreground", "oklch(0.971 0.013 17.38)"), + ("--secondary", "oklch(0.967 0.001 286.375)"), + ("--secondary-foreground", "oklch(0.21 0.006 285.885)"), + ("--chart-1", "oklch(0.808 0.114 19.571)"), + ("--chart-2", "oklch(0.637 0.237 25.331)"), + ("--chart-3", "oklch(0.577 0.245 27.325)"), + ("--chart-4", "oklch(0.505 0.213 27.518)"), + ("--chart-5", "oklch(0.444 0.177 26.899)"), + ("--sidebar-primary", "oklch(0.577 0.245 27.325)"), + ("--sidebar-primary-foreground", "oklch(0.971 0.013 17.38)"), +]; +static RED_DARK: &[(&str, &str)] = &[ + ("--primary", "oklch(0.444 0.177 26.899)"), + ("--primary-foreground", "oklch(0.971 0.013 17.38)"), + ("--secondary", "oklch(0.274 0.006 286.033)"), + ("--secondary-foreground", "oklch(0.985 0 0)"), + ("--chart-1", "oklch(0.808 0.114 19.571)"), + ("--chart-2", "oklch(0.637 0.237 25.331)"), + ("--chart-3", "oklch(0.577 0.245 27.325)"), + ("--chart-4", "oklch(0.505 0.213 27.518)"), + ("--chart-5", "oklch(0.444 0.177 26.899)"), + ("--sidebar-primary", "oklch(0.637 0.237 25.331)"), + ("--sidebar-primary-foreground", "oklch(0.971 0.013 17.38)"), +]; + +static ROSE_LIGHT: &[(&str, &str)] = &[ + ("--primary", "oklch(0.514 0.222 16.935)"), + ("--primary-foreground", "oklch(0.969 0.015 12.422)"), + ("--secondary", "oklch(0.967 0.001 286.375)"), + ("--secondary-foreground", "oklch(0.21 0.006 285.885)"), + ("--chart-1", "oklch(0.81 0.117 11.638)"), + ("--chart-2", "oklch(0.645 0.246 16.439)"), + ("--chart-3", "oklch(0.586 0.253 17.585)"), + ("--chart-4", "oklch(0.514 0.222 16.935)"), + ("--chart-5", "oklch(0.455 0.188 13.697)"), + ("--sidebar-primary", "oklch(0.586 0.253 17.585)"), + ("--sidebar-primary-foreground", "oklch(0.969 0.015 12.422)"), +]; +static ROSE_DARK: &[(&str, &str)] = &[ + ("--primary", "oklch(0.455 0.188 13.697)"), + ("--primary-foreground", "oklch(0.969 0.015 12.422)"), + ("--secondary", "oklch(0.274 0.006 286.033)"), + ("--secondary-foreground", "oklch(0.985 0 0)"), + ("--chart-1", "oklch(0.81 0.117 11.638)"), + ("--chart-2", "oklch(0.645 0.246 16.439)"), + ("--chart-3", "oklch(0.586 0.253 17.585)"), + ("--chart-4", "oklch(0.514 0.222 16.935)"), + ("--chart-5", "oklch(0.455 0.188 13.697)"), + ("--sidebar-primary", "oklch(0.645 0.246 16.439)"), + ("--sidebar-primary-foreground", "oklch(0.969 0.015 12.422)"), +]; + +static SKY_LIGHT: &[(&str, &str)] = &[ + ("--primary", "oklch(0.5 0.134 242.749)"), + ("--primary-foreground", "oklch(0.977 0.013 236.62)"), + ("--secondary", "oklch(0.967 0.001 286.375)"), + ("--secondary-foreground", "oklch(0.21 0.006 285.885)"), + ("--chart-1", "oklch(0.828 0.111 230.318)"), + ("--chart-2", "oklch(0.685 0.169 237.323)"), + ("--chart-3", "oklch(0.588 0.158 241.966)"), + ("--chart-4", "oklch(0.5 0.134 242.749)"), + ("--chart-5", "oklch(0.443 0.11 240.79)"), + ("--sidebar-primary", "oklch(0.588 0.158 241.966)"), + ("--sidebar-primary-foreground", "oklch(0.977 0.013 236.62)"), +]; +static SKY_DARK: &[(&str, &str)] = &[ + ("--primary", "oklch(0.443 0.11 240.79)"), + ("--primary-foreground", "oklch(0.977 0.013 236.62)"), + ("--secondary", "oklch(0.274 0.006 286.033)"), + ("--secondary-foreground", "oklch(0.985 0 0)"), + ("--chart-1", "oklch(0.828 0.111 230.318)"), + ("--chart-2", "oklch(0.685 0.169 237.323)"), + ("--chart-3", "oklch(0.588 0.158 241.966)"), + ("--chart-4", "oklch(0.5 0.134 242.749)"), + ("--chart-5", "oklch(0.443 0.11 240.79)"), + ("--sidebar-primary", "oklch(0.685 0.169 237.323)"), + ("--sidebar-primary-foreground", "oklch(0.293 0.066 243.157)"), +]; + +static TEAL_LIGHT: &[(&str, &str)] = &[ + ("--primary", "oklch(0.511 0.096 186.391)"), + ("--primary-foreground", "oklch(0.984 0.014 180.72)"), + ("--secondary", "oklch(0.967 0.001 286.375)"), + ("--secondary-foreground", "oklch(0.21 0.006 285.885)"), + ("--chart-1", "oklch(0.855 0.138 181.071)"), + ("--chart-2", "oklch(0.704 0.14 182.503)"), + ("--chart-3", "oklch(0.6 0.118 184.704)"), + ("--chart-4", "oklch(0.511 0.096 186.391)"), + ("--chart-5", "oklch(0.437 0.078 188.216)"), + ("--sidebar-primary", "oklch(0.6 0.118 184.704)"), + ("--sidebar-primary-foreground", "oklch(0.984 0.014 180.72)"), +]; +static TEAL_DARK: &[(&str, &str)] = &[ + ("--primary", "oklch(0.437 0.078 188.216)"), + ("--primary-foreground", "oklch(0.984 0.014 180.72)"), + ("--secondary", "oklch(0.274 0.006 286.033)"), + ("--secondary-foreground", "oklch(0.985 0 0)"), + ("--chart-1", "oklch(0.855 0.138 181.071)"), + ("--chart-2", "oklch(0.704 0.14 182.503)"), + ("--chart-3", "oklch(0.6 0.118 184.704)"), + ("--chart-4", "oklch(0.511 0.096 186.391)"), + ("--chart-5", "oklch(0.437 0.078 188.216)"), + ("--sidebar-primary", "oklch(0.704 0.14 182.503)"), + ("--sidebar-primary-foreground", "oklch(0.277 0.046 192.524)"), +]; + +static VIOLET_LIGHT: &[(&str, &str)] = &[ + ("--primary", "oklch(0.491 0.27 292.581)"), + ("--primary-foreground", "oklch(0.969 0.016 293.756)"), + ("--secondary", "oklch(0.967 0.001 286.375)"), + ("--secondary-foreground", "oklch(0.21 0.006 285.885)"), + ("--chart-1", "oklch(0.811 0.111 293.571)"), + ("--chart-2", "oklch(0.606 0.25 292.717)"), + ("--chart-3", "oklch(0.541 0.281 293.009)"), + ("--chart-4", "oklch(0.491 0.27 292.581)"), + ("--chart-5", "oklch(0.432 0.232 292.759)"), + ("--sidebar-primary", "oklch(0.541 0.281 293.009)"), + ("--sidebar-primary-foreground", "oklch(0.969 0.016 293.756)"), +]; +static VIOLET_DARK: &[(&str, &str)] = &[ + ("--primary", "oklch(0.432 0.232 292.759)"), + ("--primary-foreground", "oklch(0.969 0.016 293.756)"), + ("--secondary", "oklch(0.274 0.006 286.033)"), + ("--secondary-foreground", "oklch(0.985 0 0)"), + ("--chart-1", "oklch(0.811 0.111 293.571)"), + ("--chart-2", "oklch(0.606 0.25 292.717)"), + ("--chart-3", "oklch(0.541 0.281 293.009)"), + ("--chart-4", "oklch(0.491 0.27 292.581)"), + ("--chart-5", "oklch(0.432 0.232 292.759)"), + ("--sidebar-primary", "oklch(0.606 0.25 292.717)"), + ("--sidebar-primary-foreground", "oklch(0.969 0.016 293.756)"), +]; + +static YELLOW_LIGHT: &[(&str, &str)] = &[ + ("--primary", "oklch(0.852 0.199 91.936)"), + ("--primary-foreground", "oklch(0.421 0.095 57.708)"), + ("--secondary", "oklch(0.967 0.001 286.375)"), + ("--secondary-foreground", "oklch(0.21 0.006 285.885)"), + ("--chart-1", "oklch(0.905 0.182 98.111)"), + ("--chart-2", "oklch(0.795 0.184 86.047)"), + ("--chart-3", "oklch(0.681 0.162 75.834)"), + ("--chart-4", "oklch(0.554 0.135 66.442)"), + ("--chart-5", "oklch(0.476 0.114 61.907)"), + ("--sidebar-primary", "oklch(0.681 0.162 75.834)"), + ("--sidebar-primary-foreground", "oklch(0.987 0.026 102.212)"), +]; +static YELLOW_DARK: &[(&str, &str)] = &[ + ("--primary", "oklch(0.795 0.184 86.047)"), + ("--primary-foreground", "oklch(0.421 0.095 57.708)"), + ("--secondary", "oklch(0.274 0.006 286.033)"), + ("--secondary-foreground", "oklch(0.985 0 0)"), + ("--chart-1", "oklch(0.905 0.182 98.111)"), + ("--chart-2", "oklch(0.795 0.184 86.047)"), + ("--chart-3", "oklch(0.681 0.162 75.834)"), + ("--chart-4", "oklch(0.554 0.135 66.442)"), + ("--chart-5", "oklch(0.476 0.114 61.907)"), + ("--sidebar-primary", "oklch(0.795 0.184 86.047)"), + ("--sidebar-primary-foreground", "oklch(0.987 0.026 102.212)"), +]; + +/* ========================================================== */ +/* 🧪 TESTS 🧪 */ +/* ========================================================== */ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base_color_from_str_case_insensitive() { + assert_eq!(BaseColor::from_str("neutral"), Some(BaseColor::Neutral)); + assert_eq!(BaseColor::from_str("NEUTRAL"), Some(BaseColor::Neutral)); + assert_eq!(BaseColor::from_str("Zinc"), Some(BaseColor::Zinc)); + assert_eq!(BaseColor::from_str("unknown"), None); + } + + #[test] + fn accent_color_from_str_case_insensitive() { + assert_eq!(AccentColor::from_str("default"), Some(AccentColor::Default)); + assert_eq!(AccentColor::from_str("Blue"), Some(AccentColor::Blue)); + assert_eq!(AccentColor::from_str("AMBER"), Some(AccentColor::Amber)); + assert_eq!(AccentColor::from_str("unknown"), None); + } + + #[test] + fn base_color_from_index_defaults_on_oob() { + assert_eq!(BaseColor::from_index(0), BaseColor::Neutral); + assert_eq!(BaseColor::from_index(6), BaseColor::Taupe); + assert_eq!(BaseColor::from_index(99), BaseColor::Neutral); // default + } + + #[test] + fn accent_color_from_index_defaults_on_oob() { + assert_eq!(AccentColor::from_index(0), AccentColor::Default); + assert_eq!(AccentColor::from_index(17), AccentColor::Yellow); + assert_eq!(AccentColor::from_index(99), AccentColor::Default); // default + } + + #[test] + fn all_labels_cover_all_variants() { + assert_eq!(BaseColor::all_labels().len(), BaseColor::ALL.len()); + assert_eq!(AccentColor::all_labels().len(), AccentColor::ALL.len()); + } + + #[test] + fn generate_theme_vars_neutral_default_contains_expected_vars() { + let css = generate_theme_vars(BaseColor::Neutral, AccentColor::Default); + assert!(css.contains(":root {")); + assert!(css.contains(".dark {")); + assert!(css.contains("--background: oklch(1 0 0)")); + assert!(css.contains("--radius: 0.625rem")); + assert!(css.contains("--destructive: oklch(0.577 0.245 27.325)")); + assert!(css.contains("--destructive: oklch(0.704 0.191 22.216)")); + } + + #[test] + fn generate_theme_vars_accent_overrides_primary() { + let css = generate_theme_vars(BaseColor::Neutral, AccentColor::Blue); + // Blue accent primary + assert!(css.contains("--primary: oklch(0.488 0.243 264.376)")); + // Chart vars from accent + assert!(css.contains("--chart-1:")); + assert!(css.contains("--sidebar-primary:")); + } + + #[test] + fn generate_theme_vars_default_accent_has_no_chart_vars() { + let css = generate_theme_vars(BaseColor::Neutral, AccentColor::Default); + assert!(!css.contains("--chart-1:")); + assert!(!css.contains("--sidebar-primary:")); + } + + #[test] + fn generate_theme_vars_zinc_base_uses_zinc_background() { + let css = generate_theme_vars(BaseColor::Zinc, AccentColor::Default); + // Zinc has a bluish foreground hue + assert!(css.contains("--background: oklch(0.141 0.005 285.823)"), "dark bg: {css}"); + } + + #[test] + fn base_color_all_have_matching_labels() { + for color in BaseColor::ALL { + let found = BaseColor::from_str(color.label()); + assert_eq!(found, Some(*color), "roundtrip failed for {:?}", color); + } + } + + #[test] + fn accent_color_all_have_matching_labels() { + for color in AccentColor::ALL { + let found = AccentColor::from_str(color.label()); + assert_eq!(found, Some(*color), "roundtrip failed for {:?}", color); + } + } +} diff --git a/src/command_init/config.rs b/src/command_init/config.rs index 93da89d..e0b25b0 100644 --- a/src/command_init/config.rs +++ b/src/command_init/config.rs @@ -1,160 +1,702 @@ -// use dotenv::dotenv; -use indicatif::ProgressBar; -// use serde::de::Error; -use serde::{Deserialize, Serialize}; -use std::error::Error; -// use std::fmt::Result; -// use std::env; +use std::collections::HashSet; use std::fs; +use std::path::Path; use std::process::Command; -use std::time::Duration; -use crate::constants::others::{CARGO_TOML_FILE, SPINNER_UPDATE_DURATION}; -use crate::constants::dependencies::INIT_DEPENDENCIES; +use serde::{Deserialize, Serialize}; +use toml_edit::{DocumentMut, Item, Value}; + +use crate::command_init::crates::{Crate, INIT_CRATES}; +use crate::command_init::workspace_utils::{WorkspaceInfo, analyze_workspace, check_leptos_dependency, load_cargo_manifest}; +use crate::shared::cli_error::{CliError, CliResult}; +use crate::shared::task_spinner::TaskSpinner; + +/// +/// UiConfig - Minimal configuration stored in ui_config.toml +/// Workspace detection is done dynamically via analyze_workspace() +/// +/// Note: `tailwind_input_file` is read from Cargo.toml metadata (not stored here) +/// to avoid duplication with Leptos configuration. /// -/// AppConfig -/// -/// #[derive(Debug, Deserialize, Serialize, PartialEq, PartialOrd)] -pub struct AppConfig { - pub tailwind_input_file: String, - pub base_path_components: String +pub struct UiConfig { + pub base_color: String, + #[serde(default = "default_color_theme")] + pub color_theme: String, + pub base_path_components: String, } -#[allow(dead_code)] -impl AppConfig { +fn default_color_theme() -> String { + "default".to_string() +} - pub fn new(tailwind_input_file: &str, base_path_components: &str) -> Self { - AppConfig { - tailwind_input_file: tailwind_input_file.to_string(), - base_path_components: base_path_components.to_string() +impl UiConfig { + pub fn try_reading_ui_config(toml_path: &str) -> CliResult { + if !Path::new(toml_path).exists() { + return Err(CliError::project_not_initialized()); } - } - - pub fn try_reading_app_config(toml_path: &str) -> Result> { let contents = fs::read_to_string(toml_path)?; - let app_config: AppConfig = toml::from_str(&contents)?; - Ok(app_config) + let ui_config: UiConfig = toml::from_str(&contents)?; + Ok(ui_config) } - } -impl Default for AppConfig { - /// - /// Creates a default AppConfig - /// - /// # Example - /// ``` - /// let app_config = AppConfig::default(); - /// - /// assert_eq!( - /// app_config, - /// AppConfig { - /// tailwind_input_file: "style/tailwind.css".to_string(), - /// base_path_components: "src/components".to_string() - /// } - /// ); - /// - /// ``` +impl Default for UiConfig { fn default() -> Self { - AppConfig { - tailwind_input_file: "style/tailwind.css".to_string(), - base_path_components: "src/components".to_string() + // Detect workspace and set appropriate component path + let base_path_components = match analyze_workspace() { + Ok(info) => info.components_base_path, + Err(_) => "src/components".to_string(), + }; + + UiConfig { + base_color: "neutral".to_string(), + color_theme: default_color_theme(), + base_path_components, } } } -/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ +/* ========================================================== */ /* ✨ FUNCTIONS ✨ */ -/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ -#[allow(unused)] -pub async fn add_init_dependencies() { - - for dep in INIT_DEPENDENCIES { - let spinner = ProgressBar::new_spinner(); - spinner.set_message(format!("Adding and installing {} crate...", dep.name)); - spinner.enable_steady_tick(Duration::from_millis(SPINNER_UPDATE_DURATION)); - - let mut args = vec!["add".to_owned(), dep.name.to_owned()]; - if !dep.features.is_empty() { - args.push("--features".to_owned()); - args.push(dep.features.join(",")); +/* ========================================================== */ + +pub async fn add_init_crates() -> CliResult<()> { + let workspace_info = analyze_workspace().ok(); + let workspace_crates = get_workspace_dependencies(&workspace_info); + + for my_crate in INIT_CRATES { + if my_crate.name == "leptos" && check_leptos_dependency()? { + continue; } - let output = Command::new("cargo") - .args(args) - .output() - .expect("🔸 Failed to add crate!"); - - if output.status.success() { - spinner.finish_with_message("✔️ Crates added successfully."); - } else { - spinner.finish_with_message(format!( - "🔸 Error adding crates: {}", - String::from_utf8_lossy(&output.stderr) - )); + + let spinner = TaskSpinner::new(&format!("Adding {} crate...", my_crate.name)); + + if add_crate_to_workspace(&my_crate, &workspace_info, &workspace_crates)? { + spinner.finish_success(&format!("{} (workspace) added.", my_crate.name)); + continue; } + + add_crate_with_cargo(&my_crate, &workspace_info)?; + spinner.finish_success(&format!("{} added.", my_crate.name)); + } + Ok(()) +} + +fn add_crate_to_workspace( + my_crate: &Crate, + workspace_info: &Option, + workspace_crates: &HashSet, +) -> CliResult { + let Some(info) = workspace_info.as_ref().filter(|i| i.is_workspace) else { + return Ok(false); + }; + let Some(workspace_root) = &info.workspace_root else { + return Ok(false); + }; + let Some(member_path) = &info.target_crate_path else { + return Ok(false); + }; + + let root_cargo_toml = workspace_root.join("Cargo.toml"); + let member_cargo_toml = member_path.join("Cargo.toml"); + + if workspace_crates.contains(my_crate.name) { + add_workspace_ref_to_member(&member_cargo_toml, my_crate.name)?; + return Ok(true); + } + + if !has_workspace_dependencies_section(workspace_info) { + return Ok(false); } + let version = fetch_latest_version(my_crate.name)?; + add_to_workspace_dependencies(&root_cargo_toml, my_crate.name, &version, my_crate.features)?; + add_workspace_ref_to_member(&member_cargo_toml, my_crate.name)?; + Ok(true) } +fn add_crate_with_cargo(my_crate: &Crate, workspace_info: &Option) -> CliResult<()> { + let mut args = vec!["add".to_owned(), my_crate.name.to_owned()]; -#[allow(unused)] -fn add_tailwind_fuse_and_leptos_use() { - let spinner = ProgressBar::new_spinner(); - spinner.set_message("Adding crates: rustui_merge and leptos-use"); - spinner.enable_steady_tick(Duration::from_millis(SPINNER_UPDATE_DURATION)); + if let Some(info) = workspace_info.as_ref().filter(|i| i.is_workspace) + && let Some(crate_name) = &info.target_crate + { + args.extend(["--package".to_owned(), crate_name.clone()]); + } - let output = Command::new("cargo") - .args([ - "add", - "rustui_merge@0.3.0", - "--features", - "rustui_merge@0.3.0/variant,rustui_merge@0.3.0/debug", - "leptos-use@0.13.5", - "--features", - "leptos-use@0.13.5/storage,leptos-use@0.13.5/docs,leptos-use@0.13.5/math", - ]) - .output() - .expect("🔸 Failed to execute cargo add command"); + if let Some(features) = my_crate.features.filter(|f| !f.is_empty()) { + args.extend(["--features".to_owned(), features.join(",")]); + } - if output.status.success() { - spinner.finish_with_message("✔️ Crates added successfully."); - } else { - spinner.finish_with_message(format!( - "🔸 Error adding crates: {}", + let output = Command::new("cargo").args(&args).output().map_err(|e| { + CliError::cargo_operation(&format!("Failed to execute cargo add {}: {e}", my_crate.name)) + })?; + + if !output.status.success() { + return Err(CliError::cargo_operation(&format!( + "Failed to add crate '{}': {}", + my_crate.name, String::from_utf8_lossy(&output.stderr) - )); + ))); } + Ok(()) } -#[allow(unused)] -fn handle_adding_leptos_use_to_ssr_features() { - match fs::read_to_string(CARGO_TOML_FILE) { - Ok(mut contents) => { - if let Some(start_pos) = contents.find("ssr = [") { - // Find the position to insert the new features - if let Some(end_pos) = contents[start_pos..].find(']') { - let insert_pos = start_pos + end_pos; - let new_features = r#" "leptos-use/ssr", - "leptos-use/axum", -"#; +/* ========================================================== */ +/* ✨ HELPERS ✨ */ +/* ========================================================== */ - // Check if the features are already present to avoid duplicates - if !contents[start_pos..insert_pos].contains("leptos-use/ssr") { - contents.insert_str(insert_pos, new_features); - } - } - } else { - println!("'ssr' feature not found."); - } +/// Checks if the workspace has a [workspace.dependencies] section. +/// Uses cargo_toml::Manifest for consistent parsing with the rest of the codebase. +fn has_workspace_dependencies_section(workspace_info: &Option) -> bool { + let Some(info) = workspace_info.as_ref().filter(|i| i.is_workspace) else { + return false; + }; + let Some(root) = &info.workspace_root else { + return false; + }; + + load_cargo_manifest(&root.join("Cargo.toml")) + .ok() + .flatten() + .and_then(|manifest| manifest.workspace) + .is_some_and(|ws| !ws.dependencies.is_empty()) +} + +/// Gets the list of dependencies defined in [workspace.dependencies]. +/// Uses cargo_toml::Manifest for consistent parsing with the rest of the codebase. +fn get_workspace_dependencies(workspace_info: &Option) -> HashSet { + let Some(info) = workspace_info.as_ref().filter(|i| i.is_workspace) else { + return HashSet::new(); + }; + let Some(root) = &info.workspace_root else { + return HashSet::new(); + }; + + load_cargo_manifest(&root.join("Cargo.toml")) + .ok() + .flatten() + .and_then(|manifest| manifest.workspace) + .map(|ws| ws.dependencies.keys().cloned().collect()) + .unwrap_or_default() +} + +fn add_workspace_ref_to_member(cargo_toml_path: &Path, dep: &str) -> CliResult<()> { + let contents = fs::read_to_string(cargo_toml_path)?; + let mut doc: DocumentMut = contents + .parse() + .map_err(|e| CliError::cargo_operation(&format!("Failed to parse member Cargo.toml: {e}")))?; + + let deps = doc.entry("dependencies").or_insert(Item::Table(toml_edit::Table::new())); + + let deps_table = + deps.as_table_mut().ok_or_else(|| CliError::cargo_operation("[dependencies] is not a table"))?; + + if deps_table.contains_key(dep) { + return Ok(()); + } + + let mut dep_table = toml_edit::Table::new(); + dep_table.set_dotted(true); + dep_table.insert("workspace", Item::Value(Value::Boolean(toml_edit::Formatted::new(true)))); + deps_table.insert(dep, Item::Table(dep_table)); + + fs::write(cargo_toml_path, doc.to_string())?; + Ok(()) +} + +fn add_to_workspace_dependencies( + cargo_toml_path: &Path, + dep: &str, + version: &str, + features: Option<&[&str]>, +) -> CliResult<()> { + let contents = fs::read_to_string(cargo_toml_path)?; + let mut doc: DocumentMut = contents + .parse() + .map_err(|e| CliError::cargo_operation(&format!("Failed to parse Cargo.toml: {e}")))?; + + let workspace = doc.entry("workspace").or_insert(Item::Table(toml_edit::Table::new())); + + let workspace_table = + workspace.as_table_mut().ok_or_else(|| CliError::cargo_operation("[workspace] is not a table"))?; + + let deps = workspace_table.entry("dependencies").or_insert(Item::Table(toml_edit::Table::new())); + + let deps_table = deps + .as_table_mut() + .ok_or_else(|| CliError::cargo_operation("[workspace.dependencies] is not a table"))?; + + if deps_table.contains_key(dep) { + return Ok(()); + } + + if let Some(feats) = features + && !feats.is_empty() + { + let mut inline = toml_edit::InlineTable::new(); + inline.insert("version", version.into()); + let features_array: toml_edit::Array = feats.iter().map(|f| Value::from(*f)).collect(); + inline.insert("features", Value::Array(features_array)); + deps_table.insert(dep, Item::Value(Value::InlineTable(inline))); + } else { + deps_table.insert(dep, Item::Value(Value::String(toml_edit::Formatted::new(version.to_string())))); + } + + fs::write(cargo_toml_path, doc.to_string())?; + Ok(()) +} + +fn fetch_latest_version(crate_name: &str) -> CliResult { + let output = Command::new("cargo") + .args(["search", crate_name, "--limit", "1"]) + .output() + .map_err(|_| CliError::cargo_operation("Failed to execute cargo search"))?; + + if !output.status.success() { + return Ok("*".to_string()); + } - // Write the modified contents back to the file - if let Err(e) = fs::write(CARGO_TOML_FILE, &contents) { - eprintln!("Error writing to file: {}", e); + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + if line.starts_with(crate_name) + && let Some(version_part) = line.split('=').nth(1) + { + let version = version_part + .trim() + .trim_matches('"') + .split_whitespace() + .next() + .unwrap_or("") + .trim_matches('"'); + if !version.is_empty() { + return Ok(version.to_string()); } } - Err(e) => { - eprintln!("Error reading file: {}", e); - } } -} \ No newline at end of file + + Ok("*".to_string()) +} + +/* ========================================================== */ +/* 🧪 TESTS 🧪 */ +/* ========================================================== */ + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + + #[test] + fn test_get_workspace_dependencies_returns_crates() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + // Create workspace Cargo.toml with dependencies + fs::write( + root.join("Cargo.toml"), + r#"[workspace] +members = ["app"] + +[workspace.dependencies] +leptos = "0.7" +tw_merge = { version = "0.1", features = ["variant"] } +serde = "1.0" +"#, + ) + .unwrap(); + + let info = WorkspaceInfo { + is_workspace: true, + workspace_root: Some(root.to_path_buf()), + target_crate: Some("app".to_string()), + target_crate_path: Some(root.join("app")), + components_base_path: "app/src/components".to_string(), + }; + + let deps = get_workspace_dependencies(&Some(info)); + + assert!(deps.contains(&"leptos".to_string())); + assert!(deps.contains(&"tw_merge".to_string())); + assert!(deps.contains(&"serde".to_string())); + assert_eq!(deps.len(), 3); + } + + #[test] + fn test_get_workspace_dependencies_empty_when_no_workspace() { + let deps = get_workspace_dependencies(&None); + assert!(deps.is_empty()); + } + + #[test] + fn test_get_workspace_dependencies_empty_when_not_workspace() { + let info = WorkspaceInfo { + is_workspace: false, + workspace_root: None, + target_crate: Some("app".to_string()), + target_crate_path: None, + components_base_path: "src/components".to_string(), + }; + + let deps = get_workspace_dependencies(&Some(info)); + assert!(deps.is_empty()); + } + + #[test] + fn test_get_workspace_dependencies_empty_when_no_workspace_deps_section() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + // Create workspace Cargo.toml WITHOUT [workspace.dependencies] + fs::write( + root.join("Cargo.toml"), + r#"[workspace] +members = ["app"] +"#, + ) + .unwrap(); + + let info = WorkspaceInfo { + is_workspace: true, + workspace_root: Some(root.to_path_buf()), + target_crate: Some("app".to_string()), + target_crate_path: Some(root.join("app")), + components_base_path: "app/src/components".to_string(), + }; + + let deps = get_workspace_dependencies(&Some(info)); + assert!(deps.is_empty()); + } + + #[test] + fn test_add_workspace_ref_to_member_uses_dotted_format() { + let temp = TempDir::new().unwrap(); + let cargo_toml = temp.path().join("Cargo.toml"); + + fs::write( + &cargo_toml, + r#"[package] +name = "app" +version = "0.1.0" + +[dependencies] +leptos.workspace = true +"#, + ) + .unwrap(); + + add_workspace_ref_to_member(&cargo_toml, "tw_merge").unwrap(); + + let contents = fs::read_to_string(&cargo_toml).unwrap(); + assert!(contents.contains("tw_merge.workspace = true"), "Should use dotted format, got: {contents}"); + } + + #[test] + fn test_add_workspace_ref_skips_existing_dep() { + let temp = TempDir::new().unwrap(); + let cargo_toml = temp.path().join("Cargo.toml"); + + let original = r#"[package] +name = "app" +version = "0.1.0" + +[dependencies] +tw_merge.workspace = true +"#; + fs::write(&cargo_toml, original).unwrap(); + + // Should not error or modify when dep already exists + add_workspace_ref_to_member(&cargo_toml, "tw_merge").unwrap(); + + let contents = fs::read_to_string(&cargo_toml).unwrap(); + // Count occurrences - should still be just one + assert_eq!(contents.matches("tw_merge").count(), 1, "Should not duplicate: {contents}"); + } + + #[test] + fn test_workspace_crate_detection_for_init() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + // Create workspace with tw_merge already in workspace.dependencies + fs::write( + root.join("Cargo.toml"), + r#"[workspace] +members = ["app"] + +[workspace.dependencies] +tw_merge = { version = "0.1", features = ["variant"] } +leptos_ui = "0.3" +"#, + ) + .unwrap(); + + let info = WorkspaceInfo { + is_workspace: true, + workspace_root: Some(root.to_path_buf()), + target_crate: Some("app".to_string()), + target_crate_path: Some(root.join("app")), + components_base_path: "app/src/components".to_string(), + }; + + let workspace_crates = get_workspace_dependencies(&Some(info)); + + // These should be detected as workspace crates + assert!(workspace_crates.contains(&"tw_merge".to_string())); + assert!(workspace_crates.contains(&"leptos_ui".to_string())); + + // These should NOT be in workspace crates (not defined) + assert!(!workspace_crates.contains(&"icons".to_string())); + } + + #[test] + fn test_has_workspace_dependencies_section_true() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + fs::write( + root.join("Cargo.toml"), + r#"[workspace] +members = ["app"] + +[workspace.dependencies] +leptos = "0.7" +"#, + ) + .unwrap(); + + let info = WorkspaceInfo { + is_workspace: true, + workspace_root: Some(root.to_path_buf()), + target_crate: Some("app".to_string()), + target_crate_path: Some(root.join("app")), + components_base_path: "app/src/components".to_string(), + }; + + assert!(has_workspace_dependencies_section(&Some(info))); + } + + #[test] + fn test_has_workspace_dependencies_section_false() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + fs::write( + root.join("Cargo.toml"), + r#"[workspace] +members = ["app"] +"#, + ) + .unwrap(); + + let info = WorkspaceInfo { + is_workspace: true, + workspace_root: Some(root.to_path_buf()), + target_crate: Some("app".to_string()), + target_crate_path: Some(root.join("app")), + components_base_path: "app/src/components".to_string(), + }; + + assert!(!has_workspace_dependencies_section(&Some(info))); + } + + #[test] + fn test_add_to_workspace_dependencies_simple() { + let temp = TempDir::new().unwrap(); + let cargo_toml = temp.path().join("Cargo.toml"); + + fs::write( + &cargo_toml, + r#"[workspace] +members = ["app"] + +[workspace.dependencies] +leptos = "0.7" +"#, + ) + .unwrap(); + + add_to_workspace_dependencies(&cargo_toml, "serde", "1.0", None).unwrap(); + + let contents = fs::read_to_string(&cargo_toml).unwrap(); + assert!(contents.contains(r#"serde = "1.0""#), "got: {contents}"); + assert!(contents.contains(r#"leptos = "0.7""#), "should preserve existing: {contents}"); + } + + #[test] + fn test_add_to_workspace_dependencies_with_features() { + let temp = TempDir::new().unwrap(); + let cargo_toml = temp.path().join("Cargo.toml"); + + fs::write( + &cargo_toml, + r#"[workspace] +members = ["app"] + +[workspace.dependencies] +"#, + ) + .unwrap(); + + add_to_workspace_dependencies(&cargo_toml, "icons", "0.3", Some(&["leptos"])).unwrap(); + + let contents = fs::read_to_string(&cargo_toml).unwrap(); + assert!(contents.contains("icons"), "got: {contents}"); + assert!(contents.contains("leptos"), "should have features: {contents}"); + } + + #[test] + fn test_add_to_workspace_dependencies_skips_existing() { + let temp = TempDir::new().unwrap(); + let cargo_toml = temp.path().join("Cargo.toml"); + + fs::write( + &cargo_toml, + r#"[workspace] +members = ["app"] + +[workspace.dependencies] +icons = { version = "0.2", features = ["leptos"] } +"#, + ) + .unwrap(); + + add_to_workspace_dependencies(&cargo_toml, "icons", "0.3", Some(&["leptos"])).unwrap(); + + let contents = fs::read_to_string(&cargo_toml).unwrap(); + assert!(contents.contains(r#"version = "0.2""#), "should keep original version: {contents}"); + assert_eq!(contents.matches("icons").count(), 1, "should not duplicate: {contents}"); + } + + #[test] + fn test_has_workspace_dependencies_section_empty_deps() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + // Empty [workspace.dependencies] section (no deps defined) + fs::write( + root.join("Cargo.toml"), + r#"[workspace] +members = ["app"] + +[workspace.dependencies] +"#, + ) + .unwrap(); + + let info = WorkspaceInfo { + is_workspace: true, + workspace_root: Some(root.to_path_buf()), + target_crate: Some("app".to_string()), + target_crate_path: Some(root.join("app")), + components_base_path: "app/src/components".to_string(), + }; + + // Empty section should return false (no deps to use) + assert!(!has_workspace_dependencies_section(&Some(info))); + } + + #[test] + fn test_get_workspace_dependencies_nonexistent_workspace_root() { + let temp = TempDir::new().unwrap(); + let nonexistent_path = temp.path().join("does-not-exist"); + + let info = WorkspaceInfo { + is_workspace: true, + workspace_root: Some(nonexistent_path), + target_crate: Some("app".to_string()), + target_crate_path: None, + components_base_path: "app/src/components".to_string(), + }; + + // Should return empty set, not panic + let deps = get_workspace_dependencies(&Some(info)); + assert!(deps.is_empty()); + } + + #[test] + fn test_has_workspace_dependencies_nonexistent_workspace_root() { + let temp = TempDir::new().unwrap(); + let nonexistent_path = temp.path().join("does-not-exist"); + + let info = WorkspaceInfo { + is_workspace: true, + workspace_root: Some(nonexistent_path), + target_crate: Some("app".to_string()), + target_crate_path: None, + components_base_path: "app/src/components".to_string(), + }; + + // Should return false, not panic + assert!(!has_workspace_dependencies_section(&Some(info))); + } + + #[test] + fn test_get_workspace_dependencies_with_workspace_root_none() { + let info = WorkspaceInfo { + is_workspace: true, + workspace_root: None, // workspace but no root path + target_crate: Some("app".to_string()), + target_crate_path: None, + components_base_path: "app/src/components".to_string(), + }; + + let deps = get_workspace_dependencies(&Some(info)); + assert!(deps.is_empty()); + } + + #[test] + fn try_reading_ui_config_returns_error_when_file_missing() { + let temp = TempDir::new().unwrap(); + let missing = temp.path().join("ui_config.toml"); + let result = UiConfig::try_reading_ui_config(missing.to_str().unwrap()); + assert!(result.is_err()); + } + + #[test] + fn try_reading_ui_config_returns_error_on_invalid_toml() { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("ui_config.toml"); + fs::write(&path, "this is not valid toml = [[[").unwrap(); + let result = UiConfig::try_reading_ui_config(path.to_str().unwrap()); + assert!(result.is_err()); + } + + #[test] + fn try_reading_ui_config_parses_valid_config() { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("ui_config.toml"); + fs::write( + &path, + r#"base_color = "zinc" +base_path_components = "src/ui" +"#, + ) + .unwrap(); + let result = UiConfig::try_reading_ui_config(path.to_str().unwrap()).unwrap(); + assert_eq!(result.base_color, "zinc"); + assert_eq!(result.base_path_components, "src/ui"); + // color_theme defaults when missing from existing configs + assert_eq!(result.color_theme, "default"); + } + + #[test] + fn try_reading_ui_config_parses_color_theme() { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("ui_config.toml"); + fs::write( + &path, + r#"base_color = "stone" +color_theme = "blue" +base_path_components = "src/ui" +"#, + ) + .unwrap(); + let result = UiConfig::try_reading_ui_config(path.to_str().unwrap()).unwrap(); + assert_eq!(result.base_color, "stone"); + assert_eq!(result.color_theme, "blue"); + assert_eq!(result.base_path_components, "src/ui"); + } +} diff --git a/src/command_init/crates.rs b/src/command_init/crates.rs new file mode 100644 index 0000000..0d3e154 --- /dev/null +++ b/src/command_init/crates.rs @@ -0,0 +1,24 @@ +pub struct Crate { + pub name: &'static str, + #[allow(unused)] // TODO. Find a way to set the version to "0.8" instead of "0.8.2". + pub version: Option<&'static str>, + pub features: Option<&'static [&'static str]>, +} + +impl Crate { + const fn new( + name: &'static str, + version: Option<&'static str>, + features: Option<&'static [&'static str]>, + ) -> Self { + Crate { name, version, features } + } +} + +pub const INIT_CRATES: [Crate; 4] = [ + // TODO. Handle leptos csr or ssr based on user input. + Crate::new("leptos", None, Some(&["csr"])), + Crate::new("tw_merge", None, Some(&["variant"])), + Crate::new("icons", None, Some(&["leptos"])), + Crate::new("leptos_ui", None, None), +]; diff --git a/src/command_init/fetch.rs b/src/command_init/fetch.rs deleted file mode 100644 index 45c903b..0000000 --- a/src/command_init/fetch.rs +++ /dev/null @@ -1,22 +0,0 @@ -use crate::shared::shared_fetch_functions::shared_fetch_registry_return_json; - -pub struct Fetch {} - -impl Fetch { - pub async fn from_url(url: &str) -> Result { - let result = shared_fetch_registry_return_json(url).await; - - match result { - Ok(json) => { - let pretty_json = serde_json::to_string_pretty(&json) - .unwrap_or_else(|_| "🔸 Failed to convert to pretty JSON".to_string()); - - Ok(pretty_json) - } - Err(e) => { - eprintln!("🔸 Error fetching: {}", e); - Err(e) - } - } - } -} diff --git a/src/command_init/install.rs b/src/command_init/install.rs index 818d440..84cbff6 100644 --- a/src/command_init/install.rs +++ b/src/command_init/install.rs @@ -1,26 +1,109 @@ -use indicatif::ProgressBar; -use std::{process::Command, time::Duration}; +use std::path::Path; +use std::process::Command; -use crate::constants::others::{SPINNER_UPDATE_DURATION, TAILWIND_DEPENDENCIES}; +use strum::AsRefStr; -pub struct Install {} +use crate::shared::cli_error::{CliError, CliResult}; +use crate::shared::task_spinner::TaskSpinner; -impl Install { - pub async fn tailwind_with_pnpm() { - let spinner = ProgressBar::new_spinner(); +#[derive(Debug, Clone, AsRefStr)] +pub enum InstallType { + Tailwind, +} + +impl InstallType { + fn dependencies(&self) -> &'static [&'static str] { + match self { + Self::Tailwind => &["@tailwindcss/cli", "tailwindcss", "tw-animate-css"], + } + } + + fn name(&self) -> &str { + self.as_ref() + } +} + +#[derive(Debug, Clone, AsRefStr)] +#[strum(serialize_all = "lowercase")] +enum PackageManager { + Pnpm, + Npm, +} + +impl PackageManager { + fn command(&self) -> &str { + self.as_ref() + } + + fn is_pnpm_available() -> bool { + Command::new("pnpm").arg("--version").output().map(|output| output.status.success()).unwrap_or(false) + } + + fn detect() -> PackageManager { + if Self::is_pnpm_available() { PackageManager::Pnpm } else { PackageManager::Npm } + } +} - for dep in TAILWIND_DEPENDENCIES { - let message = format!("Installing dependencies...: {}", dep); - spinner.set_message(message); - spinner.enable_steady_tick(Duration::from_millis(SPINNER_UPDATE_DURATION)); +fn missing_dependencies<'a>(deps: &[&'a str]) -> Vec<&'a str> { + deps.iter() + .copied() + .filter(|dep| { + !Path::new("node_modules").join(dep).join("package.json").exists() + }) + .collect() +} - let output = Command::new("pnpm").arg("install").arg(dep).output(); +pub async fn install_dependencies(install_types: &[InstallType]) -> CliResult<()> { + let package_manager = PackageManager::detect(); + for install_type in install_types { + let all_deps = install_type.dependencies(); + let to_install = missing_dependencies(all_deps); - match output { - Ok(_) => spinner.finish_with_message(format!("✔️ Installed dependency: {}", dep)), - Err(_) => spinner.finish_with_message(format!("🔸 Failed to install: {}", dep)), - } + if to_install.is_empty() { + let spinner = TaskSpinner::new(&format!( + "Checking {} dependencies...", install_type.name() + )); + spinner.finish_success(&format!( + "{} dependencies already installed. Skipping.", install_type.name() + )); + continue; } - spinner.finish(); + + install_with_package_manager(install_type.clone(), package_manager.clone(), &to_install)?; + } + Ok(()) +} + +/* ========================================================== */ +/* ✨ FUNCTIONS ✨ */ +/* ========================================================== */ + +fn install_with_package_manager( + install_type: InstallType, + package_manager: PackageManager, + dependencies: &[&str], +) -> CliResult<()> { + let deps_list = dependencies.join(" "); + let pm_name = package_manager.command(); + let type_name = install_type.name(); + let message = format!("Installing {type_name} dependencies with {pm_name}: {deps_list}"); + let spinner = TaskSpinner::new(&message); + + let mut cmd = Command::new(package_manager.command()); + cmd.arg("install"); + + for dep in dependencies { + cmd.arg(dep); } + + let output = cmd.output().map_err(|_| CliError::npm_install_failed())?; + + if output.status.success() { + let success_message = format!("All {} dependencies installed successfully", install_type.name()); + spinner.finish_success(&success_message); + } else { + return Err(CliError::npm_install_failed()); + } + + Ok(()) } diff --git a/src/command_init/mod.rs b/src/command_init/mod.rs index e77ec1d..66717ec 100644 --- a/src/command_init/mod.rs +++ b/src/command_init/mod.rs @@ -1,6 +1,9 @@ pub mod _init; +pub mod backup; +pub mod colors; pub mod config; -pub mod fetch; +pub mod crates; pub mod install; -pub mod user_input; +pub mod template; +pub mod workspace_utils; diff --git a/src/command_init/template.rs b/src/command_init/template.rs new file mode 100644 index 0000000..6ec066b --- /dev/null +++ b/src/command_init/template.rs @@ -0,0 +1,109 @@ +use super::colors::{AccentColor, BaseColor, generate_theme_vars}; + +pub struct MyTemplate; + +impl MyTemplate { + const CSS_HEADER: &str = "@import \"tailwindcss\";\n@import \"tw-animate-css\";\n\n"; + + const CSS_FOOTER: &str = r#" +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + + button:not(:disabled), + [role="button"]:not(:disabled) { + cursor: pointer; + } + + dialog { + margin: auto; + } +} +"#; + + /// Build a complete tailwind.css from the chosen base + accent colors. + pub fn build_css(base: BaseColor, accent: AccentColor) -> String { + format!("{}{}{}", Self::CSS_HEADER, generate_theme_vars(base, accent), Self::CSS_FOOTER) + } + + pub const PACKAGE_JSON: &str = r#"{ + "type": "module" +} +"#; +} + +/* ========================================================== */ +/* 🧪 TESTS 🧪 */ +/* ========================================================== */ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_css_contains_tailwind_import() { + let css = MyTemplate::build_css(BaseColor::default(), AccentColor::default()); + assert!(css.contains("@import \"tailwindcss\"")); + assert!(css.contains("@import \"tw-animate-css\"")); + } + + #[test] + fn build_css_contains_theme_inline_block() { + let css = MyTemplate::build_css(BaseColor::default(), AccentColor::default()); + assert!(css.contains("@theme inline {")); + assert!(css.contains("--color-background: var(--background)")); + } + + #[test] + fn build_css_contains_layer_base() { + let css = MyTemplate::build_css(BaseColor::default(), AccentColor::default()); + assert!(css.contains("@layer base {")); + } + + #[test] + fn build_css_contains_color_vars() { + let css = MyTemplate::build_css(BaseColor::default(), AccentColor::default()); + assert!(css.contains(":root {")); + assert!(css.contains(".dark {")); + assert!(css.contains("--radius: 0.625rem")); + } + + #[test] + fn build_css_zinc_blue_has_zinc_background() { + let css = MyTemplate::build_css(BaseColor::Zinc, AccentColor::Blue); + // Zinc dark background + assert!(css.contains("--background: oklch(0.141 0.005 285.823)")); + // Blue accent primary + assert!(css.contains("--primary: oklch(0.488 0.243 264.376)")); + } +} diff --git a/src/command_init/user_input.rs b/src/command_init/user_input.rs deleted file mode 100644 index b630776..0000000 --- a/src/command_init/user_input.rs +++ /dev/null @@ -1,74 +0,0 @@ -// use dotenv::dotenv; -use serde_json; -// use std::env; -use std::io; - -use crate::command_init::fetch::Fetch; -// use crate::constants::env::ENV; -use crate::constants::url::URL; - -const LABEL: &str = "label"; - -/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ -/* 🦀 MAIN 🦀 */ -/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ - -pub struct UserInput {} - -impl UserInput { - pub async fn handle_index_styles() { - // dotenv().ok(); - - // let url_registry_styles_json = env::var(ENV::URL_REGISTRY_STYLES_JSON).unwrap_or_default(); - - let url_registry_styles_json = URL::URL_REGISTRY_STYLES_JSON; - - let styles_index_result = Fetch::from_url(&url_registry_styles_json).await; - // println!("{}", styles_index_result.as_ref().unwrap()); - - // Parse the JSON string into Vec - if let Ok(styles_index) = styles_index_result { - // Convert the String to a Vec - let vec_styles: Vec = serde_json::from_str(&styles_index).unwrap(); - ask_user_choose_style(vec_styles); - } - } -} - -/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ -/* ✨ FUNCTIONS ✨ */ -/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ - -/// Ask user to choose a style -fn ask_user_choose_style(vec_styles: Vec) { - // Print available styles - for (index, style) in vec_styles.iter().enumerate() { - if let Some(label) = style.get(LABEL) { - println!("\n{}: {}", index + 1, label); - } - } - - // Prompt user for choice - println!("Please choose a style by entering the corresponding number:"); - - let mut user_input = String::new(); - io::stdin().read_line(&mut user_input).expect("🔸 Failed to read line"); - - // Parse the choice and print the selected style - if let Ok(index) = user_input.trim().parse::() { - if index > 0 && index <= vec_styles.len() { - if let Some(selected_style) = vec_styles.get(index - 1) { - if let Some(label) = selected_style.get(LABEL) { - println!("You selected: {}", label); - } - } - } else { - println!( - "🔸 Invalid choice. Please select a number between 1 and {}.", - vec_styles.len() - ); - } - } else { - println!("🔸 Invalid input. Please enter a number."); - } -} diff --git a/src/command_init/workspace_utils.rs b/src/command_init/workspace_utils.rs new file mode 100644 index 0000000..06623c5 --- /dev/null +++ b/src/command_init/workspace_utils.rs @@ -0,0 +1,870 @@ +use std::path::{Path, PathBuf}; + +use cargo_toml::{Dependency, Manifest}; + +use crate::shared::cli_error::{CliError, CliResult}; + +/// Information about the workspace and target crate +#[derive(Debug, Clone, PartialEq)] +pub struct WorkspaceInfo { + /// Whether we're in a workspace + pub is_workspace: bool, + /// The workspace root directory (if in a workspace) + pub workspace_root: Option, + /// The target crate name where components should be installed + pub target_crate: Option, + /// The path to the target crate directory + pub target_crate_path: Option, + /// The base path for components relative to current working directory + pub components_base_path: String, +} + +impl Default for WorkspaceInfo { + fn default() -> Self { + Self { + is_workspace: false, + workspace_root: None, + target_crate: None, + target_crate_path: None, + components_base_path: "src/components".to_string(), + } + } +} + +/// Analyzes the current directory to detect workspace structure and find the appropriate +/// crate for installing components. +pub fn analyze_workspace() -> CliResult { + let current_dir = std::env::current_dir()?; + analyze_workspace_from_path(¤t_dir) +} + +/// Analyzes workspace from a specific path (useful for testing) +pub fn analyze_workspace_from_path(start_path: &Path) -> CliResult { + // First, check if we're in a workspace member directory + let local_cargo_toml = start_path.join("Cargo.toml"); + + if !local_cargo_toml.exists() { + return Err(CliError::file_operation("Cargo.toml not found in current directory")); + } + + let local_manifest = load_cargo_manifest(&local_cargo_toml)? + .ok_or_else(|| CliError::file_operation("Failed to parse Cargo.toml"))?; + + // Check if this is a workspace root + if local_manifest.workspace.is_some() { + return analyze_from_workspace_root(start_path, &local_manifest); + } + + // Check if we're in a workspace member by looking for workspace root + if let Some(workspace_root) = find_workspace_root(start_path)? { + return analyze_from_workspace_member(start_path, &workspace_root); + } + + // Not in a workspace - simple single-crate project + let has_leptos = check_leptos_in_manifest(&local_manifest); + + if !has_leptos { + return Err(CliError::config("Leptos dependency not found in Cargo.toml")); + } + + Ok(WorkspaceInfo { + is_workspace: false, + workspace_root: None, + target_crate: local_manifest.package.as_ref().map(|p| p.name.clone()), + target_crate_path: Some(start_path.to_path_buf()), + components_base_path: "src/components".to_string(), + }) +} + +/// Analyze when running from workspace root +fn analyze_from_workspace_root(workspace_root: &Path, manifest: &Manifest) -> CliResult { + let workspace = + manifest.workspace.as_ref().ok_or_else(|| CliError::config("Expected workspace manifest"))?; + + // Find workspace member with Leptos + let members = expand_workspace_members(workspace_root, &workspace.members)?; + + for member_path in &members { + let member_cargo_toml = member_path.join("Cargo.toml"); + if let Some(member_manifest) = load_cargo_manifest(&member_cargo_toml)? + && member_manifest.dependencies.contains_key("leptos") + { + let crate_name = member_manifest + .package + .as_ref() + .map(|p| p.name.clone()) + .or_else(|| member_path.file_name().map(|n| n.to_string_lossy().to_string())) + .unwrap_or_default(); + + let relative_path = member_path.strip_prefix(workspace_root).unwrap_or(member_path); + + return Ok(WorkspaceInfo { + is_workspace: true, + workspace_root: Some(workspace_root.to_path_buf()), + target_crate: Some(crate_name), + target_crate_path: Some(member_path.clone()), + components_base_path: format!("{}/src/components", relative_path.display()), + }); + } + } + + // Check workspace.dependencies for leptos + if workspace.dependencies.contains_key("leptos") { + // Leptos is in workspace deps, but we need to find which member uses it + for member_path in &members { + let member_cargo_toml = member_path.join("Cargo.toml"); + if let Some(member_manifest) = load_cargo_manifest(&member_cargo_toml)? + && let Some(dep) = member_manifest.dependencies.get("leptos") + && matches!(dep, Dependency::Inherited(_)) + { + let crate_name = member_manifest + .package + .as_ref() + .map(|p| p.name.clone()) + .or_else(|| member_path.file_name().map(|n| n.to_string_lossy().to_string())) + .unwrap_or_default(); + + let relative_path = member_path.strip_prefix(workspace_root).unwrap_or(member_path); + + return Ok(WorkspaceInfo { + is_workspace: true, + workspace_root: Some(workspace_root.to_path_buf()), + target_crate: Some(crate_name), + target_crate_path: Some(member_path.clone()), + components_base_path: format!("{}/src/components", relative_path.display()), + }); + } + } + } + + Err(CliError::config( + "No workspace member with Leptos dependency found. Please run from a crate directory with Leptos installed.", + )) +} + +/// Analyze when running from a workspace member directory +fn analyze_from_workspace_member(member_path: &Path, workspace_root: &Path) -> CliResult { + let member_cargo_toml = member_path.join("Cargo.toml"); + let member_manifest = load_cargo_manifest(&member_cargo_toml)? + .ok_or_else(|| CliError::file_operation("Failed to parse member Cargo.toml"))?; + + // Check if this member has leptos + let has_leptos = check_leptos_in_manifest(&member_manifest); + + // Also check workspace.dependencies + let workspace_cargo_toml = workspace_root.join("Cargo.toml"); + let workspace_has_leptos = if let Some(ws_manifest) = load_cargo_manifest(&workspace_cargo_toml)? { + ws_manifest.workspace.as_ref().is_some_and(|ws| ws.dependencies.contains_key("leptos")) + } else { + false + }; + + if !has_leptos && !workspace_has_leptos { + return Err(CliError::config("Leptos dependency not found in this crate or workspace")); + } + + let crate_name = member_manifest + .package + .as_ref() + .map(|p| p.name.clone()) + .or_else(|| member_path.file_name().map(|n| n.to_string_lossy().to_string())) + .unwrap_or_default(); + + Ok(WorkspaceInfo { + is_workspace: true, + workspace_root: Some(workspace_root.to_path_buf()), + target_crate: Some(crate_name), + target_crate_path: Some(member_path.to_path_buf()), + // When running from member, components go in local src/components + components_base_path: "src/components".to_string(), + }) +} + +/// Find workspace root by walking up the directory tree +fn find_workspace_root(start_path: &Path) -> CliResult> { + let mut current = start_path.parent(); + + while let Some(dir) = current { + let cargo_toml = dir.join("Cargo.toml"); + if cargo_toml.exists() + && let Some(manifest) = load_cargo_manifest(&cargo_toml)? + && manifest.workspace.is_some() + { + return Ok(Some(dir.to_path_buf())); + } + current = dir.parent(); + } + + Ok(None) +} + +/// Expand workspace member patterns (handles globs like "crates/*") +fn expand_workspace_members(workspace_root: &Path, members: &[String]) -> CliResult> { + let mut result = Vec::new(); + + for member in members { + if member.contains('*') { + // Handle glob pattern + let pattern = workspace_root.join(member); + let pattern_str = pattern.to_string_lossy(); + + if let Ok(paths) = glob::glob(&pattern_str) { + for path in paths.flatten() { + if path.is_dir() && path.join("Cargo.toml").exists() { + result.push(path); + } + } + } + } else { + let member_path = workspace_root.join(member); + if member_path.is_dir() && member_path.join("Cargo.toml").exists() { + result.push(member_path); + } + } + } + + Ok(result) +} + +/// Check if manifest has leptos dependency +fn check_leptos_in_manifest(manifest: &Manifest) -> bool { + manifest.dependencies.contains_key("leptos") +} + +/// Checks if Leptos is installed as a dependency in Cargo.toml +pub fn check_leptos_dependency() -> CliResult { + // Use the workspace analysis which handles workspaces properly + match analyze_workspace() { + Ok(_) => Ok(true), // If analysis succeeds, leptos was found + Err(e) => { + // Check if it's specifically a "leptos not found" error + let err_msg = format!("{e}"); + if err_msg.contains("Leptos") { Ok(false) } else { Err(e) } + } + } +} + +/// Gets the tailwind input file path from Cargo.toml metadata. +/// Reads from `[[workspace.metadata.leptos]]` or `[package.metadata.leptos]`. +/// Returns an error if not found - user must add Leptos metadata to Cargo.toml. +pub fn get_tailwind_input_file() -> CliResult { + let current_dir = std::env::current_dir()?; + get_tailwind_input_file_from_path(¤t_dir) +} + +/// Gets the tailwind input file from a specific path (useful for testing) +pub fn get_tailwind_input_file_from_path(start_path: &Path) -> CliResult { + // First try the local Cargo.toml + let local_cargo_toml = start_path.join("Cargo.toml"); + if let Some(manifest) = load_cargo_manifest(&local_cargo_toml)? + && let Some(tailwind_file) = extract_tailwind_from_manifest(&manifest) + { + return Ok(tailwind_file); + } + + // If not found, try to find workspace root and read from there + if let Some(workspace_root) = find_workspace_root(start_path)? + && let Some(manifest) = load_cargo_manifest(&workspace_root.join("Cargo.toml"))? + && let Some(tailwind_file) = extract_tailwind_from_manifest(&manifest) + { + return Ok(tailwind_file); + } + + Err(CliError::config( + "Missing `tailwind-input-file` in Cargo.toml. \ + Please add Leptos metadata to your Cargo.toml:\n\n\ + [package.metadata.leptos]\n\ + tailwind-input-file = \"style/tailwind.css\"\n\n\ + Or for workspaces:\n\n\ + [[workspace.metadata.leptos]]\n\ + tailwind-input-file = \"style/tailwind.css\"", + )) +} + +/// Extracts tailwind-input-file from a parsed Manifest +fn extract_tailwind_from_manifest(manifest: &Manifest) -> Option { + // Try workspace.metadata.leptos (array of tables stored as Value) + if let Some(workspace) = &manifest.workspace + && let Some(metadata) = &workspace.metadata + && let Some(leptos_value) = metadata.get("leptos") + { + // [[workspace.metadata.leptos]] is an array + if let Some(array) = leptos_value.as_array() + && let Some(first) = array.first() + && let Some(tailwind) = first.get("tailwind-input-file") + && let Some(value) = tailwind.as_str() { + return Some(value.to_string()); + } + // [workspace.metadata.leptos] could also be a table + if let Some(tailwind) = leptos_value.get("tailwind-input-file") + && let Some(value) = tailwind.as_str() { + return Some(value.to_string()); + } + } + + // Try package.metadata.leptos (single table) + if let Some(package) = &manifest.package + && let Some(metadata) = &package.metadata + && let Some(leptos) = metadata.get("leptos") + && let Some(tailwind) = leptos.get("tailwind-input-file") + && let Some(value) = tailwind.as_str() + { + return Some(value.to_string()); + } + + None +} + +/* ========================================================== */ +/* ✨ HELPERS ✨ */ +/* ========================================================== */ + +/// Helper function to load a Cargo.toml manifest from a path +/// Loads and parses a Cargo.toml file into a Manifest struct. +/// Returns None if the file doesn't exist, Ok(Manifest) if parsed successfully. +pub fn load_cargo_manifest(cargo_toml_path: &Path) -> CliResult> { + if !cargo_toml_path.exists() { + return Ok(None); + } + + // Try to load with full workspace resolution first + match Manifest::from_path(cargo_toml_path) { + Ok(manifest) => Ok(Some(manifest)), + Err(_) => { + // If workspace resolution fails (e.g., in tests), try parsing without workspace resolution + let contents = std::fs::read_to_string(cargo_toml_path)?; + let manifest = Manifest::from_slice(contents.as_bytes())?; + Ok(Some(manifest)) + } + } +} + +/* ========================================================== */ +/* 🧪 TESTS 🧪 */ +/* ========================================================== */ + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use super::*; + + /// Helper to create a Cargo.toml with given content + fn write_cargo_toml(dir: &Path, content: &str) { + fs::write(dir.join("Cargo.toml"), content).unwrap(); + } + + /// Helper to create a minimal src directory + fn create_src_dir(dir: &Path) { + fs::create_dir_all(dir.join("src")).unwrap(); + fs::write(dir.join("src/lib.rs"), "").unwrap(); + } + + #[test] + fn test_single_crate_with_leptos() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + write_cargo_toml( + root, + r#" +[package] +name = "my-app" +version = "0.1.0" +edition = "2021" + +[dependencies] +leptos = "0.7" +"#, + ); + create_src_dir(root); + + let info = analyze_workspace_from_path(root).unwrap(); + + assert!(!info.is_workspace); + assert_eq!(info.target_crate, Some("my-app".to_string())); + assert_eq!(info.components_base_path, "src/components"); + } + + #[test] + fn test_single_crate_without_leptos() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + write_cargo_toml( + root, + r#" +[package] +name = "my-app" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = "1" +"#, + ); + create_src_dir(root); + + let result = analyze_workspace_from_path(root); + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Leptos")); + } + + #[test] + fn test_workspace_with_leptos_member() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + // Create workspace root + write_cargo_toml( + root, + r#" +[workspace] +members = ["app", "server"] +"#, + ); + + // Create app member with leptos + let app_dir = root.join("app"); + fs::create_dir_all(&app_dir).unwrap(); + write_cargo_toml( + &app_dir, + r#" +[package] +name = "my-app" +version = "0.1.0" +edition = "2021" + +[dependencies] +leptos = "0.7" +"#, + ); + create_src_dir(&app_dir); + + // Create server member without leptos + let server_dir = root.join("server"); + fs::create_dir_all(&server_dir).unwrap(); + write_cargo_toml( + &server_dir, + r#" +[package] +name = "my-server" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = "0.7" +"#, + ); + create_src_dir(&server_dir); + + // Test from workspace root + let info = analyze_workspace_from_path(root).unwrap(); + + assert!(info.is_workspace); + assert_eq!(info.target_crate, Some("my-app".to_string())); + assert_eq!(info.components_base_path, "app/src/components"); + } + + #[test] + fn test_workspace_from_member_directory() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + // Create workspace root + write_cargo_toml( + root, + r#" +[workspace] +members = ["frontend"] +"#, + ); + + // Create frontend member with leptos + let frontend_dir = root.join("frontend"); + fs::create_dir_all(&frontend_dir).unwrap(); + write_cargo_toml( + &frontend_dir, + r#" +[package] +name = "frontend" +version = "0.1.0" +edition = "2021" + +[dependencies] +leptos = "0.7" +"#, + ); + create_src_dir(&frontend_dir); + + // Test from member directory + let info = analyze_workspace_from_path(&frontend_dir).unwrap(); + + assert!(info.is_workspace); + assert_eq!(info.target_crate, Some("frontend".to_string())); + // When running from member, path is relative to member + assert_eq!(info.components_base_path, "src/components"); + } + + #[test] + fn test_workspace_with_workspace_dependencies() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + // Create workspace root with workspace.dependencies + write_cargo_toml( + root, + r#" +[workspace] +members = ["app"] + +[workspace.dependencies] +leptos = "0.7" +"#, + ); + + // Create app member that inherits leptos + let app_dir = root.join("app"); + fs::create_dir_all(&app_dir).unwrap(); + write_cargo_toml( + &app_dir, + r#" +[package] +name = "my-app" +version = "0.1.0" +edition = "2021" + +[dependencies] +leptos.workspace = true +"#, + ); + create_src_dir(&app_dir); + + let info = analyze_workspace_from_path(root).unwrap(); + + assert!(info.is_workspace); + assert_eq!(info.target_crate, Some("my-app".to_string())); + } + + #[test] + fn test_workspace_no_leptos_member() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + write_cargo_toml( + root, + r#" +[workspace] +members = ["server"] +"#, + ); + + let server_dir = root.join("server"); + fs::create_dir_all(&server_dir).unwrap(); + write_cargo_toml( + &server_dir, + r#" +[package] +name = "server" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = "0.7" +"#, + ); + create_src_dir(&server_dir); + + let result = analyze_workspace_from_path(root); + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Leptos")); + } + + #[test] + fn test_no_cargo_toml() { + let temp = TempDir::new().unwrap(); + let result = analyze_workspace_from_path(temp.path()); + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Cargo.toml")); + } + + #[test] + fn test_workspace_info_default() { + let info = WorkspaceInfo::default(); + + assert!(!info.is_workspace); + assert!(info.workspace_root.is_none()); + assert!(info.target_crate.is_none()); + assert_eq!(info.components_base_path, "src/components"); + } + + // ========== Tailwind Input File Tests ========== + + #[test] + fn test_get_tailwind_from_workspace_metadata() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + write_cargo_toml( + root, + r#" +[workspace] +members = ["app"] + +[[workspace.metadata.leptos]] +name = "my-app" +tailwind-input-file = "style/main.css" +"#, + ); + + let result = get_tailwind_input_file_from_path(root).unwrap(); + assert_eq!(result, "style/main.css"); + } + + #[test] + fn test_get_tailwind_from_package_metadata() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + write_cargo_toml( + root, + r#" +[package] +name = "my-app" +version = "0.1.0" + +[package.metadata.leptos] +tailwind-input-file = "assets/tailwind.css" +"#, + ); + + let result = get_tailwind_input_file_from_path(root).unwrap(); + assert_eq!(result, "assets/tailwind.css"); + } + + #[test] + fn test_get_tailwind_from_workspace_root_when_in_member() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + // Create workspace root with tailwind config + write_cargo_toml( + root, + r#" +[workspace] +members = ["app"] + +[[workspace.metadata.leptos]] +name = "my-app" +tailwind-input-file = "style/global.css" +"#, + ); + + // Create member without tailwind config + let app_dir = root.join("app"); + fs::create_dir_all(&app_dir).unwrap(); + write_cargo_toml( + &app_dir, + r#" +[package] +name = "app" +version = "0.1.0" + +[dependencies] +leptos = "0.7" +"#, + ); + + // Should find tailwind from workspace root + let result = get_tailwind_input_file_from_path(&app_dir).unwrap(); + assert_eq!(result, "style/global.css"); + } + + #[test] + fn test_get_tailwind_missing_returns_error() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + write_cargo_toml( + root, + r#" +[package] +name = "my-app" +version = "0.1.0" + +[dependencies] +leptos = "0.7" +"#, + ); + + let result = get_tailwind_input_file_from_path(root); + assert!(result.is_err()); + + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("tailwind-input-file")); + assert!(err_msg.contains("Cargo.toml")); + } + + #[test] + fn test_get_tailwind_no_cargo_toml_returns_error() { + let temp = TempDir::new().unwrap(); + + let result = get_tailwind_input_file_from_path(temp.path()); + assert!(result.is_err()); + } + + #[test] + fn test_get_tailwind_prefers_local_over_workspace() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + // Create workspace root with one tailwind config + write_cargo_toml( + root, + r#" +[workspace] +members = ["app"] + +[[workspace.metadata.leptos]] +name = "workspace-app" +tailwind-input-file = "style/workspace.css" +"#, + ); + + // Create member with its own tailwind config + let app_dir = root.join("app"); + fs::create_dir_all(&app_dir).unwrap(); + write_cargo_toml( + &app_dir, + r#" +[package] +name = "app" +version = "0.1.0" + +[package.metadata.leptos] +tailwind-input-file = "style/local.css" +"#, + ); + + // Should prefer local config + let result = get_tailwind_input_file_from_path(&app_dir).unwrap(); + assert_eq!(result, "style/local.css"); + } + + #[test] + fn test_get_tailwind_multiple_leptos_entries_uses_first() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + // Multiple [[workspace.metadata.leptos]] entries - should use first + write_cargo_toml( + root, + r#" +[workspace] +members = ["app"] + +[[workspace.metadata.leptos]] +name = "first-app" +tailwind-input-file = "style/first.css" + +[[workspace.metadata.leptos]] +name = "second-app" +tailwind-input-file = "style/second.css" +"#, + ); + + let result = get_tailwind_input_file_from_path(root).unwrap(); + assert_eq!(result, "style/first.css"); + } + + #[test] + fn test_get_tailwind_workspace_single_table_format() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + // [workspace.metadata.leptos] as single table (not array) + write_cargo_toml( + root, + r#" +[workspace] +members = ["app"] + +[workspace.metadata.leptos] +tailwind-input-file = "style/single.css" +"#, + ); + + let result = get_tailwind_input_file_from_path(root).unwrap(); + assert_eq!(result, "style/single.css"); + } + + #[test] + fn test_get_tailwind_metadata_exists_but_no_leptos_key() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + write_cargo_toml( + root, + r#" +[package] +name = "my-app" +version = "0.1.0" + +[package.metadata] +some-other-tool = { key = "value" } +"#, + ); + + let result = get_tailwind_input_file_from_path(root); + assert!(result.is_err()); + } + + #[test] + fn test_get_tailwind_leptos_exists_but_no_tailwind_key() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + write_cargo_toml( + root, + r#" +[package] +name = "my-app" +version = "0.1.0" + +[package.metadata.leptos] +name = "my-app" +site-root = "target/site" +"#, + ); + + let result = get_tailwind_input_file_from_path(root); + assert!(result.is_err()); + } + + #[test] + fn test_get_tailwind_empty_value() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + write_cargo_toml( + root, + r#" +[package] +name = "my-app" +version = "0.1.0" + +[package.metadata.leptos] +tailwind-input-file = "" +"#, + ); + + // Empty string is technically valid - returns empty + let result = get_tailwind_input_file_from_path(root).unwrap(); + assert_eq!(result, ""); + } +} diff --git a/src/command_list/_list.rs b/src/command_list/_list.rs new file mode 100644 index 0000000..8604896 --- /dev/null +++ b/src/command_list/_list.rs @@ -0,0 +1,232 @@ +use std::collections::BTreeMap; + +use clap::{Arg, ArgMatches, Command}; +use serde::Serialize; + +use crate::command_add::tree_parser::TreeParser; +use crate::shared::cli_error::CliResult; +use crate::shared::rust_ui_client::RustUIClient; + +/* ========================================================== */ +/* 🦀 MAIN 🦀 */ +/* ========================================================== */ + +pub fn command_list() -> Command { + Command::new("list").about("List all available components from the registry").arg( + Arg::new("json").long("json").help("Output as JSON").action(clap::ArgAction::SetTrue), + ) +} + +pub async fn process_list(matches: &ArgMatches) -> CliResult<()> { + let json = matches.get_flag("json"); + + let tree_content = RustUIClient::fetch_tree_md().await?; + let tree_parser = TreeParser::parse_tree_md(&tree_content)?; + let by_category = tree_parser.get_components_by_category(); + + let output = if json { format_list_json(&by_category)? } else { format_list(&by_category) }; + println!("{output}"); + Ok(()) +} + +/* ========================================================== */ +/* ✨ HELPERS ✨ */ +/* ========================================================== */ + +#[derive(Serialize)] +struct ListJson<'a> { + total: usize, + categories: &'a BTreeMap>, +} + +/// Filters components by a case-insensitive query on the component name. +/// Returns a new map containing only matching names; empty categories are dropped. +pub fn filter_by_query(by_category: &BTreeMap>, query: &str) -> BTreeMap> { + let q = query.to_lowercase(); + by_category + .iter() + .filter_map(|(cat, names)| { + let matched: Vec = names.iter().filter(|n| n.to_lowercase().contains(&q)).cloned().collect(); + if matched.is_empty() { None } else { Some((cat.clone(), matched)) } + }) + .collect() +} + +/// Human-readable formatter — one component per line, grouped by category. +pub fn format_list(by_category: &BTreeMap>) -> String { + let total: usize = by_category.values().map(|v| v.len()).sum(); + + if total == 0 { + return "No components found.".to_string(); + } + + let mut lines: Vec = Vec::new(); + lines.push(format!("Available components ({total} total)")); + lines.push(String::new()); + + for (category, names) in by_category { + lines.push(format!(" {} ({})", category, names.len())); + for name in names { + lines.push(format!(" {name}")); + } + lines.push(String::new()); + } + + if lines.last().map(|l| l.is_empty()).unwrap_or(false) { + lines.pop(); + } + + lines.join("\n") +} + +/// Machine-readable JSON formatter. +pub fn format_list_json(by_category: &BTreeMap>) -> CliResult { + let total: usize = by_category.values().map(|v| v.len()).sum(); + let payload = ListJson { total, categories: by_category }; + serde_json::to_string_pretty(&payload).map_err(Into::into) +} + +/* ========================================================== */ +/* 🧪 TESTS 🧪 */ +/* ========================================================== */ + +#[cfg(test)] +mod tests { + use super::*; + + fn make_map(entries: &[(&str, &[&str])]) -> BTreeMap> { + entries + .iter() + .map(|(cat, names)| (cat.to_string(), names.iter().map(|n| n.to_string()).collect())) + .collect() + } + + // --- format_list --- + + #[test] + fn empty_map_shows_no_components_message() { + let result = format_list(&make_map(&[])); + assert_eq!(result, "No components found."); + } + + #[test] + fn shows_total_count() { + let map = make_map(&[("ui", &["button", "badge"]), ("demos", &["demo_button"])]); + assert!(format_list(&map).contains("3 total")); + } + + #[test] + fn shows_category_with_count() { + let map = make_map(&[("ui", &["button", "badge"])]); + assert!(format_list(&map).contains("ui (2)")); + } + + #[test] + fn shows_each_component_on_its_own_line() { + let map = make_map(&[("ui", &["button", "badge"])]); + let result = format_list(&map); + assert!(result.contains(" button")); + assert!(result.contains(" badge")); + } + + #[test] + fn categories_appear_in_alphabetical_order() { + let map = make_map(&[("ui", &["button"]), ("demos", &["demo_button"]), ("hooks", &["use_x"])]); + let result = format_list(&map); + let demos_pos = result.find("demos").unwrap(); + let hooks_pos = result.find("hooks").unwrap(); + let ui_pos = result.find(" ui").unwrap(); + assert!(demos_pos < hooks_pos); + assert!(hooks_pos < ui_pos); + } + + #[test] + fn single_category_single_component() { + let map = make_map(&[("ui", &["button"])]); + let result = format_list(&map); + assert!(result.contains("1 total")); + assert!(result.contains("ui (1)")); + assert!(result.contains(" button")); + } + + // --- filter_by_query --- + + #[test] + fn filter_returns_exact_match() { + let map = make_map(&[("ui", &["button", "badge", "card"])]); + let filtered = filter_by_query(&map, "button"); + assert_eq!(filtered["ui"], vec!["button"]); + } + + #[test] + fn filter_is_case_insensitive() { + let map = make_map(&[("ui", &["button", "Badge"])]); + let filtered = filter_by_query(&map, "BUTTON"); + assert!(filtered["ui"].contains(&"button".to_string())); + } + + #[test] + fn filter_matches_partial_name() { + let map = make_map(&[("demos", &["demo_button", "demo_badge", "demo_card"])]); + let filtered = filter_by_query(&map, "badge"); + assert_eq!(filtered["demos"], vec!["demo_badge"]); + } + + #[test] + fn filter_drops_empty_categories() { + let map = make_map(&[("ui", &["button"]), ("demos", &["demo_card"])]); + let filtered = filter_by_query(&map, "button"); + assert!(filtered.contains_key("ui")); + assert!(!filtered.contains_key("demos")); + } + + #[test] + fn filter_returns_empty_map_when_no_match() { + let map = make_map(&[("ui", &["button", "badge"])]); + let filtered = filter_by_query(&map, "zzz"); + assert!(filtered.is_empty()); + } + + #[test] + fn filter_empty_query_returns_all() { + let map = make_map(&[("ui", &["button", "badge"]), ("demos", &["demo_button"])]); + let filtered = filter_by_query(&map, ""); + assert_eq!(filtered.len(), map.len()); + } + + // --- format_list_json --- + + #[test] + fn json_output_is_valid() { + let map = make_map(&[("ui", &["button", "badge"])]); + let json = format_list_json(&map).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert!(parsed.is_object()); + } + + #[test] + fn json_contains_total_and_categories() { + let map = make_map(&[("ui", &["button", "badge"])]); + let json = format_list_json(&map).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["total"], 2); + assert!(parsed["categories"].is_object()); + } + + #[test] + fn json_categories_contain_component_arrays() { + let map = make_map(&[("ui", &["button", "badge"])]); + let json = format_list_json(&map).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + let ui = parsed["categories"]["ui"].as_array().unwrap(); + assert_eq!(ui.len(), 2); + } + + #[test] + fn json_empty_map_has_zero_total() { + let map = make_map(&[]); + let json = format_list_json(&map).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["total"], 0); + } +} diff --git a/src/command_list/mod.rs b/src/command_list/mod.rs new file mode 100644 index 0000000..c2fb0e7 --- /dev/null +++ b/src/command_list/mod.rs @@ -0,0 +1 @@ +pub mod _list; diff --git a/src/command_mcp/_mcp.rs b/src/command_mcp/_mcp.rs new file mode 100644 index 0000000..bbd9670 --- /dev/null +++ b/src/command_mcp/_mcp.rs @@ -0,0 +1,157 @@ +use clap::{Arg, ArgMatches, Command}; +use dialoguer::Select; +use rmcp::{ + ServerHandler, ServiceExt, + handler::server::{router::tool::ToolRouter, wrapper::Parameters}, + model::{ServerCapabilities, ServerInfo}, + tool, tool_handler, tool_router, +}; +use schemars::JsonSchema; +use serde::Deserialize; + +use super::config::{McpClient, write_mcp_config}; +use super::tools; +use crate::shared::cli_error::{CliError, CliResult}; + +/* ========================================================== */ +/* 🔧 CLAP COMMANDS 🔧 */ +/* ========================================================== */ + +pub fn command_mcp() -> Command { + Command::new("mcp") + .about("Start the MCP server or write editor config") + .subcommand( + Command::new("init") + .about("Write MCP config for your editor") + .arg( + Arg::new("client") + .long("client") + .value_parser(["claude", "cursor", "vscode", "opencode"]) + .help("Editor client (claude, cursor, vscode, opencode)"), + ), + ) +} + +/* ========================================================== */ +/* 🦀 PROCESS FNS 🦀 */ +/* ========================================================== */ + +pub async fn process_mcp_server() -> CliResult<()> { + let transport = rmcp::transport::stdio(); + let server = RustUiMcpServer::new() + .serve(transport) + .await + .map_err(|e| CliError::file_operation(&e.to_string()))?; + server + .waiting() + .await + .map_err(|e| CliError::file_operation(&e.to_string()))?; + Ok(()) +} + +pub fn process_mcp_init(matches: &ArgMatches) -> CliResult<()> { + let client_name = match matches.get_one::("client") { + Some(s) => s.clone(), + None => { + let names = McpClient::all_names(); + let labels = ["Claude Code", "Cursor", "VS Code", "OpenCode"]; + let idx = Select::new() + .with_prompt("Which editor are you using?") + .items(labels) + .default(0) + .interact() + .map_err(|e| CliError::file_operation(&e.to_string()))?; + names.get(idx).copied().unwrap_or("claude").to_string() + } + }; + + let client = + McpClient::from_str(&client_name).ok_or_else(|| CliError::validation("Unknown client"))?; + + let label = client.label(); + let cwd = std::env::current_dir()?; + let config_path = write_mcp_config(client, &cwd)?; + + println!("Configured rust-ui MCP server for {label}."); + println!("Config written to: {config_path}"); + println!(); + println!("Restart your editor to load the MCP server."); + Ok(()) +} + +/* ========================================================== */ +/* 🛠 MCP SERVER 🛠 */ +/* ========================================================== */ + +#[derive(Debug, Deserialize, JsonSchema)] +struct CategoryFilter { + /// Optional category name to filter components (e.g. "ui", "demos") + category: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct SearchQuery { + /// Search query string (case-insensitive, partial match) + query: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct ComponentName { + /// Component name (e.g. "button", "accordion", "demo_button") + name: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct AddComponents { + /// One or more component names to install + components: Vec, +} + +#[derive(Debug, Clone)] +struct RustUiMcpServer { + tool_router: ToolRouter, +} + +#[tool_router(router = tool_router)] +impl RustUiMcpServer { + pub fn new() -> Self { + Self { tool_router: Self::tool_router() } + } + + #[tool(description = "List all available rust-ui components, optionally filtered by category")] + async fn list_components(&self, params: Parameters) -> String { + tools::list_components(params.0.category).await.unwrap_or_else(|e| format!("Error: {e}")) + } + + #[tool(description = "Search for rust-ui components by name (case-insensitive partial match)")] + async fn search_components(&self, params: Parameters) -> String { + tools::search_components(¶ms.0.query).await.unwrap_or_else(|e| format!("Error: {e}")) + } + + #[tool(description = "View the full Rust source code of a component from the registry")] + async fn view_component(&self, params: Parameters) -> String { + tools::view_component(¶ms.0.name).await.unwrap_or_else(|e| format!("Error: {e}")) + } + + #[tool(description = "Get the 'ui add' command to install one or more components into your project")] + async fn get_add_command(&self, params: Parameters) -> String { + format!("ui add {}", params.0.components.join(" ")) + } + + #[tool(description = "Checklist to verify after adding rust-ui components (imports, Cargo.toml, Tailwind, etc.)")] + fn get_audit_checklist(&self) -> String { + tools::audit_checklist() + } +} + +#[tool_handler(router = self.tool_router)] +impl ServerHandler for RustUiMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_instructions( + "rust-ui component registry. Use list_components to browse, \ + search_components to find, view_component to inspect source, \ + get_add_command to get the install command, \ + get_audit_checklist after installing.", + ) + } +} diff --git a/src/command_mcp/config.rs b/src/command_mcp/config.rs new file mode 100644 index 0000000..a95aeec --- /dev/null +++ b/src/command_mcp/config.rs @@ -0,0 +1,197 @@ +use std::fs; +use std::path::Path; + +use crate::shared::cli_error::CliResult; + +pub enum McpClient { + Claude, + Cursor, + VsCode, + OpenCode, +} + +impl McpClient { + pub fn from_str(s: &str) -> Option { + match s { + "claude" => Some(Self::Claude), + "cursor" => Some(Self::Cursor), + "vscode" => Some(Self::VsCode), + "opencode" => Some(Self::OpenCode), + _ => None, + } + } + + pub fn label(&self) -> &'static str { + match self { + Self::Claude => "Claude Code", + Self::Cursor => "Cursor", + Self::VsCode => "VS Code", + Self::OpenCode => "OpenCode", + } + } + + pub fn config_path(&self) -> &'static str { + match self { + Self::Claude => ".mcp.json", + Self::Cursor => ".cursor/mcp.json", + Self::VsCode => ".vscode/mcp.json", + Self::OpenCode => "opencode.json", + } + } + + pub fn all_names() -> &'static [&'static str] { + &["claude", "cursor", "vscode", "opencode"] + } +} + +pub fn write_mcp_config(client: McpClient, cwd: &Path) -> CliResult { + let config_path = cwd.join(client.config_path()); + + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent)?; + } + + let new_config = match client { + McpClient::Claude | McpClient::Cursor => serde_json::json!({ + "mcpServers": { + "rust-ui": { "command": "ui", "args": ["mcp"] } + } + }), + McpClient::VsCode => serde_json::json!({ + "servers": { + "rust-ui": { "command": "ui", "args": ["mcp"] } + } + }), + McpClient::OpenCode => serde_json::json!({ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "rust-ui": { + "type": "local", + "command": ["ui", "mcp"], + "enabled": true + } + } + }), + }; + + let merged = if config_path.exists() { + let raw = fs::read_to_string(&config_path)?; + let existing: serde_json::Value = serde_json::from_str(&raw).unwrap_or(serde_json::json!({})); + merge_json(existing, new_config) + } else { + new_config + }; + + let content = serde_json::to_string_pretty(&merged)? + "\n"; + fs::write(&config_path, content)?; + + Ok(client.config_path().to_string()) +} + +/// Deep-merge two JSON objects. Object keys are merged recursively; all other +/// values are overwritten by the override. +fn merge_json(base: serde_json::Value, override_val: serde_json::Value) -> serde_json::Value { + match (base, override_val) { + (serde_json::Value::Object(mut a), serde_json::Value::Object(b)) => { + for (k, v) in b { + let merged = match a.remove(&k) { + Some(existing) => merge_json(existing, v), + None => v, + }; + a.insert(k, merged); + } + serde_json::Value::Object(a) + } + (_, b) => b, + } +} + +/* ========================================================== */ +/* 🧪 TESTS 🧪 */ +/* ========================================================== */ + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn read_json(dir: &TempDir, path: &str) -> serde_json::Value { + let content = fs::read_to_string(dir.path().join(path)).unwrap(); + serde_json::from_str(&content).unwrap() + } + + #[test] + fn claude_writes_mcp_json() { + let dir = TempDir::new().unwrap(); + let result = write_mcp_config(McpClient::Claude, dir.path()); + assert!(result.is_ok()); + assert!(dir.path().join(".mcp.json").exists()); + } + + #[test] + fn claude_config_has_correct_command() { + let dir = TempDir::new().unwrap(); + write_mcp_config(McpClient::Claude, dir.path()).unwrap(); + let json = read_json(&dir, ".mcp.json"); + assert_eq!(json["mcpServers"]["rust-ui"]["command"], "ui"); + assert_eq!(json["mcpServers"]["rust-ui"]["args"][0], "mcp"); + } + + #[test] + fn cursor_writes_to_cursor_subdir() { + let dir = TempDir::new().unwrap(); + let result = write_mcp_config(McpClient::Cursor, dir.path()); + assert!(result.is_ok()); + assert!(dir.path().join(".cursor/mcp.json").exists()); + } + + #[test] + fn vscode_uses_servers_key_not_mcp_servers() { + let dir = TempDir::new().unwrap(); + write_mcp_config(McpClient::VsCode, dir.path()).unwrap(); + let json = read_json(&dir, ".vscode/mcp.json"); + assert!(json.get("servers").is_some()); + assert!(json.get("mcpServers").is_none()); + } + + #[test] + fn opencode_has_schema_and_enabled_flag() { + let dir = TempDir::new().unwrap(); + write_mcp_config(McpClient::OpenCode, dir.path()).unwrap(); + let json = read_json(&dir, "opencode.json"); + assert!(json.get("$schema").is_some()); + assert_eq!(json["mcp"]["rust-ui"]["enabled"], true); + } + + #[test] + fn merges_with_existing_config_without_overwriting_other_keys() { + let dir = TempDir::new().unwrap(); + let existing = serde_json::json!({ "mcpServers": { "other-tool": { "command": "other" } } }); + fs::write(dir.path().join(".mcp.json"), serde_json::to_string_pretty(&existing).unwrap()).unwrap(); + + write_mcp_config(McpClient::Claude, dir.path()).unwrap(); + + let json = read_json(&dir, ".mcp.json"); + assert!(json["mcpServers"].get("other-tool").is_some(), "existing key must be preserved"); + assert!(json["mcpServers"].get("rust-ui").is_some(), "new key must be added"); + } + + #[test] + fn merge_json_preserves_base_keys() { + let base = serde_json::json!({ "a": 1, "b": 2 }); + let override_val = serde_json::json!({ "b": 99, "c": 3 }); + let merged = merge_json(base, override_val); + assert_eq!(merged["a"], 1); + assert_eq!(merged["b"], 99); + assert_eq!(merged["c"], 3); + } + + #[test] + fn merge_json_non_object_override_wins() { + let base = serde_json::json!("old"); + let override_val = serde_json::json!("new"); + let merged = merge_json(base, override_val); + assert_eq!(merged, serde_json::json!("new")); + } +} diff --git a/src/command_mcp/mod.rs b/src/command_mcp/mod.rs new file mode 100644 index 0000000..abb0c5c --- /dev/null +++ b/src/command_mcp/mod.rs @@ -0,0 +1,3 @@ +pub mod _mcp; +mod config; +mod tools; diff --git a/src/command_mcp/tools.rs b/src/command_mcp/tools.rs new file mode 100644 index 0000000..fae2efd --- /dev/null +++ b/src/command_mcp/tools.rs @@ -0,0 +1,77 @@ +use crate::command_add::tree_parser::TreeParser; +use crate::command_list::_list::{filter_by_query, format_list}; +use crate::command_search::_search::format_search_result; +use crate::command_view::_view::format_view_human; +use crate::shared::cli_error::CliResult; +use crate::shared::rust_ui_client::RustUIClient; + +pub async fn list_components(category: Option) -> CliResult { + let tree_content = RustUIClient::fetch_tree_md().await?; + let tree_parser = TreeParser::parse_tree_md(&tree_content)?; + let by_category = tree_parser.get_components_by_category(); + + let filtered = match &category { + Some(cat) => filter_by_query(&by_category, cat), + None => by_category, + }; + + Ok(format_list(&filtered)) +} + +pub async fn search_components(query: &str) -> CliResult { + let tree_content = RustUIClient::fetch_tree_md().await?; + let tree_parser = TreeParser::parse_tree_md(&tree_content)?; + let by_category = tree_parser.get_components_by_category(); + let filtered = filter_by_query(&by_category, query); + Ok(format_search_result(&filtered, query)) +} + +pub async fn view_component(name: &str) -> CliResult { + let content = RustUIClient::fetch_styles_default(name).await?; + Ok(format_view_human(name, &content)) +} + +pub fn audit_checklist() -> String { + r#"## rust-ui Audit Checklist + +After adding components: + +- [ ] Cargo.toml — all required crates added (leptos_ui, tw_merge, icons, etc.) +- [ ] mod.rs — component is pub mod'd correctly +- [ ] Imports — correct use paths (leptos::*, leptos_ui::*) +- [ ] Features — leptos feature flags match your project (csr/ssr/hydrate) +- [ ] Tailwind — input.css includes the component's source glob +- [ ] Browser — hot reload and check for hydration errors in console"# + .to_string() +} + +/* ========================================================== */ +/* 🧪 TESTS 🧪 */ +/* ========================================================== */ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn audit_checklist_contains_cargo_toml_step() { + let checklist = audit_checklist(); + assert!(checklist.contains("Cargo.toml")); + } + + #[test] + fn audit_checklist_contains_all_steps() { + let checklist = audit_checklist(); + assert!(checklist.contains("mod.rs")); + assert!(checklist.contains("Imports")); + assert!(checklist.contains("Features")); + assert!(checklist.contains("Tailwind")); + assert!(checklist.contains("Browser")); + } + + #[test] + fn audit_checklist_uses_markdown_checkboxes() { + let checklist = audit_checklist(); + assert!(checklist.contains("- [ ]")); + } +} diff --git a/src/command_search/_search.rs b/src/command_search/_search.rs new file mode 100644 index 0000000..81431bd --- /dev/null +++ b/src/command_search/_search.rs @@ -0,0 +1,110 @@ +use clap::{Arg, ArgMatches, Command}; + +use crate::command_add::tree_parser::TreeParser; +use crate::command_list::_list::{filter_by_query, format_list, format_list_json}; +use crate::shared::cli_error::CliResult; +use crate::shared::rust_ui_client::RustUIClient; + +/* ========================================================== */ +/* 🦀 MAIN 🦀 */ +/* ========================================================== */ + +pub fn command_search() -> Command { + Command::new("search") + .about("Search available components by name") + .arg(Arg::new("query").help("Search query (case-insensitive)").required(true)) + .arg(Arg::new("json").long("json").help("Output as JSON").action(clap::ArgAction::SetTrue)) +} + +pub async fn process_search(matches: &ArgMatches) -> CliResult<()> { + let query = matches.get_one::("query").map(|s| s.as_str()).unwrap_or(""); + let json = matches.get_flag("json"); + + let tree_content = RustUIClient::fetch_tree_md().await?; + let tree_parser = TreeParser::parse_tree_md(&tree_content)?; + let by_category = tree_parser.get_components_by_category(); + let filtered = filter_by_query(&by_category, query); + + let output = if json { format_list_json(&filtered)? } else { format_search_result(&filtered, query) }; + println!("{output}"); + Ok(()) +} + +/* ========================================================== */ +/* ✨ HELPERS ✨ */ +/* ========================================================== */ + +/// Wraps `format_list` with a search-specific header. +pub fn format_search_result(filtered: &std::collections::BTreeMap>, query: &str) -> String { + let total: usize = filtered.values().map(|v| v.len()).sum(); + + if total == 0 { + return format!("No components found matching \"{query}\"."); + } + + let list = format_list(filtered); + // Replace the generic header with a search-specific one + list.replacen( + &format!("Available components ({total} total)"), + &format!("Search results for \"{query}\" ({total} found)"), + 1, + ) +} + +/* ========================================================== */ +/* 🧪 TESTS 🧪 */ +/* ========================================================== */ + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + + fn make_map(entries: &[(&str, &[&str])]) -> BTreeMap> { + entries + .iter() + .map(|(cat, names)| (cat.to_string(), names.iter().map(|n| n.to_string()).collect())) + .collect() + } + + #[test] + fn no_match_shows_query_in_message() { + let map = make_map(&[("ui", &["button"])]); + let result = format_search_result(&filter_by_query(&map, "zzz"), "zzz"); + assert!(result.contains("zzz")); + assert!(result.contains("No components found")); + } + + #[test] + fn match_shows_search_header_with_query() { + let map = make_map(&[("ui", &["button", "badge"])]); + let result = format_search_result(&filter_by_query(&map, "button"), "button"); + assert!(result.contains("Search results for \"button\"")); + assert!(result.contains("1 found")); + } + + #[test] + fn match_lists_components() { + let map = make_map(&[("ui", &["button", "badge"]), ("demos", &["demo_button"])]); + let result = format_search_result(&filter_by_query(&map, "button"), "button"); + assert!(result.contains("button")); + assert!(result.contains("demo_button")); + assert!(!result.contains("badge")); + } + + #[test] + fn search_across_multiple_categories() { + let map = make_map(&[("ui", &["button"]), ("demos", &["demo_button"])]); + let filtered = filter_by_query(&map, "button"); + assert!(filtered.contains_key("ui")); + assert!(filtered.contains_key("demos")); + } + + #[test] + fn empty_query_returns_all() { + let map = make_map(&[("ui", &["button", "badge"])]); + let filtered = filter_by_query(&map, ""); + assert_eq!(filtered["ui"].len(), 2); + } +} diff --git a/src/command_search/mod.rs b/src/command_search/mod.rs new file mode 100644 index 0000000..da18828 --- /dev/null +++ b/src/command_search/mod.rs @@ -0,0 +1 @@ +pub mod _search; diff --git a/src/command_starters/_starters.rs b/src/command_starters/_starters.rs new file mode 100644 index 0000000..9402bef --- /dev/null +++ b/src/command_starters/_starters.rs @@ -0,0 +1,73 @@ +use std::process::{Command as ProcessCommand, Stdio}; + +use clap::Command; +use dialoguer::Select; +use dialoguer::theme::ColorfulTheme; +use strum::{Display, EnumIter, EnumString, IntoEnumIterator}; + +use crate::shared::cli_error::{CliError, CliResult}; + +// TODO. Use cargo-generate later for more customization. + +/* ========================================================== */ +/* 🦀 MAIN 🦀 */ +/* ========================================================== */ + +pub fn command_starters() -> Command { + Command::new("starters").about("Choose and install starter templates") +} + +/* ========================================================== */ +/* ✨ FUNCTIONS ✨ */ +/* ========================================================== */ + +#[derive(Display, EnumString, EnumIter)] +#[strum(serialize_all = "kebab-case")] +enum StarterTemplate { + Tauri, + TauriFullstack, +} + +pub async fn process_starters() -> CliResult<()> { + let templates: Vec = StarterTemplate::iter().collect(); + let template_names: Vec = templates.iter().map(|t| t.to_string()).collect(); + + let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt("Select a starter template") + .items(&template_names) + .default(0) + .interact() + .map_err(|_| CliError::validation("Failed to get user selection"))?; + + let selected_template = + templates.get(selection).ok_or_else(|| CliError::validation("Invalid selection"))?; + clone_starter_template(selected_template)?; + + Ok(()) +} + +/* ========================================================== */ +/* ✨ FUNCTIONS ✨ */ +/* ========================================================== */ + +/// Helper function to clone a starter template repository +fn clone_starter_template(template: &StarterTemplate) -> CliResult<()> { + let template_name = template.to_string(); + println!("Installing {template_name} starter..."); + + let output = ProcessCommand::new("git") + .arg("clone") + .arg(format!("https://github.com/rust-ui/start-{template_name}.git")) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .output() + .map_err(|_| CliError::git_clone_failed())?; + + if output.status.success() { + println!("✅ Successfully cloned {template_name} starter template"); + } else { + return Err(CliError::git_clone_failed()); + } + + Ok(()) +} diff --git a/src/command_starters/mod.rs b/src/command_starters/mod.rs new file mode 100644 index 0000000..46d9250 --- /dev/null +++ b/src/command_starters/mod.rs @@ -0,0 +1 @@ +pub mod _starters; diff --git a/src/command_update/_update.rs b/src/command_update/_update.rs new file mode 100644 index 0000000..24a1284 --- /dev/null +++ b/src/command_update/_update.rs @@ -0,0 +1,263 @@ +use std::path::Path; + +use clap::{Arg, ArgMatches, Command}; +use serde::Serialize; + +use crate::command_add::component_type::ComponentType; +use crate::command_add::installed::get_installed_components; +use crate::command_init::config::UiConfig; +use crate::shared::cli_error::CliResult; +use crate::shared::rust_ui_client::RustUIClient; + +const UI_CONFIG_TOML: &str = "ui_config.toml"; + +/* ========================================================== */ +/* 📦 TYPES 📦 */ +/* ========================================================== */ + +#[derive(Debug, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ComponentStatus { + UpToDate, + Outdated, + NotInRegistry, +} + +#[derive(Debug, Serialize)] +pub struct ComponentUpdateInfo { + pub name: String, + pub status: ComponentStatus, +} + +/* ========================================================== */ +/* 🦀 MAIN 🦀 */ +/* ========================================================== */ + +pub fn command_update() -> Command { + Command::new("update") + .about("Check installed components for updates against the registry") + .arg(Arg::new("json").long("json").help("Output as JSON").action(clap::ArgAction::SetTrue)) +} + +pub async fn process_update(matches: &ArgMatches) -> CliResult<()> { + let json = matches.get_flag("json"); + + let config = UiConfig::try_reading_ui_config(UI_CONFIG_TOML)?; + let base_path = config.base_path_components; + + let mut installed: Vec = get_installed_components(&base_path).into_iter().collect(); + installed.sort(); + + if installed.is_empty() { + println!("No components installed."); + return Ok(()); + } + + println!("Checking {} installed component{}...", installed.len(), if installed.len() == 1 { "" } else { "s" }); + + let mut results: Vec = Vec::new(); + + for name in &installed { + let component_type = ComponentType::from_component_name(name); + let relative_path = format!("{}/{}.rs", component_type.to_path(), name); + let local_path = Path::new(&base_path).join(&relative_path); + + let local_content = match std::fs::read_to_string(&local_path) { + Ok(c) => c, + Err(_) => { + results.push(ComponentUpdateInfo { name: name.clone(), status: ComponentStatus::NotInRegistry }); + continue; + } + }; + + let status = match RustUIClient::fetch_styles_default(name).await { + Ok(remote_content) => compare_content(&local_content, &remote_content), + Err(_) => ComponentStatus::NotInRegistry, + }; + + results.push(ComponentUpdateInfo { name: name.clone(), status }); + } + + let output = + if json { format_update_json(&results)? } else { format_update_summary(&results) }; + println!("{output}"); + + Ok(()) +} + +/* ========================================================== */ +/* ✨ HELPERS ✨ */ +/* ========================================================== */ + +/// Compares local and remote content, trimming whitespace to avoid false positives. +pub fn compare_content(local: &str, remote: &str) -> ComponentStatus { + if local.trim() == remote.trim() { ComponentStatus::UpToDate } else { ComponentStatus::Outdated } +} + +/// Human-readable summary of update check results. +pub fn format_update_summary(results: &[ComponentUpdateInfo]) -> String { + if results.is_empty() { + return String::new(); + } + + let name_width = results.iter().map(|r| r.name.len()).max().unwrap_or(0); + + let mut lines: Vec = results + .iter() + .map(|r| { + let padded = format!("{: format!(" ✅ {padded} up to date"), + ComponentStatus::Outdated => { + format!(" ⚠️ {padded} outdated → ui add {} -y", r.name) + } + ComponentStatus::NotInRegistry => format!(" ❓ {padded} not in registry"), + } + }) + .collect(); + + let outdated_count = results.iter().filter(|r| r.status == ComponentStatus::Outdated).count(); + lines.push(String::new()); + if outdated_count == 0 { + lines.push("All components are up to date.".to_string()); + } else { + lines.push(format!( + "{outdated_count} component{} outdated.", + if outdated_count == 1 { " is" } else { "s are" } + )); + } + + lines.join("\n") +} + +/// Machine-readable JSON output. +pub fn format_update_json(results: &[ComponentUpdateInfo]) -> CliResult { + serde_json::to_string_pretty(results).map_err(Into::into) +} + +/* ========================================================== */ +/* 🧪 TESTS 🧪 */ +/* ========================================================== */ + +#[cfg(test)] +mod tests { + use super::*; + + fn info(name: &str, status: ComponentStatus) -> ComponentUpdateInfo { + ComponentUpdateInfo { name: name.to_string(), status } + } + + // --- compare_content --- + + #[test] + fn identical_content_is_up_to_date() { + assert_eq!(compare_content("fn foo() {}", "fn foo() {}"), ComponentStatus::UpToDate); + } + + #[test] + fn different_content_is_outdated() { + assert_eq!(compare_content("fn foo() {}", "fn bar() {}"), ComponentStatus::Outdated); + } + + #[test] + fn trailing_newline_difference_is_ignored() { + assert_eq!(compare_content("fn foo() {}\n", "fn foo() {}"), ComponentStatus::UpToDate); + } + + #[test] + fn leading_whitespace_difference_is_ignored() { + assert_eq!(compare_content(" fn foo() {}", "fn foo() {}"), ComponentStatus::UpToDate); + } + + #[test] + fn empty_strings_are_equal() { + assert_eq!(compare_content("", ""), ComponentStatus::UpToDate); + } + + // --- format_update_summary --- + + #[test] + fn empty_results_returns_empty_string() { + assert_eq!(format_update_summary(&[]), String::new()); + } + + #[test] + fn all_up_to_date_shows_success_message() { + let results = vec![info("button", ComponentStatus::UpToDate), info("badge", ComponentStatus::UpToDate)]; + let out = format_update_summary(&results); + assert!(out.contains("All components are up to date.")); + assert!(!out.contains("outdated")); + } + + #[test] + fn outdated_component_shows_update_hint() { + let results = vec![info("button", ComponentStatus::Outdated)]; + let out = format_update_summary(&results); + assert!(out.contains("ui add button -y")); + assert!(out.contains("outdated")); + } + + #[test] + fn outdated_count_is_correct_singular() { + let results = vec![info("button", ComponentStatus::Outdated), info("badge", ComponentStatus::UpToDate)]; + let out = format_update_summary(&results); + assert!(out.contains("1 component is outdated.")); + } + + #[test] + fn outdated_count_is_correct_plural() { + let results = + vec![info("button", ComponentStatus::Outdated), info("badge", ComponentStatus::Outdated)]; + let out = format_update_summary(&results); + assert!(out.contains("2 components are outdated.")); + } + + #[test] + fn not_in_registry_shows_question_mark() { + let results = vec![info("my_custom", ComponentStatus::NotInRegistry)]; + let out = format_update_summary(&results); + assert!(out.contains("not in registry")); + } + + #[test] + fn all_statuses_shown_together() { + let results = vec![ + info("button", ComponentStatus::UpToDate), + info("badge", ComponentStatus::Outdated), + info("custom", ComponentStatus::NotInRegistry), + ]; + let out = format_update_summary(&results); + assert!(out.contains("up to date")); + assert!(out.contains("outdated")); + assert!(out.contains("not in registry")); + } + + // --- format_update_json --- + + #[test] + fn json_output_is_valid() { + let results = vec![info("button", ComponentStatus::UpToDate)]; + let json = format_update_json(&results).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert!(parsed.is_array()); + } + + #[test] + fn json_contains_name_and_status() { + let results = vec![info("button", ComponentStatus::Outdated)]; + let json = format_update_json(&results).unwrap(); + assert!(json.contains("button")); + assert!(json.contains("outdated")); + } + + #[test] + fn json_status_serialized_as_snake_case() { + let results = vec![ + info("a", ComponentStatus::UpToDate), + info("b", ComponentStatus::NotInRegistry), + ]; + let json = format_update_json(&results).unwrap(); + assert!(json.contains("up_to_date")); + assert!(json.contains("not_in_registry")); + } +} diff --git a/src/command_update/mod.rs b/src/command_update/mod.rs new file mode 100644 index 0000000..75dc944 --- /dev/null +++ b/src/command_update/mod.rs @@ -0,0 +1 @@ +pub mod _update; diff --git a/src/command_view/_view.rs b/src/command_view/_view.rs new file mode 100644 index 0000000..c6bce0c --- /dev/null +++ b/src/command_view/_view.rs @@ -0,0 +1,145 @@ +use clap::{Arg, ArgMatches, Command}; +use serde::Serialize; + +use crate::shared::cli_error::CliResult; +use crate::shared::rust_ui_client::RustUIClient; + +/* ========================================================== */ +/* 📦 TYPES 📦 */ +/* ========================================================== */ + +#[derive(Debug, Serialize)] +pub struct ComponentView { + pub name: String, + pub content: String, +} + +/* ========================================================== */ +/* 🔧 COMMAND 🔧 */ +/* ========================================================== */ + +pub fn command_view() -> Command { + Command::new("view") + .about("View a component's source from the registry without installing it") + .arg(Arg::new("component").help("Component name to view").required(true)) + .arg(Arg::new("json").long("json").help("Output as JSON").action(clap::ArgAction::SetTrue)) +} + +/* ========================================================== */ +/* 🦀 MAIN 🦀 */ +/* ========================================================== */ + +/// Fetch and print registry source for a list of component names. +/// Names are processed in the order given; sort before calling if needed. +pub async fn view_components(names: &[String]) -> CliResult<()> { + for name in names { + let content = RustUIClient::fetch_styles_default(name).await?; + println!("{}", format_view_human(name, &content)); + } + Ok(()) +} + +pub async fn process_view(matches: &ArgMatches) -> CliResult<()> { + let name = matches.get_one::("component").map(|s| s.as_str()).unwrap_or(""); + let json = matches.get_flag("json"); + + let content = RustUIClient::fetch_styles_default(name).await?; + + let output = if json { + format_view_json(&ComponentView { name: name.to_string(), content })? + } else { + format_view_human(name, &content) + }; + + println!("{output}"); + Ok(()) +} + +/* ========================================================== */ +/* 🖨 FORMATTERS 🖨 */ +/* ========================================================== */ + +pub fn format_view_human(name: &str, content: &str) -> String { + let line_count = content.lines().count(); + let mut out = String::new(); + out.push_str(&format!("// {name}.rs ({line_count} lines)\n\n")); + out.push_str(content); + out +} + +pub fn format_view_json(view: &ComponentView) -> CliResult { + serde_json::to_string_pretty(view).map_err(Into::into) +} + +/* ========================================================== */ +/* 🧪 TESTS 🧪 */ +/* ========================================================== */ + +#[cfg(test)] +mod tests { + use super::*; + + // --- format_view_human --- + + #[test] + fn human_output_includes_component_name_in_header() { + let out = format_view_human("button", "fn foo() {}"); + assert!(out.contains("button.rs")); + } + + #[test] + fn human_output_includes_line_count() { + let content = "line1\nline2\nline3"; + let out = format_view_human("button", content); + assert!(out.contains("3 lines")); + } + + #[test] + fn human_output_includes_content() { + let content = "pub fn Button() {}"; + let out = format_view_human("button", content); + assert!(out.contains(content)); + } + + #[test] + fn human_output_single_line_says_line_not_lines() { + let out = format_view_human("badge", "fn x() {}"); + assert!(out.contains("1 lines")); // intentionally not pluralizing — keep simple + } + + // --- format_view_json --- + + #[test] + fn json_output_is_valid() { + let view = ComponentView { name: "button".to_string(), content: "fn x() {}".to_string() }; + let json = format_view_json(&view).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert!(parsed.is_object()); + } + + #[test] + fn json_contains_name_and_content() { + let view = ComponentView { name: "button".to_string(), content: "fn x() {}".to_string() }; + let json = format_view_json(&view).unwrap(); + assert!(json.contains("\"name\"")); + assert!(json.contains("button")); + assert!(json.contains("\"content\"")); + assert!(json.contains("fn x()")); + } + + #[test] + fn json_name_field_matches_input() { + let view = ComponentView { name: "badge".to_string(), content: String::new() }; + let json = format_view_json(&view).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["name"], "badge"); + } + + // --- view_components --- + + #[tokio::test] + async fn view_components_empty_names_returns_ok() { + let result = view_components(&[]).await; + assert!(result.is_ok()); + } +} diff --git a/src/command_view/mod.rs b/src/command_view/mod.rs new file mode 100644 index 0000000..ef40286 --- /dev/null +++ b/src/command_view/mod.rs @@ -0,0 +1 @@ +pub mod _view; diff --git a/src/constants/commands.rs b/src/constants/commands.rs deleted file mode 100644 index 581faf3..0000000 --- a/src/constants/commands.rs +++ /dev/null @@ -1,24 +0,0 @@ -pub struct COMMAND; -pub struct ADD; -pub struct INIT; - -/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ -/* ✨ FUNCTIONS ✨ */ -/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ - -impl COMMAND { - pub const ADD: &'static str = "add"; - pub const INIT: &'static str = "init"; -} - -impl ADD { - pub const COMPONENTS: &'static str = "components"; - pub const HELP: &'static str = "The components to add (space-separated)"; - pub const ABOUT: &'static str = "Add components and dependencies to your project"; -} - -impl INIT { - pub const PROJECT_NAME: &'static str = "project_name"; - pub const HELP: &'static str = "The name of the project to initialize"; - pub const ABOUT: &'static str = "Initialize the project"; -} diff --git a/src/constants/dependencies.rs b/src/constants/dependencies.rs deleted file mode 100644 index c372bea..0000000 --- a/src/constants/dependencies.rs +++ /dev/null @@ -1,37 +0,0 @@ -#[allow(unused)] -pub struct Dependency<'a> { - pub name: &'a str, - pub version: Option<&'a str>, - pub features: &'a [&'a str] -} - -impl<'a> Dependency<'a> { - const fn new( - name: &'a str, - version: Option<&'a str>, - features: &'a [&'a str] - ) -> Self { - Dependency { name, version, features } - } -} - - -/// -/// Dependencies to initialize the ui lib -/// -pub const INIT_DEPENDENCIES: [Dependency<'static>; 2] = [ - Dependency::new( - "leptos", - None, - &["csr"] - ), - Dependency::new( - "tw_merge", - None, - &["variant"] - ) -]; - -/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ -/* ✨ FUNCTIONS ✨ */ -/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ diff --git a/src/constants/env.rs b/src/constants/env.rs deleted file mode 100644 index 70ba05f..0000000 --- a/src/constants/env.rs +++ /dev/null @@ -1,8 +0,0 @@ -// pub struct ENV; - -// impl ENV { -// pub const BASE_URL: &'static str = "BASE_URL"; -// pub const BASE_URL_STYLES_DEFAULT: &'static str = "BASE_URL_STYLES_DEFAULT"; -// pub const URL_CONFIG_SCHEMA_JSON: &'static str = "URL_CONFIG_SCHEMA_JSON"; -// pub const URL_REGISTRY_STYLES_JSON: &'static str = "URL_REGISTRY_STYLES_JSON"; -// } diff --git a/src/constants/file_name.rs b/src/constants/file_name.rs deleted file mode 100644 index 656e0ff..0000000 --- a/src/constants/file_name.rs +++ /dev/null @@ -1,9 +0,0 @@ -#[allow(non_camel_case_types)] -pub struct FILE_NAME; - -impl FILE_NAME { - pub const APP_CONFIG_TOML: &str = "app_config.toml"; - pub const TAILWIND_CONFIG_JS: &str = "tailwind.config.js"; - pub const COMPONENTS_TOML: &str = "Components.toml"; - pub const PACKAGE_JSON: &str = "package.json"; -} diff --git a/src/constants/mod.rs b/src/constants/mod.rs deleted file mode 100644 index 6640059..0000000 --- a/src/constants/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -pub mod commands; -pub mod dependencies; -pub mod env; -pub mod file_name; -pub mod others; -pub mod paths; -pub mod template; -pub mod url; diff --git a/src/constants/others.rs b/src/constants/others.rs deleted file mode 100644 index 4c9395e..0000000 --- a/src/constants/others.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub const SPINNER_UPDATE_DURATION: u64 = 100; - -pub const TAILWIND_DEPENDENCIES: [&str; 3] = ["@tailwindcss/cli", "tailwindcss", "tw-animate-css"]; - -pub const CARGO_TOML_FILE: &str = "Cargo.toml"; diff --git a/src/constants/paths.rs b/src/constants/paths.rs deleted file mode 100644 index 4fc3159..0000000 --- a/src/constants/paths.rs +++ /dev/null @@ -1,3 +0,0 @@ -// PATHS -// pub const RELATIVE_PATH_COMPONENTS_DIR: &str = "src/components"; -pub const RELATIVE_PATH_PROJECT_DIR: &str = "."; diff --git a/src/constants/template.rs b/src/constants/template.rs deleted file mode 100644 index 7ea6147..0000000 --- a/src/constants/template.rs +++ /dev/null @@ -1,120 +0,0 @@ -pub struct TEMPLATE; - -impl TEMPLATE { - - pub const STYLE_TAILWIND_CSS: &str = r#"@import "tailwindcss"; -@import "tw-animate-css"; - -@config "../tailwind.config.js"; - - -:root { - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); -} - -.dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); -} - - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); -} - -@layer base { - * { - @apply border-border outline-ring/50; - } - body { - @apply bg-background text-foreground; - } - - dialog { - margin: auto; - } -} -"#; - - pub const TAILWIND_CONFIG: &str = r#"/** @type {import('tailwindcss').Config} */ -export default { - darkMode: "class", - content: { - files: ["./src/**/*.rs"], - }, - theme: { - container: { - center: true, - padding: "2rem", - screens: { - "2xl": "1400px", - }, - }, - extend: { - keyframes: {}, - animation: {}, - }, - } -};"#; - - pub const PACKAGE_JSON: &str = r#"{ - "type": "module" -} -"#; -} diff --git a/src/constants/url.rs b/src/constants/url.rs deleted file mode 100644 index 83ccf62..0000000 --- a/src/constants/url.rs +++ /dev/null @@ -1,9 +0,0 @@ -pub struct URL; - -#[allow(unused)] -impl URL { - pub const URL_REGISTRY_INDEX_JSON: &str = "https://www.rust-ui.com/registry/index.json"; - pub const URL_CONFIG_SCHEMA_JSON: &str = "https://www.rust-ui.com/schema.json"; - pub const URL_REGISTRY_STYLES_JSON: &str = "https://www.rust-ui.com/registry/styles/index.json"; - pub const BASE_URL_STYLES_DEFAULT: &str = "https://www.rust-ui.com/registry/styles/default"; -} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..596bd14 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,9 @@ +// Library interface for ui-cli +// This allows integration tests and external consumers to access shared functionality + +pub mod shared { + pub mod cli_error; + pub mod markdown_utils; + pub mod rust_ui_client; + pub mod task_spinner; +} diff --git a/src/main.rs b/src/main.rs index 3d440f5..7aea858 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,23 +1,34 @@ -use clap::Command; +#![cfg_attr( + not(test), + deny(clippy::expect_used, clippy::unwrap_used, clippy::panic, clippy::todo, clippy::indexing_slicing,) +)] +#![deny(irrefutable_let_patterns)] + use std::process; +use clap::Command; + mod command_add; +mod command_diff; +mod command_docs; +mod command_info; mod command_init; -mod constants; +mod command_list; +mod command_mcp; +mod command_search; +mod command_starters; +mod command_update; +mod command_view; mod shared; -use constants::commands::COMMAND; - // * cargo run --bin ui init // * cargo run --bin ui add button demo_button demo_button_variants demo_button_sizes // * cargo run --bin ui add demo_use_floating_placement +// * cargo run --bin ui starters -// TODO 🐛 add [primitives/dialog] -// └──> 🔸 Write file in primitives/primitives/dialog.tsx - -/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ -/* 🦀 MAIN 🦀 */ -/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ +/* ========================================================== */ +/* 🦀 MAIN 🦀 */ +/* ========================================================== */ #[tokio::main] async fn main() { @@ -25,20 +36,115 @@ async fn main() { .about(env!("CARGO_PKG_DESCRIPTION")) .version(env!("CARGO_PKG_VERSION")) .subcommand(command_init::_init::command_init()) - .subcommand(command_add::_add::command_add()); + .subcommand(command_add::_add::command_add()) + .subcommand(command_info::_info::command_info()) + .subcommand(command_list::_list::command_list()) + .subcommand(command_search::_search::command_search()) + .subcommand(command_update::_update::command_update()) + .subcommand(command_diff::_diff::command_diff()) + .subcommand(command_docs::_docs::command_docs()) + .subcommand(command_starters::_starters::command_starters()) + .subcommand(command_view::_view::command_view()) + .subcommand(command_mcp::_mcp::command_mcp()); let matches = mut_program.clone().get_matches(); // Handle commands match matches.subcommand() { - Some((COMMAND::INIT, _)) => { - command_init::_init::init_project().await; + Some(("init", sub_matches)) => { + let force = sub_matches.get_flag("yes") || sub_matches.get_flag("force"); + let reinstall = if sub_matches.get_flag("reinstall") { Some(true) } else { None }; + match command_init::_init::process_init(force, reinstall).await { + Err(e) => { + eprintln!("{e}"); + process::exit(1); + } + Ok(outcome) if !outcome.to_reinstall.is_empty() => { + if let Err(e) = command_add::_add::process_add_components( + outcome.to_reinstall, + &outcome.base_path, + ) + .await + { + eprintln!("{e}"); + process::exit(1); + } + } + Ok(_) => {} + } + } + Some(("add", sub_matches)) => { + if let Err(e) = command_add::_add::process_add(sub_matches).await { + eprintln!("{e}"); + process::exit(1); + } + } + Some(("info", sub_matches)) => { + if let Err(e) = command_info::_info::process_info(sub_matches) { + eprintln!("{e}"); + process::exit(1); + } + } + Some(("list", sub_matches)) => { + if let Err(e) = command_list::_list::process_list(sub_matches).await { + eprintln!("{e}"); + process::exit(1); + } + } + Some(("search", sub_matches)) => { + if let Err(e) = command_search::_search::process_search(sub_matches).await { + eprintln!("{e}"); + process::exit(1); + } + } + Some(("update", sub_matches)) => { + if let Err(e) = command_update::_update::process_update(sub_matches).await { + eprintln!("{e}"); + process::exit(1); + } + } + Some(("diff", sub_matches)) => { + if let Err(e) = command_diff::_diff::process_diff(sub_matches).await { + eprintln!("{e}"); + process::exit(1); + } + } + Some(("docs", _)) => { + if let Err(e) = command_docs::_docs::process_docs() { + eprintln!("{e}"); + process::exit(1); + } + } + Some(("starters", _)) => { + if let Err(e) = command_starters::_starters::process_starters().await { + eprintln!("{e}"); + process::exit(1); + } } - Some((COMMAND::ADD, sub_matches)) => { - let _ = command_add::_add::process_add(sub_matches).await; + Some(("view", sub_matches)) => { + if let Err(e) = command_view::_view::process_view(sub_matches).await { + eprintln!("{e}"); + process::exit(1); + } } + Some(("mcp", sub_matches)) => match sub_matches.subcommand() { + Some(("init", init_matches)) => { + if let Err(e) = command_mcp::_mcp::process_mcp_init(init_matches) { + eprintln!("{e}"); + process::exit(1); + } + } + _ => { + if let Err(e) = command_mcp::_mcp::process_mcp_server().await { + eprintln!("{e}"); + process::exit(1); + } + } + }, _ => { - mut_program.print_help().unwrap(); + if let Err(err) = mut_program.print_help() { + eprintln!("Error printing help: {err}"); + } process::exit(1); } } diff --git a/src/shared/cli_error.rs b/src/shared/cli_error.rs new file mode 100644 index 0000000..ab82917 --- /dev/null +++ b/src/shared/cli_error.rs @@ -0,0 +1,133 @@ +#[derive(Debug, thiserror::Error)] +pub enum CliError { + #[error("🔸 Registry request failed")] + RegistryRequestFailed, + + #[error("🔸 Network request failed: {source}")] + Network { + #[from] + source: reqwest::Error, + }, + + #[error("🔸 File operation failed: {message}")] + FileOperation { message: String }, + + #[error("🔸 Failed to create directory")] + DirectoryCreateFailed, + + #[error("🔸 Failed to write file")] + FileWriteFailed, + + #[error("🔸 Failed to read file")] + FileReadFailed, + + #[error("🔸 IO error: {source}")] + Io { + #[from] + source: std::io::Error, + }, + + #[error("🔸 Configuration error: {message}")] + Config { message: String }, + + #[error("🔸 Failed to parse TOML configuration: {source}")] + TomlParse { + #[from] + source: toml::de::Error, + }, + + #[error("🔸 Failed to serialize TOML configuration: {source}")] + TomlSerialize { + #[from] + source: toml::ser::Error, + }, + + #[error("🔸 Failed to parse Cargo.toml: {source}")] + CargoTomlParse { + #[from] + source: cargo_toml::Error, + }, + + #[error("🔸 JSON parsing error: {source}")] + JsonParse { + #[from] + source: serde_json::Error, + }, + + #[error("🔸 npm install failed")] + NpmInstallFailed, + + #[error("🔸 Git clone failed")] + GitCloneFailed, + + #[error("🔸 Cargo operation failed: {message}")] + CargoOperation { message: String }, + + #[error("🔸 Path validation error: {path} - {reason}")] + InvalidPath { path: String, reason: String }, + + #[error("🔸 Validation error: {message}")] + Validation { message: String }, + + #[error("🔸 Registry component missing required fields")] + RegistryComponentMissing, + + #[error("🔸 Project not initialized. Run 'ui init' to initialize the project first.")] + ProjectNotInitialized, +} + +impl CliError { + pub fn file_operation(message: &str) -> Self { + Self::FileOperation { message: message.to_string() } + } + + pub fn config(message: &str) -> Self { + Self::Config { message: message.to_string() } + } + + pub fn cargo_operation(message: &str) -> Self { + Self::CargoOperation { message: message.to_string() } + } + + pub fn invalid_path(path: &str, reason: &str) -> Self { + Self::InvalidPath { path: path.to_string(), reason: reason.to_string() } + } + + pub fn validation(message: &str) -> Self { + Self::Validation { message: message.to_string() } + } + + pub fn registry_request_failed() -> Self { + Self::RegistryRequestFailed + } + + pub fn directory_create_failed() -> Self { + Self::DirectoryCreateFailed + } + + pub fn file_write_failed() -> Self { + Self::FileWriteFailed + } + + pub fn file_read_failed() -> Self { + Self::FileReadFailed + } + + pub fn npm_install_failed() -> Self { + Self::NpmInstallFailed + } + + pub fn git_clone_failed() -> Self { + Self::GitCloneFailed + } + + pub fn registry_component_missing() -> Self { + Self::RegistryComponentMissing + } + + pub fn project_not_initialized() -> Self { + Self::ProjectNotInitialized + } +} + +pub type CliResult = std::result::Result; diff --git a/src/shared/markdown_utils.rs b/src/shared/markdown_utils.rs new file mode 100644 index 0000000..a52ba98 --- /dev/null +++ b/src/shared/markdown_utils.rs @@ -0,0 +1,71 @@ +pub fn extract_rust_code_from_markdown(markdown: &str) -> Option { + let lines: Vec<&str> = markdown.lines().collect(); + let mut in_rust_block = false; + let mut rust_code_lines = Vec::new(); + + for line in lines { + if line.trim() == "```rust" { + in_rust_block = true; + continue; + } + + if in_rust_block && line.trim() == "```" { + break; + } + + if in_rust_block { + rust_code_lines.push(line); + } + } + + if rust_code_lines.is_empty() { None } else { Some(rust_code_lines.join("\n")) } +} + +/* ========================================================== */ +/* 🧪 TESTS 🧪 */ +/* ========================================================== */ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn returns_none_when_no_rust_block() { + assert_eq!(extract_rust_code_from_markdown("just prose\nno code"), None); + } + + #[test] + fn returns_none_for_empty_input() { + assert_eq!(extract_rust_code_from_markdown(""), None); + } + + #[test] + fn returns_none_for_empty_rust_block() { + assert_eq!(extract_rust_code_from_markdown("```rust\n```"), None); + } + + #[test] + fn extracts_code_from_rust_block() { + let md = "# Title\n\n```rust\nfn main() {}\n```\n\nsome prose"; + assert_eq!(extract_rust_code_from_markdown(md), Some("fn main() {}".to_string())); + } + + #[test] + fn returns_only_first_rust_block() { + let md = "```rust\nfn first() {}\n```\n```rust\nfn second() {}\n```"; + assert_eq!(extract_rust_code_from_markdown(md), Some("fn first() {}".to_string())); + } + + #[test] + fn ignores_non_rust_fenced_blocks() { + assert_eq!(extract_rust_code_from_markdown("```toml\nkey = \"value\"\n```"), None); + } + + #[test] + fn preserves_multiline_code() { + let md = "```rust\nuse leptos::*;\n\nfn foo() {}\n```"; + let result = extract_rust_code_from_markdown(md).unwrap(); + assert!(result.contains("use leptos::*;")); + assert!(result.contains("fn foo() {}")); + } +} diff --git a/src/shared/mod.rs b/src/shared/mod.rs index 3d9b605..67c3a1d 100644 --- a/src/shared/mod.rs +++ b/src/shared/mod.rs @@ -1,3 +1,4 @@ -pub mod shared_check_file_ask_overwrite; -pub mod shared_fetch_functions; -pub mod shared_write_template_file; +pub mod cli_error; +pub mod markdown_utils; +pub mod rust_ui_client; +pub mod task_spinner; diff --git a/src/shared/rust_ui_client.rs b/src/shared/rust_ui_client.rs new file mode 100644 index 0000000..aeb1f01 --- /dev/null +++ b/src/shared/rust_ui_client.rs @@ -0,0 +1,64 @@ +use crate::shared::cli_error::{CliError, CliResult}; +use crate::shared::markdown_utils::extract_rust_code_from_markdown; + +pub struct RustUIClient; + +impl RustUIClient { + const BASE_URL: &str = "https://www.rust-ui.com/registry"; + const SITE_URL: &str = "https://www.rust-ui.com"; + + // URL builders - centralized URL construction + fn tree_url() -> String { + format!("{}/tree.md", Self::BASE_URL) + } + + fn component_url(component_name: &str) -> String { + format!("{}/styles/default/{component_name}.md", Self::BASE_URL) + } + + fn js_file_url(path: &str) -> String { + format!("{}{path}", Self::SITE_URL) + } + + // Consolidated HTTP fetch method + async fn fetch_response(url: &str) -> CliResult { + let response = reqwest::get(url).await.map_err(|_| CliError::registry_request_failed())?; + + if !response.status().is_success() { + return Err(CliError::registry_request_failed()); + } + + Ok(response) + } + + // Public API methods + pub async fn fetch_tree_md() -> CliResult { + let response = Self::fetch_response(&Self::tree_url()).await?; + let content = response.text().await.map_err(|_| CliError::registry_request_failed())?; + + if content.is_empty() { + return Err(CliError::registry_request_failed()); + } + + Ok(content) + } + + pub async fn fetch_styles_default(component_name: &str) -> CliResult { + let response = Self::fetch_response(&Self::component_url(component_name)).await?; + let markdown_content = response.text().await.map_err(|_| CliError::registry_request_failed())?; + + extract_rust_code_from_markdown(&markdown_content).ok_or_else(CliError::registry_component_missing) + } + + /// Fetch a JS file from the site (e.g., /hooks/lock_scroll.js) + pub async fn fetch_js_file(path: &str) -> CliResult { + let response = Self::fetch_response(&Self::js_file_url(path)).await?; + let content = response.text().await.map_err(|_| CliError::registry_request_failed())?; + + if content.is_empty() { + return Err(CliError::registry_request_failed()); + } + + Ok(content) + } +} diff --git a/src/shared/shared_check_file_ask_overwrite.rs b/src/shared/shared_check_file_ask_overwrite.rs deleted file mode 100644 index 7741440..0000000 --- a/src/shared/shared_check_file_ask_overwrite.rs +++ /dev/null @@ -1,36 +0,0 @@ -use colored::*; -use std::io::{self}; -use std::path::Path; - -/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ -/* ✨ FUNCTIONS ✨ */ -/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ - -#[allow(dead_code)] -pub async fn shared_check_file_exist_and_ask_overwrite(file_path: &str, file_name: &str) -> bool { - if Path::new(file_path).exists() { - println!( - "⚠️ {} {} {} {}", - file_name.yellow().bold(), - "already exists.".yellow().bold(), - "Do you want to overwrite it?".yellow(), - "(y/n)".yellow().underline() - ); - - let mut input = String::new(); - io::stdin().read_line(&mut input).expect("Failed to read line"); - - match input.trim().to_lowercase().as_str() { - "y" | "yes" => return true, // User confirmed overwrite - _ => { - println!( - "{} {}", - "🚧 Operation canceled.".blue().bold(), - "The file will not be overwritten".blue() - ); - return false; // User declined overwrite - } - } - } - true // File does not exist, proceed -} diff --git a/src/shared/shared_fetch_functions.rs b/src/shared/shared_fetch_functions.rs deleted file mode 100644 index 13f048c..0000000 --- a/src/shared/shared_fetch_functions.rs +++ /dev/null @@ -1,7 +0,0 @@ -use reqwest; - -// ADD + INIT -pub async fn shared_fetch_registry_return_json(url: &str) -> Result { - let response = reqwest::get(url).await?; - response.json::().await -} diff --git a/src/shared/shared_write_template_file.rs b/src/shared/shared_write_template_file.rs deleted file mode 100644 index edf7b41..0000000 --- a/src/shared/shared_write_template_file.rs +++ /dev/null @@ -1,31 +0,0 @@ -use indicatif::ProgressBar; -use std::fs::{self, File}; -use std::io::{self, Write}; -use std::time::Duration; - -use crate::constants::others::SPINNER_UPDATE_DURATION; - -/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ -/* ✨ FUNCTIONS ✨ */ -/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ - -pub async fn shared_write_template_file(file_path: &str, spinner: &ProgressBar, template: &str) -> io::Result<()> { - // Create the directory if it doesn't exist - if let Some(dir) = std::path::Path::new(file_path).parent() { - fs::create_dir_all(dir)?; - } - - match File::create(file_path) { - Ok(mut file) => { - // Start the spinner - spinner.enable_steady_tick(Duration::from_millis(SPINNER_UPDATE_DURATION)); - - file.write_all(template.as_bytes())?; - Ok(()) - } - Err(e) => { - eprintln!("🔸 Error: {}", e); - Err(e) - } - } -} diff --git a/src/shared/task_spinner.rs b/src/shared/task_spinner.rs new file mode 100644 index 0000000..5537bbb --- /dev/null +++ b/src/shared/task_spinner.rs @@ -0,0 +1,30 @@ +use std::time::Duration; + +use indicatif::ProgressBar; + +const SPINNER_UPDATE_DURATION: u64 = 100; + +pub struct TaskSpinner { + spinner: ProgressBar, +} + +impl TaskSpinner { + pub fn new(message: &str) -> Self { + let spinner = ProgressBar::new_spinner(); + spinner.set_message(message.to_string()); + spinner.enable_steady_tick(Duration::from_millis(SPINNER_UPDATE_DURATION)); + Self { spinner } + } + + pub fn set_message(&self, message: &str) { + self.spinner.set_message(message.to_string()); + } + + pub fn finish_success(self, message: &str) { + self.spinner.finish_with_message(format!("✔️ {message}")); + } + + pub fn finish_with_message(self, message: &str) { + self.spinner.finish_with_message(message.to_string()); + } +} diff --git a/style/tailwind.css b/style/tailwind.css deleted file mode 100644 index 7a3a8b7..0000000 --- a/style/tailwind.css +++ /dev/null @@ -1,88 +0,0 @@ -@import "tailwindcss"; -@import "tw-animate-css"; - -@config "../tailwind.config.js"; - - -:root { - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); -} - -.dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); -} - - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); -} - -@layer base { - * { - @apply border-border outline-ring/50; - } - body { - @apply bg-background text-foreground; - } - - dialog { - margin: auto; - } -} diff --git a/tailwind.config.js b/tailwind.config.js deleted file mode 100644 index 1741864..0000000 --- a/tailwind.config.js +++ /dev/null @@ -1,20 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -export default { - darkMode: "class", - content: { - files: ["./src/**/*.rs"], - }, - theme: { - container: { - center: true, - padding: "2rem", - screens: { - "2xl": "1400px", - }, - }, - extend: { - keyframes: {}, - animation: {}, - }, - } -}; \ No newline at end of file diff --git a/tests/test_registry_http.rs b/tests/test_registry_http.rs new file mode 100644 index 0000000..bf6e78a --- /dev/null +++ b/tests/test_registry_http.rs @@ -0,0 +1,44 @@ +use ui_cli::shared::rust_ui_client::RustUIClient; + +#[tokio::test] +async fn test_fetch_tree_md() { + let result = RustUIClient::fetch_tree_md().await; + + assert!(result.is_ok(), "Failed to fetch tree.md: {:?}", result.err()); + + let content = result.unwrap(); + assert!(!content.is_empty(), "tree.md content should not be empty"); +} + +#[tokio::test] +async fn test_fetch_styles_default_alert() { + let result = RustUIClient::fetch_styles_default("alert").await; + + assert!(result.is_ok(), "Failed to fetch alert.md: {:?}", result.err()); + + let rust_code = result.unwrap(); + assert!(!rust_code.is_empty(), "Extracted Rust code from alert.md should not be empty"); + // Basic sanity check that it contains Rust code + assert!( + rust_code.contains("fn") || rust_code.contains("use") || rust_code.contains("pub"), + "Content should contain Rust code" + ); +} + +#[tokio::test] +async fn test_fetch_styles_default_button() { + let result = RustUIClient::fetch_styles_default("button").await; + + assert!(result.is_ok(), "Failed to fetch button.md: {:?}", result.err()); + + let rust_code = result.unwrap(); + assert!(!rust_code.is_empty(), "Extracted Rust code from button.md should not be empty"); +} + +#[tokio::test] +async fn test_fetch_nonexistent_component() { + let result = RustUIClient::fetch_styles_default("nonexistent_component_xyz").await; + + // Should fail for nonexistent components + assert!(result.is_err(), "Should fail when fetching nonexistent component"); +} diff --git a/tree_registry.json b/tree_registry.json deleted file mode 100644 index 44091d6..0000000 --- a/tree_registry.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "button": { - "component": { - "name": "button", - "registry_dependencies": [], - "cargo_dependencies": ["std"], - "component_type": "components:ui", - "parent_dir": "ui" - }, - "resolved_registry_dependencies": {} - }, - "demo_button": { - "component": { - "name": "demo_button", - "registry_dependencies": ["button"], - "cargo_dependencies": [], - "component_type": "components:demos", - "parent_dir": "demos" - }, - "resolved_registry_dependencies": { - "button" - } - }, - "demo_button_variants": { - "component": { - "name": "demo_button_variants", - "registry_dependencies": ["button"], - "cargo_dependencies": [], - "component_type": "components:demos", - "parent_dir": "demos" - }, - "resolved_registry_dependencies": { - "button" - } - }, - "demo_button_sizes": { - "component": { - "name": "demo_button_sizes", - "registry_dependencies": ["button"], - "cargo_dependencies": [], - "component_type": "components:demos", - "parent_dir": "demos" - }, - "resolved_registry_dependencies": { - "button" - } - } -} \ No newline at end of file