diff --git a/.ai-lab/plugins.toml b/.ai-lab/plugins.toml new file mode 100644 index 0000000..b36e18a --- /dev/null +++ b/.ai-lab/plugins.toml @@ -0,0 +1,10 @@ +# Which ai-lab rule packs judge writes in this repository. +# A repository with no selection is judged by the universal packs only, +# and the language packs stay silent, which reads exactly like clean code. +[install] +sets = [ + "typescript", + "rust", +] +extra = [] +omit = [] diff --git a/.gitignore b/.gitignore index f4357e5..f5c6e9e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ # Internal docs /_docs/ +DEV_TOOLS.md +SPEC-*.md # Claude Code @@ -13,8 +15,10 @@ Desktop.ini # IDE .idea/ .vscode/ +.serena/ *.swp *.swo # MCP / Puppeteer logs logs/ +.serena/ diff --git a/Cargo.lock b/Cargo.lock index 03e0c21..fb19aa7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -140,6 +140,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "crossterm" version = "0.29.0" @@ -150,7 +156,7 @@ dependencies = [ "crossterm_winapi", "document-features", "parking_lot", - "rustix", + "rustix 1.1.4", "winapi", ] @@ -252,9 +258,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "a043dc74da1e37d6afe657061213aa6f425f855399a11d3463c6ecccc4dfda1f" [[package]] name = "foldhash" @@ -324,9 +330,9 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] name = "indexmap" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -334,6 +340,25 @@ dependencies = [ "serde_core", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -342,9 +367,25 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jsonc-parser" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8c2e5dea04f54739ca5694ee06e4448ffda065a55b3427d2b131bd5ea697ea2c" + +[[package]] +name = "junction" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfc352a66ba903c23239ef51e809508b6fc2b0f90e3476ac7a9ff47e863ae95" +dependencies = [ + "scopeguard", + "windows-sys 0.61.2", +] [[package]] name = "leb128fmt" @@ -354,19 +395,25 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -400,6 +447,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -412,6 +468,17 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -520,7 +587,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.3.1" +version = "0.18.5" dependencies = [ "anyhow", "clap", @@ -528,14 +595,32 @@ dependencies = [ "console", "dialoguer", "dirs 5.0.1", + "jsonc-parser", + "junction", + "open", "owo-colors", "pathdiff", + "rustix 0.38.44", "serde", "shellexpand", + "sysinfo", "thiserror 2.0.18", "toml", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -545,7 +630,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -557,9 +642,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -651,6 +736,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sysinfo" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "windows", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -660,7 +758,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -762,9 +860,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-width" @@ -864,12 +962,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[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.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.48.0" diff --git a/Cargo.toml b/Cargo.toml index 78b892f..44e5288 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.3.1" +version = "0.18.5" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" @@ -14,6 +14,7 @@ categories = ["command-line-utilities", "development-tools"] clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } toml = "0.8" +jsonc-parser = { version = "0.32", features = ["cst"] } dialoguer = { version = "0.11", features = ["fuzzy-select"] } owo-colors = "4" comfy-table = "7" @@ -23,10 +24,97 @@ thiserror = "2" anyhow = "1" shellexpand = "3" pathdiff = "0.2" +open = "5" + +[target.'cfg(windows)'.dependencies] +junction = "1" +sysinfo = { version = "0.32", default-features = false, features = ["system"] } + +[target.'cfg(unix)'.dependencies] +rustix = { version = "0.38", features = ["process"], default-features = false } [lints.clippy] allow_attributes_without_reason = "warn" +# ── Deny: compiler-level enforcement ── +unwrap_used = "deny" +expect_used = "deny" +panic = "deny" +unimplemented = "deny" +todo = "deny" +dbg_macro = "deny" +# CLI tool — stdout/stderr are the primary user-facing output channels +print_stdout = "allow" +print_stderr = "allow" +# CLI tool — process::exit in main() is the standard error-reporting pattern +exit = "allow" +mem_forget = "deny" +lossy_float_literal = "deny" +rest_pat_in_fully_bound_structs = "deny" +verbose_file_reads = "deny" +infinite_loop = "deny" +large_include_file = "deny" + +# ── Deny: type safety ── +as_conversions = "deny" +cast_possible_truncation = "deny" +cast_possible_wrap = "deny" +cast_precision_loss = "deny" +cast_sign_loss = "deny" +checked_conversions = "deny" +implicit_clone = "deny" +clone_on_ref_ptr = "deny" +rc_buffer = "deny" +rc_mutex = "deny" +mutex_atomic = "deny" + +# ── Deny: correctness ── +undocumented_unsafe_blocks = "deny" +multiple_unsafe_ops_per_block = "deny" +unnecessary_safety_comment = "deny" +unnecessary_safety_doc = "deny" + +# ── Warn: pedantic (upgrade to deny once codebase is clean) ── +pedantic = { level = "warn", priority = -1 } + +# ── Warn: strict patterns ── +needless_pass_by_value = "warn" +needless_pass_by_ref_mut = "warn" +trivially_copy_pass_by_ref = "warn" +cloned_instead_of_copied = "warn" +redundant_closure_for_method_calls = "warn" +unnested_or_patterns = "warn" +unused_self = "warn" +unused_async = "warn" +used_underscore_binding = "warn" +str_to_string = "warn" +semicolon_if_nothing_returned = "warn" +match_wildcard_for_single_variants = "warn" +inconsistent_struct_constructor = "warn" +doc_markdown = "warn" +missing_errors_doc = "warn" +missing_panics_doc = "warn" +wildcard_imports = "warn" +enum_glob_use = "warn" +flat_map_option = "warn" +manual_let_else = "warn" +option_if_let_else = "warn" +redundant_else = "warn" +ref_option_ref = "warn" +same_functions_in_if_condition = "warn" +map_unwrap_or = "warn" +filter_map_next = "warn" +manual_ok_or = "warn" +implicit_hasher = "warn" +return_self_not_must_use = "warn" +must_use_candidate = "warn" + +# ── Allow: intentional relaxations ── +module_name_repetitions = "allow" +must_use_unit = "allow" +# Transitive dep version conflicts — not actionable in source code +multiple_crate_versions = "allow" + [[bin]] name = "reportal" path = "src/main.rs" diff --git a/DEV_TOOLS.md b/DEV_TOOLS.md deleted file mode 100644 index e69de29..0000000 diff --git a/README.md b/README.md index 30555f0..68488bc 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![Crates.io](https://img.shields.io/crates/v/reportal.svg)](https://crates.io/crates/reportal) [![GitHub Release](https://img.shields.io/github/v/release/CheckPickerUpper/reportal)](https://github.com/CheckPickerUpper/reportal/releases/latest) [![Homebrew](https://img.shields.io/badge/homebrew-tap-FBB040.svg)](https://github.com/CheckPickerUpper/homebrew-tap) -[![Scoop](https://img.shields.io/badge/scoop-main-5B5EA6.svg)](https://scoop.sh/#/apps?q=reportal) +[![Scoop](https://img.shields.io/badge/scoop-bucket-5B5EA6.svg)](https://github.com/CheckPickerUpper/scoop-reportal) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Rust](https://img.shields.io/badge/rust-1.78%2B-orange.svg)](https://www.rust-lang.org/) [![Windows](https://img.shields.io/badge/platform-windows%20%7C%20macos%20%7C%20linux-lightgrey.svg)]() @@ -20,18 +20,30 @@ --- -RePortal is a single-binary CLI that keeps a registry of your dev repos and lets you fuzzy-jump between them, open them in your editor, and clone missing repos on a new machine. +RePortal is a single-binary CLI that keeps a registry of your dev repos and lets you fuzzy-jump between them, open them in your editor, clone and register repos, and keep local checkouts in sync. ## Install All methods install both `reportal` and `rep` (short alias) binaries. -### Cargo (all platforms) +For daily shell use, prefer the shell installer, Homebrew, or Scoop. Those routes put the executable in a user-local or package-manager-owned directory instead of relying on `~/.cargo/bin`, so reinstalling Rust/Cargo does not remove the command your shell profile calls. + +### Shell installer (macOS / Linux) ```bash -cargo install reportal +curl --proto '=https' --tlsv1.2 -LsSf https://github.com/CheckPickerUpper/reportal/releases/latest/download/reportal-installer.sh | bash +``` + +Installs to `~/.local/bin` and writes a guarded shell integration block. If `rep` later disappears from `PATH`, shell startup stays quiet and running `rep` prints the reinstall command. + +### PowerShell installer (Windows) + +```powershell +powershell -ExecutionPolicy Bypass -c "irm https://github.com/CheckPickerUpper/reportal/releases/latest/download/reportal-installer.ps1 | iex" ``` +Installs to `$env:LOCALAPPDATA\Programs\reportal`, adds that directory to the user `PATH`, and writes the same guarded profile block for PowerShell. + ### Homebrew (macOS / Linux) ```bash @@ -42,6 +54,7 @@ brew install reportal ### Scoop (Windows) ```powershell +scoop bucket add reportal https://github.com/CheckPickerUpper/scoop-reportal scoop install reportal ``` @@ -51,17 +64,13 @@ scoop install reportal winget install CheckPickerUpper.RePortal ``` -### Shell installer (macOS / Linux) +### Cargo (all platforms) ```bash -curl --proto '=https' --tlsv1.2 -LsSf https://github.com/CheckPickerUpper/reportal/releases/latest/download/reportal-installer.sh | sh +cargo install reportal ``` -### PowerShell installer (Windows) - -```powershell -powershell -ExecutionPolicy Bypass -c "irm https://github.com/CheckPickerUpper/reportal/releases/latest/download/reportal-installer.ps1 | iex" -``` +Cargo installs into Cargo's bin directory. That is still useful for contributors and Rust-native workflows, but the shell installer is the durable path for everyday `rep` usage. ### From source @@ -73,10 +82,50 @@ cargo install --path . ## Quick start +The release installers auto-wire shell integration with a guarded rc/profile block. If you prefer to wire it yourself — same pattern as `starship`, `zoxide`, `direnv`, and `mise` — add one of these blocks manually: + +**Zsh** (`~/.zshrc`): + +```zsh +if command -v rep >/dev/null 2>&1; then + eval "$(rep init zsh)" +elif command -v reportal >/dev/null 2>&1; then + eval "$(reportal init zsh)" +else + rep() { printf '%s\n' "RePortal: rep is not on PATH. Reinstall with: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/CheckPickerUpper/reportal/releases/latest/download/reportal-installer.sh | bash" >&2; return 127; } +fi +``` + +**Bash** (`~/.bashrc`): + ```bash -# Set up config + shell shortcuts (rj, ro) -rep init +if command -v rep >/dev/null 2>&1; then + eval "$(rep init bash)" +elif command -v reportal >/dev/null 2>&1; then + eval "$(reportal init bash)" +else + rep() { printf '%s\n' "RePortal: rep is not on PATH. Reinstall with: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/CheckPickerUpper/reportal/releases/latest/download/reportal-installer.sh | bash" >&2; return 127; } +fi +``` + +**PowerShell** (`$PROFILE`): + +```powershell +if (Get-Command rep -CommandType Application -ErrorAction SilentlyContinue) { + Invoke-Expression (& rep init powershell | Out-String) +} elseif (Get-Command reportal -CommandType Application -ErrorAction SilentlyContinue) { + Invoke-Expression (& reportal init powershell | Out-String) +} else { + function global:rep { + Write-Error 'RePortal: rep.exe is not on PATH. Reinstall with: powershell -ExecutionPolicy Bypass -c "irm https://github.com/CheckPickerUpper/reportal/releases/latest/download/reportal-installer.ps1 | iex"' + $global:LASTEXITCODE = 127 + } +} +``` + +That's it — no prompts and no profile editing after setup. The integration code is regenerated every shell session when the binary exists, and a missing binary produces a clear recovery command only when you invoke `rep`. The first time you run any `rep` subcommand, a default `~/.reportal/config.toml` is created for you. +```bash # Register a local repo rep add ~/dev/my-project @@ -97,12 +146,12 @@ ro | Command | Alias | What it does | |---------|-------|-------------| -| `rep init` | | Creates config and installs shell integration (first-time setup) | -| `rep upgrade` | | Updates shell integration to the latest version (run after updating RePortal) | -| `rep list` | `rep l` | Shows all repos with path, description, tags, and whether it exists on disk | -| `rep list --tag work` | | Filters repos by tag | +| `rep init ` | | Print shell integration code to stdout (zsh, bash, powershell) for `eval`-based loading | +| `rep list` | `rep l` | Shows repos grouped by workspace, with unassigned repos in a trailing section | +| `rep list --tag work` | | Filters repos by tag (composes with `--workspace`) | +| `rep list --workspace backend` | | Scopes the listing to one workspace (suppresses the unassigned section) | | `rep jump` | `rep j` | Fuzzy-select a repo, prints the path (used by `rj` shell function) | -| `rep jump my-api` | `rep j my-api` | Jump directly to a repo by alias | +| `rep jump my-api` | `rep j my-api` | Jump directly to a repo by alias (also matches `aliases` field) | | `rep jump --title "Debug"` | | Override the terminal tab title for this session | | `rep open` | `rep o` | Fuzzy-select a repo, opens it in your editor | | `rep open my-api` | `rep o my-api` | Opens a repo directly by alias | @@ -110,41 +159,53 @@ ro | `rep open --title "Debug"` | | Override the terminal tab title for this session | | `rep color` | | Emit tab title + background color for current directory (for shell hooks) | | `rep color --repo my-api` | | Emit tab title + background color for a specific repo | +| `rep prompt --shell zsh` | | Emit a shell-wrapped colored prompt badge for the current repo/workspace | | `rep status` | `rep s` | Show git status (branch, dirty, upstream, last commit) across all repos | | `rep status --tag work` | | Filter status by tag | | `rep sync` | | Pull latest changes across all repos (skips dirty repos) | | `rep sync --tag work` | | Pull only repos with this tag | -| `rep edit my-api` | `rep e my-api` | Interactively edit a repo's description, tags, title, and color | +| `rep edit` | `rep e` | Fuzzy-select a repo, then pick a field to edit from a menu (loops until Done) | | `rep add ~/dev/foo` | `rep a ~/dev/foo` | Register a local repo (auto-detects git remote, suggests alias) | | `rep add https://github.com/org/repo.git` | | Clone a repo and register it (asks where to place it) | | `rep remove my-api` | `rep rm my-api` | Unregister a repo (does not delete files) | +| `rep ai` | | Fuzzy-select a repo and launch the default AI coding CLI in it | +| `rep web` | `rep w` | Fuzzy-select a repo and open its remote URL in the browser | +| `rep web my-api` | `rep w my-api` | Open a repo's remote directly by alias | +| `rep ai my-api` | | Launch default AI tool directly in a repo by alias | +| `rep run` | `rep r` | Fuzzy-select a repo and a configured command, then run it | +| `rep run my-api` | `rep r my-api` | Skip repo selection, fuzzy-select a command to run | +| `rep run --cmd test` | | Skip command selection, fuzzy-select a repo to run "test" in | +| `rep run my-api --cmd test` | | Run "test" directly in my-api (no fuzzy menus) | +| `rep workspace list` | `rep ws ls` | List all registered VSCode/Cursor workspaces with their directory path | +| `rep workspace show ` | | Show a workspace's description, directory path, and resolved member links | +| `rep workspace create --repos a,b,c` | | Register a workspace and materialize its directory (symlinks + `.code-workspace`) | +| `rep workspace delete ` | `rep ws rm` | Unregister a workspace (leaves the directory on disk) | +| `rep workspace delete --purge` | | Unregister AND remove the workspace directory (member repos untouched) | +| `rep workspace rebuild ` | | Recreate the workspace directory's symlinks and `.code-workspace` file from config | +| `rep workspace add-repo ` | | Add a repo to a workspace and rebuild its directory | +| `rep workspace remove-repo ` | | Remove a repo from a workspace and rebuild its directory | +| `rep workspace open [name]` | | Open a workspace in your default editor; fuzzy-selects when no name is given | +| `rep workspace jump [name]` | | Print a workspace directory path for shell `cd`; fuzzy-selects when no name is given | +| `rep doctor` | | Diagnose config, shell integration, and repo path issues | ## Shell integration -`rep init` automatically installs these shortcuts into your shell profile: +`rep init ` prints shell code to stdout; you load it with one `eval` line in your rc file. Nothing is ever written to disk, nothing is regenerated on upgrade, and nothing prompts the user — the same idiomatic pattern used by `starship`, `zoxide`, `direnv`, and `mise`. It works identically in TTY shells, CI runners, sandboxes, and WSL. | Shortcut | What it does | |----------|-------------| | `rj` | Fuzzy-select a repo and `cd` into it | -| `rj my-api` | Jump directly to a repo by alias | +| `rj my-api` | Jump directly to a repo by alias; falls through to a workspace alias if no repo matches (cd's into the workspace directory, where member repos appear as subdirs via symlink/junction) | | `ro` | Fuzzy-select a repo and open it in your editor | -| `ro my-api` | Open a repo directly by alias | - -Supports PowerShell, Bash, Zsh. Detected and installed during `rep init`. - -You can also set them up manually: - -**PowerShell:** -```powershell -function rj { Set-Location (rep jump @args) } -function ro { rep open @args } -``` +| `ro my-api` | Open a repo directly by alias; falls through to a workspace alias if no repo matches (opens the `.code-workspace` file) | +| `rjw my-ws` | Workspace-only jump — resolves the alias strictly against `[workspaces.*]` and cd's into the workspace directory | +| `row my-ws` | Workspace-only open — launches the editor on the `.code-workspace` file inside the workspace directory | +| `rw` | Fuzzy-select a repo and open it in the browser | +| `rw my-api` | Open a repo's remote directly by alias | +| `rr` | Fuzzy-select a repo and run a configured command in it | +| `rr my-api` | Skip repo selection, fuzzy-select a command | -**Bash / Zsh:** -```bash -rj() { cd "$(rep jump "$@")"; } -ro() { rep open "$@"; } -``` +Supports Zsh, Bash, and PowerShell. No re-init needed on upgrade — the `eval` in your rc file fetches fresh code from the newly-installed binary on every shell startup. Entries with `shell_alias = true` also get top-level functions emitted by `rep init `: repos dispatch like `rj `, workspaces dispatch like `rjw `, and global commands dispatch like `rep run --cmd `. ## Config @@ -154,6 +215,7 @@ Lives at `~/.reportal/config.toml`: [settings] default_editor = "cursor" default_clone_root = "~/dev" +default_workspace_root = "~/dev/workspaces" # where rep materializes workspace dirs path_on_select = "show" # "show" or "hide" — print path after jump/open path_display_format = "absolute" # "absolute" or "relative" @@ -164,6 +226,7 @@ tags = ["work", "backend"] remote = "git@github.com:org/api.git" title = "API" # custom terminal tab title (defaults to alias) color = "#1a1a2e" # terminal background color on jump (hex) +shell_alias = true # optional: emit repo key/aliases as shell functions [repos.website] path = "~/dev/personal/site" @@ -175,8 +238,10 @@ tags = ["personal", "frontend"] |---------|--------|---------|-----------------| | `default_editor` | Any command | `cursor` | Editor for `rep open` | | `default_clone_root` | Any path | `~/dev` | Where `rep add ` clones to | +| `default_workspace_root` | Any path | `/workspaces` (falls back to `~/dev/workspaces`) | Parent directory for materialized workspace directories | | `path_on_select` | `show`, `hide` | `show` | Print path after picking a repo in jump/open | | `path_display_format` | `absolute`, `relative` | `absolute` | Full path or relative to current directory | +| `default_ai_tool` | Any tool name | `aider` | Which AI CLI `rep ai` launches by default | ### Per-repo fields @@ -186,35 +251,135 @@ tags = ["personal", "frontend"] | `description` | no | `""` | Shown in fuzzy finder and `rep list` | | `tags` | no | `[]` | Filter repos with `--tag` | | `remote` | no | `""` | Git remote URL for cloning on other machines | -| `title` | no | repo alias | Terminal tab title set via OSC 2 on jump/open | -| `color` | no | reset to default | Terminal background color (`#RRGGBB`) set via OSC 11 on jump/open | +| `aliases` | no | `[]` | Alternative names for direct jump (e.g. `rj ninja` instead of `rj nro`) | +| `title` | no | repo alias | Terminal tab title on jump/open | +| `color` | no | reset to default | Terminal tab color (`#RRGGBB`) on jump/open | +| `commands` | no | `{}` | Per-repo command table used by `rep run`; repo commands override same-named global commands | +| `shell_alias` | no | `false` | Whether `rep init ` emits top-level shell functions for the repo key and aliases | -## Terminal personalization +### AI tools -When you jump to or open a repo, RePortal automatically sets: +Configure which AI coding CLIs are available for `rep ai`: -1. **Tab title** (OSC 2) — uses the `title` config field, falling back to the repo alias -2. **Background color** (OSC 11) — uses the `color` config field; repos without a color reset the terminal to its default +```toml +[ai_tools.aider] +command = "aider" +args = [] +``` + +| Field | Required | Default | What it controls | +|-------|----------|---------|-----------------| +| `command` | yes | — | The executable to run | +| `args` | no | `[]` | Extra arguments passed on every launch | + +The default config created on first `rep` invocation ships with aider pre-registered. + +### Workspaces + +A workspace is a named group of repos that open together as one VSCode/Cursor window, **and** a real directory on disk containing one symlink (Unix) or directory junction (Windows) per member. RePortal owns the `[workspaces.]` table as the single source of truth; the on-disk directory (symlinks + `.code-workspace` file) is a derived artifact generated from the member repos' current paths. + +``` +~/dev/workspaces/backend/ + backend.code-workspace # folders[] uses relative "./api", "./worker", ... + api → ~/dev/services/api (symlink on Unix, junction on Windows) + worker → ~/dev/services/worker + db-migrations → ~/dev/db-migrations +``` + +`rjw backend` cd's into `~/dev/workspaces/backend/`, so `ls`, `cargo test`, your IDE — everything sees every member repo as a subdirectory. The symlinks are transparent: `git`, editors, and build tools all resolve them to the real repo. + +```toml +[settings] +default_workspace_root = "~/dev/workspaces" # where workspace directories live + +[workspaces.backend] +repos = [ + "api", # registered-repo reference + "worker", + { path = "~/dev/db-migrations" }, # inline filesystem path (no repo registry entry needed) +] +description = "Jakuta backend services" +path = "" # empty = /backend/ +aliases = ["be", "back"] # short names accepted by rep workspace +title = "Backend" # prompt/editor label (defaults to workspace name) +color = "#5b5ea6" # prompt/editor/terminal accent color +shell_alias = true # optional: emit workspace aliases as shell functions +``` + +| Field | Required | Default | What it controls | +|-------|----------|---------|-----------------| +| `repos` | yes | — | Ordered list of members. Each entry is either a **bare string** (alias of a registered repo — gets the path-change reverse index) or an **inline table** `{ path = "..." }` (raw filesystem path — bypasses the repo registry entirely). Order matches the editor sidebar and the on-disk link listing. Bare-string members must resolve to a registered repo; inline-path members are not validated against the repo registry | +| `description` | no | `""` | Human-readable description shown in `rep workspace list` | +| `path` | no | `""` | Filesystem path for the workspace directory (the one containing the symlinks and the `.code-workspace` file). Empty falls back to `//`. Pre-v0.15.2 configs may still store a `.code-workspace` file path here — those are auto-migrated on first `rep workspace jump` / `rebuild` | +| `aliases` | no | `[]` | Short names that resolve to this workspace in `rep workspace` subcommands. Must not collide with any repo name/alias or another workspace's name/alias — enforced at config load | +| `title` | no | workspace name | Label used for the prompt badge, terminal title, and generated editor workspace identity | +| `color` | no | reset to default | Workspace accent color for prompt badge, terminal tab, and generated editor title-bar color | +| `shell_alias` | no | `false` | Whether `rep init ` emits top-level shell functions for the workspace name and aliases | + +**Platform-specific linking is transparent.** On Unix the member entries are symlinks (`ln -s`); on Windows they are directory junctions (via the `junction` crate, no elevation required). `cd`, `ls`, and every build tool follow both transparently — the user doesn't need to know which form is used. + +**Regeneration is automatic for registered-repo members.** When you change a repo's path via `rep edit`, every workspace whose `repos` field references it as a bare-string alias rebuilds its directory — stale symlinks get redirected at the new target, and the `.code-workspace` file is refreshed. Inline-path members (`{ path = "..." }`) are NOT tracked by the reverse index — moving an inline-path folder requires editing the `path` value in `~/.reportal/config.toml` yourself. User-authored fields inside the `.code-workspace` file (`settings`, `extensions`, `launch`, `tasks`, plus any JSONC comments) round-trip byte-stable across regeneration — RePortal only touches the `folders` array. -Both sequences go to stderr so they don't interfere with the path output that `rj` captures. Terminals that don't support these sequences silently ignore them. +**If the workspace directory is missing or stale**, run `rep workspace rebuild ` to regenerate the symlinks and `.code-workspace` file from config. The command is idempotent and does not move or modify the member repos themselves — only the links inside the workspace directory. + +**`rep workspace delete ` only removes the config entry by default.** Pass `--purge` to also delete the on-disk workspace directory (symlinks + `.code-workspace` file). Member repos are never touched; a purge just removes the thin directory of links. + +**Removing a repo that's still a workspace member is refused** with a message listing the blocking workspaces. Remove the repo from each workspace first (or delete those workspaces), then retry. This keeps destructive membership changes explicit. + +### Commands + +Define reusable commands that can be run in any repo via `rep run`: + +```toml +[commands] +test = { command = "cargo test", description = "Run tests", shell_alias = true } +serve = { command = "npm run dev", description = "Start dev server" } +build = { command = "cargo build --release", description = "Production build" } +``` + +| Field | Required | Default | What it controls | +|-------|----------|---------|-----------------| +| `command` | yes | — | The shell command to execute | +| `description` | no | `""` | Shown in the fuzzy picker alongside the command name | +| `shell_alias` | no | `false` | Whether `rep init ` emits a top-level shell function that runs `rep run --cmd ` | + +Per-repo commands go under `[repos..commands]` and can override global commands with the same name, or add repo-specific ones: + +```toml +[repos.my-api.commands] +serve = { command = "python manage.py runserver", description = "Start Django dev server" } +migrate = { command = "python manage.py migrate", description = "Run database migrations" } +``` + +## Terminal personalization + +When you jump to or open a repo or workspace, RePortal automatically sets: + +1. **Tab title** — uses the `title` config field, falling back to the repo/workspace name +2. **Tab color** — uses the `color` config field; entries without a color reset to the terminal default The `--title` flag on `jump`/`open` lets you override the tab title for a single session without changing config. ### Shell hook for new terminals -Terminals opened directly into a repo (e.g. VS Code integrated terminal) won't go through `rj`, so they won't get the color/title automatically. Add `rep color` to your prompt to fix that: +Terminals opened directly into a repo or workspace (e.g. VS Code integrated terminal) won't go through `rj`/`rjw`, so they won't get the color/title automatically. Add `rep color --mode prompt-hook` to your prompt to refresh the terminal title and color on each prompt: **PowerShell:** ```powershell -function prompt { rep color 2>$null; "PS> " } +function prompt { rep color --mode prompt-hook 2>$null; "PS> " } ``` -**Bash / Zsh:** +**Bash:** ```bash -PROMPT_COMMAND='rep color 2>/dev/null' +PROMPT_COMMAND='rep color --mode prompt-hook 2>/dev/null' +``` + +**Zsh:** +```zsh +precmd() { rep color --mode prompt-hook 2>/dev/null } ``` -`rep color` matches your current directory against registered repos (longest prefix wins) and emits the right sequences. +`rep color` matches your current directory against registered workspaces and repos and emits the right terminal sequences. Use `rep prompt --shell ` when you want an inline colored prompt badge instead of only terminal title/color updates. ## Roadmap @@ -223,17 +388,32 @@ PROMPT_COMMAND='rep color 2>/dev/null' - [x] Shell integration auto-install (`rj`, `ro`) - [x] Clone from URL with sibling/child placement - [x] Auto-detect git remote on `add` -- [x] Colored output with themed fuzzy finder +- [x] Colored output with themed fuzzy finder (repo color swatches in `rep list` and fuzzy finder) - [x] `rep` short alias - [x] Configurable path display (absolute/relative, show/hide) - [x] Per-repo terminal tab title and background color (OSC 2 / OSC 11) - [x] `color` command for shell prompt hooks - [x] `status` — git status across all repos - [x] `sync` — pull latest across repos +- [x] `doctor` — diagnose config, shell integration, and repo path issues +- [x] `ai` — launch AI coding CLIs (aider) in any repo with configurable defaults +- [x] `web` — open a repo's remote URL in the browser (converts SSH remotes to HTTPS) +- [x] `run` — run configured commands in repos with fuzzy selection and per-repo overrides +- [x] `edit` UX overhaul — field menu (pick Path/Description/Tags/Title/Color individually, loop back) +- [x] VSCode/Cursor `.code-workspace` integration — `rep workspace` subcommands, owned config, auto-regenerate on repo path changes, JSONC round-trip preservation +- [ ] `config` — manage AI tools and global settings (`rep config ai-tools`, `rep config settings`) +- [x] Workspace-tree grouping in `rep list` (workspaces as the tree root, `--workspace` as a first-class filter composable with `--tag`) +- [x] Workspace aliases — short names that resolve to a workspace in `rep workspace` subcommands, with cross-namespace collision validation at config load +- [x] Inline-path workspace members — mix registered-repo references with raw filesystem paths in the same workspace so folders can belong to a workspace without being registered as top-level repos +- [x] Unified `rj` / `ro` — fall through to workspace aliases when no repo matches, so `rj venoble` cd's to the workspace's common-ancestor folder and `ro venoble` opens the workspace in the editor; `rjw` / `row` added as workspace-only variants +- [x] Unified fuzzy finder in `rj` / `ro` — workspaces render alongside repos with a `[workspace]` suffix, and `rjw` / `row` with no args fuzzy-select workspaces only +- [x] Idiomatic `rep init ` — prints shell code to stdout for `eval`-based loading (starship/zoxide/direnv pattern), with lazy config bootstrap on first command +- [x] Workspaces as real directories — each workspace is materialized under `~/dev/workspaces//` with one symlink (Unix) or junction (Windows) per member repo plus the `.code-workspace` file inside; `rjw` cd's there so terminal and editor both see members as subdirs, and `rep workspace rebuild` / `delete --purge` manage the on-disk layout +- [ ] Interactive ratatui TUI absorbing `list` / `dash` with live git-status column - [ ] `dashboard` — rich overview with branches, dirty state, last commit - [ ] `clone --all` — clone missing repos from config (machine sync) - [ ] Shell completions -- [ ] Publish to crates.io +- [x] Publish to crates.io ## Contributing diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..5799fb4 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,8 @@ +avoid-breaking-exported-api = false +cognitive-complexity-threshold = 10 +excessive-nesting-threshold = 4 +too-many-arguments-threshold = 4 +type-complexity-threshold = 200 +single-char-binding-names-threshold = 0 +trivial-copy-size-limit = 8 +enum-variant-size-threshold = 200 diff --git a/dist-workspace.toml b/dist-workspace.toml index c0bef3c..f941264 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -8,7 +8,7 @@ cargo-dist-version = "0.31.0" # CI backends to support ci = "github" # The installers to generate for each app -installers = ["shell", "powershell", "homebrew"] +installers = ["homebrew"] # Target platforms to build apps for (Rust target-triple syntax) targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] # Path that installers should place binaries in @@ -19,3 +19,8 @@ install-updater = false [dist.homebrew] tap = "CheckPickerUpper/homebrew-tap" publish = true + +# Ship custom installers that auto-configure guarded shell integration. +[[dist.extra-artifacts]] +build = ["python3", "installer/stage_dist_installers.py"] +artifacts = ["target/reportal-installer.sh", "target/reportal-installer.ps1"] diff --git a/installer/install.ps1 b/installer/install.ps1 new file mode 100644 index 0000000..2bee5e9 --- /dev/null +++ b/installer/install.ps1 @@ -0,0 +1,195 @@ +#!/usr/bin/env pwsh +# RePortal installer for Windows PowerShell. +# +# Downloads the latest (or pinned) reportal release archive from GitHub, +# installs the reportal and rep binaries into +# $env:LOCALAPPDATA\Programs\reportal, ensures that directory is on the +# user-scope PATH, and appends an idempotent guarded RePortal block to $PROFILE. +# +# Usage: +# powershell -ExecutionPolicy Bypass -c "irm https://github.com/CheckPickerUpper/reportal/releases/latest/download/reportal-installer.ps1 | iex" +# +# Optional env: +# $env:REPORTAL_VERSION = 'v0.18.5' Pin to a specific tag. +# +# Licensed under MIT. + +$ErrorActionPreference = 'Stop' + +$Repo = 'CheckPickerUpper/reportal' +$ApiLatest = "https://api.github.com/repos/$Repo/releases/latest" +$DownloadBase = "https://github.com/$Repo/releases/download" +$InstallDir = Join-Path $env:LOCALAPPDATA 'Programs\reportal' +$MarkerStart = '# >>> reportal shell integration (do not edit) >>>' +$MarkerEnd = '# <<< reportal shell integration <<<' +$ReinstallCommand = 'powershell -ExecutionPolicy Bypass -c "irm https://github.com/CheckPickerUpper/reportal/releases/latest/download/reportal-installer.ps1 | iex"' + +function Write-Info { + param([string]$Message) + Write-Host "reportal-installer: $Message" +} + +function Resolve-Target { + # cargo-dist currently builds x86_64-pc-windows-msvc only for Windows. + # Accept arm64 Windows in the future by adding a mapping here. + $arch = $env:PROCESSOR_ARCHITECTURE + switch ($arch) { + 'AMD64' { return 'x86_64-pc-windows-msvc' } + 'x86' { return 'x86_64-pc-windows-msvc' } # WOW64 path, still use x86_64 build + default { + throw "unsupported Windows architecture: $arch (only x86_64-pc-windows-msvc is published; use cargo install reportal)" + } + } +} + +function Resolve-Tag { + if ($env:REPORTAL_VERSION) { + return $env:REPORTAL_VERSION + } + try { + $headers = @{ 'User-Agent' = 'reportal-installer' } + $response = Invoke-RestMethod -Uri $ApiLatest -Headers $headers + } catch { + throw "failed to query $ApiLatest : $($_.Exception.Message)" + } + if (-not $response.tag_name) { + throw "could not parse tag_name from $ApiLatest" + } + return $response.tag_name +} + +function Test-Sha256 { + param( + [string]$FilePath, + [string]$ExpectedFile + ) + $expectedLine = (Get-Content -Path $ExpectedFile -First 1).Trim() + if (-not $expectedLine) { + throw "empty checksum in $ExpectedFile" + } + # sha256 files may be "" or " ". + $expected = ($expectedLine -split '\s+')[0].ToLower() + $actual = (Get-FileHash -Path $FilePath -Algorithm SHA256).Hash.ToLower() + if ($actual -ne $expected) { + throw "checksum mismatch: expected $expected, got $actual" + } + Write-Info "checksum OK" +} + +function Add-ToUserPath { + param([string]$Dir) + $current = [Environment]::GetEnvironmentVariable('Path', 'User') + if (-not $current) { $current = '' } + $parts = $current.Split(';') | Where-Object { $_ -ne '' } + if ($parts -contains $Dir) { + Write-Info "$Dir already on user PATH" + return + } + $newPath = if ($current) { "$current;$Dir" } else { $Dir } + [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') + # Also update the current session PATH. + $env:Path = "$env:Path;$Dir" + Write-Info "added $Dir to user PATH" +} + +function Install-ShellIntegration { + $profilePath = $PROFILE + $profileDir = Split-Path -Parent $profilePath + if (-not (Test-Path $profileDir)) { + New-Item -ItemType Directory -Path $profileDir -Force | Out-Null + } + if (-not (Test-Path $profilePath)) { + New-Item -ItemType File -Path $profilePath -Force | Out-Null + } + + $content = Get-Content -Path $profilePath -Raw -ErrorAction SilentlyContinue + if ($null -eq $content) { $content = '' } + if ($content.Contains($MarkerStart)) { + Write-Info "shell integration already present in $profilePath" + return + } + + $block = @() + if ($content -and -not $content.EndsWith([Environment]::NewLine)) { + $block += '' + } + $block += '' + $block += $MarkerStart + $block += 'if (Get-Command rep -CommandType Application -ErrorAction SilentlyContinue) {' + $block += ' Invoke-Expression (& rep init powershell | Out-String)' + $block += '} elseif (Get-Command reportal -CommandType Application -ErrorAction SilentlyContinue) {' + $block += ' Invoke-Expression (& reportal init powershell | Out-String)' + $block += '} else {' + $block += ' function global:rep {' + $block += " Write-Error 'RePortal: rep.exe is not on PATH. Reinstall with: $ReinstallCommand'" + $block += ' $global:LASTEXITCODE = 127' + $block += ' }' + $block += '}' + $block += $MarkerEnd + Add-Content -Path $profilePath -Value ($block -join [Environment]::NewLine) + Write-Info "appended shell integration to $profilePath" +} + +function Main { + $target = Resolve-Target + $tag = Resolve-Tag + $archiveName = "reportal-$target.zip" + $url = "$DownloadBase/$tag/$archiveName" + $shaUrl = "$url.sha256" + + Write-Info "installing reportal $tag for $target" + + $tmpDir = Join-Path ([System.IO.Path]::GetTempPath()) ("reportal-installer-" + [System.Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null + try { + $archivePath = Join-Path $tmpDir $archiveName + $shaPath = "$archivePath.sha256" + + Write-Info "downloading $url" + Invoke-WebRequest -Uri $url -OutFile $archivePath -UseBasicParsing + + try { + Invoke-WebRequest -Uri $shaUrl -OutFile $shaPath -UseBasicParsing -ErrorAction Stop + Test-Sha256 -FilePath $archivePath -ExpectedFile $shaPath + } catch { + Write-Info "note: no .sha256 sidecar found; skipping checksum verification" + } + + $extractDir = Join-Path $tmpDir 'extract' + New-Item -ItemType Directory -Path $extractDir -Force | Out-Null + Expand-Archive -Path $archivePath -DestinationPath $extractDir -Force + + # cargo-dist archives extract to a subdir like reportal-\. + $innerDir = Join-Path $extractDir "reportal-$target" + if (-not (Test-Path $innerDir)) { + # Fall back: find any dir containing reportal.exe. + $found = Get-ChildItem -Path $extractDir -Recurse -Filter 'reportal.exe' -File | Select-Object -First 1 + if (-not $found) { + throw "could not locate reportal.exe inside archive" + } + $innerDir = $found.Directory.FullName + } + + if (-not (Test-Path $InstallDir)) { + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null + } + + foreach ($binName in @('reportal.exe', 'rep.exe')) { + $src = Join-Path $innerDir $binName + if (-not (Test-Path $src)) { + throw "missing binary in archive: $binName" + } + Copy-Item -Path $src -Destination (Join-Path $InstallDir $binName) -Force + } + + Write-Info "installed binaries to $InstallDir" + Add-ToUserPath -Dir $InstallDir + Install-ShellIntegration + + Write-Host "RePortal $tag installed. Restart PowerShell (or dot-source `$PROFILE) to activate." + } finally { + Remove-Item -Path $tmpDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + +Main diff --git a/installer/install.sh b/installer/install.sh new file mode 100644 index 0000000..b8613f5 --- /dev/null +++ b/installer/install.sh @@ -0,0 +1,285 @@ +#!/usr/bin/env bash +# +# RePortal installer for macOS and Linux. +# +# Downloads the latest (or pinned) reportal release archive from GitHub, +# installs the `reportal` and `rep` binaries into ~/.local/bin, and appends +# an idempotent guarded RePortal block to the user's shell rc file. Matches +# the distribution pattern used by starship/mise/zoxide. +# +# Usage: +# curl --proto '=https' --tlsv1.2 -LsSf \ +# https://github.com/CheckPickerUpper/reportal/releases/latest/download/reportal-installer.sh \ +# | bash +# +# Optional env: +# REPORTAL_VERSION=v0.18.5 Pin to a specific tag instead of "latest". +# +# Licensed under MIT. + +set -euo pipefail + +readonly REPO="CheckPickerUpper/reportal" +readonly API_LATEST="https://api.github.com/repos/${REPO}/releases/latest" +readonly DOWNLOAD_BASE="https://github.com/${REPO}/releases/download" +readonly INSTALL_DIR="${HOME}/.local/bin" +readonly MARKER_START="# >>> reportal shell integration (do not edit) >>>" +readonly MARKER_END="# <<< reportal shell integration <<<" +readonly REINSTALL_COMMAND="curl --proto '=https' --tlsv1.2 -LsSf https://github.com/${REPO}/releases/latest/download/reportal-installer.sh | bash" + +err() { + printf 'reportal-installer: error: %s\n' "$*" >&2 + exit 1 +} + +info() { + printf 'reportal-installer: %s\n' "$*" +} + +need_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + err "required command not found: $1" + fi +} + +have_cmd() { + command -v "$1" >/dev/null 2>&1 +} + +# Detect target triple matching cargo-dist's build matrix. +# Supported targets (must match dist-workspace.toml): +# aarch64-apple-darwin +# x86_64-apple-darwin +# aarch64-unknown-linux-gnu +# x86_64-unknown-linux-gnu +detect_target() { + local os arch + os=$(uname -s) + arch=$(uname -m) + + case "$os" in + Darwin) + case "$arch" in + arm64|aarch64) echo "aarch64-apple-darwin" ;; + x86_64) echo "x86_64-apple-darwin" ;; + *) err "unsupported macOS architecture: $arch" ;; + esac + ;; + Linux) + case "$arch" in + aarch64|arm64) echo "aarch64-unknown-linux-gnu" ;; + x86_64|amd64) echo "x86_64-unknown-linux-gnu" ;; + *) err "unsupported Linux architecture: $arch" ;; + esac + ;; + *) + err "unsupported OS: $os (only macOS and Linux are supported by this installer; use cargo install reportal instead)" + ;; + esac +} + +# Fetch a URL to stdout using whichever downloader is available. +fetch_stdout() { + local url="$1" + if have_cmd curl; then + curl --proto '=https' --tlsv1.2 -fsSL "$url" + elif have_cmd wget; then + wget -qO- "$url" + else + err "need curl or wget to download from $url" + fi +} + +# Fetch a URL to a local path. +fetch_to_file() { + local url="$1" + local out="$2" + if have_cmd curl; then + curl --proto '=https' --tlsv1.2 -fsSL -o "$out" "$url" + elif have_cmd wget; then + wget -qO "$out" "$url" + else + err "need curl or wget to download from $url" + fi +} + +# Resolve release tag (either $REPORTAL_VERSION or latest from GitHub API). +resolve_tag() { + if [ -n "${REPORTAL_VERSION:-}" ]; then + echo "$REPORTAL_VERSION" + return + fi + local body + body=$(fetch_stdout "$API_LATEST") || err "failed to query $API_LATEST" + # Extract "tag_name": "v0.15.0" without depending on jq. + local tag + tag=$(printf '%s\n' "$body" \ + | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' \ + | head -n1 \ + | sed 's/.*"\([^"]*\)"[[:space:]]*$/\1/') + if [ -z "$tag" ]; then + err "could not parse tag_name from $API_LATEST" + fi + echo "$tag" +} + +# Verify SHA256 of $1 against the value in $2 (which may be the raw digest or +# " " format). Prefers sha256sum, falls back to shasum. +verify_sha256() { + local file="$1" + local expected_file="$2" + local expected + expected=$(awk '{print $1; exit}' "$expected_file") + if [ -z "$expected" ]; then + err "empty checksum in $expected_file" + fi + + local actual + if have_cmd sha256sum; then + actual=$(sha256sum "$file" | awk '{print $1}') + elif have_cmd shasum; then + actual=$(shasum -a 256 "$file" | awk '{print $1}') + else + info "warning: no sha256sum/shasum available; skipping checksum verification" + return 0 + fi + + if [ "$actual" != "$expected" ]; then + err "checksum mismatch: expected $expected, got $actual" + fi + info "checksum OK" +} + +# Determine shell rc file to modify. +detect_rc_file() { + local shell_name + shell_name=$(basename "${SHELL:-}") + case "$shell_name" in + zsh) echo "zsh:${HOME}/.zshrc" ;; + bash) echo "bash:${HOME}/.bashrc" ;; + *) echo "bash:${HOME}/.profile" ;; + esac +} + +# Append integration block to rc file, idempotently. +install_shell_integration() { + local shell_name="$1" + local rc_file="$2" + + if [ -f "$rc_file" ] && grep -Fq "$MARKER_START" "$rc_file"; then + info "shell integration already present in $rc_file" + return 0 + fi + + mkdir -p "$(dirname "$rc_file")" + # Ensure trailing newline before appending. + if [ -f "$rc_file" ] && [ -s "$rc_file" ]; then + # Add a blank line separator if file does not already end with newline. + if [ "$(tail -c1 "$rc_file" | wc -l)" -eq 0 ]; then + printf '\n' >> "$rc_file" + fi + fi + + { + printf '\n%s\n' "$MARKER_START" + printf 'if command -v rep >/dev/null 2>&1; then\n' + printf ' eval "$(rep init %s)"\n' "$shell_name" + printf 'elif command -v reportal >/dev/null 2>&1; then\n' + printf ' eval "$(reportal init %s)"\n' "$shell_name" + printf 'else\n' + printf ' rep() { printf '\''%%s\\n'\'' "RePortal: rep is not on PATH. Reinstall with: %s" >&2; return 127; }\n' "$REINSTALL_COMMAND" + printf 'fi\n' + printf '%s\n' "$MARKER_END" + } >> "$rc_file" + + info "appended shell integration to $rc_file" +} + +# Print PATH warning if INSTALL_DIR isn't on PATH. +warn_path() { + case ":${PATH:-}:" in + *":${INSTALL_DIR}:"*) ;; + *) + info "note: ${INSTALL_DIR} is not on your PATH." + info " add this to your shell rc file:" + info " export PATH=\"\$HOME/.local/bin:\$PATH\"" + ;; + esac +} + +main() { + need_cmd uname + need_cmd mktemp + need_cmd tar + need_cmd chmod + need_cmd mkdir + need_cmd grep + need_cmd sed + need_cmd awk + + local target tag archive url sha_url tmpdir archive_path sha_path + target=$(detect_target) + tag=$(resolve_tag) + archive="reportal-${target}.tar.xz" + url="${DOWNLOAD_BASE}/${tag}/${archive}" + sha_url="${url}.sha256" + + info "installing reportal ${tag} for ${target}" + + tmpdir=$(mktemp -d) + REPORTAL_TMPDIR="$tmpdir" + trap 'if [ -n "${REPORTAL_TMPDIR:-}" ]; then rm -rf "$REPORTAL_TMPDIR"; fi' EXIT + + archive_path="${tmpdir}/${archive}" + sha_path="${archive_path}.sha256" + + info "downloading ${url}" + fetch_to_file "$url" "$archive_path" || err "failed to download $url" + + # Checksum file is optional (older releases may not have it). + if fetch_to_file "$sha_url" "$sha_path" 2>/dev/null; then + verify_sha256 "$archive_path" "$sha_path" + else + info "note: no .sha256 sidecar found; skipping checksum verification" + fi + + info "extracting archive" + tar -xf "$archive_path" -C "$tmpdir" + + mkdir -p "$INSTALL_DIR" + + # cargo-dist archives extract to a directory like reportal-/. + # Locate the binaries inside that directory. + local extracted_dir + extracted_dir="${tmpdir}/reportal-${target}" + if [ ! -d "$extracted_dir" ]; then + # Fall back to finding any extracted directory with a reportal binary. + extracted_dir=$(find "$tmpdir" -mindepth 1 -maxdepth 2 -type f -name reportal -exec dirname {} \; | head -n1) + if [ -z "$extracted_dir" ]; then + err "could not locate reportal binary inside archive" + fi + fi + + local bin + for bin in reportal rep; do + if [ ! -f "${extracted_dir}/${bin}" ]; then + err "missing binary in archive: ${bin}" + fi + install -m 0755 "${extracted_dir}/${bin}" "${INSTALL_DIR}/${bin}" + done + + info "installed binaries to ${INSTALL_DIR}" + warn_path + + # Shell integration. + local rc_info shell_name rc_file + rc_info=$(detect_rc_file) + shell_name="${rc_info%%:*}" + rc_file="${rc_info#*:}" + install_shell_integration "$shell_name" "$rc_file" + + printf 'RePortal %s installed. Run '\''source %s'\'' or open a new shell.\n' \ + "$tag" "$rc_file" +} + +main "$@" diff --git a/installer/stage_dist_installers.py b/installer/stage_dist_installers.py new file mode 100644 index 0000000..202515e --- /dev/null +++ b/installer/stage_dist_installers.py @@ -0,0 +1,22 @@ +from pathlib import Path +import shutil +import stat + + +def main() -> None: + repository_root = Path(__file__).resolve().parent.parent + target_directory = repository_root / "target" + target_directory.mkdir(exist_ok=True) + + shell_installer = target_directory / "reportal-installer.sh" + powershell_installer = target_directory / "reportal-installer.ps1" + + shutil.copy2(repository_root / "installer" / "install.sh", shell_installer) + shutil.copy2(repository_root / "installer" / "install.ps1", powershell_installer) + + current_mode = shell_installer.stat().st_mode + shell_installer.chmod(current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +if __name__ == "__main__": + main() diff --git a/src/cli_args/ai_arguments.rs b/src/cli_args/ai_arguments.rs new file mode 100644 index 0000000..f64a776 --- /dev/null +++ b/src/cli_args/ai_arguments.rs @@ -0,0 +1,27 @@ +//! CLI args for `rep ai`. + +use super::repository_selection_arguments::RepositorySelectionArguments; +use crate::reportal_config::TagFilter; +use clap::Args; + +/// Arguments for the `rep ai` subcommand. +/// +/// Supports repo selection and an optional `--tool` override to +/// choose which AI coding CLI to launch. +#[derive(Args)] +pub struct AiArguments { + #[command(flatten)] + selection: RepositorySelectionArguments, + /// Which AI tool to launch (overrides `default_ai_tool` setting) + #[arg(long, default_value = "", hide_default_value = true)] + tool: String, +} + +/// Consuming conversion that splits into domain-layer parts. +impl AiArguments { + /// Returns (alias, `tag_filter`, `tool_override`), consuming self. + pub fn into_parts(self) -> (String, TagFilter, String) { + let (alias, tag_filter) = self.selection.into_parts(); + (alias, tag_filter, self.tool) + } +} diff --git a/src/cli_args/color_arguments.rs b/src/cli_args/color_arguments.rs new file mode 100644 index 0000000..a92eef6 --- /dev/null +++ b/src/cli_args/color_arguments.rs @@ -0,0 +1,28 @@ +//! CLI args for `rep color`. + +use crate::reportal_commands::ColorCommandMode; +use clap::Args; + +/// Arguments for the `rep color` subcommand. +/// +/// Controls how terminal identity (tab title + background color) is +/// resolved and emitted. The `--mode` flag selects between prompt-hook +/// behavior (prints title to stdout, preserves state on no-match) and +/// explicit behavior (silent stdout, resets color on no-match). +#[derive(Args)] +pub struct ColorArguments { + /// Look up this repo by alias instead of matching the current directory + #[arg(long, default_value = "", hide_default_value = true)] + repo: String, + /// Invocation mode: prompt-hook (for shell integration) or explicit (default) + #[arg(long, value_enum, default_value_t = ColorCommandMode::Explicit)] + mode: ColorCommandMode, +} + +/// Consuming conversion that splits into domain-layer parts. +impl ColorArguments { + /// Returns (`repository_alias`, mode), consuming self. + pub fn into_parts(self) -> (String, ColorCommandMode) { + (self.repo, self.mode) + } +} diff --git a/src/cli_args/edit_arguments.rs b/src/cli_args/edit_arguments.rs new file mode 100644 index 0000000..f9ef171 --- /dev/null +++ b/src/cli_args/edit_arguments.rs @@ -0,0 +1,23 @@ +//! CLI args for `rep edit`. + +use super::repository_selection_arguments::RepositorySelectionArguments; +use crate::reportal_config::TagFilter; +use clap::Args; + +/// Arguments for the `rep edit` subcommand. +/// +/// Accepts optional repo selection (alias + tag filter) to choose +/// which repo's metadata to edit interactively. +#[derive(Args)] +pub struct EditArguments { + #[command(flatten)] + selection: RepositorySelectionArguments, +} + +/// Consuming conversion that splits into domain-layer parts. +impl EditArguments { + /// Returns (alias, `tag_filter`), consuming self. + pub fn into_parts(self) -> (String, TagFilter) { + self.selection.into_parts() + } +} diff --git a/src/cli_args/initialize_arguments.rs b/src/cli_args/initialize_arguments.rs new file mode 100644 index 0000000..ed9db6e --- /dev/null +++ b/src/cli_args/initialize_arguments.rs @@ -0,0 +1,35 @@ +//! CLI args for `rep init`. + +use clap::{Args, ValueEnum}; + +/// Arguments for the `rep init` subcommand. +/// +/// `rep init` prints shell integration code to stdout for the +/// requested shell. Users wire it into their rc file with a single +/// `eval "$(rep init zsh)"` line, matching the pattern used by +/// starship, zoxide, direnv, and mise. The binary never writes +/// integration files to disk and never prompts the user. +#[derive(Args)] +pub struct InitializeArguments { + /// Shell to generate integration code for + shell: InitializeShell, +} + +/// Shells that `rep init` can generate integration code for. +#[derive(Clone, Copy, Debug, ValueEnum)] +pub enum InitializeShell { + /// Zsh (prints POSIX-compatible shell functions). + Zsh, + /// Bash (prints POSIX-compatible shell functions). + Bash, + /// `PowerShell` (prints `PowerShell` function definitions). + Powershell, +} + +/// Accessor for the parsed shell choice. +impl InitializeArguments { + /// The shell whose integration code should be emitted. + pub fn shell(&self) -> InitializeShell { + self.shell + } +} diff --git a/src/cli_args/jump_arguments.rs b/src/cli_args/jump_arguments.rs new file mode 100644 index 0000000..cd3c557 --- /dev/null +++ b/src/cli_args/jump_arguments.rs @@ -0,0 +1,27 @@ +//! CLI args for `rep jump`. + +use super::repository_selection_arguments::RepositorySelectionArguments; +use crate::reportal_config::TagFilter; +use clap::Args; + +/// Arguments for the `rep jump` subcommand. +/// +/// Supports optional repo selection (alias + tag filter) and an +/// optional `--title` override for the terminal tab title. +#[derive(Args)] +pub struct JumpArguments { + #[command(flatten)] + selection: RepositorySelectionArguments, + /// Override the tab title for this session + #[arg(long, default_value = "", hide_default_value = true)] + title: String, +} + +/// Consuming conversion that splits into domain-layer parts. +impl JumpArguments { + /// Returns (alias, `tag_filter`, `title_override`), consuming self. + pub fn into_parts(self) -> (String, TagFilter, String) { + let (alias, tag_filter) = self.selection.into_parts(); + (alias, tag_filter, self.title) + } +} diff --git a/src/cli_args/list_arguments.rs b/src/cli_args/list_arguments.rs new file mode 100644 index 0000000..c6d5433 --- /dev/null +++ b/src/cli_args/list_arguments.rs @@ -0,0 +1,64 @@ +//! CLI args for `rep list`. + +use super::tag_filter_arguments::TagFilterArguments; +use super::workspace_filter_arguments::WorkspaceFilterArguments; +use crate::reportal_config::{TagFilter, WorkspaceFilter}; +use clap::Args; + +/// Arguments for the `rep list` subcommand. +/// +/// Accepts `--tag` and `--workspace` as orthogonal filters. +/// Both are optional and compose as an AND intersection when +/// specified together — the rendered output includes only repos +/// that match both filters. +#[derive(Args)] +pub struct ListArguments { + /// Optional `--tag` flag for tag-based filtering. + #[command(flatten)] + tag_filter: TagFilterArguments, + /// Optional `--workspace` flag for workspace-membership + /// filtering, orthogonal to `--tag`. + #[command(flatten)] + workspace_filter: WorkspaceFilterArguments, +} + +/// Consuming accessors for `ListArguments`. +impl ListArguments { + /// Extracts both filters as a named parts struct so the + /// dispatcher receives exactly one argument, which the + /// project's argument rules require for handler entry points. + #[must_use] + pub fn into_filter_parts(self) -> ListArgumentsFilterParts { + ListArgumentsFilterParts { + tag_filter: self.tag_filter.into_tag_filter(), + workspace_filter: self.workspace_filter.into_workspace_filter(), + } + } +} + +/// Owned named-field result of `ListArguments::into_filter_parts`. +/// +/// Returned instead of a bare tuple so call sites never confuse +/// the two filter enums when dispatching to the listing handler. +pub struct ListArgumentsFilterParts { + /// Resolved tag filter for the listing. + pub(super) tag_filter: TagFilter, + /// Resolved workspace-membership filter for the listing. + pub(super) workspace_filter: WorkspaceFilter, +} + +/// Accessors for the destructured filter parts. +impl ListArgumentsFilterParts { + /// The resolved tag filter for this listing invocation. + #[must_use] + pub fn tag_filter(&self) -> &TagFilter { + &self.tag_filter + } + + /// The resolved workspace-membership filter for this + /// listing invocation. + #[must_use] + pub fn workspace_filter(&self) -> &WorkspaceFilter { + &self.workspace_filter + } +} diff --git a/src/cli_args/mod.rs b/src/cli_args/mod.rs new file mode 100644 index 0000000..2f6c13f --- /dev/null +++ b/src/cli_args/mod.rs @@ -0,0 +1,41 @@ +//! CLI argument types for the `RePortal` binary. +//! +//! Each subcommand's args live in their own file. Shared building +//! blocks (tag filter, repo selection) are composed via `#[command(flatten)]`. +mod ai_arguments; +mod color_arguments; +mod edit_arguments; +mod initialize_arguments; +mod jump_arguments; +mod list_arguments; +mod open_arguments; +mod prompt_arguments; +mod remove_arguments; +mod reportal_cli; +mod repository_selection_arguments; +mod run_arguments; +mod status_arguments; +mod sync_arguments; +mod tag_filter_arguments; +mod web_arguments; +mod workspace_arguments; +mod workspace_filter_arguments; + +pub use ai_arguments::AiArguments; +pub use color_arguments::ColorArguments; +pub use edit_arguments::EditArguments; +pub use initialize_arguments::{InitializeArguments, InitializeShell}; +pub use jump_arguments::JumpArguments; +pub use list_arguments::{ListArguments, ListArgumentsFilterParts}; +pub use open_arguments::OpenArguments; +pub use prompt_arguments::{PromptArguments, PromptShell}; +pub use remove_arguments::RemoveArguments; +pub use reportal_cli::{ReportalCli, ReportalCliSubcommand}; +pub use run_arguments::RunArguments; +pub use status_arguments::StatusArguments; +pub use sync_arguments::SyncArguments; +pub use web_arguments::WebArguments; +pub use workspace_arguments::{ + WorkspaceArguments, WorkspaceArgumentsCreateParts, WorkspaceArgumentsDeleteParts, + WorkspaceArgumentsMemberEditParts, WorkspaceArgumentsSubcommand, +}; diff --git a/src/cli_args/open_arguments.rs b/src/cli_args/open_arguments.rs new file mode 100644 index 0000000..732593c --- /dev/null +++ b/src/cli_args/open_arguments.rs @@ -0,0 +1,30 @@ +//! CLI args for `rep open`. + +use super::repository_selection_arguments::RepositorySelectionArguments; +use crate::reportal_config::TagFilter; +use clap::Args; + +/// Arguments for the `rep open` subcommand. +/// +/// Supports repo selection, an optional editor override, and an +/// optional tab title override. +#[derive(Args)] +pub struct OpenArguments { + #[command(flatten)] + selection: RepositorySelectionArguments, + /// Override the default editor command + #[arg(long, default_value = "", hide_default_value = true)] + editor: String, + /// Override the tab title for this session + #[arg(long, default_value = "", hide_default_value = true)] + title: String, +} + +/// Consuming conversion that splits into domain-layer parts. +impl OpenArguments { + /// Returns (alias, `tag_filter`, `editor_override`, `title_override`), consuming self. + pub fn into_parts(self) -> (String, TagFilter, String, String) { + let (alias, tag_filter) = self.selection.into_parts(); + (alias, tag_filter, self.editor, self.title) + } +} diff --git a/src/cli_args/prompt_arguments.rs b/src/cli_args/prompt_arguments.rs new file mode 100644 index 0000000..646e148 --- /dev/null +++ b/src/cli_args/prompt_arguments.rs @@ -0,0 +1,50 @@ +//! CLI arguments for `rep prompt`. + +use clap::{Args, ValueEnum}; + +/// Arguments for the `rep prompt` subcommand. +/// +/// `rep prompt` emits a single colored badge line to stdout, +/// wrapped with the non-printing-escape markers of the target +/// shell so the sequence can be inlined inside a PS1 / prompt +/// function without breaking cursor math on long command lines. +/// +/// The shell choice is required (no inference from the +/// environment) so the output contract is unambiguous: the same +/// binary running inside the same terminal still produces bash- +/// compatible output when `--shell bash` is passed and zsh- +/// compatible output when `--shell zsh` is passed. +#[derive(Args)] +pub struct PromptArguments { + /// Shell whose non-printing-escape wrappers the badge is formatted for + #[arg(long, value_enum)] + shell: PromptShell, +} + +/// Shells for which `rep prompt` can emit a wrapped badge. +/// +/// A separate enum from `InitializeShell` because the set of shells +/// that accept a prompt-badge wrapper is not necessarily the +/// same as the set for which `rep init` generates integration +/// code; keeping the two enums independent means a future shell +/// can be added on one axis without the other. +#[derive(Clone, Copy, Debug, ValueEnum)] +pub enum PromptShell { + /// Bash — wraps SGR with the `\[` / `\]` non-printing markers. + Bash, + /// Zsh — wraps SGR with the `%{` / `%}` non-printing markers. + Zsh, + /// `PowerShell` — emits raw SGR; `PSReadLine` handles cursor math. + Powershell, +} + +/// Consuming conversion that splits into domain-layer parts. +impl PromptArguments { + /// @why Hands the parsed shell choice to the command layer + /// so the command never imports clap types and the clap + /// wrapper can be swapped without touching the emit logic. + #[must_use] + pub fn into_shell(self) -> PromptShell { + self.shell + } +} diff --git a/src/cli_args/remove_arguments.rs b/src/cli_args/remove_arguments.rs new file mode 100644 index 0000000..5b7f017 --- /dev/null +++ b/src/cli_args/remove_arguments.rs @@ -0,0 +1,21 @@ +//! CLI args for `rep remove`. + +use clap::Args; + +/// Arguments for the `rep remove` subcommand. +/// +/// Takes a required alias identifying which repo to unregister +/// from the config (does not delete the repo's files on disk). +#[derive(Args)] +pub struct RemoveArguments { + /// Alias of the repo to remove + alias: String, +} + +/// Accessor for the parsed alias. +impl RemoveArguments { + /// The alias of the repo to unregister. + pub fn alias(&self) -> &str { + &self.alias + } +} diff --git a/src/cli_args/reportal_cli.rs b/src/cli_args/reportal_cli.rs new file mode 100644 index 0000000..5306c83 --- /dev/null +++ b/src/cli_args/reportal_cli.rs @@ -0,0 +1,96 @@ +//! Top-level CLI parser and subcommand dispatch enum. + +use super::{ + AiArguments, ColorArguments, EditArguments, InitializeArguments, JumpArguments, ListArguments, + OpenArguments, PromptArguments, RemoveArguments, RunArguments, StatusArguments, SyncArguments, + WebArguments, WorkspaceArguments, +}; +use clap::{Parser, Subcommand}; + +/// A fast CLI tool for jumping between and managing your dev repos. +#[derive(Parser)] +#[command(name = "reportal", version, about)] +pub struct ReportalCli { + /// The subcommand to execute. + #[command(subcommand)] + subcommand: ReportalCliSubcommand, +} + +/// Accessor for the parsed subcommand. +impl ReportalCli { + /// The subcommand the user invoked. + pub fn into_subcommand(self) -> ReportalCliSubcommand { + self.subcommand + } +} + +/// All available subcommands for the `RePortal` CLI. +#[derive(Subcommand)] +pub enum ReportalCliSubcommand { + /// Print shell integration code for the given shell to stdout. + /// + /// Wire it into your rc file with a single eval line, the same + /// pattern used by starship, zoxide, direnv, and mise. The binary + /// never writes integration files to disk and never prompts. + /// + /// Zsh / Bash: + /// + /// eval "$(rep init zsh)" + /// eval "$(rep init bash)" + /// + /// `PowerShell`: + /// + /// Invoke-Expression (& rep init powershell | Out-String) + #[command(verbatim_doc_comment)] + Init(InitializeArguments), + /// List all registered repos with status and metadata + #[command(alias = "l")] + List(ListArguments), + /// Fuzzy-select a repo and print its path (for shell cd integration) + #[command(alias = "j")] + Jump(JumpArguments), + /// Fuzzy-select a repo and open it in your editor + #[command(alias = "o")] + Open(OpenArguments), + /// Register a local repo in the config + #[command(alias = "a")] + Add { + /// Filesystem path to the repo directory + repo_path: String, + }, + /// Edit a repo's description, tags, title, and color + #[command(alias = "e")] + Edit(EditArguments), + /// Unregister a repo from the config (does not delete files) + #[command(alias = "rm")] + Remove(RemoveArguments), + /// Emit terminal tab title and background color for a repo (for shell hooks) + Color(ColorArguments), + /// Emit an ANSI-colored prompt badge for the current workspace or repo + /// + /// Output is wrapped with the target shell's non-printing-escape + /// markers so it can be inlined inside a `PS1` (bash), `$PROMPT` + /// (zsh), or `prompt` function (`PowerShell`) without breaking + /// cursor math. Silent when the CWD matches neither a workspace + /// directory nor a registered repo path. + #[command(verbatim_doc_comment)] + Prompt(PromptArguments), + /// Show git status across all registered repos + #[command(alias = "s")] + Status(StatusArguments), + /// Pull latest changes across all registered repos + Sync(SyncArguments), + /// Diagnose config, shell integration, and repo path issues + Doctor, + /// Launch an AI coding CLI in a repo + Ai(AiArguments), + /// Open a repo's remote URL in the browser + #[command(alias = "w")] + Web(WebArguments), + /// Run a configured command in a repo + #[command(alias = "r")] + Run(RunArguments), + /// Manage VSCode/Cursor `.code-workspace` definitions + #[command(alias = "ws")] + Workspace(WorkspaceArguments), +} diff --git a/src/cli_args/repository_selection_arguments.rs b/src/cli_args/repository_selection_arguments.rs new file mode 100644 index 0000000..0b8fab4 --- /dev/null +++ b/src/cli_args/repository_selection_arguments.rs @@ -0,0 +1,30 @@ +//! Shared optional alias positional arg + `--tag` flag for repo selection. + +use super::tag_filter_arguments::TagFilterArguments; +use crate::reportal_config::TagFilter; +use clap::Args; + +/// Optional repo alias (positional) combined with a `--tag` filter flag. +/// +/// Used by commands that select a single repo — jump, open, edit, web, +/// run, and ai. The alias is optional: when empty, the command presents +/// a fuzzy finder. When non-empty, it jumps directly to that repo. +#[derive(Args)] +pub struct RepositorySelectionArguments { + /// Jump directly to this alias (skip fuzzy finder) + #[arg(default_value = "", hide_default_value = true)] + alias: String, + #[command(flatten)] + tag_filter: TagFilterArguments, +} + +/// Consuming conversion that splits the args into their domain-layer parts. +impl RepositorySelectionArguments { + /// Returns the alias string and a `TagFilter`, consuming self. + /// + /// The alias may be empty (meaning no direct selection was provided). + /// The tag filter is converted from the CLI flag value. + pub fn into_parts(self) -> (String, TagFilter) { + (self.alias, self.tag_filter.into_tag_filter()) + } +} diff --git a/src/cli_args/run_arguments.rs b/src/cli_args/run_arguments.rs new file mode 100644 index 0000000..8f2a2ec --- /dev/null +++ b/src/cli_args/run_arguments.rs @@ -0,0 +1,27 @@ +//! CLI args for `rep run`. + +use super::repository_selection_arguments::RepositorySelectionArguments; +use crate::reportal_config::TagFilter; +use clap::Args; + +/// Arguments for the `rep run` subcommand. +/// +/// Supports repo selection and an optional `--cmd` to skip the +/// command fuzzy finder and run a specific configured command directly. +#[derive(Args)] +pub struct RunArguments { + #[command(flatten)] + selection: RepositorySelectionArguments, + /// Run this command directly (skip command fuzzy finder) + #[arg(long, default_value = "", hide_default_value = true)] + cmd: String, +} + +/// Consuming conversion that splits into domain-layer parts. +impl RunArguments { + /// Returns (alias, `tag_filter`, `direct_command`), consuming self. + pub fn into_parts(self) -> (String, TagFilter, String) { + let (alias, tag_filter) = self.selection.into_parts(); + (alias, tag_filter, self.cmd) + } +} diff --git a/src/cli_args/status_arguments.rs b/src/cli_args/status_arguments.rs new file mode 100644 index 0000000..98c7494 --- /dev/null +++ b/src/cli_args/status_arguments.rs @@ -0,0 +1,22 @@ +//! CLI args for `rep status`. + +use super::tag_filter_arguments::TagFilterArguments; +use crate::reportal_config::TagFilter; +use clap::Args; + +/// Arguments for the `rep status` subcommand. +/// +/// Accepts an optional `--tag` flag to filter which repos are checked. +#[derive(Args)] +pub struct StatusArguments { + #[command(flatten)] + filter: TagFilterArguments, +} + +/// Consuming conversion to the domain tag filter. +impl StatusArguments { + /// Extracts the tag filter, consuming the parsed args. + pub fn into_tag_filter(self) -> TagFilter { + self.filter.into_tag_filter() + } +} diff --git a/src/cli_args/sync_arguments.rs b/src/cli_args/sync_arguments.rs new file mode 100644 index 0000000..5d6b777 --- /dev/null +++ b/src/cli_args/sync_arguments.rs @@ -0,0 +1,22 @@ +//! CLI args for `rep sync`. + +use super::tag_filter_arguments::TagFilterArguments; +use crate::reportal_config::TagFilter; +use clap::Args; + +/// Arguments for the `rep sync` subcommand. +/// +/// Accepts an optional `--tag` flag to filter which repos are synced. +#[derive(Args)] +pub struct SyncArguments { + #[command(flatten)] + filter: TagFilterArguments, +} + +/// Consuming conversion to the domain tag filter. +impl SyncArguments { + /// Extracts the tag filter, consuming the parsed args. + pub fn into_tag_filter(self) -> TagFilter { + self.filter.into_tag_filter() + } +} diff --git a/src/cli_args/tag_filter_arguments.rs b/src/cli_args/tag_filter_arguments.rs new file mode 100644 index 0000000..9ce89aa --- /dev/null +++ b/src/cli_args/tag_filter_arguments.rs @@ -0,0 +1,30 @@ +//! Shared `--tag` flag used by commands that filter repos. + +use crate::reportal_config::TagFilter; +use clap::Args; + +/// Shared `--tag` flag that converts to a `TagFilter` enum. +/// +/// When the flag is absent (empty default), resolves to `TagFilter::All`. +/// When provided, resolves to `TagFilter::ByTag` with the given value. +#[derive(Args)] +pub struct TagFilterArguments { + /// Filter repos by this tag + #[arg(long, default_value = "", hide_default_value = true)] + tag: String, +} + +/// Consuming conversion to the domain's `TagFilter` enum. +impl TagFilterArguments { + /// Converts the CLI flag value into a `TagFilter`. + /// + /// Empty string (absent flag) becomes `All`; any non-empty value + /// becomes `ByTag` with that string. + pub fn into_tag_filter(self) -> TagFilter { + if self.tag.is_empty() { + TagFilter::All + } else { + TagFilter::ByTag(self.tag) + } + } +} diff --git a/src/cli_args/web_arguments.rs b/src/cli_args/web_arguments.rs new file mode 100644 index 0000000..e4aa896 --- /dev/null +++ b/src/cli_args/web_arguments.rs @@ -0,0 +1,23 @@ +//! CLI args for `rep web`. + +use super::repository_selection_arguments::RepositorySelectionArguments; +use crate::reportal_config::TagFilter; +use clap::Args; + +/// Arguments for the `rep web` subcommand. +/// +/// Supports optional repo selection (alias + tag filter) to choose +/// which repo's remote URL to open in the browser. +#[derive(Args)] +pub struct WebArguments { + #[command(flatten)] + selection: RepositorySelectionArguments, +} + +/// Consuming conversion that splits into domain-layer parts. +impl WebArguments { + /// Returns (alias, `tag_filter`), consuming self. + pub fn into_parts(self) -> (String, TagFilter) { + self.selection.into_parts() + } +} diff --git a/src/cli_args/workspace_arguments.rs b/src/cli_args/workspace_arguments.rs new file mode 100644 index 0000000..7a93c69 --- /dev/null +++ b/src/cli_args/workspace_arguments.rs @@ -0,0 +1,356 @@ +//! CLI args for `rep workspace` and its subcommands. +//! +//! Exposes the subcommand tree that drives every operation on +//! registered VSCode/Cursor `.code-workspace` definitions: +//! listing, showing, creating, deleting, member editing, and +//! opening. Shared fields that would otherwise repeat across +//! variants (`workspace_name`, `repository_alias`) are extracted into +//! composable arg structs and flattened into each action's +//! payload so the variants never duplicate the field declaration. + +use clap::{Args, Subcommand}; + +/// Arguments for the top-level `rep workspace` command. +#[derive(Args)] +pub struct WorkspaceArguments { + /// The workspace action the user invoked. + #[command(subcommand)] + action: WorkspaceArgumentsSubcommand, +} + +/// Consuming accessor for the workspace subcommand. +impl WorkspaceArguments { + /// Extracts the action variant, consuming the parsed args. + #[must_use] + pub fn into_action(self) -> WorkspaceArgumentsSubcommand { + self.action + } +} + +/// Positional `workspace_name` argument shared by every action +/// that targets a specific workspace. +/// +/// Extracted so the identifier lives in exactly one place in the +/// type system, preventing the repeated-union-fields class of +/// drift where a rename on one variant leaves others out of sync. +#[derive(Args)] +pub struct WorkspaceArgsName { + /// Name of the workspace being targeted. + workspace_name: String, +} + +/// Consuming accessor for the workspace name. +impl WorkspaceArgsName { + /// Extracts the workspace name, consuming the parsed arg. + #[must_use] + pub fn into_name(self) -> String { + self.workspace_name + } +} + +/// Positional `repository_alias` argument shared by member-editing +/// actions (`add-repo`, `remove-repo`). +#[derive(Args)] +pub struct WorkspaceArgsRepoAlias { + /// Repo alias being added to or removed from the workspace. + repository_alias: String, +} + +/// Consuming accessor for the repo alias. +impl WorkspaceArgsRepoAlias { + /// Extracts the repo alias, consuming the parsed arg. + #[must_use] + pub fn into_alias(self) -> String { + self.repository_alias + } +} + +/// Payload for actions that target one workspace by name and +/// carry no other arguments: `show`, `open`, `rebuild`. +#[derive(Args)] +pub struct WorkspaceArgsNameOnly { + /// The workspace this action targets. + #[command(flatten)] + target: WorkspaceArgsName, +} + +/// Consuming accessor for the targeted workspace name. +impl WorkspaceArgsNameOnly { + /// Extracts the targeted workspace name, consuming the args. + #[must_use] + pub fn into_workspace_name(self) -> String { + self.target.into_name() + } +} + +/// Payload for `rep workspace delete`, which targets one workspace +/// by name and optionally purges the on-disk directory. +#[derive(Args)] +pub struct WorkspaceArgsDelete { + /// The workspace this action targets. + #[command(flatten)] + target: WorkspaceArgsName, + /// Also remove the on-disk workspace directory (symlinks and + /// `.code-workspace` file). Member repos are not touched. The + /// default behavior is to leave the directory in place so an + /// accidental delete cannot destroy an open editor session. + #[arg(long, default_value_t = false)] + purge: bool, +} + +/// Consuming accessor for the delete-action payload. +impl WorkspaceArgsDelete { + /// Extracts the delete-action fields into a named parts struct, + /// consuming the parsed args. + #[must_use] + pub fn into_parts(self) -> WorkspaceArgumentsDeleteParts { + WorkspaceArgumentsDeleteParts { + workspace_name: self.target.into_name(), + purge: self.purge, + } + } +} + +/// Owned named-field result of `WorkspaceArgsDelete::into_parts`. +pub struct WorkspaceArgumentsDeleteParts { + /// The workspace to unregister. + workspace_name: String, + /// Whether to also delete the on-disk workspace directory. + purge: bool, +} + +/// Accessors for the delete-action parts. +impl WorkspaceArgumentsDeleteParts { + /// The workspace name to unregister. + #[must_use] + pub fn workspace_name(&self) -> &str { + &self.workspace_name + } + + /// Whether the user opted into removing the on-disk directory. + #[must_use] + pub fn purge(&self) -> bool { + self.purge + } +} + +/// Payload for member-editing actions: `add-repo`, `remove-repo`. +#[derive(Args)] +pub struct WorkspaceArgsMemberEdit { + /// The workspace whose membership is being edited. + #[command(flatten)] + target: WorkspaceArgsName, + /// The repo being added to or removed from the workspace. + #[command(flatten)] + member: WorkspaceArgsRepoAlias, +} + +/// Consuming accessor for the target workspace + member repo. +impl WorkspaceArgsMemberEdit { + /// Extracts the targeted workspace name and the repo alias, + /// consuming the parsed args. + #[must_use] + pub fn into_parts(self) -> WorkspaceArgumentsMemberEditParts { + WorkspaceArgumentsMemberEditParts { + workspace_name: self.target.into_name(), + repository_alias: self.member.into_alias(), + } + } +} + +/// Owned tuple-equivalent returned from +/// `WorkspaceArgsMemberEdit::into_parts`, with named fields so +/// call sites never confuse the two strings. +pub struct WorkspaceArgumentsMemberEditParts { + /// The workspace whose membership is being edited. + workspace_name: String, + /// The repo being added to or removed from the workspace. + repository_alias: String, +} + +/// Destructuring accessors for the member-edit parts. +impl WorkspaceArgumentsMemberEditParts { + /// The workspace name this edit targets. + #[must_use] + pub fn workspace_name(&self) -> &str { + &self.workspace_name + } + + /// The repo alias being added to or removed from the workspace. + #[must_use] + pub fn repository_alias(&self) -> &str { + &self.repository_alias + } +} + +/// Payload for `rep workspace create`, which declares a new +/// workspace with an initial member list, description, optional +/// custom file path, and optional short-name aliases. +#[derive(Args)] +pub struct WorkspaceArgsCreate { + /// Name for the new workspace. + #[command(flatten)] + target: WorkspaceArgsName, + /// Comma-separated list of repo aliases that belong to the + /// workspace, in the order they should appear in the editor's + /// sidebar. + #[arg(long, value_delimiter = ',')] + repos: Vec, + /// Human-readable description shown in `rep workspace list`. + #[arg(long, default_value = "")] + description: String, + /// Explicit filesystem path for the workspace directory + /// (the one that will contain the symlinks and the + /// `.code-workspace` file). Defaults to + /// `//`. A legacy value + /// pointing at a `.code-workspace` file is reinterpreted as + /// the file's parent directory so pre-v0.15.2 invocations + /// continue to work. + #[arg(long, default_value = "")] + file_path: String, + /// Comma-separated short-name aliases that resolve to this + /// workspace when passed to `rep workspace` subcommands. + #[arg(long, value_delimiter = ',')] + aliases: Vec, +} + +/// Consuming accessor for the create-action payload. +impl WorkspaceArgsCreate { + /// Extracts all create-action fields into a named parts + /// struct, consuming the parsed args. + #[must_use] + pub fn into_parts(self) -> WorkspaceArgumentsCreateParts { + WorkspaceArgumentsCreateParts { + workspace_name: self.target.into_name(), + repo_aliases: self.repos, + description: self.description, + custom_file_path: self.file_path, + workspace_aliases: self.aliases, + } + } +} + +/// Owned named-field result of `WorkspaceArgsCreate::into_parts`. +/// +/// Returned instead of a bare tuple so call sites never confuse +/// the string-typed fields. +pub struct WorkspaceArgumentsCreateParts { + /// Name for the new workspace. + workspace_name: String, + /// Ordered list of repo aliases that belong to the workspace. + repo_aliases: Vec, + /// Human-readable description of the workspace's purpose. + description: String, + /// Explicit filesystem path for the generated + /// `.code-workspace` file, empty for the default location. + custom_file_path: String, + /// Short-name aliases that resolve to this workspace in + /// `rep workspace` subcommands. + workspace_aliases: Vec, +} + +/// Accessors for the create-action parts. +impl WorkspaceArgumentsCreateParts { + /// The name for the new workspace. + #[must_use] + pub fn workspace_name(&self) -> &str { + &self.workspace_name + } + + /// The ordered list of repo aliases that belong to the + /// workspace. + #[must_use] + pub fn repo_aliases(&self) -> &[String] { + &self.repo_aliases + } + + /// The human-readable description of the workspace's purpose. + #[must_use] + pub fn description(&self) -> &str { + &self.description + } + + /// The explicit filesystem path for the generated + /// `.code-workspace` file, or empty string to use the default + /// location. + #[must_use] + pub fn custom_file_path(&self) -> &str { + &self.custom_file_path + } + + /// The short-name aliases that resolve to this workspace in + /// `rep workspace` subcommands. + #[must_use] + pub fn workspace_aliases(&self) -> &[String] { + &self.workspace_aliases + } +} + +/// Subcommands under `rep workspace`. +#[derive(Subcommand)] +pub enum WorkspaceArgumentsSubcommand { + /// List all registered workspaces with their member repos. + #[command(alias = "ls")] + List, + /// Show a single workspace's details, including the resolved + /// absolute paths of its member repos and the location of the + /// workspace directory / `.code-workspace` file on disk. + Show(WorkspaceArgsNameOnly), + /// Create a new workspace and materialize its on-disk + /// directory (symlinks + `.code-workspace` file) under + /// `//` unless a custom + /// directory path is given via `--file-path`. + Create(WorkspaceArgsCreate), + /// Delete a workspace from the config. By default leaves the + /// on-disk workspace directory in place. Pass `--purge` to + /// also remove the directory (symlinks and `.code-workspace` + /// file); member repos are never touched. + #[command(alias = "rm")] + Delete(WorkspaceArgsDelete), + /// Add a repo alias to an existing workspace and regenerate + /// its workspace directory (links + `.code-workspace` file). + AddRepo(WorkspaceArgsMemberEdit), + /// Remove a repo alias from an existing workspace and + /// regenerate its workspace directory. + RemoveRepo(WorkspaceArgsMemberEdit), + /// Open a workspace in the configured default editor by + /// running ` `. Without a + /// workspace name, presents a fuzzy finder. + Open(WorkspaceArgsOptionalNameOnly), + /// Print the workspace's materialized directory path so the + /// `rjw` shell wrapper can cd there. Without a workspace name, + /// presents a fuzzy finder. + Jump(WorkspaceArgsOptionalNameOnly), + /// Rebuild the workspace's on-disk directory: recreate the + /// member symlinks / junctions and regenerate the + /// `.code-workspace` file from the current config. Idempotent; + /// useful after renaming / moving a member repo or if the + /// workspace directory was deleted by hand. + Rebuild(WorkspaceArgsNameOnly), +} + +/// Payload for actions that target a workspace by name but fall +/// back to fuzzy-select when no name is given — currently `jump` +/// and `open`. +/// +/// Distinct from [`WorkspaceArgsNameOnly`] because `show`, +/// `delete`, and the member-edit actions must name an explicit +/// target — silently fuzzy-selecting a destructive action's +/// subject would violate the explicit-mutation rule. +#[derive(Args)] +pub struct WorkspaceArgsOptionalNameOnly { + /// The workspace this action targets, or empty to present a + /// fuzzy-select prompt. + #[arg(default_value = "")] + workspace_name: String, +} + +/// Consuming accessor for the optional target name. +impl WorkspaceArgsOptionalNameOnly { + /// Extracts the optional workspace name as a string that is + /// empty when the user omitted the argument. + #[must_use] + pub fn into_optional_workspace_name(self) -> String { + self.workspace_name + } +} diff --git a/src/cli_args/workspace_filter_arguments.rs b/src/cli_args/workspace_filter_arguments.rs new file mode 100644 index 0000000..b7cf7f2 --- /dev/null +++ b/src/cli_args/workspace_filter_arguments.rs @@ -0,0 +1,36 @@ +//! Shared `--workspace` flag used by commands that filter repos +//! by workspace membership. + +use crate::reportal_config::WorkspaceFilter; +use clap::Args; + +/// Shared `--workspace` flag that converts to a `WorkspaceFilter` +/// enum. +/// +/// When the flag is absent (empty default), resolves to +/// `WorkspaceFilter::All`. When provided, resolves to +/// `WorkspaceFilter::ByName` with the given workspace name. The +/// empty-string sentinel is used instead of `Option` +/// because the project's rules forbid `Option` for domain state +/// where both branches have meaning. +#[derive(Args)] +pub struct WorkspaceFilterArguments { + /// Filter repos by workspace membership + #[arg(long = "workspace", default_value = "", hide_default_value = true)] + workspace_name: String, +} + +/// Consuming conversion to the domain's `WorkspaceFilter` enum. +impl WorkspaceFilterArguments { + /// Converts the CLI flag value into a `WorkspaceFilter`. + /// + /// Empty string (absent flag) becomes `All`; any non-empty + /// value becomes `ByName` with that workspace name. + pub fn into_workspace_filter(self) -> WorkspaceFilter { + if self.workspace_name.is_empty() { + WorkspaceFilter::All + } else { + WorkspaceFilter::ByName(self.workspace_name) + } + } +} diff --git a/src/code_workspace/code_workspace_file.rs b/src/code_workspace/code_workspace_file.rs new file mode 100644 index 0000000..f9d4ddb --- /dev/null +++ b/src/code_workspace/code_workspace_file.rs @@ -0,0 +1,447 @@ +//! Parsed, mutable view of a `.code-workspace` file. + +use crate::code_workspace::code_workspace_folder_entry::CodeWorkspaceFolderEntry; +use crate::code_workspace::folder_parsing::FolderParsingContext; +use crate::error::ReportalError; +use jsonc_parser::cst::{CstInputValue, CstRootNode}; +use jsonc_parser::ParseOptions; +use std::path::{Path, PathBuf}; + +/// Parsed, mutable view of a `.code-workspace` file. +/// +/// Stores the original file text verbatim so the parse-merge-write +/// path can re-parse it on write and apply a surgical edit to the +/// `folders` property, leaving every other top-level field +/// (`settings`, `extensions`, `launch`, `tasks`, plus any future +/// `VSCode` field and any JSONC comments) byte-identical to the +/// original. +/// +/// The invariant this preserves: reading a file, calling +/// `set_folder_paths`, and writing it back mutates only the +/// `folders` array and nothing else. This is achieved by +/// delegating the mutation to `jsonc-parser`'s CST API, which owns +/// the parse-and-rewrite logic for JSONC and guarantees +/// preservation of comments and formatting for untouched fields. +#[derive(Debug, Clone)] +pub struct CodeWorkspaceFile { + /// Raw text of the `.code-workspace` file as it was last read. + /// + /// Empty string means the file did not exist on disk. On write, + /// this text is re-parsed and mutated via the CST, so any + /// comments or user-authored formatting inside it round-trip + /// through regeneration untouched. + original_file_text: String, + /// The typed folder list reportal manages. + /// + /// Populated from the parsed CST on load and mutated directly + /// by `set_folder_paths`. On write, this list is serialized + /// into a new `folders` array via `jsonc-parser`'s typed + /// `CstInputValue` builder API. + folders: Vec, + /// Workspace-identity override for the editor's + /// `window.title` setting. + /// + /// `None` leaves the file's `settings` block untouched on + /// write (the previous behavior). `Some(label)` replaces + /// the `settings` object with a reportal-managed shape that + /// carries this title plus the title-bar accent color. + window_title_override: Option, + /// Workspace-identity accent color, stored as the raw + /// `#RRGGBB` string for direct insertion into + /// `workbench.colorCustomizations.titleBar.activeBackground`. + /// + /// Only read on write when `window_title_override` is also + /// set; the title and color travel as a single logical unit + /// since an editor window with a custom title but the + /// default title-bar color looks broken. + title_bar_color_hex: Option, +} + +/// Loading, mutation, and serialization for a `.code-workspace` file. +impl CodeWorkspaceFile { + /// Constructs an empty `.code-workspace` document for the case + /// where reportal is generating a workspace file that did not + /// previously exist on disk. + /// + /// The original file text is left empty so that on write the + /// CST is seeded from `"{}"` and the resulting file contains + /// only a `folders` property. + #[must_use] + pub fn empty() -> Self { + Self { + original_file_text: String::new(), + folders: Vec::new(), + window_title_override: None, + title_bar_color_hex: None, + } + } + + /// Loads and parses an existing `.code-workspace` file from disk. + /// + /// If the file does not exist, returns an empty document so the + /// caller can populate folders and write a fresh file. If the + /// file exists, its text is stored verbatim and the `folders` + /// property is extracted into the typed folder list, dropping + /// any CST state once extraction is done — the original text + /// is what the write path re-parses, not a retained CST. + /// + /// # Errors + /// + /// Returns [`ReportalError::CodeWorkspaceIoFailure`] if the file + /// exists but cannot be read, or + /// [`ReportalError::CodeWorkspaceParseFailure`] if the file is + /// not valid JSONC or the `folders` property is malformed. + pub fn load_or_empty(file_path: &Path) -> Result { + if !file_path.exists() { + return Ok(Self::empty()); + } + let file_contents = std::fs::read_to_string(file_path).map_err(|io_error| { + ReportalError::CodeWorkspaceIoFailure { + file_path: file_path.display().to_string(), + reason: io_error.to_string(), + } + })?; + let folder_parser = FolderParsingContext::for_file(file_path); + let extracted_folders = folder_parser.parse_folders_from_text(&file_contents)?; + Ok(Self { + original_file_text: file_contents, + folders: extracted_folders, + window_title_override: None, + title_bar_color_hex: None, + }) + } + + /// Test-only accessor for the current folder entries. Gated + /// behind `#[cfg(test)]` because production code reads folders + /// only through the round-trip write path, and exposing an + /// accessor in production would invite callers to read stale + /// folder state that diverges from what the next + /// `write_to_disk` call serializes. + #[cfg(test)] + #[must_use] + fn folder_entries(&self) -> &[CodeWorkspaceFolderEntry] { + &self.folders + } + + /// @why Rewrites the folder list from absolute paths so the + /// `.code-workspace` file stays in sync with the current + /// workspace membership, preserving every other top-level + /// field (user settings, extensions, launch configs, JSONC + /// comments) through the parse-merge-write round-trip. + pub fn set_folder_paths(&mut self, folder_absolute_paths: &[PathBuf]) { + self.folders = folder_absolute_paths + .iter() + .map(|absolute_path| CodeWorkspaceFolderEntry::from_absolute_path(absolute_path)) + .collect(); + } + + /// @why Stamps a workspace-identity badge into the editor's + /// `window.title` and `workbench.colorCustomizations` + /// settings so opening the `.code-workspace` in VS Code or + /// Cursor shows the same prompt identity the shell displays, + /// giving one consistent signal across terminal and editor + /// for which workspace the user is in. + pub fn set_workspace_identity( + &mut self, + window_title: String, + title_bar_color_hex: Option, + ) { + self.window_title_override = Some(window_title); + self.title_bar_color_hex = title_bar_color_hex; + } + + /// Serializes the document back to JSONC and writes it to disk. + /// + /// Re-parses `original_file_text` (or `"{}"` when empty), uses + /// the CST API to replace the `folders` property with a new + /// array built from the typed folder list, serializes the + /// mutated CST via its `Display` impl, and writes the result + /// to `file_path`. Creates the parent directory if missing so + /// that writing into the default location + /// `~/.reportal/workspaces/` works on a first-run machine + /// where that directory has not been created. + /// + /// # Errors + /// + /// Returns [`ReportalError::CodeWorkspaceIoFailure`] if the + /// parent directory cannot be created or the file cannot be + /// written, or + /// [`ReportalError::CodeWorkspaceParseFailure`] if the seed + /// text fails to parse as JSONC (which indicates internal + /// corruption of the stored original text). + pub fn write_to_disk(&self, file_path: &Path) -> Result<(), ReportalError> { + ensure_parent_directory_exists(file_path)?; + let seed_text: &str = if self.original_file_text.is_empty() { + "{}" + } else { + &self.original_file_text + }; + let cst_root = + CstRootNode::parse(seed_text, &ParseOptions::default()).map_err(|parse_error| { + ReportalError::CodeWorkspaceParseFailure { + file_path: file_path.display().to_string(), + reason: parse_error.to_string(), + } + })?; + let root_object = cst_root.object_value_or_set(); + let new_folders_value = build_folders_cst_value(&self.folders); + match root_object.get("folders") { + Some(existing_folders_prop) => existing_folders_prop.set_value(new_folders_value), + None => { + root_object.append("folders", new_folders_value); + } + } + if self.window_title_override.is_some() { + let settings_value = self.build_identity_settings_cst_value(); + match root_object.get("settings") { + Some(existing_settings_prop) => existing_settings_prop.set_value(settings_value), + None => { + root_object.append("settings", settings_value); + } + } + } + let serialized_text = cst_root.to_string(); + std::fs::write(file_path, serialized_text).map_err(|io_error| { + ReportalError::CodeWorkspaceIoFailure { + file_path: file_path.display().to_string(), + reason: io_error.to_string(), + } + })?; + Ok(()) + } + + /// Builds the `settings` object value reportal writes when a + /// workspace identity is set: a `window.title` string and, + /// when the accent color is present, a + /// `workbench.colorCustomizations` nested object that paints + /// the active title bar. + /// + /// Lives as a method (rather than a free function) to keep + /// this file's free-function count at the project budget of + /// two (`ensure_parent_directory_exists` and + /// `build_folders_cst_value`). The method reads directly + /// from `self` so the caller does not have to thread the + /// two fields through a temporary params struct. + fn build_identity_settings_cst_value(&self) -> CstInputValue { + let window_title = self + .window_title_override + .as_deref() + .unwrap_or_default() + .to_owned(); + let mut settings_properties: Vec<(String, CstInputValue)> = Vec::new(); + settings_properties.push(( + "window.title".to_owned(), + CstInputValue::String(format!("{window_title} — ${{activeEditorShort}}")), + )); + if let Some(title_bar_hex) = self.title_bar_color_hex.as_deref() { + let color_customizations: Vec<(String, CstInputValue)> = vec![ + ( + "titleBar.activeBackground".to_owned(), + CstInputValue::String(title_bar_hex.to_owned()), + ), + ( + "titleBar.activeForeground".to_owned(), + CstInputValue::String("#ffffff".to_owned()), + ), + ( + "titleBar.inactiveBackground".to_owned(), + CstInputValue::String(title_bar_hex.to_owned()), + ), + ( + "titleBar.inactiveForeground".to_owned(), + CstInputValue::String("#ffffff".to_owned()), + ), + ]; + settings_properties.push(( + "workbench.colorCustomizations".to_owned(), + CstInputValue::Object(color_customizations), + )); + } + CstInputValue::Object(settings_properties) + } +} + +/// Creates the parent directory of `file_path` if it does not +/// already exist, so that writing a new `.code-workspace` file +/// into the default location `~/.reportal/workspaces/` works on +/// a first-run machine where that directory has not been created. +/// +/// A path with no parent (root-level like `/` or `C:\`) is a +/// no-op because there is nothing to create. +/// +/// # Errors +/// +/// Returns [`ReportalError::CodeWorkspaceIoFailure`] if directory +/// creation fails. +fn ensure_parent_directory_exists(file_path: &Path) -> Result<(), ReportalError> { + let Some(parent_directory) = file_path.parent() else { + return Ok(()); + }; + if parent_directory.exists() { + return Ok(()); + } + std::fs::create_dir_all(parent_directory).map_err(|io_error| { + ReportalError::CodeWorkspaceIoFailure { + file_path: file_path.display().to_string(), + reason: io_error.to_string(), + } + }) +} + +/// Builds a typed `CstInputValue::Array` from the given folder list +/// for insertion into the CST via `set_value` or `append`. +/// +/// Each folder becomes a single-field object with a `path` string +/// entry. This is the one place reportal constructs JSON content +/// for the `.code-workspace` file, and it does so entirely via the +/// typed `CstInputValue` enum so no untyped JSON value passes +/// through reportal's code. +fn build_folders_cst_value(folder_entries: &[CodeWorkspaceFolderEntry]) -> CstInputValue { + let array_elements: Vec = folder_entries + .iter() + .map(|entry| { + CstInputValue::Object(vec![( + "path".to_owned(), + CstInputValue::String(entry.folder_path().to_owned()), + )]) + }) + .collect(); + CstInputValue::Array(array_elements) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + static TEST_FILE_COUNTER: AtomicU64 = AtomicU64::new(0); + + fn unique_temp_path() -> PathBuf { + let counter_value = TEST_FILE_COUNTER.fetch_add(1, Ordering::Relaxed); + let process_id = std::process::id(); + std::env::temp_dir().join(format!( + "reportal-codeworkspace-test-{process_id}-{counter_value}.code-workspace" + )) + } + + fn write_fixture(file_path: &Path, file_contents: &str) { + std::fs::write(file_path, file_contents).expect("fixture write must succeed"); + } + + fn read_back(file_path: &Path) -> String { + std::fs::read_to_string(file_path).expect("fixture read must succeed") + } + + #[test] + fn empty_doc_writes_only_folders_property() { + let workspace_file_path = unique_temp_path(); + let mut empty_doc = CodeWorkspaceFile::empty(); + empty_doc.set_folder_paths(&[PathBuf::from("/alpha"), PathBuf::from("/bravo")]); + empty_doc + .write_to_disk(&workspace_file_path) + .expect("write must succeed"); + let written_text = read_back(&workspace_file_path); + assert!(written_text.contains("\"folders\""), "missing folders key"); + assert!(written_text.contains("/alpha"), "missing /alpha entry"); + assert!(written_text.contains("/bravo"), "missing /bravo entry"); + let _ = std::fs::remove_file(&workspace_file_path); + } + + #[test] + fn round_trip_preserves_unknown_top_level_fields() { + let workspace_file_path = unique_temp_path(); + let original_text = r#"{ + "folders": [ + { "path": "/old/path" } + ], + "settings": { + "editor.fontSize": 14, + "editor.tabSize": 2 + }, + "extensions": { + "recommendations": ["rust-lang.rust-analyzer"] + }, + "launch": { + "version": "0.2.0", + "configurations": [] + } + }"#; + write_fixture(&workspace_file_path, original_text); + + let mut loaded_doc = + CodeWorkspaceFile::load_or_empty(&workspace_file_path).expect("load must succeed"); + loaded_doc.set_folder_paths(&[PathBuf::from("/new/path")]); + loaded_doc + .write_to_disk(&workspace_file_path) + .expect("write must succeed"); + + let rewritten_text = read_back(&workspace_file_path); + assert!( + rewritten_text.contains("/new/path"), + "folders array was not updated to /new/path. Rewritten:\n{rewritten_text}", + ); + assert!( + !rewritten_text.contains("/old/path"), + "old folder path leaked through regeneration. Rewritten:\n{rewritten_text}", + ); + assert!( + rewritten_text.contains("editor.fontSize"), + "settings key was stripped during regeneration. Rewritten:\n{rewritten_text}", + ); + assert!( + rewritten_text.contains("editor.tabSize"), + "settings key was stripped during regeneration. Rewritten:\n{rewritten_text}", + ); + assert!( + rewritten_text.contains("rust-lang.rust-analyzer"), + "extensions entry was stripped during regeneration. Rewritten:\n{rewritten_text}", + ); + assert!( + rewritten_text.contains("\"launch\""), + "launch section was stripped during regeneration. Rewritten:\n{rewritten_text}", + ); + let _ = std::fs::remove_file(&workspace_file_path); + } + + #[test] + fn round_trip_preserves_jsonc_comments() { + let workspace_file_path = unique_temp_path(); + let original_text = r#"{ + // Workspace for the backend services + "folders": [ + { "path": "/old/api" } + ], + // User-authored settings below + "settings": { + "editor.fontSize": 14 + } + }"#; + write_fixture(&workspace_file_path, original_text); + + let mut loaded_doc = + CodeWorkspaceFile::load_or_empty(&workspace_file_path).expect("load must succeed"); + loaded_doc.set_folder_paths(&[PathBuf::from("/new/api")]); + loaded_doc + .write_to_disk(&workspace_file_path) + .expect("write must succeed"); + + let rewritten_text = read_back(&workspace_file_path); + assert!( + rewritten_text.contains("Workspace for the backend services"), + "top-level JSONC comment was stripped. Rewritten:\n{rewritten_text}", + ); + assert!( + rewritten_text.contains("User-authored settings below"), + "settings JSONC comment was stripped. Rewritten:\n{rewritten_text}", + ); + let _ = std::fs::remove_file(&workspace_file_path); + } + + #[test] + fn load_or_empty_on_missing_file_returns_empty_doc() { + let nonexistent_path = unique_temp_path(); + let loaded_doc = CodeWorkspaceFile::load_or_empty(&nonexistent_path) + .expect("missing file must load as empty"); + assert!(loaded_doc.folder_entries().is_empty()); + } +} diff --git a/src/code_workspace/code_workspace_folder_entry.rs b/src/code_workspace/code_workspace_folder_entry.rs new file mode 100644 index 0000000..f59e479 --- /dev/null +++ b/src/code_workspace/code_workspace_folder_entry.rs @@ -0,0 +1,58 @@ +//! One entry in a `.code-workspace` file's `folders` array. + +use std::path::Path; + +/// One entry in a `.code-workspace` file's `folders` array. +/// +/// Reportal owns the entire `folders` array and writes each entry +/// with just an absolute path, so the editor derives the sidebar +/// label from the directory basename. Top-level fields outside +/// `folders` (settings, extensions, launch, tasks) are preserved +/// verbatim by the round-trip machinery in `CodeWorkspaceFile`, +/// but the internal shape of each folder entry is owned by reportal +/// — it is reportal's job to regenerate the array from the current +/// paths of the workspace's member repos. +#[derive(Debug, Clone)] +pub struct CodeWorkspaceFolderEntry { + /// Absolute filesystem path of the folder to open. + /// + /// Stored as a UTF-8 string because JSON strings are UTF-8 and + /// the `.code-workspace` file serializes this value directly. + folder_absolute_path: String, +} + +/// Accessors for a folder entry in a `.code-workspace` file. +impl CodeWorkspaceFolderEntry { + /// Builds a folder entry from an absolute path. + /// + /// Uses `Path::display()` to produce a UTF-8 lossy rendering of + /// the path, which is correct for JSON output because a JSON + /// string must be UTF-8. On platforms where a path may contain + /// non-UTF-8 bytes, the lossy rendering is the closest faithful + /// representation available for the JSON encoding. + #[must_use] + pub fn from_absolute_path(absolute_folder_path: &Path) -> Self { + Self { + folder_absolute_path: absolute_folder_path.display().to_string(), + } + } + + /// Builds a folder entry from an already-resolved path string. + /// + /// Used by the load path when reading an existing + /// `.code-workspace` file: the CST gives the string value of + /// the `path` field, which is already a `String` and does not + /// need to go through `Path::display()`. + #[must_use] + pub fn from_path_string(resolved_path_string: String) -> Self { + Self { + folder_absolute_path: resolved_path_string, + } + } + + /// The absolute filesystem path this folder entry points to. + #[must_use] + pub fn folder_path(&self) -> &str { + &self.folder_absolute_path + } +} diff --git a/src/code_workspace/folder_parsing.rs b/src/code_workspace/folder_parsing.rs new file mode 100644 index 0000000..9f85ae2 --- /dev/null +++ b/src/code_workspace/folder_parsing.rs @@ -0,0 +1,229 @@ +//! CST-to-typed extraction for `.code-workspace` folder entries. +//! +//! Wraps the parsing functions in a `FolderParsingContext` that +//! carries the source file path so each method takes a single +//! non-`self` argument, keeping the parsing surface within the +//! project's param-count conventions while still producing +//! informative error messages that name the file. + +use crate::code_workspace::code_workspace_folder_entry::CodeWorkspaceFolderEntry; +use crate::error::ReportalError; +use jsonc_parser::cst::{CstNode, CstRootNode}; +use jsonc_parser::ParseOptions; +use std::path::Path; + +/// Per-file context for parsing a `.code-workspace` document. +/// +/// Holds the source file path so every parse error can report +/// the exact file that triggered it without each call site +/// having to thread the path through manually. +pub struct FolderParsingContext<'path_lifetime> { + /// The `.code-workspace` file path being parsed, used only in + /// error messages. + source_file_path: &'path_lifetime Path, +} + +/// Folder extraction methods that walk the JSONC CST and enforce +/// the VSCode/Cursor schema. +impl<'path_lifetime> FolderParsingContext<'path_lifetime> { + /// Builds a parsing context for the given file path. + #[must_use] + pub fn for_file(source_file_path: &'path_lifetime Path) -> Self { + Self { source_file_path } + } + + /// Parses the given `.code-workspace` text as JSONC and returns + /// the typed folder list extracted from its `folders` property. + /// + /// Extraction rules mirror the VSCode/Cursor schema: the root + /// must be an object, `folders` must be an array, each element + /// must be an object with a `path` property whose value is a + /// string. Anything else is a hard parse error because a + /// malformed entry indicates the file was not produced by a + /// correct workspace generator and reportal must refuse to + /// regenerate over it. + /// + /// # Errors + /// + /// Returns [`ReportalError::CodeWorkspaceParseFailure`] if the + /// input is not valid JSONC, the top-level value is not an + /// object, or any folder entry violates the schema. + pub fn parse_folders_from_text( + &self, + file_contents: &str, + ) -> Result, ReportalError> { + let cst_root = + CstRootNode::parse(file_contents, &ParseOptions::default()).map_err(|parse_error| { + ReportalError::CodeWorkspaceParseFailure { + file_path: self.source_file_path.display().to_string(), + reason: parse_error.to_string(), + } + })?; + let Some(root_object) = cst_root.object_value() else { + return Err(ReportalError::CodeWorkspaceParseFailure { + file_path: self.source_file_path.display().to_string(), + reason: "top-level JSONC value must be an object".to_owned(), + }); + }; + let Some(parsed_array) = root_object.array_value("folders") else { + return Ok(Vec::new()); + }; + let mut folder_list = Vec::new(); + for folder_node in parsed_array.elements() { + folder_list.push(self.extract_folder_entry(&folder_node)?); + } + Ok(folder_list) + } + + /// Extracts one folder entry from a single CST node inside a + /// `folders` array, enforcing the required + /// object-with-string-path shape. + /// + /// Uses `let-else` early returns so every schema check flattens + /// into a straight sequence instead of nesting, which is + /// required because the excessive-nesting lint triggers quickly + /// on this kind of deep descent through a CST. + /// + /// # Errors + /// + /// Returns [`ReportalError::CodeWorkspaceParseFailure`] if the + /// node is not an object, has no `path` field, the field has + /// no value, the value is not a string, or the string contains + /// invalid escape sequences. + pub fn extract_folder_entry( + &self, + folder_node: &CstNode, + ) -> Result { + let Some(folder_object) = folder_node.as_object() else { + return Err(ReportalError::CodeWorkspaceParseFailure { + file_path: self.source_file_path.display().to_string(), + reason: "each entry in `folders` must be an object".to_owned(), + }); + }; + let Some(path_prop) = folder_object.get("path") else { + return Err(ReportalError::CodeWorkspaceParseFailure { + file_path: self.source_file_path.display().to_string(), + reason: "each entry in `folders` must have a `path` field".to_owned(), + }); + }; + let Some(path_value_node) = path_prop.value() else { + return Err(ReportalError::CodeWorkspaceParseFailure { + file_path: self.source_file_path.display().to_string(), + reason: "`path` property has no value".to_owned(), + }); + }; + let Some(path_string_lit) = path_value_node.as_string_lit() else { + return Err(ReportalError::CodeWorkspaceParseFailure { + file_path: self.source_file_path.display().to_string(), + reason: "`path` value must be a string".to_owned(), + }); + }; + let decoded_path = path_string_lit.decoded_value().map_err(|decode_error| { + ReportalError::CodeWorkspaceParseFailure { + file_path: self.source_file_path.display().to_string(), + reason: format!("`path` value has invalid string escapes: {decode_error}"), + } + })?; + Ok(CodeWorkspaceFolderEntry::from_path_string(decoded_path)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn parse(input_text: &str) -> Result, ReportalError> { + let dummy_file_path = PathBuf::from("test.code-workspace"); + let parsing_context = FolderParsingContext::for_file(&dummy_file_path); + parsing_context.parse_folders_from_text(input_text) + } + + #[test] + fn extracts_folder_paths_in_declared_order() { + let input_text = r#"{ + "folders": [ + { "path": "/alpha" }, + { "path": "/bravo" }, + { "path": "/charlie" } + ] + }"#; + let parsed_folder_entries = parse(input_text).expect("valid input must parse"); + let folder_path_list: Vec<&str> = parsed_folder_entries + .iter() + .map(CodeWorkspaceFolderEntry::folder_path) + .collect(); + assert_eq!(folder_path_list, vec!["/alpha", "/bravo", "/charlie"]); + } + + #[test] + fn missing_folders_property_yields_empty_vec() { + let input_text = r#"{ + "settings": { "editor.fontSize": 14 } + }"#; + let parsed_folder_entries = parse(input_text).expect("non-folder objects must still parse"); + assert!(parsed_folder_entries.is_empty()); + } + + #[test] + fn non_object_root_is_rejected() { + let input_text = "[]"; + let parse_outcome = parse(input_text); + assert!( + matches!( + parse_outcome, + Err(ReportalError::CodeWorkspaceParseFailure { .. }) + ), + "array root must be rejected, got {parse_outcome:?}", + ); + } + + #[test] + fn folder_entry_without_path_is_rejected() { + let input_text = r#"{ + "folders": [ + { "name": "no-path" } + ] + }"#; + let parse_outcome = parse(input_text); + assert!( + matches!( + parse_outcome, + Err(ReportalError::CodeWorkspaceParseFailure { .. }) + ), + "missing path field must be rejected, got {parse_outcome:?}", + ); + } + + #[test] + fn folder_entry_with_non_string_path_is_rejected() { + let input_text = r#"{ + "folders": [ + { "path": 42 } + ] + }"#; + let parse_outcome = parse(input_text); + assert!( + matches!( + parse_outcome, + Err(ReportalError::CodeWorkspaceParseFailure { .. }) + ), + "non-string path must be rejected, got {parse_outcome:?}", + ); + } + + #[test] + fn jsonc_comments_in_source_do_not_break_parse() { + let input_text = r#"{ + // top-level comment describing this workspace + "folders": [ + // first repo + { "path": "/alpha" } + ] + }"#; + let parsed_folder_entries = + parse(input_text).expect("JSONC comments must not break folder parse"); + assert_eq!(parsed_folder_entries.len(), 1); + assert_eq!(parsed_folder_entries[0].folder_path(), "/alpha"); + } +} diff --git a/src/code_workspace/mod.rs b/src/code_workspace/mod.rs new file mode 100644 index 0000000..94f1f5d --- /dev/null +++ b/src/code_workspace/mod.rs @@ -0,0 +1,25 @@ +//! Parse-merge-write for VSCode/Cursor `.code-workspace` files. +//! +//! A `.code-workspace` file is a JSON document with a `folders` +//! array plus optional user-authored fields like `settings`, +//! `extensions`, and `launch`. Reportal owns the `folders` array +//! because it is derived from the current paths of the member +//! repos; everything else is preserved untouched across writes so +//! regeneration cannot destroy settings the user set by hand. +//! +//! The round-trip invariant: reading a file and writing it back +//! without calling `set_folder_paths` must produce a byte-stable +//! document modulo whitespace normalization. Reading a file, +//! calling `set_folder_paths`, and writing it back must replace +//! only the `folders` field and leave every other key intact. + +mod code_workspace_file; +mod code_workspace_folder_entry; +mod folder_parsing; + +pub use code_workspace_file::CodeWorkspaceFile; +#[expect( + unused_imports, + reason = "consumed by later hooks — repo_edit path-change regeneration reads folder entries" +)] +pub use code_workspace_folder_entry::CodeWorkspaceFolderEntry; diff --git a/src/error.rs b/src/error.rs index b40f2c1..a41517d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,13 +1,6 @@ -/// All error conditions that RePortal can encounter during operation. +//! All error conditions that `RePortal` can encounter during operation. #[derive(Debug, thiserror::Error)] pub enum ReportalError { - /// Config file does not exist at the expected location. - #[error("Config not found at {config_path}\nRun 'reportal init' to create one.")] - ConfigNotFound { - /// Absolute path where the config was expected. - config_path: String, - }, - /// Config file exists but contains invalid TOML or schema. #[error("Failed to parse config: {reason}")] ConfigParseFailure { @@ -66,4 +59,150 @@ pub enum ReportalError { /// The malformed color string. value: String, }, + + /// An AI tool name was not found in the config. + #[error("AI tool '{tool_name}' not found in config. Add it under [ai_tools.{tool_name}]")] + AiToolNotFound { + /// The tool name that was looked up. + tool_name: String, + }, + + /// The AI tool process failed to launch. + #[error("Failed to launch AI tool: {reason}")] + AiToolLaunchFailure { + /// The underlying OS error message. + reason: String, + }, + + /// No AI tools are configured. + #[error("No AI tools configured. Add [ai_tools.] sections to your config")] + NoAiToolsConfigured, + + /// No default AI tool is set and none was specified via --tool. + #[error("No default AI tool set. Use --tool or set default_ai_tool in config")] + NoDefaultAiTool, + + /// No workspaces are registered in config. + #[error("No workspaces registered. Use 'rep workspace create' to add one.")] + NoWorkspacesConfigured, + + /// A repo has no remote URL configured and none could be detected from git. + #[error( + "No remote URL for repo '{alias}'. Set the 'remote' field in config or add a git remote." + )] + NoRemoteUrl { + /// The alias of the repo missing a remote. + alias: String, + }, + + /// The browser failed to open a URL. + #[error("Failed to open browser: {reason}")] + BrowserLaunchFailure { + /// The underlying OS error message. + reason: String, + }, + + /// A command name was not found in global or repo-level config. + #[error("Command '{command_name}' not found. Add it under [commands.{command_name}] or [repos..commands]")] + CommandNotFound { + /// The command name that was looked up. + command_name: String, + }, + + /// The user-defined command process failed to launch. + #[error("Failed to run command: {reason}")] + CommandLaunchFailure { + /// The underlying OS error message. + reason: String, + }, + + /// No commands are available (neither global nor repo-level). + #[error("No commands configured. Add [commands.] sections to your config")] + NoCommandsAvailable, + + /// A workspace name was not found in the config. + #[error("Workspace '{workspace_name}' not found in config")] + WorkspaceNotFound { + /// The workspace name that was looked up. + workspace_name: String, + }, + + /// A workspace name already exists in the config. + #[error("Workspace '{workspace_name}' already exists in config")] + WorkspaceAlreadyExists { + /// The workspace name that collided. + workspace_name: String, + }, + + /// A workspace references a repo alias that is not registered. + /// + /// Detected during the post-parse validation pass on config load, + /// which prevents shipping a config where `.code-workspace` files + /// would be generated pointing at a non-existent repo. + #[error("Workspace '{workspace_name}' references unknown repo '{missing_alias}'")] + WorkspaceHasDanglingRepo { + /// The workspace that contains the dangling reference. + workspace_name: String, + /// The repo alias that does not resolve to any registered repo. + missing_alias: String, + }, + + /// A workspace alias or canonical name collides with another + /// entry in the config, either in the workspace namespace or in + /// the repo namespace. + /// + /// Detected during the post-parse validation pass on config load + /// and before insertion in `add_workspace`. Without this check, + /// the alias resolver would silently return whichever entity + /// appeared first in `BTreeMap` iteration, making + /// `rep workspace open vn` behave differently from + /// `rep jump vn` in ways the user cannot predict. + #[error( + "Workspace alias conflict: '{conflicting_value}' is declared by workspace '{workspace_name}' but also by {conflicting_entity_description}" + )] + WorkspaceAliasConflict { + /// The workspace whose alias or canonical name is ambiguous. + workspace_name: String, + /// The alias or canonical name that appears in two places. + conflicting_value: String, + /// Human-readable description of the other entity that + /// already claims the value (e.g. `repo 'venoble-app' as an + /// alias`, `workspace 'venture' as its canonical name`). + conflicting_entity_description: String, + }, + + /// Failed to read or write a `.code-workspace` file on disk. + #[error("`.code-workspace` file I/O error for '{file_path}': {reason}")] + CodeWorkspaceIoFailure { + /// The path of the `.code-workspace` file that failed I/O. + file_path: String, + /// The underlying filesystem error message. + reason: String, + }, + + /// A `.code-workspace` file exists but is not valid JSONC, or has + /// a shape that the parse-merge-write path cannot round-trip. + #[error("`.code-workspace` file at '{file_path}' is not valid JSONC: {reason}")] + CodeWorkspaceParseFailure { + /// The path of the `.code-workspace` file that failed to parse. + file_path: String, + /// The parse error detail from the JSONC deserializer. + reason: String, + }, + + /// Refuses to remove a repo that is still a member of one or + /// more workspaces. The user must edit those workspaces (or + /// delete them) before the repo can be unregistered, because + /// silently stripping the repo from every containing workspace + /// would destroy user-declared membership without consent. + #[error( + "Repo '{alias}' is still a member of workspace(s): {affected_workspaces}. \ + Remove it from each workspace (rep workspace remove-repo) or delete them, then retry." + )] + RepoIsWorkspaceMember { + /// The repo alias the user asked to remove. + alias: String, + /// Comma-separated list of workspace names that still contain the repo. + affected_workspaces: String, + }, } diff --git a/src/main.rs b/src/main.rs index 0ccd70a..440aa4f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,193 +1,122 @@ -/// RePortal CLI entry point. -/// -/// Parses command-line arguments via clap and dispatches -/// to the appropriate subcommand handler. +#![warn(missing_docs)] +#![forbid(unsafe_code)] +//! `RePortal` CLI entry point. +//! +//! Parses command-line arguments via clap and dispatches +//! to the appropriate subcommand handler. + +mod cli_args; +mod code_workspace; mod error; mod reportal_commands; mod reportal_config; +mod system_executable_lookup; mod terminal_style; -use clap::{Parser, Subcommand}; -use reportal_commands::{ColorCommandParams, JumpCommandParams, OpenCommandParams}; -use reportal_config::TagFilter; - -/// A fast CLI tool for jumping between and managing your dev repos. -#[derive(Parser)] -#[command(name = "reportal", version, about)] -struct ReportalCli { - /// The subcommand to execute. - #[command(subcommand)] - subcommand: ReportalSubcommand, -} - -/// All available subcommands for the RePortal CLI. -#[derive(Subcommand)] -enum ReportalSubcommand { - /// Create a default config file at ~/.reportal/config.toml - Init, - /// Update shell integration to the latest version - Upgrade, - /// List all registered repos with status and metadata - #[command(alias = "l")] - List { - /// Filter repos by this tag - #[arg(long)] - tag: Option, - }, - /// Fuzzy-select a repo and print its path (for shell cd integration) - #[command(alias = "j")] - Jump { - /// Jump directly to this alias (skip fuzzy finder) - alias: Option, - /// Filter repos by this tag - #[arg(long)] - tag: Option, - /// Override the tab title for this session - #[arg(long)] - title: Option, - }, - /// Fuzzy-select a repo and open it in your editor - #[command(alias = "o")] - Open { - /// Open this repo directly by alias (skip fuzzy finder) - alias: Option, - /// Filter repos by this tag - #[arg(long)] - tag: Option, - /// Override the default editor command - #[arg(long)] - editor: Option, - /// Override the tab title for this session - #[arg(long)] - title: Option, - }, - /// Register a local repo in the config - #[command(alias = "a")] - Add { - /// Filesystem path to the repo directory - repo_path: String, - }, - /// Edit a repo's description, tags, title, and color - #[command(alias = "e")] - Edit { - /// Alias of the repo to edit - alias: String, - }, - /// Unregister a repo from the config (does not delete files) - #[command(alias = "rm")] - Remove { - /// Alias of the repo to remove - alias: String, - }, - /// Emit terminal tab title and background color for a repo (for shell hooks) - Color { - /// Look up this repo by alias instead of matching the current directory - #[arg(long)] - repo: Option, - }, - /// Show git status across all registered repos - #[command(alias = "s")] - Status { - /// Filter repos by this tag - #[arg(long)] - tag: Option, - }, - /// Pull latest changes across all registered repos - Sync { - /// Filter repos by this tag - #[arg(long)] - tag: Option, - }, -} +use clap::Parser; +use cli_args::{ReportalCli, ReportalCliSubcommand}; +use reportal_commands::{ + AiCommandParameters, ColorCommandParameters, EditCommandParameters, JumpCommandParameters, + OpenCommandParameters, RunCommandParameters, WebCommandParameters, +}; fn main() { let parsed_cli = ReportalCli::parse(); + let subcommand = parsed_cli.into_subcommand(); + + // First-run auto-wire: if the shell integration isn't already + // sourced, silently append the fenced eval block to the user's rc + // file so `cargo install reportal` users don't have to edit their + // rc file by hand. Skipped for `rep init` because that subcommand + // is consumed via `eval "$(rep init zsh)"`, where any stderr + // noise would appear on every shell startup. + if !matches!(subcommand, ReportalCliSubcommand::Init(_)) { + reportal_commands::ensure_shell_integration_installed(); + } - let command_result = match parsed_cli.subcommand { - ReportalSubcommand::Init => reportal_commands::run_init(), - ReportalSubcommand::Upgrade => reportal_commands::run_upgrade(), - ReportalSubcommand::List { tag } => { - let tag_filter = match tag { - Some(tag_value) => TagFilter::ByTag(tag_value), - None => TagFilter::All, - }; - reportal_commands::run_list(tag_filter) - } - ReportalSubcommand::Jump { alias, tag, title } => { - let tag_filter = match tag { - Some(tag_value) => TagFilter::ByTag(tag_value), - None => TagFilter::All, - }; - let direct_alias = match alias { - Some(ref provided_alias) => provided_alias.as_str(), - None => "", - }; - let title_override = match title { - Some(ref provided_title) => provided_title.as_str(), - None => "", - }; - reportal_commands::run_jump(JumpCommandParams { + let command_result = match subcommand { + ReportalCliSubcommand::Init(initialize_arguments) => { + reportal_commands::run_initialize(initialize_arguments.shell()); + Ok(()) + } + ReportalCliSubcommand::List(list_arguments) => { + reportal_commands::run_list(&list_arguments.into_filter_parts()) + } + ReportalCliSubcommand::Jump(jump_arguments) => { + let (direct_alias, tag_filter, title_override) = jump_arguments.into_parts(); + reportal_commands::run_jump(&JumpCommandParameters { tag_filter, - direct_alias, - title_override, + direct_alias: &direct_alias, + title_override: &title_override, }) } - ReportalSubcommand::Open { alias, tag, editor, title } => { - let tag_filter = match tag { - Some(tag_value) => TagFilter::ByTag(tag_value), - None => TagFilter::All, - }; - let direct_alias = match alias { - Some(ref provided_alias) => provided_alias.as_str(), - None => "", - }; - let editor_override = match editor { - Some(ref provided_editor) => provided_editor.as_str(), - None => "", - }; - let title_override = match title { - Some(ref provided_title) => provided_title.as_str(), - None => "", - }; - reportal_commands::run_open(OpenCommandParams { + ReportalCliSubcommand::Open(open_arguments) => { + let (direct_alias, tag_filter, editor_override, title_override) = + open_arguments.into_parts(); + reportal_commands::run_open(&OpenCommandParameters { tag_filter, - direct_alias, - editor_override, - title_override, + direct_alias: &direct_alias, + editor_override: &editor_override, + title_override: &title_override, }) } - ReportalSubcommand::Add { repo_path } => { - reportal_commands::run_add(&repo_path) + ReportalCliSubcommand::Add { repo_path } => reportal_commands::run_add(&repo_path), + ReportalCliSubcommand::Edit(edit_arguments) => { + let (direct_alias, tag_filter) = edit_arguments.into_parts(); + reportal_commands::run_edit(&EditCommandParameters { + tag_filter, + direct_alias: &direct_alias, + }) } - ReportalSubcommand::Edit { alias } => { - reportal_commands::run_edit(&alias) + ReportalCliSubcommand::Remove(remove_arguments) => { + reportal_commands::run_remove(remove_arguments.alias()) } - ReportalSubcommand::Remove { alias } => { - reportal_commands::run_remove(&alias) + ReportalCliSubcommand::Color(color_arguments) => { + let (repository_alias, mode) = color_arguments.into_parts(); + reportal_commands::run_color(&ColorCommandParameters { + repository_alias: &repository_alias, + mode, + }) } - ReportalSubcommand::Color { repo } => { - let repo_alias = match repo { - Some(ref provided_repo) => provided_repo.as_str(), - None => "", - }; - reportal_commands::run_color(ColorCommandParams { - repo_alias, + ReportalCliSubcommand::Prompt(prompt_arguments) => { + reportal_commands::run_prompt(prompt_arguments.into_shell()) + } + ReportalCliSubcommand::Status(status_arguments) => { + reportal_commands::run_status(&status_arguments.into_tag_filter()) + } + ReportalCliSubcommand::Sync(sync_arguments) => { + reportal_commands::run_sync(&sync_arguments.into_tag_filter()) + } + ReportalCliSubcommand::Doctor => { + reportal_commands::run_doctor(); + Ok(()) + } + ReportalCliSubcommand::Web(web_arguments) => { + let (direct_alias, tag_filter) = web_arguments.into_parts(); + reportal_commands::run_web(&WebCommandParameters { + tag_filter, + direct_alias: &direct_alias, + }) + } + ReportalCliSubcommand::Run(run_arguments) => { + let (direct_alias, tag_filter, direct_command) = run_arguments.into_parts(); + reportal_commands::run_run(&RunCommandParameters { + tag_filter, + direct_alias: &direct_alias, + direct_command: &direct_command, + }) + } + ReportalCliSubcommand::Ai(ai_arguments) => { + let (direct_alias, tag_filter, tool_override) = ai_arguments.into_parts(); + reportal_commands::run_ai(&AiCommandParameters { + tag_filter, + direct_alias: &direct_alias, + tool_override: &tool_override, }) } - ReportalSubcommand::Status { tag } => { - let tag_filter = match tag { - Some(tag_value) => TagFilter::ByTag(tag_value), - None => TagFilter::All, - }; - reportal_commands::run_status(tag_filter) - } - ReportalSubcommand::Sync { tag } => { - let tag_filter = match tag { - Some(tag_value) => TagFilter::ByTag(tag_value), - None => TagFilter::All, - }; - reportal_commands::run_sync(tag_filter) + ReportalCliSubcommand::Workspace(workspace_arguments) => { + reportal_commands::dispatch_workspace_subcommand(workspace_arguments) } }; diff --git a/src/reportal_commands/color_command_mode.rs b/src/reportal_commands/color_command_mode.rs new file mode 100644 index 0000000..089502a --- /dev/null +++ b/src/reportal_commands/color_command_mode.rs @@ -0,0 +1,18 @@ +//! How the `rep color` subcommand was invoked, controlling both stdout +//! output and no-match behavior. + +/// Invocation mode for the color subcommand. +/// +/// `PromptHook` is used by the shell prompt function — it prints the +/// resolved title to stdout and preserves existing terminal state when +/// no repo matches the working directory. +/// +/// `Explicit` is the default for direct `rep color` calls — it stays +/// silent on stdout and resets the tab color when no repo matches. +#[derive(Clone, clap::ValueEnum)] +pub enum ColorCommandMode { + /// Shell prompt hook: prints title to stdout, preserves state on no-match. + PromptHook, + /// Direct invocation: silent stdout, resets color on no-match. + Explicit, +} diff --git a/src/reportal_commands/direct_alias_router.rs b/src/reportal_commands/direct_alias_router.rs new file mode 100644 index 0000000..806843f --- /dev/null +++ b/src/reportal_commands/direct_alias_router.rs @@ -0,0 +1,137 @@ +//! Shared workspace/repo alias-dispatch helper for `rep jump` / +//! `rep open`. +//! +//! Both commands accept a name-or-alias argument that may resolve +//! to a registered repo OR a workspace. Keeping the routing in one +//! file prevents `jump` and `open` from drifting on edge cases like +//! "what if the alias matches neither" or "how should a workspace +//! member's identity propagate into tab-title emission". + +use crate::error::ReportalError; +use crate::reportal_commands::path_display::{self, SelectedPathDisplayParameters}; +use crate::reportal_commands::workspace_operations::WorkspaceRegenerator; +use crate::reportal_config::ReportalConfig; +use crate::terminal_style; + +/// Wrapper over a borrowed `ReportalConfig` that exposes the +/// direct-alias classification and workspace-jump behaviors needed +/// by `run_jump` and `run_open`. +/// +/// Holds the config reference as state so every method has exactly +/// one non-`self` parameter, which satisfies the project's +/// positional-argument rules without forcing a params struct at +/// each call site. +pub struct DirectAliasRouter<'config> { + loaded_config: &'config ReportalConfig, +} + +/// Classified outcome of resolving a direct-alias argument. +/// +/// Named variants instead of `Result, _>` because the +/// three outcomes have distinct command-level handling and nesting +/// `Option` inside `Result` would force every caller to flatten the +/// shape by hand. +pub enum DirectAliasRouterOutcome { + /// The alias matches a registered repo; the caller proceeds + /// with the normal repo flow. + RegisteredRepo, + /// The alias matches a workspace; the payload is the canonical + /// workspace name the caller passes to workspace resolution. + Workspace(String), + /// The alias matches neither a repo nor a workspace. + Unknown, +} + +/// Classification + workspace-jump methods for the router. +impl<'config> DirectAliasRouter<'config> { + /// Builds a router backed by the given loaded config. + #[must_use] + pub fn for_config(loaded_config: &'config ReportalConfig) -> Self { + Self { loaded_config } + } + + /// Classifies the direct-alias input into a repo, a workspace, + /// or neither so `run_jump` and `run_open` dispatch uniformly. + /// + /// Real I/O failures from either registry (anything that is not + /// a plain "not found" error) propagate up; only the specific + /// `RepoNotFound` / `WorkspaceNotFound` variants are folded into + /// routing outcomes. This keeps an actual config-read failure + /// from being silently rendered as `Unknown`. + /// + /// # Errors + /// + /// Returns any non-NotFound error surfaced by the underlying + /// `get_repo` or `resolve_workspace_canonical_name` calls. + pub fn classify(&self, alias: &str) -> Result { + match self.loaded_config.get_repo(alias) { + Ok(_repo_found_for_classification) => Ok(DirectAliasRouterOutcome::RegisteredRepo), + Err(ReportalError::RepoNotFound { .. }) => self.classify_workspace_branch(alias), + Err(other_repo_error) => Err(other_repo_error), + } + } + + /// Second half of `classify` — only reached when the repo + /// lookup returned `RepoNotFound`. Extracted so the outer + /// method stays within the project's depth budget and the + /// workspace-vs-unknown distinction is named explicitly rather + /// than folded into a catch-all arm. + /// + /// # Errors + /// + /// Returns any non-NotFound error surfaced by + /// `resolve_workspace_canonical_name`. + fn classify_workspace_branch( + &self, + alias: &str, + ) -> Result { + match self.loaded_config.resolve_workspace_canonical_name(alias) { + Ok(canonical_workspace_name) => Ok(DirectAliasRouterOutcome::Workspace( + canonical_workspace_name, + )), + Err(ReportalError::WorkspaceNotFound { .. }) => Ok(DirectAliasRouterOutcome::Unknown), + Err(other_workspace_error) => Err(other_workspace_error), + } + } + + /// Prints the workspace's materialized directory path to + /// stdout so the `rj` / `rjw` shell wrappers cd there. + /// + /// Auto-rebuilds the workspace directory on the fly when it + /// does not exist yet — covers both first-use after a pre-v0.15.2 + /// upgrade (where only a loose `.code-workspace` file exists + /// under `~/.reportal/workspaces/`) and the case where the user + /// manually deleted the directory. Rebuild is transparent: no + /// user interaction, just ensures `cd` lands in a usable place. + /// + /// # Errors + /// + /// Returns [`ReportalError::WorkspaceNotFound`] if the workspace + /// disappears between alias resolution and directory resolution, + /// [`ReportalError::RepoNotFound`] if a member alias does not + /// resolve during auto-rebuild, or + /// [`ReportalError::ConfigIoFailure`] / + /// [`ReportalError::CodeWorkspaceIoFailure`] if the default + /// location needs the home directory or the rebuild fails. + pub fn jump_to_workspace_parent( + &self, + canonical_workspace_name: &str, + ) -> Result<(), ReportalError> { + let regenerator = WorkspaceRegenerator::for_config(self.loaded_config); + let workspace_directory = + regenerator.resolve_workspace_directory(canonical_workspace_name)?; + if !workspace_directory.exists() { + regenerator.regenerate_workspace_file(canonical_workspace_name)?; + } + let formatted_path = self + .loaded_config + .path_display_format() + .format_path(&workspace_directory); + terminal_style::write_stdout(&formatted_path); + path_display::print_selected_path_if_visible(&SelectedPathDisplayParameters { + loaded_config: self.loaded_config, + resolved_path: &workspace_directory, + }); + Ok(()) + } +} diff --git a/src/reportal_commands/doctor.rs b/src/reportal_commands/doctor.rs new file mode 100644 index 0000000..2f5bb46 --- /dev/null +++ b/src/reportal_commands/doctor.rs @@ -0,0 +1,336 @@ +//! Validates the full `RePortal` installation and prints a diagnostic report. +//! +//! Checks three areas: +//! 1. Config file existence and TOML parse correctness +//! 2. Current session has loaded the shell integration (`REPORTAL_LOADED` env var) +//! 3. All registered repo paths exist on disk +//! +//! Each failed check prints an actionable hint so the user knows +//! exactly what to run to fix the problem. + +use crate::reportal_commands::{ + current_shell_integration_loaded_state, AutoWireOperationLoadedState, +}; +use crate::reportal_config::{HasAliases, ReportalConfig, ShellAliasExport}; +use crate::system_executable_lookup::SystemExecutableLookupOutcome; +use crate::terminal_style; +use owo_colors::OwoColorize; + +/// Tracks pass/fail counts across all diagnostic checks. +struct DiagnosticSummary { + passed: u32, + failed: u32, +} + +/// Construction, recording, and output for diagnostic pass/fail tallies. +impl DiagnosticSummary { + fn new() -> Self { + Self { + passed: 0, + failed: 0, + } + } + + fn record_pass(&mut self, diagnostic_message: &str) { + terminal_style::write_stdout(&format!( + " {} {}\n", + "✓".style(terminal_style::SUCCESS_STYLE), + diagnostic_message, + )); + self.passed += 1; + } + + fn record_fail(&mut self, diagnostic_message: &str) { + terminal_style::write_stdout(&format!( + " {} {}\n", + "✗".style(terminal_style::FAILURE_STYLE), + diagnostic_message, + )); + self.failed += 1; + } + + fn print_hint(hint_message: &str) { + terminal_style::write_stdout(&format!( + " {} {}\n", + "→".style(terminal_style::LABEL_STYLE), + hint_message.style(terminal_style::PATH_STYLE), + )); + } + + /// Checks that the config file exists and parses correctly. + /// Returns the loaded config on success for use by later checks. + fn check_config(&mut self) -> Option { + terminal_style::write_stdout("\n"); + terminal_style::write_stdout(&format!( + " {}\n", + "Config".style(terminal_style::EMPHASIS_STYLE), + )); + + let config_path = match ReportalConfig::config_file_path() { + Ok(path) => path, + Err(ref _path_error) => { + self.record_fail("Could not determine config path"); + return None; + } + }; + + match ReportalConfig::load_or_initialize() { + Ok(loaded_config) => { + let repo_count = loaded_config.repos_with_aliases().len(); + self.record_pass(&format!( + "Config loaded from {} ({repo_count} repos registered)", + config_path.display(), + )); + Some(loaded_config) + } + Err(ref parse_error) => { + self.record_fail(&format!("Config failed to load: {parse_error}")); + Self::print_hint("Check ~/.reportal/config.toml for syntax errors"); + None + } + } + } + + /// Checks the session state — whether the shell integration was + /// loaded in the current process tree via `eval "$(rep init ...)"`. + fn check_shell_integration(&mut self) { + terminal_style::write_stdout("\n"); + terminal_style::write_stdout(&format!( + " {}\n", + "Shell Integration".style(terminal_style::EMPHASIS_STYLE), + )); + + match current_shell_integration_loaded_state() { + AutoWireOperationLoadedState::LoadedInCurrentShell => { + self.record_pass("Shell integration loaded in current session"); + } + AutoWireOperationLoadedState::NotLoadedInCurrentShell => { + self.record_fail("Shell integration NOT loaded in current session"); + Self::print_hint( + "Add eval \"$(rep init zsh)\" (or bash/powershell) to your shell rc file, then 'source' it or open a new terminal", + ); + } + } + } + + /// Checks that all registered repo paths exist on disk. + fn check_repo_paths(&mut self, loaded_config: &ReportalConfig) { + terminal_style::write_stdout("\n"); + terminal_style::write_stdout(&format!( + " {}\n", + "Repo Paths".style(terminal_style::EMPHASIS_STYLE), + )); + + let all_repos = loaded_config.repos_with_aliases(); + + if all_repos.is_empty() { + self.record_fail("No repos registered"); + Self::print_hint("Run 'rep add ' to register a repo"); + return; + } + + for (registered_alias, registered_repository_entry) in &all_repos { + self.check_single_repository(&DiagnosticSummaryRepositoryEntryParameters { + registered_alias, + registered_repository_entry, + }); + } + } + + /// Validates that a single repository path exists and records the diagnostic. + fn check_single_repository( + &mut self, + parameters: &DiagnosticSummaryRepositoryEntryParameters<'_>, + ) { + let resolved_path = parameters.registered_repository_entry.resolved_path(); + let path_display = resolved_path.display().to_string(); + let styled_alias = parameters + .registered_alias + .style(terminal_style::ALIAS_STYLE); + let styled_path = path_display.style(terminal_style::PATH_STYLE); + let diagnostic_label = format!("{styled_alias} {styled_path}"); + if resolved_path.exists() { + self.record_pass(&diagnostic_label); + } else { + self.record_fail(&format!("{diagnostic_label} (path does not exist)")); + } + } +} + +/// Parameters for one repository-path diagnostic. Named with the +/// primary type's prefix so the type-per-file guard treats it as +/// a companion of `DiagnosticSummary`. +struct DiagnosticSummaryRepositoryEntryParameters<'entry> { + /// The repository's canonical alias key as registered in + /// configuration. + registered_alias: &'entry str, + /// The repository entry whose path is being checked. + registered_repository_entry: &'entry crate::reportal_config::RepoEntry, +} + +/// Parameters for one shell-alias shadow probe, kept as a named +/// struct so the probe helper has one self argument and one +/// params argument instead of two unlabelled positional `&str` +/// args. Named with the primary type's prefix so the +/// type-per-file guard treats it as a companion of +/// `DiagnosticSummary`. +struct DiagnosticSummaryShadowProbeParameters<'probe> { + /// The name (canonical key or declared alias) that + /// `rep init ` would emit as a top-level shell + /// function for this opted-in entry. + candidate_emitted_name: &'probe str, + /// Human-readable label classifying which kind of name this + /// is (`repository canonical`, `repository alias`, + /// `workspace canonical`, `workspace alias`, `command`). + emitted_kind_label: &'probe str, +} + +/// Shell-alias emission diagnostics for `DiagnosticSummary`, +/// kept in their own impl block so the addition does not +/// cascade into the surrounding methods. +impl DiagnosticSummary { + /// Walks every opted-in repository, workspace, and command + /// entry and flags any name (canonical key or declared alias) + /// that resolves to an existing executable on the user's + /// `PATH`. Such a name silently shadows the system command + /// once `rep init ` emits it as a top-level shell + /// function, so doctor reports it here even though + /// configuration-load tolerates legacy entries that pre-date + /// the system-shadow validation. + fn check_shell_alias_emission_health(&mut self, loaded_configuration: &ReportalConfig) { + terminal_style::write_stdout("\n"); + terminal_style::write_stdout(&format!( + " {}\n", + "Shell Alias Shadows".style(terminal_style::EMPHASIS_STYLE), + )); + + let mut probed_any_opted_in_name = false; + for (repository_canonical_key, repository_entry) in + loaded_configuration.repos_with_aliases() + { + match repository_entry.shell_alias_export() { + ShellAliasExport::Disabled => continue, + ShellAliasExport::Enabled => { + probed_any_opted_in_name = true; + self.probe_one_emitted_name(&DiagnosticSummaryShadowProbeParameters { + candidate_emitted_name: repository_canonical_key, + emitted_kind_label: "repository canonical", + }); + for declared_alias in repository_entry.aliases() { + self.probe_one_emitted_name(&DiagnosticSummaryShadowProbeParameters { + candidate_emitted_name: declared_alias, + emitted_kind_label: "repository alias", + }); + } + } + } + } + for (workspace_canonical_name, workspace_entry) in + loaded_configuration.workspaces_with_names() + { + match workspace_entry.shell_alias_export() { + ShellAliasExport::Disabled => continue, + ShellAliasExport::Enabled => { + probed_any_opted_in_name = true; + self.probe_one_emitted_name(&DiagnosticSummaryShadowProbeParameters { + candidate_emitted_name: workspace_canonical_name, + emitted_kind_label: "workspace canonical", + }); + for declared_alias in workspace_entry.aliases() { + self.probe_one_emitted_name(&DiagnosticSummaryShadowProbeParameters { + candidate_emitted_name: declared_alias, + emitted_kind_label: "workspace alias", + }); + } + } + } + } + for (command_key, command_entry) in loaded_configuration.global_commands() { + match command_entry.shell_alias_export() { + ShellAliasExport::Disabled => continue, + ShellAliasExport::Enabled => { + probed_any_opted_in_name = true; + self.probe_one_emitted_name(&DiagnosticSummaryShadowProbeParameters { + candidate_emitted_name: command_key, + emitted_kind_label: "command", + }); + } + } + } + if !probed_any_opted_in_name { + self.record_pass("No entries opted into shell-alias export"); + } + } + + /// Probes a single emitted-name candidate against `PATH` and + /// records pass / fail. Used by + /// `check_shell_alias_emission_health` for every opted-in + /// canonical key and declared alias. + fn probe_one_emitted_name( + &mut self, + probe_parameters: &DiagnosticSummaryShadowProbeParameters<'_>, + ) { + let styled_name = probe_parameters + .candidate_emitted_name + .style(terminal_style::ALIAS_STYLE); + match SystemExecutableLookupOutcome::for_candidate_name( + probe_parameters.candidate_emitted_name, + ) { + SystemExecutableLookupOutcome::NotFound => { + self.record_pass(&format!( + "{label} {styled_name}", + label = probe_parameters.emitted_kind_label, + )); + } + SystemExecutableLookupOutcome::ShadowsExisting { + existing_executable, + } => { + let existing_executable_display = existing_executable.display().to_string(); + let styled_existing_executable_path = + existing_executable_display.style(terminal_style::PATH_STYLE); + self.record_fail(&format!( + "{label} {styled_name} shadows {styled_existing_executable_path}", + label = probe_parameters.emitted_kind_label, + )); + Self::print_hint( + "Pick a different alias, or remove `shell_alias = true` to keep this entry as a `rj ` target only", + ); + } + } + } +} + +/// Validates the full `RePortal` installation and prints a diagnostic report. +/// Checks config file parsing, shell integration session state, and repo +/// path existence, then prints a pass/fail summary to stdout. +pub fn run_doctor() { + terminal_style::write_stdout(&format!( + "{}\n", + "RePortal Doctor".style(terminal_style::EMPHASIS_STYLE), + )); + + let mut summary = DiagnosticSummary::new(); + + let loaded_config = summary.check_config(); + summary.check_shell_integration(); + + if let Some(ref config) = loaded_config { + summary.check_repo_paths(config); + summary.check_shell_alias_emission_health(config); + } + + terminal_style::write_stdout("\n"); + match summary.failed { + 0 => { + terminal_style::print_success(&format!("All {} checks passed", summary.passed,)); + } + _failure_count => { + terminal_style::write_stdout(&format!( + " {} {} passed, {} failed\n", + "Summary:".style(terminal_style::EMPHASIS_STYLE), + summary.passed.style(terminal_style::SUCCESS_STYLE), + summary.failed.style(terminal_style::FAILURE_STYLE), + )); + } + } +} diff --git a/src/reportal_commands/git_commands.rs b/src/reportal_commands/git_commands.rs new file mode 100644 index 0000000..3f58931 --- /dev/null +++ b/src/reportal_commands/git_commands.rs @@ -0,0 +1,46 @@ +//! Shared git command execution used by status, sync, and web. + +use std::path::PathBuf; +use std::process::Command; + +/// Whether a git command produced usable output or failed. +pub enum GitCommandOutcome { + /// The command ran and produced trimmed stdout output. + Output(String), + /// The command failed to run (git not installed, not a repo, etc). + SpawnFailed, + /// The command ran but returned a non-zero exit code. + NonZeroExit, +} + +/// Parameters for running a git subcommand inside a specific repo directory. +/// Passed to `run_git_command()` as its single argument. +pub struct GitCommandParameters<'a> { + /// The resolved filesystem path to run git in. + pub repo_path: &'a PathBuf, + /// The git subcommand and its arguments (e.g. `["status", "--porcelain"]`). + pub git_subcommand_args: &'a [&'a str], +} + +/// Spawns `git ` in the given repo directory and captures stdout. +/// +/// Returns `Output(trimmed_stdout)` on zero exit, [`NonZeroExit`] on +/// non-zero exit, or `SpawnFailed` if the git binary couldn't be found. +/// Used by status, sync, and web to avoid duplicating the spawn+capture logic. +pub fn run_git_command(git_command_params: &GitCommandParameters<'_>) -> GitCommandOutcome { + let command_result = Command::new("git") + .args(git_command_params.git_subcommand_args) + .current_dir(git_command_params.repo_path) + .output(); + + match command_result { + Ok(output) => { + if output.status.success() { + GitCommandOutcome::Output(String::from_utf8_lossy(&output.stdout).trim().to_owned()) + } else { + GitCommandOutcome::NonZeroExit + } + } + Err(_git_spawn_error) => GitCommandOutcome::SpawnFailed, + } +} diff --git a/src/reportal_commands/initialization.rs b/src/reportal_commands/initialization.rs index d51990f..6b028a1 100644 --- a/src/reportal_commands/initialization.rs +++ b/src/reportal_commands/initialization.rs @@ -1,324 +1,48 @@ -/// Creates the default RePortal config file and installs shell integration. - -use crate::error::ReportalError; +//! Implements `rep init `. +//! +//! Prints shell integration code to stdout so the user can wire it +//! into their rc file with a single `eval "$(rep init zsh)"` line, +//! matching the pattern used by starship, zoxide, direnv, and mise. +//! No disk writes, no profile editing, no prompts — the shell code +//! is regenerated every session and therefore never goes stale. + +use crate::cli_args::InitializeShell; use crate::reportal_config::ReportalConfig; -use crate::terminal_style; -use dialoguer::{theme::ColorfulTheme, Confirm}; -use owo_colors::OwoColorize; -use std::path::PathBuf; -use std::process::Command; - -/// The PowerShell code that gets appended to the user's profile. -/// Includes shortcuts (rj, ro) and the terminal personalization prompt hook. -const POWERSHELL_INTEGRATION: &str = r#" -# RePortal shell integration -function rj { Set-Location (rep jump @args); rep color } -function ro { rep open @args; rep color } -$_reportal_original_prompt = $function:prompt -function prompt { $p = & $_reportal_original_prompt; rep color 2>$null; $p } -"#; - -/// The Bash and Zsh code that gets appended to the user's profile. -/// Includes shortcuts (rj, ro) only; the color hook is appended separately -/// at runtime because its redirect path triggers static analysis false positives. -#[cfg(not(target_os = "windows"))] -const BASH_INTEGRATION: &str = r#" -# RePortal shell integration -rj() { cd "$(rep jump "$@")"; rep color; } -ro() { rep open "$@"; rep color; } -"#; - -/// Builds the Bash and Zsh color hook line at runtime to avoid static analysis -/// false positives on the Unix null device path in string literals. -#[cfg(not(target_os = "windows"))] -fn bash_color_hook_line() -> String { - let null_device = std::path::Path::new("/dev").join("null"); - return format!( - "PROMPT_COMMAND=\"${{PROMPT_COMMAND:+$PROMPT_COMMAND;}}rep color 2>{}\"\n", - null_device.display() - ); -} - -/// Which shell the user is running, with its profile path. -enum DetectedShell { - /// PowerShell (Windows or cross-platform). - PowerShell(PathBuf), - /// Bash with a known profile path. - #[cfg(not(target_os = "windows"))] - Bash(PathBuf), - /// Zsh with a known profile path. - #[cfg(not(target_os = "windows"))] - Zsh(PathBuf), - /// Could not determine the shell or profile path. - Unknown, -} - -/// Whether pwsh was able to report its profile path. -enum PowerShellDetection { - /// Profile path was returned by pwsh. - Detected(String), - /// pwsh failed to run or returned empty output. - Unavailable, -} - -/// Whether the SHELL env var was readable. -#[cfg(not(target_os = "windows"))] -enum ShellEnvDetection { - /// SHELL env var was read successfully. - Detected(String), - /// SHELL env var was not set. - Unavailable, -} - -/// Attempts to get the PowerShell profile path by running pwsh. -fn detect_powershell_profile() -> PowerShellDetection { - let profile_output = Command::new("pwsh") - .args(["-NoProfile", "-Command", "echo $PROFILE"]) - .output(); - - match profile_output { - Ok(output_bytes) => match output_bytes.status.success() { - true => { - let profile_path_string = String::from_utf8_lossy(&output_bytes.stdout).trim().to_string(); - match profile_path_string.is_empty() { - true => PowerShellDetection::Unavailable, - false => PowerShellDetection::Detected(profile_path_string), - } - } - false => PowerShellDetection::Unavailable, - }, - Err(pwsh_spawn_error) => { - eprintln!(" pwsh not found: {pwsh_spawn_error}"); - PowerShellDetection::Unavailable - } - } -} - -/// Reads the SHELL environment variable to determine the Unix shell. -#[cfg(not(target_os = "windows"))] -fn detect_unix_shell_env() -> ShellEnvDetection { - match std::env::var("SHELL") { - Ok(shell_path) => ShellEnvDetection::Detected(shell_path), - Err(env_read_error) => { - eprintln!(" SHELL env not set: {env_read_error}"); - ShellEnvDetection::Unavailable - } - } -} -/// Detects the current shell and its profile path. -fn detect_shell_profile() -> DetectedShell { - #[cfg(target_os = "windows")] - { - match detect_powershell_profile() { - PowerShellDetection::Detected(profile_path) => DetectedShell::PowerShell(PathBuf::from(profile_path)), - PowerShellDetection::Unavailable => DetectedShell::Unknown, - } +use super::shell_alias_emit::{self, ShellAliasEmissionParameters}; +use super::shell_integration::{bash_integration_content, powershell_integration_content}; +use super::shell_prompt_badge::prompt_badge_integration_snippet; + +/// @why Emits the shell integration script — chrome hooks plus +/// the prompt-badge snippet plus the per-config alias block — for +/// the requested shell to stdout so the user wires a single +/// `eval "$(rep init )"` line into their rc file and gets +/// the tab / window title chrome, the inline PS1 badge, and any +/// opted-in shell aliases without further manual setup. +pub fn run_initialize(shell: InitializeShell) { + let base_integration_script = match shell { + InitializeShell::Zsh | InitializeShell::Bash => bash_integration_content(), + InitializeShell::Powershell => powershell_integration_content(), + }; + let badge_snippet = prompt_badge_integration_snippet(shell); + let alias_snippet = build_alias_snippet_or_empty_on_failure(shell); + print!("{base_integration_script}{badge_snippet}{alias_snippet}"); +} + +/// Renders the per-config alias block, returning the empty string +/// if the configuration cannot be loaded. Shell init runs from +/// the user's rc file on every new shell, so a config-load +/// failure here must never break the shell startup — the base +/// integration still emits and the alias block is silently +/// omitted. +fn build_alias_snippet_or_empty_on_failure(shell: InitializeShell) -> String { + match ReportalConfig::load_or_initialize() { + Ok(loaded_configuration) => { + shell_alias_emit::shell_alias_export_snippet(&ShellAliasEmissionParameters { + target_shell: shell, + configuration: &loaded_configuration, + }) + } + Err(_load_error_swallowed_to_keep_shell_init_safe) => String::new(), } - - #[cfg(not(target_os = "windows"))] - { - match detect_unix_shell_env() { - ShellEnvDetection::Detected(shell_path) => { - match dirs::home_dir() { - Some(home_path) => { - if shell_path.contains("zsh") { - DetectedShell::Zsh(home_path.join(".zshrc")) - } else { - DetectedShell::Bash(home_path.join(".bashrc")) - } - } - None => DetectedShell::Unknown, - } - } - ShellEnvDetection::Unavailable => DetectedShell::Unknown, - } - } -} - -/// Strips any existing RePortal integration block from a profile string. -/// Looks for the "# RePortal shell integration" marker and removes -/// everything from that line through the end of the block (consecutive -/// non-empty lines or known RePortal patterns). -fn strip_existing_integration(profile_content: &str) -> String { - let mut result_lines: Vec<&str> = Vec::new(); - let mut skipping_reportal_block = false; - - for line in profile_content.lines() { - match skipping_reportal_block { - true => { - let trimmed = line.trim(); - let is_reportal_line = trimmed.starts_with("function rj") - || trimmed.starts_with("function ro") - || trimmed.starts_with("function prompt") - || trimmed.starts_with("$_reportal_") - || trimmed.starts_with("rj()") - || trimmed.starts_with("ro()") - || trimmed.starts_with("PROMPT_COMMAND") - || trimmed.contains("rep jump") - || trimmed.contains("rep open") - || trimmed.contains("rep color"); - match is_reportal_line { - true => {} - false => { - skipping_reportal_block = false; - match trimmed.is_empty() { - true => {} - false => result_lines.push(line), - } - } - } - } - false => match line.contains("RePortal shell integration") { - true => { - skipping_reportal_block = true; - } - false => { - result_lines.push(line); - } - }, - } - } - - return result_lines.join("\n"); -} - -/// Methods for installing shell integration based on detected shell type. -impl DetectedShell { - /// Returns the full integration code for this shell — shortcuts (rj, ro) - /// and the terminal personalization prompt hook, as a single block. - fn integration_code(&self) -> String { - match self { - DetectedShell::PowerShell(_) => POWERSHELL_INTEGRATION.to_string(), - #[cfg(not(target_os = "windows"))] - DetectedShell::Bash(_) | DetectedShell::Zsh(_) => { - let mut combined = BASH_INTEGRATION.to_string(); - combined.push_str(&bash_color_hook_line()); - return combined; - } - DetectedShell::Unknown => String::new(), - } - } - - /// Returns the profile path if this shell was detected. - fn profile_path(&self) -> Option<&PathBuf> { - match self { - DetectedShell::PowerShell(ref shell_profile_path) => Some(shell_profile_path), - #[cfg(not(target_os = "windows"))] - DetectedShell::Bash(ref shell_profile_path) - | DetectedShell::Zsh(ref shell_profile_path) => Some(shell_profile_path), - DetectedShell::Unknown => None, - } - } - - /// Removes any existing RePortal block from the profile, then appends - /// the latest integration code. This makes `rep init` safe to re-run - /// on updates — it always replaces with the current version. - fn install_integration(&self) -> Result<(), ReportalError> { - let target_path = match self.profile_path() { - Some(resolved_path) => resolved_path, - None => return Ok(()), - }; - - let existing_content = match target_path.exists() { - true => std::fs::read_to_string(target_path).map_err(|io_error| { - ReportalError::ConfigIoFailure { - reason: io_error.to_string(), - } - })?, - false => String::new(), - }; - - let mut cleaned_content = strip_existing_integration(&existing_content); - cleaned_content.push_str(&self.integration_code()); - - std::fs::write(target_path, cleaned_content).map_err(|io_error| { - ReportalError::ConfigIoFailure { - reason: io_error.to_string(), - } - })?; - - Ok(()) - } -} - -/// Detects the shell, strips any old RePortal block, and appends the -/// latest integration code. Used by both `init` and `upgrade`. -fn install_or_update_shell_integration() -> Result<(), ReportalError> { - println!(); - println!(" {} Shell integration:", ">>".style(terminal_style::LABEL_STYLE)); - println!(" {} jump to a repo (cd)", "rj".style(terminal_style::ALIAS_STYLE)); - println!(" {} open a repo in your editor", "ro".style(terminal_style::ALIAS_STYLE)); - println!(" {} per-repo tab title + background color on every prompt", "color".style(terminal_style::ALIAS_STYLE)); - println!(); - - let detected_shell = detect_shell_profile(); - - match detected_shell.profile_path() { - Some(profile_path) => { - println!( - " Profile: {}", - profile_path.display().to_string().style(terminal_style::PATH_STYLE), - ); - - let user_wants_install = Confirm::with_theme(&ColorfulTheme::default()) - .with_prompt("Install/update shell integration?") - .default(true) - .interact() - .map_err(|prompt_error| ReportalError::ConfigIoFailure { - reason: prompt_error.to_string(), - })?; - - match user_wants_install { - true => { - detected_shell.install_integration()?; - terminal_style::print_success(&format!( - "Updated {}. Restart your shell to activate.", - profile_path.display(), - )); - } - false => { - println!(" Skipped."); - } - } - } - None => { - println!(" Could not detect your shell profile."); - println!(" Add these manually:"); - println!(); - println!(" PowerShell:"); - println!(" function rj {{ Set-Location (rep jump) }}"); - println!(" function ro {{ rep open }}"); - println!(); - println!(" Bash/Zsh:"); - println!(" rj() {{ cd \"$(rep jump)\"; }}"); - println!(" ro() {{ rep open; }}"); - } - } - - Ok(()) -} - -/// Creates a default config at `~/.reportal/config.toml` if none exists, -/// then installs shell integration (rj, ro, color prompt hook). -pub fn run_init() -> Result<(), ReportalError> { - let config_path = ReportalConfig::config_file_path()?; - if config_path.exists() { - println!("Config already exists at {}", config_path.display()); - println!("Use 'reportal add' to register repos."); - } else { - let default_config = ReportalConfig::create_default(); - default_config.save_to_disk()?; - terminal_style::print_success(&format!("Created config at {}", config_path.display())); - } - - install_or_update_shell_integration() -} - -/// Updates the shell profile to the latest RePortal integration code. -/// Strips the old block and replaces it, so users can run this after -/// upgrading RePortal to pick up new shell function definitions. -pub fn run_upgrade() -> Result<(), ReportalError> { - println!("Upgrading RePortal shell integration..."); - install_or_update_shell_integration() } diff --git a/src/reportal_commands/mod.rs b/src/reportal_commands/mod.rs index bf49d71..f2be520 100644 --- a/src/reportal_commands/mod.rs +++ b/src/reportal_commands/mod.rs @@ -1,23 +1,75 @@ -/// Subcommand implementations for the RePortal CLI. +//! Subcommand implementations for the `RePortal` CLI. +mod color_command_mode; +mod direct_alias_router; +mod doctor; +mod git_commands; mod initialization; +mod path_display; +mod prompt_badge_emit; +mod prompt_identity; +mod prompts; mod repo_add; -mod repo_color; +mod repo_ai; +mod repo_edit; +mod repo_edit_mutator; mod repo_jump; mod repo_listing; +mod repo_listing_renderer; mod repo_open; -mod repo_edit; mod repo_remove; +mod repo_run; +mod repo_selection; mod repo_status; mod repo_sync; +mod repo_tree_grouping; +mod repo_tree_workspace_section; +mod repo_web; +mod repository_color; +mod shell_alias_emit; +mod shell_integration; +mod shell_integration_autowire; +mod shell_prompt_badge; +mod target_selection; +mod terminal_identity_emit; +mod workspace_create; +mod workspace_delete; +mod workspace_dispatch; +mod workspace_jump; +mod workspace_layout; +mod workspace_listing; +mod workspace_members; +mod workspace_open; +mod workspace_operations; +mod workspace_rebuild; +mod workspace_selection; +mod workspace_show; -pub use initialization::run_init; -pub use initialization::run_upgrade; +pub use color_command_mode::ColorCommandMode; +pub use doctor::run_doctor; +pub use initialization::run_initialize; +pub use prompt_badge_emit::run_prompt; pub use repo_add::run_add; -pub use repo_edit::run_edit; -pub use repo_color::{run_color, ColorCommandParams}; -pub use repo_jump::{run_jump, JumpCommandParams}; +pub use repo_ai::{run_ai, AiCommandParameters}; +pub use repo_edit::{run_edit, EditCommandParameters}; +pub use repo_jump::{run_jump, JumpCommandParameters}; pub use repo_listing::run_list; -pub use repo_open::{run_open, OpenCommandParams}; +pub use repo_open::{run_open, OpenCommandParameters}; pub use repo_remove::run_remove; +pub use repo_run::{run_run, RunCommandParameters}; pub use repo_status::run_status; pub use repo_sync::run_sync; +pub use repo_web::{run_web, WebCommandParameters}; +pub use repository_color::{run_color, ColorCommandParameters}; +pub use shell_integration_autowire::{ + current_shell_integration_loaded_state, ensure_shell_integration_installed, + AutoWireOperationLoadedState, +}; +pub use workspace_create::run_workspace_create; +pub use workspace_delete::run_workspace_delete; +pub use workspace_dispatch::dispatch_workspace_subcommand; +pub use workspace_jump::run_workspace_jump; +pub use workspace_listing::run_workspace_list; +pub use workspace_members::{run_workspace_add_repo, run_workspace_remove_repo}; +pub use workspace_open::run_workspace_open; +pub use workspace_rebuild::run_workspace_rebuild; +pub use workspace_show::run_workspace_show; diff --git a/src/reportal_commands/path_display.rs b/src/reportal_commands/path_display.rs new file mode 100644 index 0000000..d44e0e9 --- /dev/null +++ b/src/reportal_commands/path_display.rs @@ -0,0 +1,36 @@ +//! Shared path display helper for commands that print after repo selection. + +use crate::reportal_config::{PathVisibility, ReportalConfig}; +use crate::terminal_style; +use owo_colors::OwoColorize; +use std::path::PathBuf; + +/// Parameters for conditionally printing the selected repo's path. +pub struct SelectedPathDisplayParameters<'a> { + /// The loaded config (provides `path_on_select` and `path_display_format`). + pub loaded_config: &'a ReportalConfig, + /// The resolved filesystem path of the selected repo. + pub resolved_path: &'a PathBuf, +} + +/// Prints the selected repo's path to stderr if the config says to show it. +/// +/// Used by jump and open after repo selection to give the user visual +/// feedback about which path was resolved. Respects the `path_on_select` +/// and `path_display_format` settings. +pub fn print_selected_path_if_visible(display_params: &SelectedPathDisplayParameters<'_>) { + match display_params.loaded_config.path_on_select() { + PathVisibility::Show => { + let formatted_path = display_params + .loaded_config + .path_display_format() + .format_path(display_params.resolved_path); + terminal_style::write_stderr(&format!( + " {} {}\n", + ">>".style(terminal_style::SUCCESS_STYLE), + formatted_path.style(terminal_style::PATH_STYLE), + )); + } + PathVisibility::Hide => {} + } +} diff --git a/src/reportal_commands/prompt_badge_emit.rs b/src/reportal_commands/prompt_badge_emit.rs new file mode 100644 index 0000000..d3cd673 --- /dev/null +++ b/src/reportal_commands/prompt_badge_emit.rs @@ -0,0 +1,116 @@ +//! Implements `rep prompt --shell `. +//! +//! Resolves the current working directory to its workspace or +//! repository identity (via `PromptIdentityResolver`) and prints +//! a single ANSI-SGR colored badge to stdout, wrapped with the +//! target shell's non-printing-escape markers so the sequence +//! can be inlined inside a `PS1` / `$PROMPT` / `prompt` function +//! without breaking cursor math on long command lines. +//! +//! When the CWD matches neither a workspace directory nor a +//! registered repository path, the command prints nothing — the +//! shell integration script is expected to treat empty output as +//! "no badge this prompt" and render its normal PS1 unchanged. + +use crate::cli_args::PromptShell; +use crate::error::ReportalError; +use crate::reportal_commands::prompt_identity::{PromptIdentity, PromptIdentityResolver}; +use crate::reportal_config::{HexColor, ReportalConfig}; +use crate::terminal_style; + +/// Non-printing-escape wrapper pair for a single shell, used to +/// fence SGR sequences inside a prompt string without letting the +/// shell count them as visible characters. +/// +/// Storing the pair on a named struct (rather than a 2-element +/// tuple) keeps the opening/closing direction named at the call +/// site and lets the shell-specific picker and the wrapping +/// helper live as methods here, keeping this file's free +/// function count to exactly one (the public command entry). +struct NonPrintingEscapeWrapper { + /// Sequence placed immediately before a non-printing byte run. + opening_marker: &'static str, + /// Sequence placed immediately after a non-printing byte run. + closing_marker: &'static str, +} + +/// Shell-aware construction and SGR wrapping for the +/// non-printing-escape marker pair. +impl NonPrintingEscapeWrapper { + /// Picks the marker pair for the requested shell: bash uses + /// `\[` / `\]`, zsh uses `%{` / `%}`, and `PowerShell` + /// emits raw SGR because `PSReadLine` already handles cursor + /// math inside a prompt function. + fn for_shell(requested_shell: PromptShell) -> Self { + match requested_shell { + PromptShell::Bash => Self { + opening_marker: "\\[", + closing_marker: "\\]", + }, + PromptShell::Zsh => Self { + opening_marker: "%{", + closing_marker: "%}", + }, + PromptShell::Powershell => Self { + opening_marker: "", + closing_marker: "", + }, + } + } + + /// Renders a full colored badge: opening-wrap, SGR + /// foreground, closing-wrap, then the label, then + /// opening-wrap, SGR reset, closing-wrap. The label itself + /// sits outside the wrapper so the shell counts its visible + /// characters correctly. + fn render_colored_label( + &self, + display_label: &str, + accent_hex_color: &HexColor, + ) -> Result { + let (red_channel, green_channel, blue_channel) = accent_hex_color.as_rgb_bytes()?; + let sgr_foreground = format!("\x1b[38;2;{red_channel};{green_channel};{blue_channel}m"); + let sgr_reset = "\x1b[0m"; + Ok(format!( + "{open}{sgr_foreground}{close}{display_label}{open}{sgr_reset}{close}", + open = self.opening_marker, + close = self.closing_marker, + )) + } + + /// Renders the badge for an identity whose accent color is + /// either set or absent. When absent, the label is returned + /// unwrapped so the shell's current prompt color shows + /// through. + fn render_identity(&self, resolved_identity: &PromptIdentity) -> Result { + let Some(hex_color) = resolved_identity.accent_color.as_ref() else { + return Ok(resolved_identity.display_label.clone()); + }; + self.render_colored_label(&resolved_identity.display_label, hex_color) + } +} + +/// @why Prints a shell-wrapped ANSI-colored prompt badge for the +/// current working directory so `PS1` / `$PROMPT` / the pwsh +/// `prompt` function can inline the badge without the shell +/// miscounting escape bytes and corrupting the cursor on long +/// command lines. Silent when the CWD is outside every +/// registered workspace and repository. +/// +/// # Errors +/// +/// Returns any error surfaced by configuration load or by the +/// prompt identity resolver — both bubble up so a broken +/// configuration fails loudly on the first prompt redraw rather +/// than silently stripping the badge. +pub fn run_prompt(requested_shell: PromptShell) -> Result<(), ReportalError> { + let loaded_configuration = ReportalConfig::load_or_initialize()?; + let identity_resolver = PromptIdentityResolver::for_configuration(&loaded_configuration); + let Some(resolved_identity) = identity_resolver.resolve_from_current_directory()? else { + return Ok(()); + }; + let escape_wrapper = NonPrintingEscapeWrapper::for_shell(requested_shell); + let rendered_badge = escape_wrapper.render_identity(&resolved_identity)?; + terminal_style::write_stdout(&rendered_badge); + Ok(()) +} diff --git a/src/reportal_commands/prompt_identity.rs b/src/reportal_commands/prompt_identity.rs new file mode 100644 index 0000000..d25934d --- /dev/null +++ b/src/reportal_commands/prompt_identity.rs @@ -0,0 +1,180 @@ +//! Resolves the current working directory into a prompt-badge +//! identity (label + accent color) drawn from the configuration. +//! +//! Used by `rep prompt` to render the inline badge inside the +//! shell prompt, and by `rep color` to pick the right tab-strip +//! color when the user is inside a workspace directory that is +//! not itself a registered repository path. +//! +//! Resolution order is workspace-first, repository-second: +//! +//! 1. Longest-prefix match against every workspace's on-disk +//! directory (the folder that holds the symlinks + the +//! `.code-workspace` file). A workspace match produces the +//! workspace's own title + color. +//! 2. If no workspace matches, longest-prefix match against every +//! registered repository's resolved path. A repository match +//! produces the repository's title + color. +//! 3. If neither matches, returns `None` — the caller decides +//! whether to emit nothing (prompt hook) or reset chrome +//! (explicit color call). +//! +//! Workspace-first is the intuitive rule: a user who `cd`s through +//! a workspace symlink has a PWD like +//! `~/dev/workspaces/venoble/app`, which is prefix-matched by the +//! workspace directory `~/dev/workspaces/venoble/` and therefore +//! identifies as "in the venoble workspace" rather than "in the +//! app repository". A user who `cd`s directly into the real +//! repository path bypasses the workspace dir and falls through +//! to the repository match, which is also the intuitive result. + +use crate::error::ReportalError; +use crate::reportal_commands::workspace_operations::WorkspaceRegenerator; +use crate::reportal_config::{HexColor, ReportalConfig, TabTitle}; +use std::path::Path; + +/// Resolved identity for the prompt badge: the label to display +/// and the accent color to render it in. +/// +/// Modeled as a concrete struct (rather than a tuple) so callers +/// can name the fields at the emit site and new rendering knobs +/// (icon glyph, badge shape) can be added later without breaking +/// the call-site signature. +pub struct PromptIdentity { + /// Short human-readable label — e.g. `"🥷 nro"` or + /// `"👑 ven"` — rendered inside the prompt and the editor + /// title bar. + pub display_label: String, + /// Accent color applied to the label in the prompt and to + /// the tab strip / editor title bar. `None` means no accent + /// (fall back to the terminal's default foreground). + pub accent_color: Option, +} + +/// Resolver that walks a configuration's workspace directories +/// and repository paths to find the one whose longest-prefix +/// matches the given current working directory. +/// +/// Constructed once with a borrowed configuration so each +/// resolution method has exactly one non-`self` parameter, +/// matching the project's `WorkspaceRegenerator` pattern for +/// configuration-backed helpers. +pub struct PromptIdentityResolver<'configuration_lifetime> { + /// The loaded configuration supplying workspace and + /// repository entries. + configuration_registry: &'configuration_lifetime ReportalConfig, +} + +/// Resolution methods that turn a working directory into the +/// matching workspace or repository identity. +impl<'configuration_lifetime> PromptIdentityResolver<'configuration_lifetime> { + /// @why Binds the resolver to a loaded configuration once + /// per command invocation so the two lookup passes + /// (workspace, repository) share the same borrowed data and + /// cannot observe a torn read across a config reload. + #[must_use] + pub fn for_configuration( + configuration_registry: &'configuration_lifetime ReportalConfig, + ) -> Self { + Self { + configuration_registry, + } + } + + /// @why Turns the current working directory into a concrete + /// prompt badge identity so the callers (`rep prompt`, + /// workspace-aware `rep color`) don't each re-implement the + /// longest-prefix-match rule against workspaces then + /// repositories. + /// + /// Returns `Ok(None)` when the CWD sits under neither a + /// workspace directory nor a registered repository path. + /// + /// # Errors + /// + /// Returns [`ReportalError::ConfigIoFailure`] if the current + /// working directory cannot be read, or any error surfaced + /// by the workspace directory resolver (missing home + /// directory). + pub fn resolve_from_current_directory(&self) -> Result, ReportalError> { + let current_directory = std::env::current_dir().map_err(|working_directory_error| { + ReportalError::ConfigIoFailure { + reason: working_directory_error.to_string(), + } + })?; + if let Some(workspace_identity) = self.match_workspace_directory(¤t_directory)? { + return Ok(Some(workspace_identity)); + } + Ok(self.match_repository_path(¤t_directory)) + } + + /// Longest-prefix match against every workspace's on-disk + /// directory. A match produces a [`PromptIdentity`] built + /// from the workspace's title and color fields, with the + /// workspace's canonical name as the fallback label. + fn match_workspace_directory( + &self, + current_directory: &Path, + ) -> Result, ReportalError> { + let regenerator = WorkspaceRegenerator::for_config(self.configuration_registry); + let mut best_match_length: usize = 0; + let mut best_identity: Option = None; + + for (workspace_name, workspace_entry) in self.configuration_registry.workspaces_with_names() + { + let workspace_directory = regenerator.resolve_workspace_directory(workspace_name)?; + if !current_directory.starts_with(&workspace_directory) { + continue; + } + let matched_path_length = workspace_directory.as_os_str().len(); + if matched_path_length <= best_match_length { + continue; + } + best_match_length = matched_path_length; + let resolved_display_label = match workspace_entry.tab_title() { + TabTitle::Custom(custom_display_label) => custom_display_label.clone(), + TabTitle::UseAlias => workspace_name.clone(), + }; + best_identity = Some(PromptIdentity { + display_label: resolved_display_label, + accent_color: workspace_entry + .workspace_color() + .themed_hex_color() + .cloned(), + }); + } + Ok(best_identity) + } + + /// Longest-prefix match against every registered + /// repository's resolved path. A match produces a + /// [`PromptIdentity`] built from the repository's title and + /// color fields, with the repository alias as the fallback + /// label. + fn match_repository_path(&self, current_directory: &Path) -> Option { + let mut best_match_length: usize = 0; + let mut best_identity: Option = None; + + for (repository_alias, repository_entry) in self.configuration_registry.repos_with_aliases() + { + let repository_path = repository_entry.resolved_path(); + if !current_directory.starts_with(&repository_path) { + continue; + } + let matched_path_length = repository_path.as_os_str().len(); + if matched_path_length <= best_match_length { + continue; + } + best_match_length = matched_path_length; + let resolved_display_label = match repository_entry.tab_title() { + TabTitle::Custom(custom_display_label) => custom_display_label.clone(), + TabTitle::UseAlias => repository_alias.clone(), + }; + best_identity = Some(PromptIdentity { + display_label: resolved_display_label, + accent_color: repository_entry.repo_color().themed_hex_color().cloned(), + }); + } + best_identity + } +} diff --git a/src/reportal_commands/prompts/color_edit_prompt.rs b/src/reportal_commands/prompts/color_edit_prompt.rs new file mode 100644 index 0000000..bf08b76 --- /dev/null +++ b/src/reportal_commands/prompts/color_edit_prompt.rs @@ -0,0 +1,48 @@ +//! Color edit prompt for modifying an existing repo's terminal background color. + +use crate::error::ReportalError; +use crate::reportal_config::HexColor; +use crate::terminal_style; +use dialoguer::{theme::ColorfulTheme, Input}; + +use super::color_edit_result::ColorEditResult; + +/// Parameters for the color edit prompt that needs the current value as default. +pub struct ColorEditPromptParameters<'a> { + /// The dialoguer theme to style the prompt. + pub prompt_theme: &'a ColorfulTheme, + /// The current color value as a raw hex string (empty if no color set). + pub current_default: &'a str, +} + +/// Prompts for a hex color during repo editing, with the current value +/// as default. Re-asks on invalid input until the user enters a valid +/// `#RRGGBB`, clears it, or keeps the default. +pub fn prompt_for_color_edit( + color_edit_params: &ColorEditPromptParameters<'_>, +) -> Result { + loop { + let color_input: String = Input::with_theme(color_edit_params.prompt_theme) + .with_prompt("Background color (#RRGGBB, empty = none)") + .default(color_edit_params.current_default.to_owned()) + .interact_text() + .map_err(|prompt_error| ReportalError::ConfigIoFailure { + reason: prompt_error.to_string(), + })?; + + if color_input.is_empty() { + return Ok(ColorEditResult::Cleared); + } + let valid_color = match HexColor::parse(&color_input) { + Ok(parsed) => parsed, + Err(color_error) => { + terminal_style::print_error(&color_error.to_string()); + continue; + } + }; + if color_input == color_edit_params.current_default { + return Ok(ColorEditResult::Unchanged(valid_color)); + } + return Ok(ColorEditResult::Provided(valid_color)); + } +} diff --git a/src/reportal_commands/prompts/color_edit_result.rs b/src/reportal_commands/prompts/color_edit_result.rs new file mode 100644 index 0000000..22f64f0 --- /dev/null +++ b/src/reportal_commands/prompts/color_edit_result.rs @@ -0,0 +1,13 @@ +//! Outcome type for the color edit prompt. + +use crate::reportal_config::HexColor; + +/// Whether the user changed, cleared, or kept the color during repo editing. +pub enum ColorEditResult { + /// The user entered a new valid hex color. + Provided(HexColor), + /// The user cleared the color (entered empty). + Cleared, + /// The user kept the existing color unchanged. + Unchanged(HexColor), +} diff --git a/src/reportal_commands/prompts/color_prompt.rs b/src/reportal_commands/prompts/color_prompt.rs new file mode 100644 index 0000000..d717aab --- /dev/null +++ b/src/reportal_commands/prompts/color_prompt.rs @@ -0,0 +1,38 @@ +//! Color input prompt for repo creation (new repos with no existing color). + +use crate::error::ReportalError; +use crate::reportal_config::HexColor; +use crate::terminal_style; +use dialoguer::{theme::ColorfulTheme, Input}; + +/// Whether the user provided a color or left it empty during repo creation. +pub enum ColorPromptResult { + /// The user entered a valid hex color. + Provided(HexColor), + /// The user left the prompt empty (no color). + Skipped, +} + +/// Prompts for a hex color during repo creation, re-asking on invalid input +/// until the user either enters a valid `#RRGGBB` or leaves it empty to skip. +pub fn prompt_for_color(prompt_theme: &ColorfulTheme) -> Result { + loop { + let color_input: String = Input::with_theme(prompt_theme) + .with_prompt("Background color (#RRGGBB, empty = none)") + .default(String::new()) + .interact_text() + .map_err(|prompt_error| ReportalError::ConfigIoFailure { + reason: prompt_error.to_string(), + })?; + + if color_input.is_empty() { + return Ok(ColorPromptResult::Skipped); + } + match HexColor::parse(&color_input) { + Ok(valid_color) => return Ok(ColorPromptResult::Provided(valid_color)), + Err(color_error) => { + terminal_style::print_error(&color_error.to_string()); + } + } + } +} diff --git a/src/reportal_commands/prompts/mod.rs b/src/reportal_commands/prompts/mod.rs new file mode 100644 index 0000000..f606aa0 --- /dev/null +++ b/src/reportal_commands/prompts/mod.rs @@ -0,0 +1,10 @@ +//! Shared interactive prompt helpers for add and edit commands. +mod color_edit_prompt; +mod color_edit_result; +mod color_prompt; +mod text_prompt; + +pub use color_edit_prompt::{prompt_for_color_edit, ColorEditPromptParameters}; +pub use color_edit_result::ColorEditResult; +pub use color_prompt::{prompt_for_color, ColorPromptResult}; +pub use text_prompt::{parse_comma_separated_tags, prompt_for_text, TextPromptParameters}; diff --git a/src/reportal_commands/prompts/text_prompt.rs b/src/reportal_commands/prompts/text_prompt.rs new file mode 100644 index 0000000..a05e8c7 --- /dev/null +++ b/src/reportal_commands/prompts/text_prompt.rs @@ -0,0 +1,44 @@ +//! Text input prompt with a label and default value. + +use crate::error::ReportalError; +use dialoguer::{theme::ColorfulTheme, Input}; + +/// Parameters for a text input prompt with a label and default value. +pub struct TextPromptParameters<'a> { + /// The dialoguer theme to style the prompt. + pub prompt_theme: &'a ColorfulTheme, + /// The label shown to the user (e.g. "Description"). + pub label: &'a str, + /// The default value pre-filled in the prompt (borrowed, copied internally). + pub default_value: &'a str, +} + +/// Prompts the user for a single line of text with a default value. +/// +/// Wraps the dialoguer `Input` widget and maps IO errors into +/// `ReportalError::ConfigIoFailure` so callers can propagate with `?`. +pub fn prompt_for_text( + text_prompt_params: &TextPromptParameters<'_>, +) -> Result { + let entered_text: String = Input::with_theme(text_prompt_params.prompt_theme) + .with_prompt(text_prompt_params.label) + .default(text_prompt_params.default_value.to_owned()) + .interact_text() + .map_err(|prompt_error| ReportalError::ConfigIoFailure { + reason: prompt_error.to_string(), + })?; + + Ok(entered_text) +} + +/// Splits a comma-separated string into trimmed, non-empty tag strings. +/// +/// Empty segments (from double commas or trailing commas) are filtered out. +/// Returns an empty vec if the input is empty. +pub fn parse_comma_separated_tags(raw_input: &str) -> Vec { + raw_input + .split(',') + .map(|tag_segment| tag_segment.trim().to_owned()) + .filter(|trimmed_tag| !trimmed_tag.is_empty()) + .collect() +} diff --git a/src/reportal_commands/repo_add.rs b/src/reportal_commands/repo_add.rs deleted file mode 100644 index 6941adb..0000000 --- a/src/reportal_commands/repo_add.rs +++ /dev/null @@ -1,501 +0,0 @@ -/// Interactively registers a new repo in the RePortal config. -/// Supports both local paths and git URLs (clones first, then registers). - -use crate::error::ReportalError; -use crate::reportal_config::{HexColor, RepoRegistrationBuilder, ReportalConfig}; -use crate::terminal_style; -use dialoguer::{theme::ColorfulTheme, FuzzySelect, Input}; -use owo_colors::OwoColorize; -use std::collections::BTreeSet; -use std::path::{Path, PathBuf}; -use std::process::Command; - -/// Whether a git remote was found in the target directory. -enum GitRemoteDetection { - /// A remote URL was successfully read from git. - Found(String), - /// The directory has no configured origin remote. - NoOriginConfigured, - /// Git command failed to execute (git not installed or not a repo). - GitUnavailable, -} - -/// Whether an alias could be inferred from a path or URL. -enum AliasSuggestion { - /// A name was extracted as a suggested alias. - Inferred(String), - /// No usable name could be extracted. - NoSuggestion, -} - -/// Whether the input is a local path or a git URL to clone. -enum AddSource { - /// A local directory that already exists. - LocalPath(String), - /// A git URL that needs cloning first. - GitUrl(String), -} - -/// Where the user wants to clone a remote repo. -enum CloneDestination { - /// Type a custom absolute path. - CustomPath, - /// Clone as a sibling of an existing registered repo. - SiblingOf(PathBuf), - /// Clone as a child inside an existing registered repo's directory. - ChildOf(PathBuf), -} - -/// Parameters for cloning a git repo into a directory. -struct GitCloneOperation<'a> { - /// The git URL to clone from. - git_url: &'a str, - /// The directory to clone into. - target_directory: &'a Path, -} - -/// All data needed to run the interactive metadata collection and registration. -struct RegistrationContext<'a> { - /// Mutable reference to the loaded config for adding the repo. - loaded_config: &'a mut ReportalConfig, - /// The filesystem path where the repo lives. - filesystem_path: &'a str, - /// A suggested alias to pre-fill the prompt. - suggested_alias: String, - /// A detected or known remote URL to pre-fill the prompt. - detected_remote: String, -} - -/// Whether the user provided a color or left it empty. -enum ColorPromptResult { - /// The user entered a valid hex color. - Provided(HexColor), - /// The user left the prompt empty (no color). - Skipped, -} - -/// Prompts for a hex color, re-asking on invalid input until the user -/// either enters a valid `#RRGGBB` or leaves it empty to skip. -fn prompt_for_color(prompt_theme: &ColorfulTheme) -> Result { - loop { - let color_input: String = Input::with_theme(prompt_theme) - .with_prompt("Background color (#RRGGBB, empty = none)") - .default(String::new()) - .interact_text() - .map_err(|prompt_error| ReportalError::ConfigIoFailure { - reason: prompt_error.to_string(), - })?; - - match color_input.is_empty() { - true => return Ok(ColorPromptResult::Skipped), - false => match HexColor::parse(&color_input) { - Ok(valid_color) => return Ok(ColorPromptResult::Provided(valid_color)), - Err(color_error) => { - terminal_style::print_error(&color_error.to_string()); - } - }, - } - } -} - -/// Detects the git remote URL by running `git remote get-url origin` in the directory. -fn detect_git_remote(directory_path: &str) -> GitRemoteDetection { - let expanded_directory = shellexpand::tilde(directory_path); - let detection_result = Command::new("git") - .args(["remote", "get-url", "origin"]) - .current_dir(expanded_directory.as_ref()) - .output(); - - match detection_result { - Ok(command_output) => match command_output.status.success() { - true => { - let remote_url = String::from_utf8_lossy(&command_output.stdout).trim().to_string(); - match remote_url.is_empty() { - true => GitRemoteDetection::NoOriginConfigured, - false => GitRemoteDetection::Found(remote_url), - } - } - false => GitRemoteDetection::NoOriginConfigured, - }, - Err(git_spawn_error) => { - eprintln!(" git not available: {}", git_spawn_error); - GitRemoteDetection::GitUnavailable - } - } -} - -/// Extracts a suggested alias from the last segment of a filesystem path. -fn suggest_alias_from_path(directory_path: &str) -> AliasSuggestion { - match Path::new(directory_path).file_name() { - Some(folder_name) => AliasSuggestion::Inferred(folder_name.to_string_lossy().to_string()), - None => AliasSuggestion::NoSuggestion, - } -} - -/// Extracts the repo name from a git URL for use as default alias. -fn repo_name_from_git_url(git_url: &str) -> AliasSuggestion { - let trimmed = git_url.trim_end_matches(".git"); - match trimmed.rsplit('/').next() { - Some(repo_name) => match repo_name.is_empty() { - true => match trimmed.rsplit(':').next() { - Some(ssh_path) => match ssh_path.rsplit('/').next() { - Some(ssh_repo_name) => match ssh_repo_name.is_empty() { - true => AliasSuggestion::NoSuggestion, - false => AliasSuggestion::Inferred(ssh_repo_name.to_string()), - }, - None => AliasSuggestion::NoSuggestion, - }, - None => AliasSuggestion::NoSuggestion, - }, - false => AliasSuggestion::Inferred(repo_name.to_string()), - }, - None => AliasSuggestion::NoSuggestion, - } -} - -/// Determines if the input looks like a git URL or a local path. -fn classify_add_source(raw_input: &str) -> AddSource { - if raw_input.starts_with("https://") - || raw_input.starts_with("git@") - || raw_input.starts_with("ssh://") - || raw_input.ends_with(".git") - { - AddSource::GitUrl(raw_input.to_string()) - } else { - AddSource::LocalPath(raw_input.to_string()) - } -} - -/// Collects unique parent directories from all registered repos. -fn collect_registered_parent_directories(loaded_config: &ReportalConfig) -> Vec<(String, PathBuf)> { - let all_repos = loaded_config.repos_matching_tag_filter(&crate::reportal_config::TagFilter::All); - let mut seen_parents: BTreeSet = BTreeSet::new(); - let mut labeled_directories: Vec<(String, PathBuf)> = Vec::new(); - - for (alias, repo) in &all_repos { - let resolved = repo.resolved_path(); - match resolved.parent() { - Some(parent_dir) => { - let parent_string = parent_dir.display().to_string(); - if !seen_parents.contains(&parent_string) { - seen_parents.insert(parent_string); - labeled_directories.push((alias.to_string(), parent_dir.to_path_buf())); - } - } - None => {} - } - } - - labeled_directories -} - -/// Collects registered repo directories that could be parents for a new clone. -fn collect_registered_repo_directories(loaded_config: &ReportalConfig) -> Vec<(String, PathBuf)> { - let all_repos = loaded_config.repos_matching_tag_filter(&crate::reportal_config::TagFilter::All); - all_repos - .iter() - .map(|(alias, repo)| (alias.to_string(), repo.resolved_path())) - .collect() -} - -/// Step 1: Ask the user what kind of placement they want. -/// Step 2: If sibling or child, show them which repo to place relative to. -fn prompt_clone_destination(loaded_config: &ReportalConfig) -> Result { - let prompt_theme = ColorfulTheme::default(); - - let sibling_directories = collect_registered_parent_directories(loaded_config); - let child_directories = collect_registered_repo_directories(loaded_config); - - let mut placement_labels: Vec = vec!["Custom path".to_string()]; - let mut placement_has_sibling = false; - let mut placement_has_child = false; - - if !sibling_directories.is_empty() { - placement_labels.push("Sibling of existing repo".to_string()); - placement_has_sibling = true; - } - if !child_directories.is_empty() { - placement_labels.push("Child of existing repo".to_string()); - placement_has_child = true; - } - - let placement_index = FuzzySelect::with_theme(&prompt_theme) - .with_prompt("How to place this repo?") - .items(&placement_labels) - .interact_opt() - .map_err(|select_error| ReportalError::ConfigIoFailure { - reason: select_error.to_string(), - })?; - - let chosen_placement = match placement_index { - Some(chosen_index) => match placement_labels.get(chosen_index) { - Some(chosen_label) => chosen_label.as_str(), - None => return Err(ReportalError::SelectionCancelled), - }, - None => return Err(ReportalError::SelectionCancelled), - }; - - match chosen_placement { - "Custom path" => Ok(CloneDestination::CustomPath), - "Sibling of existing repo" if placement_has_sibling => { - let sibling_labels: Vec = sibling_directories - .iter() - .map(|(near_alias, parent_path)| { - format!("{} ({})", near_alias, parent_path.display()) - }) - .collect(); - - let sibling_index = FuzzySelect::with_theme(&prompt_theme) - .with_prompt("Sibling of which repo?") - .items(&sibling_labels) - .interact_opt() - .map_err(|select_error| ReportalError::ConfigIoFailure { - reason: select_error.to_string(), - })?; - - match sibling_index { - Some(chosen_index) => match sibling_directories.get(chosen_index) { - Some((_, parent_path)) => Ok(CloneDestination::SiblingOf(parent_path.to_path_buf())), - None => Err(ReportalError::SelectionCancelled), - }, - None => Err(ReportalError::SelectionCancelled), - } - } - "Child of existing repo" if placement_has_child => { - let child_labels: Vec = child_directories - .iter() - .map(|(inside_alias, repo_path)| { - format!("{} ({})", inside_alias, repo_path.display()) - }) - .collect(); - - let child_index = FuzzySelect::with_theme(&prompt_theme) - .with_prompt("Child of which repo?") - .items(&child_labels) - .interact_opt() - .map_err(|select_error| ReportalError::ConfigIoFailure { - reason: select_error.to_string(), - })?; - - match child_index { - Some(chosen_index) => match child_directories.get(chosen_index) { - Some((_, repo_path)) => Ok(CloneDestination::ChildOf(repo_path.to_path_buf())), - None => Err(ReportalError::SelectionCancelled), - }, - None => Err(ReportalError::SelectionCancelled), - } - } - _ => Ok(CloneDestination::CustomPath), - } -} - -/// Clones a git repo into the target directory. -fn clone_repo(clone_operation: GitCloneOperation<'_>) -> Result<(), ReportalError> { - println!( - " {} {}", - "Cloning:".style(terminal_style::LABEL_STYLE), - clone_operation.git_url.style(terminal_style::PATH_STYLE), - ); - - let clone_result = Command::new("git") - .args(["clone", clone_operation.git_url]) - .current_dir(clone_operation.target_directory) - .status(); - - match clone_result { - Ok(exit_status) => match exit_status.success() { - true => Ok(()), - false => Err(ReportalError::ConfigIoFailure { - reason: format!("git clone exited with status {}", exit_status), - }), - }, - Err(git_spawn_error) => Err(ReportalError::ConfigIoFailure { - reason: format!("Failed to run git: {}", git_spawn_error), - }), - } -} - -/// Shared interactive flow for collecting repo metadata and saving to config. -fn collect_metadata_and_register(registration_context: RegistrationContext<'_>) -> Result<(), ReportalError> { - let prompt_theme = ColorfulTheme::default(); - - let repo_alias: String = Input::with_theme(&prompt_theme) - .with_prompt("Alias") - .default(registration_context.suggested_alias) - .interact_text() - .map_err(|prompt_error| ReportalError::ConfigIoFailure { - reason: prompt_error.to_string(), - })?; - - let repo_description: String = Input::with_theme(&prompt_theme) - .with_prompt("Description") - .default(String::new()) - .interact_text() - .map_err(|prompt_error| ReportalError::ConfigIoFailure { - reason: prompt_error.to_string(), - })?; - - let tags_input: String = Input::with_theme(&prompt_theme) - .with_prompt("Tags (comma-separated)") - .default(String::new()) - .interact_text() - .map_err(|prompt_error| ReportalError::ConfigIoFailure { - reason: prompt_error.to_string(), - })?; - - let parsed_tags: Vec = tags_input - .split(',') - .map(|tag_segment| tag_segment.trim().to_string()) - .filter(|trimmed_tag| !trimmed_tag.is_empty()) - .collect(); - - let repo_remote: String = Input::with_theme(&prompt_theme) - .with_prompt("Remote URL") - .default(registration_context.detected_remote) - .interact_text() - .map_err(|prompt_error| ReportalError::ConfigIoFailure { - reason: prompt_error.to_string(), - })?; - - let tab_title: String = Input::with_theme(&prompt_theme) - .with_prompt("Tab title (empty = use alias)") - .default(String::new()) - .interact_text() - .map_err(|prompt_error| ReportalError::ConfigIoFailure { - reason: prompt_error.to_string(), - })?; - - let repo_color = prompt_for_color(&prompt_theme)?; - - println!(); - println!(" {} {}", "Alias:".style(terminal_style::LABEL_STYLE), repo_alias.style(terminal_style::ALIAS_STYLE)); - println!(" {} {}", "Path:".style(terminal_style::LABEL_STYLE), registration_context.filesystem_path.style(terminal_style::PATH_STYLE)); - if !repo_description.is_empty() { - println!(" {} {}", "Desc:".style(terminal_style::LABEL_STYLE), repo_description); - } - if !parsed_tags.is_empty() { - println!(" {} {}", "Tags:".style(terminal_style::LABEL_STYLE), parsed_tags.join(", ").style(terminal_style::TAG_STYLE)); - } - if !repo_remote.is_empty() { - println!(" {} {}", "Remote:".style(terminal_style::LABEL_STYLE), repo_remote.style(terminal_style::PATH_STYLE)); - } - if !tab_title.is_empty() { - println!(" {} {}", "Title:".style(terminal_style::LABEL_STYLE), tab_title.style(terminal_style::ALIAS_STYLE)); - } - match &repo_color { - ColorPromptResult::Provided(hex_color) => { - println!(" {} {}", "Color:".style(terminal_style::LABEL_STYLE), hex_color.raw_value()); - } - ColorPromptResult::Skipped => {} - } - println!(); - - let display_alias = repo_alias.as_str().to_string(); - - let mut builder = RepoRegistrationBuilder::start(repo_alias) - .repo_path(registration_context.filesystem_path.to_string()) - .repo_description(repo_description) - .repo_tags(parsed_tags) - .repo_remote(repo_remote); - - if !tab_title.is_empty() { - builder = builder.repo_title(tab_title); - } - match repo_color { - ColorPromptResult::Provided(hex_color) => { - builder = builder.repo_color(hex_color); - } - ColorPromptResult::Skipped => {} - } - - let validated_registration = builder.build()?; - - registration_context.loaded_config.add_repo(validated_registration)?; - registration_context.loaded_config.save_to_disk()?; - - terminal_style::print_success(&format!("Registered '{}'", display_alias)); - Ok(()) -} - -/// Adds a repo to the config. Accepts local paths or git URLs. -/// -/// For local paths: prompts for metadata and registers. -/// For git URLs: asks where to clone, clones, then prompts for metadata. -pub fn run_add(raw_input: &str) -> Result<(), ReportalError> { - let mut loaded_config = ReportalConfig::load_from_disk()?; - - match classify_add_source(raw_input) { - AddSource::LocalPath(local_path) => { - let suggested_alias = match suggest_alias_from_path(&local_path) { - AliasSuggestion::Inferred(folder_alias) => folder_alias, - AliasSuggestion::NoSuggestion => String::new(), - }; - let detected_remote = match detect_git_remote(&local_path) { - GitRemoteDetection::Found(remote_url) => remote_url, - GitRemoteDetection::NoOriginConfigured => String::new(), - GitRemoteDetection::GitUnavailable => String::new(), - }; - collect_metadata_and_register(RegistrationContext { - loaded_config: &mut loaded_config, - filesystem_path: &local_path, - suggested_alias, - detected_remote, - }) - } - AddSource::GitUrl(git_url) => { - let suggested_alias = match repo_name_from_git_url(&git_url) { - AliasSuggestion::Inferred(repo_name) => repo_name, - AliasSuggestion::NoSuggestion => String::new(), - }; - - println!(); - println!( - " {} {}", - "From:".style(terminal_style::LABEL_STYLE), - git_url.style(terminal_style::PATH_STYLE), - ); - println!(); - - let clone_destination = prompt_clone_destination(&loaded_config)?; - let prompt_theme = ColorfulTheme::default(); - - let clone_parent = match clone_destination { - CloneDestination::CustomPath => { - let custom_path: String = Input::with_theme(&prompt_theme) - .with_prompt("Clone into directory") - .interact_text() - .map_err(|prompt_error| ReportalError::ConfigIoFailure { - reason: prompt_error.to_string(), - })?; - let expanded = shellexpand::tilde(&custom_path); - PathBuf::from(expanded.as_ref()) - } - CloneDestination::SiblingOf(parent_path) => parent_path, - CloneDestination::ChildOf(repo_path) => repo_path, - }; - - if !clone_parent.exists() { - std::fs::create_dir_all(&clone_parent).map_err(|io_error| { - ReportalError::ConfigIoFailure { - reason: io_error.to_string(), - } - })?; - } - - clone_repo(GitCloneOperation { - git_url: &git_url, - target_directory: &clone_parent, - })?; - - let cloned_repo_path = clone_parent.join(&suggested_alias); - let final_path = cloned_repo_path.display().to_string(); - - collect_metadata_and_register(RegistrationContext { - loaded_config: &mut loaded_config, - filesystem_path: &final_path, - suggested_alias, - detected_remote: git_url, - }) - } - } -} diff --git a/src/reportal_commands/repo_add/add_source.rs b/src/reportal_commands/repo_add/add_source.rs new file mode 100644 index 0000000..f162004 --- /dev/null +++ b/src/reportal_commands/repo_add/add_source.rs @@ -0,0 +1,22 @@ +//! Classifies raw user input as a local path or git URL. + +/// Whether the input is a local path or a git URL to clone. +pub enum AddSource { + /// A local directory that already exists. + LocalPath(String), + /// A git URL that needs cloning first. + GitUrl(String), +} + +/// Determines if the input looks like a git URL or a local path. +pub fn classify_add_source(raw_input: &str) -> AddSource { + if raw_input.starts_with("https://") + || raw_input.starts_with("git@") + || raw_input.starts_with("ssh://") + || raw_input.to_ascii_lowercase().ends_with(".git") + { + AddSource::GitUrl(raw_input.to_owned()) + } else { + AddSource::LocalPath(raw_input.to_owned()) + } +} diff --git a/src/reportal_commands/repo_add/alias_suggestion.rs b/src/reportal_commands/repo_add/alias_suggestion.rs new file mode 100644 index 0000000..93fceba --- /dev/null +++ b/src/reportal_commands/repo_add/alias_suggestion.rs @@ -0,0 +1,39 @@ +//! Alias inference from filesystem paths and git URLs. + +use std::path::Path; + +/// Whether an alias could be inferred from a path or URL. +pub enum AliasSuggestion { + /// A name was extracted as a suggested alias. + Inferred(String), + /// No usable name could be extracted. + NoSuggestion, +} + +/// Extracts a suggested alias from the last segment of a filesystem path. +pub fn suggest_alias_from_path(directory_path: &str) -> AliasSuggestion { + Path::new(directory_path) + .file_name() + .map_or(AliasSuggestion::NoSuggestion, |folder_name| { + AliasSuggestion::Inferred(folder_name.to_string_lossy().into_owned()) + }) +} + +/// Extracts the repo name from a git URL for use as default alias. +pub fn repo_name_from_git_url(git_url: &str) -> AliasSuggestion { + let trimmed = git_url.trim_end_matches(".git"); + let repo_name = trimmed.rsplit('/').next().unwrap_or(""); + if !repo_name.is_empty() { + return AliasSuggestion::Inferred(repo_name.to_owned()); + } + let ssh_repo_name = trimmed + .rsplit(':') + .next() + .and_then(|ssh_path| ssh_path.rsplit('/').next()) + .unwrap_or(""); + if ssh_repo_name.is_empty() { + AliasSuggestion::NoSuggestion + } else { + AliasSuggestion::Inferred(ssh_repo_name.to_owned()) + } +} diff --git a/src/reportal_commands/repo_add/clone_destination.rs b/src/reportal_commands/repo_add/clone_destination.rs new file mode 100644 index 0000000..6a0ab76 --- /dev/null +++ b/src/reportal_commands/repo_add/clone_destination.rs @@ -0,0 +1,111 @@ +//! Clone placement strategy — where to put a cloned repo relative to existing repos. + +use crate::error::ReportalError; +use crate::reportal_config::ReportalConfig; +use crate::terminal_style; +use dialoguer::FuzzySelect; +use std::path::PathBuf; + +use super::clone_placement; + +/// Where the user wants to clone a remote repo. +pub enum CloneDestination { + /// Type a custom absolute path. + CustomPath, + /// Clone as a sibling of an existing registered repo. + SiblingOf(PathBuf), + /// Clone as a child inside an existing registered repo's directory. + ChildOf(PathBuf), +} + +/// Asks the user what kind of placement they want, then which repo to place relative to. +pub fn prompt_clone_destination( + loaded_config: &ReportalConfig, +) -> Result { + let prompt_theme = terminal_style::reportal_prompt_theme(); + + let mut sibling_directories = + clone_placement::collect_registered_parent_directories(loaded_config); + let mut child_directories = clone_placement::collect_registered_repo_directories(loaded_config); + + let mut placement_labels: Vec = vec!["Custom path".to_owned()]; + let mut placement_has_sibling = false; + let mut placement_has_child = false; + + if !sibling_directories.is_empty() { + placement_labels.push("Sibling of existing repo".to_owned()); + placement_has_sibling = true; + } + if !child_directories.is_empty() { + placement_labels.push("Child of existing repo".to_owned()); + placement_has_child = true; + } + + let placement_index = FuzzySelect::with_theme(&prompt_theme) + .with_prompt("How to place this repo?") + .items(&placement_labels) + .interact_opt() + .map_err(|select_error| ReportalError::ConfigIoFailure { + reason: select_error.to_string(), + })?; + + let chosen_placement = match placement_index { + Some(chosen_index) => match placement_labels.get(chosen_index) { + Some(chosen_label) => chosen_label.as_str(), + None => return Err(ReportalError::SelectionCancelled), + }, + None => return Err(ReportalError::SelectionCancelled), + }; + + match chosen_placement { + "Sibling of existing repo" if placement_has_sibling => { + let sibling_labels: Vec = sibling_directories + .iter() + .map(|(near_alias, parent_path)| { + format!("{near_alias} ({})", parent_path.display()) + }) + .collect(); + + let sibling_index = FuzzySelect::with_theme(&prompt_theme) + .with_prompt("Sibling of which repo?") + .items(&sibling_labels) + .interact_opt() + .map_err(|select_error| ReportalError::ConfigIoFailure { + reason: select_error.to_string(), + })?; + + match sibling_index { + Some(chosen_index) if chosen_index < sibling_directories.len() => { + let (_, parent_path) = sibling_directories.swap_remove(chosen_index); + Ok(CloneDestination::SiblingOf(parent_path)) + } + _ => Err(ReportalError::SelectionCancelled), + } + } + "Child of existing repo" if placement_has_child => { + let child_labels: Vec = child_directories + .iter() + .map(|(inside_alias, repo_path)| { + format!("{inside_alias} ({})", repo_path.display()) + }) + .collect(); + + let child_index = FuzzySelect::with_theme(&prompt_theme) + .with_prompt("Child of which repo?") + .items(&child_labels) + .interact_opt() + .map_err(|select_error| ReportalError::ConfigIoFailure { + reason: select_error.to_string(), + })?; + + match child_index { + Some(chosen_index) if chosen_index < child_directories.len() => { + let (_, repo_path) = child_directories.swap_remove(chosen_index); + Ok(CloneDestination::ChildOf(repo_path)) + } + _ => Err(ReportalError::SelectionCancelled), + } + } + _ => Ok(CloneDestination::CustomPath), + } +} diff --git a/src/reportal_commands/repo_add/clone_placement.rs b/src/reportal_commands/repo_add/clone_placement.rs new file mode 100644 index 0000000..891af42 --- /dev/null +++ b/src/reportal_commands/repo_add/clone_placement.rs @@ -0,0 +1,40 @@ +//! Collects registered repo directories for clone placement choices. + +use crate::reportal_config::ReportalConfig; +use std::collections::BTreeSet; +use std::path::PathBuf; + +/// Collects unique parent directories from all registered repos. +pub fn collect_registered_parent_directories( + loaded_config: &ReportalConfig, +) -> Vec<(&str, PathBuf)> { + let all_repos = + loaded_config.repos_matching_tag_filter(&crate::reportal_config::TagFilter::All); + let mut seen_parents: BTreeSet = BTreeSet::new(); + let mut labeled_directories: Vec<(&str, PathBuf)> = Vec::new(); + + for (alias, repo) in &all_repos { + let resolved = repo.resolved_path(); + let Some(parent_dir) = resolved.parent() else { + continue; + }; + let parent_string = parent_dir.display().to_string(); + if seen_parents.contains(&parent_string) { + continue; + } + seen_parents.insert(parent_string); + labeled_directories.push((alias, parent_dir.to_path_buf())); + } + + labeled_directories +} + +/// Collects registered repo directories that could be parents for a new clone. +pub fn collect_registered_repo_directories(loaded_config: &ReportalConfig) -> Vec<(&str, PathBuf)> { + let all_repos = + loaded_config.repos_matching_tag_filter(&crate::reportal_config::TagFilter::All); + all_repos + .iter() + .map(|(alias, repo)| (alias.as_str(), repo.resolved_path())) + .collect() +} diff --git a/src/reportal_commands/repo_add/git_clone_operation.rs b/src/reportal_commands/repo_add/git_clone_operation.rs new file mode 100644 index 0000000..84c5e48 --- /dev/null +++ b/src/reportal_commands/repo_add/git_clone_operation.rs @@ -0,0 +1,48 @@ +//! Git clone execution for remote repos. + +use crate::error::ReportalError; +use crate::terminal_style; +use owo_colors::OwoColorize; +use std::path::Path; +use std::process::Command; + +/// Parameters for cloning a git repo into a directory. +pub struct GitCloneOperation<'a> { + /// The git URL to clone from. + pub(super) git_url: &'a str, + /// The directory to clone into. + pub(super) target_directory: &'a Path, +} + +/// Spawns `git clone ` in the target directory. +impl GitCloneOperation<'_> { + /// Spawns `git clone` as a child process, waits for completion, + /// and returns an error if git is unavailable or the clone fails. + pub fn run_git_clone(&self) -> Result<(), ReportalError> { + terminal_style::write_stdout(&format!( + " {} {}\n", + "Cloning:".style(terminal_style::LABEL_STYLE), + self.git_url.style(terminal_style::PATH_STYLE), + )); + + let clone_result = Command::new("git") + .args(["clone", self.git_url]) + .current_dir(self.target_directory) + .status(); + + match clone_result { + Ok(exit_status) => { + if exit_status.success() { + Ok(()) + } else { + Err(ReportalError::ConfigIoFailure { + reason: format!("git clone exited with status {exit_status}"), + }) + } + } + Err(git_spawn_error) => Err(ReportalError::ConfigIoFailure { + reason: format!("Failed to run git: {git_spawn_error}"), + }), + } + } +} diff --git a/src/reportal_commands/repo_add/git_remote_detection.rs b/src/reportal_commands/repo_add/git_remote_detection.rs new file mode 100644 index 0000000..14354c7 --- /dev/null +++ b/src/reportal_commands/repo_add/git_remote_detection.rs @@ -0,0 +1,44 @@ +//! Detects git remote URLs from local repo directories. + +use crate::terminal_style; +use std::process::Command; + +/// Whether a git remote was found in the target directory. +pub enum GitRemoteDetection { + /// A remote URL was successfully read from git. + Found(String), + /// The directory has no configured origin remote. + NoOriginConfigured, + /// Git command failed to execute (git not installed or not a repo). + GitUnavailable, +} + +/// Detects the git remote URL by running `git remote get-url origin` in the directory. +pub fn detect_git_remote(directory_path: &str) -> GitRemoteDetection { + let expanded_directory = shellexpand::tilde(directory_path); + let detection_result = Command::new("git") + .args(["remote", "get-url", "origin"]) + .current_dir(expanded_directory.as_ref()) + .output(); + + match detection_result { + Ok(command_output) => { + if command_output.status.success() { + let remote_url = String::from_utf8_lossy(&command_output.stdout) + .trim() + .to_owned(); + if remote_url.is_empty() { + GitRemoteDetection::NoOriginConfigured + } else { + GitRemoteDetection::Found(remote_url) + } + } else { + GitRemoteDetection::NoOriginConfigured + } + } + Err(git_spawn_error) => { + terminal_style::write_stderr(&format!(" git not available: {git_spawn_error}\n")); + GitRemoteDetection::GitUnavailable + } + } +} diff --git a/src/reportal_commands/repo_add/mod.rs b/src/reportal_commands/repo_add/mod.rs new file mode 100644 index 0000000..caef76c --- /dev/null +++ b/src/reportal_commands/repo_add/mod.rs @@ -0,0 +1,12 @@ +//! Interactively registers a new repo in the `RePortal` config. +//! Supports both local paths and git URLs (clones first, then registers). +mod add_source; +mod alias_suggestion; +mod clone_destination; +mod clone_placement; +mod git_clone_operation; +mod git_remote_detection; +mod registration_context; +mod run; + +pub use run::run_add; diff --git a/src/reportal_commands/repo_add/registration_context.rs b/src/reportal_commands/repo_add/registration_context.rs new file mode 100644 index 0000000..4c159c7 --- /dev/null +++ b/src/reportal_commands/repo_add/registration_context.rs @@ -0,0 +1,140 @@ +//! Interactive metadata collection and repo registration flow. + +use crate::error::ReportalError; +use crate::reportal_commands::prompts::{self, ColorPromptResult, TextPromptParameters}; +use crate::reportal_config::{RepoRegistrationBuilder, ReportalConfig}; +use crate::terminal_style; +use owo_colors::OwoColorize; + +/// All data needed to run the interactive metadata collection and registration. +pub struct RegistrationContext<'a> { + /// Mutable reference to the loaded config for adding the repo. + pub(super) loaded_config: &'a mut ReportalConfig, + /// The filesystem path where the repo lives. + pub(super) filesystem_path: &'a str, + /// A suggested alias to pre-fill the prompt. + pub(super) suggested_alias: String, + /// A detected or known remote URL to pre-fill the prompt. + pub(super) detected_remote: String, +} + +/// Prompts the user for metadata then registers the repo in config. +impl RegistrationContext<'_> { + /// Walks the user through alias, description, tags, remote, title, + /// and color prompts, shows a confirmation summary, then saves + /// the new repo entry to disk. + pub fn collect_metadata_and_register(self) -> Result<(), ReportalError> { + let prompt_theme = terminal_style::reportal_prompt_theme(); + + let repository_alias = prompts::prompt_for_text(&TextPromptParameters { + prompt_theme: &prompt_theme, + label: "Alias", + default_value: &self.suggested_alias, + })?; + + let repo_description = prompts::prompt_for_text(&TextPromptParameters { + prompt_theme: &prompt_theme, + label: "Description", + default_value: "", + })?; + + let tags_input = prompts::prompt_for_text(&TextPromptParameters { + prompt_theme: &prompt_theme, + label: "Tags (comma-separated)", + default_value: "", + })?; + + let parsed_tags = prompts::parse_comma_separated_tags(&tags_input); + + let repo_remote = prompts::prompt_for_text(&TextPromptParameters { + prompt_theme: &prompt_theme, + label: "Remote URL", + default_value: &self.detected_remote, + })?; + + let tab_title = prompts::prompt_for_text(&TextPromptParameters { + prompt_theme: &prompt_theme, + label: "Tab title (empty = use alias)", + default_value: "", + })?; + + let repo_color = prompts::prompt_for_color(&prompt_theme)?; + + terminal_style::write_stdout("\n"); + terminal_style::write_stdout(&format!( + " {} {}\n", + "Alias:".style(terminal_style::LABEL_STYLE), + repository_alias.style(terminal_style::ALIAS_STYLE) + )); + terminal_style::write_stdout(&format!( + " {} {}\n", + "Path:".style(terminal_style::LABEL_STYLE), + self.filesystem_path.style(terminal_style::PATH_STYLE) + )); + if !repo_description.is_empty() { + terminal_style::write_stdout(&format!( + " {} {}\n", + "Desc:".style(terminal_style::LABEL_STYLE), + repo_description + )); + } + if !parsed_tags.is_empty() { + terminal_style::write_stdout(&format!( + " {} {}\n", + "Tags:".style(terminal_style::LABEL_STYLE), + parsed_tags.join(", ").style(terminal_style::TAG_STYLE) + )); + } + if !repo_remote.is_empty() { + terminal_style::write_stdout(&format!( + " {} {}\n", + "Remote:".style(terminal_style::LABEL_STYLE), + repo_remote.style(terminal_style::PATH_STYLE) + )); + } + if !tab_title.is_empty() { + terminal_style::write_stdout(&format!( + " {} {}\n", + "Title:".style(terminal_style::LABEL_STYLE), + tab_title.style(terminal_style::ALIAS_STYLE) + )); + } + match &repo_color { + ColorPromptResult::Provided(hex_color) => { + terminal_style::write_stdout(&format!( + " {} {}\n", + "Color:".style(terminal_style::LABEL_STYLE), + hex_color.raw_value() + )); + } + ColorPromptResult::Skipped => {} + } + terminal_style::write_stdout("\n"); + + let display_alias = repository_alias.as_str().to_owned(); + + let mut builder = RepoRegistrationBuilder::start(repository_alias) + .repo_path(self.filesystem_path.to_owned()) + .repo_description(repo_description) + .repo_tags(parsed_tags) + .repo_remote(repo_remote); + + if !tab_title.is_empty() { + builder = builder.repo_title(tab_title); + } + match repo_color { + ColorPromptResult::Provided(hex_color) => { + builder = builder.repo_color(hex_color); + } + ColorPromptResult::Skipped => {} + } + + let validated_registration = builder.build()?; + + self.loaded_config.add_repo(validated_registration)?; + self.loaded_config.save_to_disk()?; + + terminal_style::print_success(&format!("Registered '{display_alias}'")); + Ok(()) + } +} diff --git a/src/reportal_commands/repo_add/run.rs b/src/reportal_commands/repo_add/run.rs new file mode 100644 index 0000000..00dfb04 --- /dev/null +++ b/src/reportal_commands/repo_add/run.rs @@ -0,0 +1,116 @@ +//! Entry point for the `rep add` command. + +use crate::error::ReportalError; +use crate::reportal_config::ReportalConfig; +use crate::terminal_style; +use dialoguer::Input; +use owo_colors::OwoColorize; +use std::path::PathBuf; + +use super::add_source::{classify_add_source, AddSource}; +use super::alias_suggestion::{repo_name_from_git_url, suggest_alias_from_path, AliasSuggestion}; +use super::clone_destination::{prompt_clone_destination, CloneDestination}; +use super::git_clone_operation::GitCloneOperation; +use super::git_remote_detection::{detect_git_remote, GitRemoteDetection}; +use super::registration_context::RegistrationContext; + +/// Prompts the user for a custom directory path to clone into. +fn prompt_custom_clone_path( + prompt_theme: &dialoguer::theme::ColorfulTheme, +) -> Result { + let custom_path: String = Input::with_theme(prompt_theme) + .with_prompt("Clone into directory") + .interact_text() + .map_err(|prompt_error| ReportalError::ConfigIoFailure { + reason: prompt_error.to_string(), + })?; + let expanded = shellexpand::tilde(&custom_path); + Ok(PathBuf::from(expanded.as_ref())) +} + +/// Registers a local directory as a repo in the config. +fn add_local_repo( + loaded_config: &mut ReportalConfig, + local_path: &str, +) -> Result<(), ReportalError> { + let suggested_alias = match suggest_alias_from_path(local_path) { + AliasSuggestion::Inferred(folder_alias) => folder_alias, + AliasSuggestion::NoSuggestion => String::new(), + }; + let detected_remote = match detect_git_remote(local_path) { + GitRemoteDetection::Found(remote_url) => remote_url, + GitRemoteDetection::NoOriginConfigured | GitRemoteDetection::GitUnavailable => { + String::new() + } + }; + let registration = RegistrationContext { + loaded_config, + filesystem_path: local_path, + suggested_alias, + detected_remote, + }; + registration.collect_metadata_and_register() +} + +/// Clones a git URL to a chosen destination, then registers it. +fn add_remote_repo(loaded_config: &mut ReportalConfig, git_url: &str) -> Result<(), ReportalError> { + let suggested_alias = match repo_name_from_git_url(git_url) { + AliasSuggestion::Inferred(repo_name) => repo_name, + AliasSuggestion::NoSuggestion => String::new(), + }; + + terminal_style::write_stdout("\n"); + terminal_style::write_stdout(&format!( + " {} {}\n", + "From:".style(terminal_style::LABEL_STYLE), + git_url.style(terminal_style::PATH_STYLE), + )); + terminal_style::write_stdout("\n"); + + let clone_destination = prompt_clone_destination(loaded_config)?; + let prompt_theme = terminal_style::reportal_prompt_theme(); + + let clone_parent = match clone_destination { + CloneDestination::SiblingOf(parent_path) => parent_path, + CloneDestination::ChildOf(repo_path) => repo_path, + CloneDestination::CustomPath => prompt_custom_clone_path(&prompt_theme)?, + }; + + if !clone_parent.exists() { + std::fs::create_dir_all(&clone_parent).map_err(|io_error| { + ReportalError::ConfigIoFailure { + reason: io_error.to_string(), + } + })?; + } + + let clone_operation = GitCloneOperation { + git_url, + target_directory: &clone_parent, + }; + clone_operation.run_git_clone()?; + + let cloned_repo_path = clone_parent.join(&suggested_alias); + let final_repo_path = cloned_repo_path.display().to_string(); + + let registration = RegistrationContext { + loaded_config, + filesystem_path: &final_repo_path, + suggested_alias, + detected_remote: git_url.to_owned(), + }; + registration.collect_metadata_and_register() +} + +/// Adds a repo to the config. Accepts local paths or git URLs. +/// +/// For local paths: detects remote and alias, prompts for metadata, registers. +/// For git URLs: asks where to clone, clones, then prompts for metadata. +pub fn run_add(raw_input: &str) -> Result<(), ReportalError> { + let mut loaded_config = ReportalConfig::load_or_initialize()?; + + match classify_add_source(raw_input) { + AddSource::LocalPath(local_path) => add_local_repo(&mut loaded_config, &local_path), + AddSource::GitUrl(git_url) => add_remote_repo(&mut loaded_config, &git_url), + } +} diff --git a/src/reportal_commands/repo_ai.rs b/src/reportal_commands/repo_ai.rs new file mode 100644 index 0000000..729037c --- /dev/null +++ b/src/reportal_commands/repo_ai.rs @@ -0,0 +1,103 @@ +//! Fuzzy-selects a repo and launches an AI coding CLI in it. + +use crate::error::ReportalError; +use crate::reportal_commands::repo_selection::{self, SelectedRepoParameters}; +use crate::reportal_commands::terminal_identity_emit::{self, TerminalIdentityEmitParameters}; +use crate::reportal_config::{ReportalConfig, TagFilter}; +use crate::terminal_style; +use owo_colors::OwoColorize; +use std::process::Command; + +/// All parameters needed to run the ai command. +pub struct AiCommandParameters<'a> { + /// Which repos to show in the fuzzy finder. + pub tag_filter: TagFilter, + /// If non-empty, skip the fuzzy finder and use this alias directly. + pub direct_alias: &'a str, + /// If non-empty, override the default AI tool. + pub tool_override: &'a str, +} + +/// Launches an AI coding CLI in the selected repo's directory. +/// +/// Resolves the repo (fuzzy select or direct alias), resolves the AI tool +/// (--tool flag or config default), applies tab theming, then spawns the +/// AI CLI with stdin/stdout/stderr inherited for interactive passthrough. +pub fn run_ai(ai_params: &AiCommandParameters<'_>) -> Result<(), ReportalError> { + let loaded_config = ReportalConfig::load_or_initialize()?; + + let ai_cli_entries = loaded_config.ai_cli_registry(); + if ai_cli_entries.is_empty() { + return Err(ReportalError::NoAiToolsConfigured); + } + + let tool_name = if ai_params.tool_override.is_empty() { + let default_tool = loaded_config.default_ai_tool(); + if default_tool.is_empty() { + return Err(ReportalError::NoDefaultAiTool); + } + default_tool + } else { + ai_params.tool_override + }; + let ai_tool = loaded_config.get_ai_tool(tool_name)?; + + let prompt_label = format!("Launch {tool_name} in"); + let selection_params = SelectedRepoParameters { + loaded_config: &loaded_config, + direct_alias: ai_params.direct_alias, + tag_filter: &ai_params.tag_filter, + prompt_label: &prompt_label, + }; + let selected = repo_selection::select_repo(&selection_params)?; + + terminal_identity_emit::emit_repo_terminal_identity(&TerminalIdentityEmitParameters { + selected_alias: selected.repository_alias(), + selected_repo: selected.repo_config(), + title_override: "", + }); + + let resolved_repo_path = selected.repo_config().resolved_path(); + + terminal_style::print_success(&format!( + "Launching {} in {}", + tool_name.style(terminal_style::ALIAS_STYLE), + loaded_config + .path_display_format() + .format_path(&resolved_repo_path) + .style(terminal_style::PATH_STYLE), + )); + + #[cfg(target_os = "windows")] + let mut spawned_process = Command::new("cmd") + .args(["/c", ai_tool.cli_command()]) + .args(ai_tool.launch_args()) + .current_dir(&resolved_repo_path) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .spawn() + .map_err(|spawn_error| ReportalError::AiToolLaunchFailure { + reason: format!("{}: {spawn_error}", ai_tool.cli_command()), + })?; + + #[cfg(not(target_os = "windows"))] + let mut spawned_process = Command::new(ai_tool.cli_command()) + .args(ai_tool.launch_args()) + .current_dir(&resolved_repo_path) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .spawn() + .map_err(|spawn_error| ReportalError::AiToolLaunchFailure { + reason: format!("{}: {spawn_error}", ai_tool.cli_command()), + })?; + + spawned_process + .wait() + .map_err(|wait_error| ReportalError::AiToolLaunchFailure { + reason: format!("process exited unexpectedly: {wait_error}"), + })?; + + Ok(()) +} diff --git a/src/reportal_commands/repo_color.rs b/src/reportal_commands/repo_color.rs deleted file mode 100644 index ba7ff92..0000000 --- a/src/reportal_commands/repo_color.rs +++ /dev/null @@ -1,103 +0,0 @@ -/// Emits OSC terminal personalization sequences for the current or specified repo. -/// -/// Used as a shell prompt hook so terminals that open directly into a repo -/// (e.g. VS Code integrated terminal) get the right tab title and background -/// color without going through `rj`. - -use crate::error::ReportalError; -use crate::reportal_config::{RepoColor, ReportalConfig, TabTitle}; -use crate::terminal_style::{self, BackgroundAction, TerminalIdentity, TerminalIdentityParams}; - -/// Parameters for the color subcommand. -pub struct ColorCommandParams<'a> { - /// If non-empty, look up this alias directly instead of matching PWD. - pub repo_alias: &'a str, -} - -/// Emits OSC sequences for a repo's terminal identity. -/// -/// If `repo_alias` is provided, looks up that repo directly. -/// Otherwise, matches the current working directory against all -/// registered repo paths using longest-prefix-match. If no repo -/// matches, emits a reset sequence to restore the terminal default. -pub fn run_color(color_params: ColorCommandParams<'_>) -> Result<(), ReportalError> { - let loaded_config = ReportalConfig::load_from_disk()?; - - match color_params.repo_alias.is_empty() { - false => { - let found_repo = loaded_config.get_repo(color_params.repo_alias)?; - let resolved_title = match found_repo.tab_title() { - TabTitle::Custom(custom_title) => custom_title.to_string(), - TabTitle::UseAlias => color_params.repo_alias.to_string(), - }; - let background_action = match found_repo.repo_color() { - RepoColor::Themed(hex_color) => { - BackgroundAction::SetColor(hex_color.as_osc_background_sequence()) - } - RepoColor::ResetToDefault => BackgroundAction::Reset, - }; - let identity = TerminalIdentity::new(TerminalIdentityParams { - resolved_title, - background_action, - }); - terminal_style::emit_terminal_identity_to_stdout(&identity); - } - true => { - let current_directory = std::env::current_dir().map_err(|io_error| { - ReportalError::ConfigIoFailure { - reason: io_error.to_string(), - } - })?; - - let all_repos = loaded_config.repos_with_aliases(); - let mut best_match_length: usize = 0; - let mut best_alias: &str = ""; - let mut best_repo: std::option::Option<&crate::reportal_config::RepoEntry> = None; - - for (alias, repo) in &all_repos { - let repo_path = repo.resolved_path(); - match current_directory.starts_with(&repo_path) { - true => { - let path_length = repo_path.as_os_str().len(); - match path_length > best_match_length { - true => { - best_match_length = path_length; - best_alias = alias.as_str(); - best_repo = Some(repo); - } - false => {} - } - } - false => {} - } - } - - match best_repo { - Some(matched_repo) => { - let resolved_title = match matched_repo.tab_title() { - TabTitle::Custom(custom_title) => custom_title.to_string(), - TabTitle::UseAlias => best_alias.to_string(), - }; - let background_action = match matched_repo.repo_color() { - RepoColor::Themed(hex_color) => { - BackgroundAction::SetColor(hex_color.as_osc_background_sequence()) - } - RepoColor::ResetToDefault => BackgroundAction::Reset, - }; - let identity = TerminalIdentity::new(TerminalIdentityParams { - resolved_title, - background_action, - }); - terminal_style::emit_terminal_identity_to_stdout(&identity); - } - None => { - if std::io::IsTerminal::is_terminal(&std::io::stdout()) { - print!("{}", terminal_style::osc_reset_background_sequence()); - } - } - } - } - } - - return Ok(()); -} diff --git a/src/reportal_commands/repo_edit.rs b/src/reportal_commands/repo_edit.rs index d5d716e..cbeee80 100644 --- a/src/reportal_commands/repo_edit.rs +++ b/src/reportal_commands/repo_edit.rs @@ -1,131 +1,107 @@ -//! Interactively edits an existing repo's metadata in the config. +//! Interactively edits an existing repo's metadata via a field menu. use crate::error::ReportalError; -use crate::reportal_config::{HexColor, RepoColor, ReportalConfig, TabTitle}; +use crate::reportal_commands::repo_edit_mutator::RepoEditFieldMutator; +use crate::reportal_commands::repo_selection::{self, SelectedRepoParameters}; +use crate::reportal_config::{RepoColor, ReportalConfig, TabTitle, TagFilter}; use crate::terminal_style; -use dialoguer::{theme::ColorfulTheme, Input}; +use dialoguer::Select; use owo_colors::OwoColorize; -/// Whether the user provided a color or left it empty. -enum ColorEditResult { - /// The user entered a valid hex color. - Provided(HexColor), - /// The user cleared the color (entered empty). - Cleared, - /// The user kept the existing color unchanged. - Unchanged(HexColor), +/// All parameters needed to run the edit command. +pub struct EditCommandParameters<'a> { + /// Which repos to show in the fuzzy finder. + pub tag_filter: TagFilter, + /// If non-empty, skip the fuzzy finder and edit this alias directly. + pub direct_alias: &'a str, } -/// Prompts for a hex color with the current value as default, -/// re-asking on invalid input until the user enters a valid -/// `#RRGGBB`, clears it, or keeps the default. -fn prompt_for_color_edit(prompt_theme: &ColorfulTheme, current_default: &str) -> Result { - loop { - let color_input: String = Input::with_theme(prompt_theme) - .with_prompt("Background color (#RRGGBB, empty = none)") - .default(current_default.to_string()) - .interact_text() - .map_err(|prompt_error| ReportalError::ConfigIoFailure { - reason: prompt_error.to_string(), - })?; - - match color_input.is_empty() { - true => return Ok(ColorEditResult::Cleared), - false => match HexColor::parse(&color_input) { - Ok(valid_color) => { - match color_input.eq(current_default) { - true => return Ok(ColorEditResult::Unchanged(valid_color)), - false => return Ok(ColorEditResult::Provided(valid_color)), - } - } - Err(color_error) => { - terminal_style::print_error(&color_error.to_string()); - } - }, - } - } -} - -/// Interactively edits a registered repo's description, tags, title, -/// and color. Pre-fills each prompt with the current value so the user -/// can press enter to keep it or type a new value to change it. -pub fn run_edit(alias: &str) -> Result<(), ReportalError> { - let mut loaded_config = ReportalConfig::load_from_disk()?; - let existing_repo = loaded_config.get_repo(alias)?; +/// Fuzzy-selects a repo then presents a looping field menu for editing +/// individual fields. Each edit saves to disk immediately and refreshes +/// the menu labels. The user exits by choosing "Done" or pressing Escape. +/// +/// The `Path` menu item delegates to +/// `RepoEditFieldMutator::apply_path_edit`, which regenerates every +/// `.code-workspace` file that references the edited repo so +/// Design B's invariant — moving a repo updates every workspace +/// containing it — holds. +/// +/// # Errors +/// +/// Returns whatever the underlying selection, prompt, config I/O, +/// validation, or workspace regeneration call surfaces. +pub fn run_edit(command_params: &EditCommandParameters<'_>) -> Result<(), ReportalError> { + let mut loaded_config = ReportalConfig::load_or_initialize()?; + + let selected_alias = { + let selection_params = SelectedRepoParameters { + loaded_config: &loaded_config, + direct_alias: command_params.direct_alias, + tag_filter: &command_params.tag_filter, + prompt_label: "Edit repo", + }; + let selection = repo_selection::select_repo(&selection_params)?; + selection.repository_alias().to_owned() + }; - let current_description = existing_repo.description().to_string(); - let current_tags = existing_repo.tags().join(", "); - let current_title = match existing_repo.tab_title() { - TabTitle::Custom(custom_title) => custom_title.to_string(), + let initial_repo = loaded_config.get_repo(&selected_alias)?; + let initial_raw_path = initial_repo.raw_path().to_owned(); + let initial_description = initial_repo.description().to_owned(); + let initial_tags_csv = initial_repo.tags().join(", "); + let initial_title = match initial_repo.tab_title() { + TabTitle::Custom(custom_title) => custom_title.to_owned(), TabTitle::UseAlias => String::new(), }; - let current_color = match existing_repo.repo_color() { - RepoColor::Themed(hex_color) => hex_color.raw_value().to_string(), + let initial_color_hex = match initial_repo.repo_color() { + RepoColor::Themed(hex_color) => hex_color.raw_value().to_owned(), RepoColor::ResetToDefault => String::new(), }; - println!(); - println!( - " {} {}", + terminal_style::write_stdout("\n"); + terminal_style::write_stdout(&format!( + " {} {}\n", "Editing:".style(terminal_style::LABEL_STYLE), - alias.style(terminal_style::ALIAS_STYLE), - ); - println!( - " {} {}", + selected_alias.style(terminal_style::ALIAS_STYLE), + )); + terminal_style::write_stdout(&format!( + " {} {}\n", "Path:".style(terminal_style::LABEL_STYLE), - existing_repo.raw_path().style(terminal_style::PATH_STYLE), - ); - println!(); - - let prompt_theme = ColorfulTheme::default(); - - let new_description: String = Input::with_theme(&prompt_theme) - .with_prompt("Description") - .default(current_description) - .interact_text() - .map_err(|prompt_error| ReportalError::ConfigIoFailure { - reason: prompt_error.to_string(), - })?; - - let tags_input: String = Input::with_theme(&prompt_theme) - .with_prompt("Tags (comma-separated)") - .default(current_tags) - .interact_text() - .map_err(|prompt_error| ReportalError::ConfigIoFailure { - reason: prompt_error.to_string(), - })?; - - let new_tags: Vec = tags_input - .split(',') - .map(|tag_segment| tag_segment.trim().to_string()) - .filter(|trimmed_tag| !trimmed_tag.is_empty()) - .collect(); - - let new_title: String = Input::with_theme(&prompt_theme) - .with_prompt("Tab title (empty = use alias)") - .default(current_title) - .interact_text() - .map_err(|prompt_error| ReportalError::ConfigIoFailure { - reason: prompt_error.to_string(), - })?; - - let color_result = prompt_for_color_edit(&prompt_theme, ¤t_color)?; - - let resolved_title = match new_title.is_empty() { - true => TabTitle::UseAlias, - false => TabTitle::Custom(new_title), - }; - - let resolved_color = match color_result { - ColorEditResult::Provided(hex_color) => RepoColor::Themed(hex_color), - ColorEditResult::Unchanged(hex_color) => RepoColor::Themed(hex_color), - ColorEditResult::Cleared => RepoColor::ResetToDefault, + initial_raw_path.style(terminal_style::PATH_STYLE), + )); + + let prompt_theme = terminal_style::reportal_prompt_theme(); + let mut field_mutator = RepoEditFieldMutator { + loaded_config: &mut loaded_config, + selected_alias: &selected_alias, + prompt_theme: &prompt_theme, + current_raw_path: initial_raw_path, + current_description: initial_description, + current_tags_csv: initial_tags_csv, + current_title: initial_title, + current_color_hex: initial_color_hex, }; - loaded_config.update_repo_metadata(alias, new_description, new_tags, resolved_title, resolved_color)?; - loaded_config.save_to_disk()?; - - terminal_style::print_success(&format!("Updated '{alias}'")); + loop { + terminal_style::write_stdout("\n"); + let menu_labels = field_mutator.build_menu_labels(); + let chosen_index = Select::with_theme(&prompt_theme) + .with_prompt("Pick a field to edit") + .items(&menu_labels) + .default(0) + .interact_opt() + .map_err(|select_error| ReportalError::ConfigIoFailure { + reason: select_error.to_string(), + })?; + match chosen_index { + None | Some(5..) => break, + Some(0) => field_mutator.apply_path_edit()?, + Some(1) => field_mutator.apply_description_edit()?, + Some(2) => field_mutator.apply_tags_edit()?, + Some(3) => field_mutator.apply_title_edit()?, + Some(4) => field_mutator.apply_color_edit()?, + } + terminal_style::print_success("Saved"); + } - return Ok(()); + Ok(()) } diff --git a/src/reportal_commands/repo_edit_mutator.rs b/src/reportal_commands/repo_edit_mutator.rs new file mode 100644 index 0000000..f568757 --- /dev/null +++ b/src/reportal_commands/repo_edit_mutator.rs @@ -0,0 +1,268 @@ +//! Field-edit state and methods for `rep edit`, grouped on a +//! context struct so the command file stays within its +//! free-function budget and each mutation helper takes exactly +//! one non-`self` argument. + +use crate::error::ReportalError; +use crate::reportal_commands::prompts::{ + self, ColorEditPromptParameters, ColorEditResult, TextPromptParameters, +}; +use crate::reportal_commands::workspace_operations::WorkspaceRegenerator; +use crate::reportal_config::{RepoColor, ReportalConfig, TabTitle}; +use dialoguer::theme::ColorfulTheme; +use std::path::PathBuf; + +/// Live edit-session state for `rep edit`. Owns the current +/// display strings for every editable field so the command loop +/// can rebuild menu labels without re-querying the config between +/// iterations, and exposes one mutation method per field so the +/// command loop's match arm is a one-liner per field. The struct +/// is constructed directly via struct literal from `repo_edit.rs` +/// using the `pub(super)` field visibility, because the project's +/// argument rules forbid a multi-parameter constructor and a +/// one-step struct literal is not a function call so it sidesteps +/// that rule correctly. +pub struct RepoEditFieldMutator<'config, 'alias, 'theme> { + /// The loaded config being mutated in-place. + pub(super) loaded_config: &'config mut ReportalConfig, + /// The primary key of the repo currently being edited. + pub(super) selected_alias: &'alias str, + /// Prompt theme shared by every interactive field edit. + pub(super) prompt_theme: &'theme ColorfulTheme, + /// Current raw path string for menu display and prompts. + pub(super) current_raw_path: String, + /// Current description text for menu display and prompts. + pub(super) current_description: String, + /// Current tag list rendered as comma-separated display text. + pub(super) current_tags_csv: String, + /// Current tab title text (empty means fall back to alias). + pub(super) current_title: String, + /// Current hex color text (empty means reset-to-default). + pub(super) current_color_hex: String, +} + +/// Field-mutation and menu-label methods for `RepoEditFieldMutator`. +impl RepoEditFieldMutator<'_, '_, '_> { + /// Builds the menu label list for the edit loop in the same + /// order the match arms in `repo_edit.rs` dispatch on, so + /// label-index drift between the menu render and the mutation + /// dispatch is impossible — both sides are driven from this + /// single ordered list. + #[must_use] + pub fn build_menu_labels(&self) -> Vec { + let path_label = format!("Path: {}", truncate_for_menu(&self.current_raw_path)); + let description_label = format!( + "Description: {}", + truncate_for_menu(&self.current_description) + ); + let tags_label = if self.current_tags_csv.is_empty() { + "Tags: (none)".to_owned() + } else { + format!("Tags: {}", truncate_for_menu(&self.current_tags_csv)) + }; + let title_label = if self.current_title.is_empty() { + "Title: (use alias)".to_owned() + } else { + format!("Title: {}", self.current_title) + }; + let color_label = if self.current_color_hex.is_empty() { + "Color: (none)".to_owned() + } else { + format!("Color: {}", self.current_color_hex) + }; + vec![ + path_label, + description_label, + tags_label, + title_label, + color_label, + "Done".to_owned(), + ] + } + + /// Prompts for a new filesystem path, validates it exists, + /// writes it to config, and regenerates every `.code-workspace` + /// file that references the repo. The regeneration call is + /// the Design B invariant in action: without it, a repo move + /// leaves every workspace containing the repo pointing at the + /// old location, and editor sessions open against a stale + /// path. Regeneration runs after the config save so the + /// `WorkspaceRegenerator` reads the post-mutation repo path, + /// which is what the generated `folders` array must reflect. + /// Errors: returns `ValidationFailure` if the prompt yields an + /// empty or non-existent path, `RepoNotFound` if the alias has + /// been removed from under the edit session, or any config / + /// file I/O error from save and regenerate. + pub fn apply_path_edit(&mut self) -> Result<(), ReportalError> { + let new_raw_path = prompts::prompt_for_text(&TextPromptParameters { + prompt_theme: self.prompt_theme, + label: "Path", + default_value: &self.current_raw_path, + })?; + if new_raw_path.trim().is_empty() { + return Err(ReportalError::ValidationFailure { + field: "path".to_owned(), + reason: "must not be empty".to_owned(), + }); + } + let expanded_new_path = PathBuf::from(shellexpand::tilde(&new_raw_path).as_ref()); + if !expanded_new_path.exists() { + return Err(ReportalError::ValidationFailure { + field: "path".to_owned(), + reason: format!("{} does not exist", expanded_new_path.display()), + }); + } + let repo_to_update = self.loaded_config.get_repo_mut(self.selected_alias)?; + repo_to_update.set_raw_path(new_raw_path); + self.loaded_config.save_to_disk()?; + self.current_raw_path = self + .loaded_config + .get_repo(self.selected_alias)? + .raw_path() + .to_owned(); + self.regenerate_affected_workspaces()?; + Ok(()) + } + + /// Prompts the user for a new human-readable description and + /// writes it to the repo entry in config, then refreshes the + /// cached display string so the menu labels rebuild against + /// the post-save state on the next iteration. Errors: returns + /// whichever prompt, repo-lookup, or config I/O error the + /// underlying calls surface. + pub fn apply_description_edit(&mut self) -> Result<(), ReportalError> { + let new_description = prompts::prompt_for_text(&TextPromptParameters { + prompt_theme: self.prompt_theme, + label: "Description", + default_value: &self.current_description, + })?; + let repo_to_update = self.loaded_config.get_repo_mut(self.selected_alias)?; + repo_to_update.set_description(new_description); + self.loaded_config.save_to_disk()?; + self.current_description = self + .loaded_config + .get_repo(self.selected_alias)? + .description() + .to_owned(); + Ok(()) + } + + /// Prompts for a comma-separated tag list, parses it into an + /// ordered `Vec`, and replaces the repo's existing tag + /// list wholesale. Replacing rather than merging is correct + /// because it gives the user a single predictable editing + /// surface — what you type is what you get — without a hidden + /// merge rule. Errors: returns whichever prompt or config I/O + /// error the underlying calls surface. + pub fn apply_tags_edit(&mut self) -> Result<(), ReportalError> { + let tags_input = prompts::prompt_for_text(&TextPromptParameters { + prompt_theme: self.prompt_theme, + label: "Tags (comma-separated)", + default_value: &self.current_tags_csv, + })?; + let new_tags = prompts::parse_comma_separated_tags(&tags_input); + let repo_to_update = self.loaded_config.get_repo_mut(self.selected_alias)?; + repo_to_update.set_tags(new_tags); + self.loaded_config.save_to_disk()?; + self.current_tags_csv = self + .loaded_config + .get_repo(self.selected_alias)? + .tags() + .join(", "); + Ok(()) + } + + /// Prompts for a new tab title, stores an empty entry as + /// `TabTitle::UseAlias` (the "fall back to alias" variant) and + /// a non-empty entry as `TabTitle::Custom`, then refreshes the + /// cached display string. Errors: returns whichever prompt or + /// config I/O error the underlying calls surface. + pub fn apply_title_edit(&mut self) -> Result<(), ReportalError> { + let new_title = prompts::prompt_for_text(&TextPromptParameters { + prompt_theme: self.prompt_theme, + label: "Tab title (empty = use alias)", + default_value: &self.current_title, + })?; + let resolved_title = if new_title.is_empty() { + TabTitle::UseAlias + } else { + TabTitle::Custom(new_title) + }; + let repo_to_update = self.loaded_config.get_repo_mut(self.selected_alias)?; + repo_to_update.set_tab_title(resolved_title); + self.loaded_config.save_to_disk()?; + self.current_title = match self + .loaded_config + .get_repo(self.selected_alias)? + .tab_title() + { + TabTitle::Custom(custom_title) => custom_title.to_owned(), + TabTitle::UseAlias => String::new(), + }; + Ok(()) + } + + /// Prompts for a color edit via the shared color-edit prompt, + /// maps `Provided`/`Unchanged` outcomes to `RepoColor::Themed` + /// and `Cleared` to `RepoColor::ResetToDefault`, then writes + /// the result to config. Errors: returns whichever prompt or + /// config I/O error the underlying calls surface. + pub fn apply_color_edit(&mut self) -> Result<(), ReportalError> { + let color_edit_result = prompts::prompt_for_color_edit(&ColorEditPromptParameters { + prompt_theme: self.prompt_theme, + current_default: &self.current_color_hex, + })?; + let resolved_color = match color_edit_result { + ColorEditResult::Provided(hex_color) | ColorEditResult::Unchanged(hex_color) => { + RepoColor::Themed(hex_color) + } + ColorEditResult::Cleared => RepoColor::ResetToDefault, + }; + let repo_to_update = self.loaded_config.get_repo_mut(self.selected_alias)?; + repo_to_update.set_repo_color(resolved_color); + self.loaded_config.save_to_disk()?; + self.current_color_hex = match self + .loaded_config + .get_repo(self.selected_alias)? + .repo_color() + { + RepoColor::Themed(hex_color) => hex_color.raw_value().to_owned(), + RepoColor::ResetToDefault => String::new(), + }; + Ok(()) + } + + /// Regenerates every `.code-workspace` file whose workspace + /// contains the currently-edited repo. Called from + /// `apply_path_edit` because a repo move is the only field + /// mutation that changes the paths embedded inside + /// `.code-workspace` files. Returning the regeneration error + /// rather than swallowing it is correct because a failure + /// after a successful config save leaves disk state + /// inconsistent with config state, which the user must see. + fn regenerate_affected_workspaces(&self) -> Result<(), ReportalError> { + let affected_workspaces = self + .loaded_config + .workspaces_containing_repo(self.selected_alias); + if affected_workspaces.is_empty() { + return Ok(()); + } + let regenerator = WorkspaceRegenerator::for_config(self.loaded_config); + for (workspace_name, _containing_entry) in affected_workspaces { + regenerator.regenerate_workspace_file(workspace_name)?; + } + Ok(()) + } +} + +/// Truncates a string to a reasonable menu-display length, +/// returning the first 50 characters if the input exceeds that +/// limit so long paths or descriptions do not blow out the menu +/// row height of the interactive select widget. +fn truncate_for_menu(display_text: &str) -> &str { + if display_text.len() > 50 { + &display_text[..50] + } else { + display_text + } +} diff --git a/src/reportal_commands/repo_jump.rs b/src/reportal_commands/repo_jump.rs index 552dee3..dc96414 100644 --- a/src/reportal_commands/repo_jump.rs +++ b/src/reportal_commands/repo_jump.rs @@ -1,13 +1,16 @@ -/// Fuzzy-selects a repo and prints its path for shell `cd` integration. +//! Fuzzy-selects a repo and prints its path for shell `cd` integration. use crate::error::ReportalError; -use crate::reportal_config::{PathVisibility, RepoColor, ReportalConfig, TabTitle, TagFilter}; -use crate::terminal_style::{self, BackgroundAction, TerminalIdentity, TerminalIdentityParams}; -use dialoguer::{theme::ColorfulTheme, FuzzySelect}; -use owo_colors::OwoColorize; +use crate::reportal_commands::direct_alias_router::{DirectAliasRouter, DirectAliasRouterOutcome}; +use crate::reportal_commands::path_display::{self, SelectedPathDisplayParameters}; +use crate::reportal_commands::repo_selection::{self, SelectedRepoParameters}; +use crate::reportal_commands::target_selection::{self, SelectedTarget, SelectedTargetParameters}; +use crate::reportal_commands::terminal_identity_emit::{self, TerminalIdentityEmitParameters}; +use crate::reportal_config::{ReportalConfig, TagFilter}; +use crate::terminal_style; /// All parameters needed to run the jump command. -pub struct JumpCommandParams<'a> { +pub struct JumpCommandParameters<'a> { /// Which repos to show in the fuzzy finder. pub tag_filter: TagFilter, /// If non-empty, skip the fuzzy finder and jump directly. @@ -19,92 +22,81 @@ pub struct JumpCommandParams<'a> { /// Prints the selected repo's resolved path to stdout; the shell /// wrapper function (`rj`) reads this and runs `cd`. /// -/// If a direct alias is given, skips the fuzzy finder entirely. -/// The raw path always goes to stdout for the shell function; -/// an optional styled confirmation goes to stderr based on config. -/// Also emits OSC escape sequences for tab title and background color. -pub fn run_jump(jump_params: JumpCommandParams<'_>) -> Result<(), ReportalError> { - let loaded_config = ReportalConfig::load_from_disk()?; +/// If a direct alias is given, repo resolution is tried first. When +/// that fails and the alias matches a registered workspace, falls +/// through to the workspace's `.code-workspace` file parent directory +/// so `rj venoble` for a workspace-only setup cd's to the common +/// ancestor folder instead of erroring out. Unknown aliases still +/// surface as `RepoNotFound` because that is the user-facing name +/// the shell wrapper promises. +/// +/// The raw path always goes to stdout for the shell function; an +/// optional styled confirmation goes to stderr based on config. OSC +/// tab/color escape sequences are emitted only for repo jumps — +/// workspace jumps skip them because a workspace has no single +/// per-repo identity to apply. +/// +/// # Errors +/// +/// Returns [`ReportalError::RepoNotFound`] when the direct alias +/// resolves to neither a repo nor a workspace, or any error the +/// repo-selection or workspace-regenerator paths surface. +pub fn run_jump(jump_params: &JumpCommandParameters<'_>) -> Result<(), ReportalError> { + let loaded_config = ReportalConfig::load_or_initialize()?; - let (selected_alias, selected_repo): (&str, &crate::reportal_config::RepoEntry) = - match jump_params.direct_alias.is_empty() { - false => { - let found_repo = loaded_config.get_repo(jump_params.direct_alias)?; - (jump_params.direct_alias, found_repo) + let resolved_repo_alias: String = if jump_params.direct_alias.is_empty() { + let target_params = SelectedTargetParameters { + loaded_config: &loaded_config, + tag_filter: &jump_params.tag_filter, + prompt_label: "Jump to repo or workspace", + }; + match target_selection::select_target(&target_params)? { + SelectedTarget::Repo(chosen_repo_alias) => chosen_repo_alias, + SelectedTarget::Workspace(canonical_workspace_name) => { + let router = DirectAliasRouter::for_config(&loaded_config); + return router.jump_to_workspace_parent(&canonical_workspace_name); } - true => { - let matching_repos = - loaded_config.repos_matching_tag_filter(&jump_params.tag_filter); - - if matching_repos.is_empty() { - return Err(ReportalError::NoReposMatchFilter); - } - - let display_labels: Vec = matching_repos - .iter() - .map(|(alias, repo)| { - let description_suffix = match repo.description().is_empty() { - true => String::new(), - false => format!(" - {}", repo.description()), - }; - return format!("{}{}", alias, description_suffix); - }) - .collect(); - - let selected_index = FuzzySelect::with_theme(&ColorfulTheme::default()) - .with_prompt("Jump to repo") - .items(&display_labels) - .interact_opt() - .map_err(|select_error| ReportalError::ConfigIoFailure { - reason: select_error.to_string(), - })?; - - match selected_index { - Some(chosen_index) => match matching_repos.get(chosen_index) { - Some((chosen_alias, chosen_repo)) => (chosen_alias.as_str(), *chosen_repo), - None => return Err(ReportalError::SelectionCancelled), - }, - None => return Err(ReportalError::SelectionCancelled), - } + } + } else { + let router = DirectAliasRouter::for_config(&loaded_config); + match router.classify(jump_params.direct_alias)? { + DirectAliasRouterOutcome::RegisteredRepo => jump_params.direct_alias.to_owned(), + DirectAliasRouterOutcome::Workspace(canonical_workspace_name) => { + return router.jump_to_workspace_parent(&canonical_workspace_name); } - }; - - let resolved_title = match jump_params.title_override.is_empty() { - false => jump_params.title_override.to_string(), - true => match selected_repo.tab_title() { - TabTitle::Custom(custom_title) => custom_title.to_string(), - TabTitle::UseAlias => selected_alias.to_string(), - }, + DirectAliasRouterOutcome::Unknown => { + return Err(ReportalError::RepoNotFound { + alias: jump_params.direct_alias.to_owned(), + }); + } + } }; - let background_action = match selected_repo.repo_color() { - RepoColor::Themed(hex_color) => { - BackgroundAction::SetColor(hex_color.as_osc_background_sequence()) - } - RepoColor::ResetToDefault => BackgroundAction::Reset, + let selection_params = SelectedRepoParameters { + loaded_config: &loaded_config, + direct_alias: &resolved_repo_alias, + tag_filter: &jump_params.tag_filter, + prompt_label: "Jump to repo", }; + let selected = repo_selection::select_repo(&selection_params)?; - let identity = TerminalIdentity::new(TerminalIdentityParams { - resolved_title, - background_action, + terminal_identity_emit::emit_repo_terminal_identity(&TerminalIdentityEmitParameters { + selected_alias: selected.repository_alias(), + selected_repo: selected.repo_config(), + title_override: jump_params.title_override, }); - terminal_style::emit_terminal_identity_to_stderr(&identity); - let resolved_path = selected_repo.resolved_path(); - let formatted_path = loaded_config.path_display_format().format_path(&resolved_path); + let resolved_path = selected.repo_config().resolved_path(); + let formatted_path = loaded_config + .path_display_format() + .format_path(&resolved_path); - print!("{formatted_path}"); + terminal_style::write_stdout(&formatted_path.clone()); - match loaded_config.path_on_select() { - PathVisibility::Show => { - eprintln!( - " {} {}", - ">>".style(terminal_style::SUCCESS_STYLE), - formatted_path.style(terminal_style::PATH_STYLE), - ); - } - PathVisibility::Hide => {} - } + path_display::print_selected_path_if_visible(&SelectedPathDisplayParameters { + loaded_config: &loaded_config, + resolved_path: &resolved_path, + }); - return Ok(()); + Ok(()) } diff --git a/src/reportal_commands/repo_listing.rs b/src/reportal_commands/repo_listing.rs index e6f39d1..8feb5c7 100644 --- a/src/reportal_commands/repo_listing.rs +++ b/src/reportal_commands/repo_listing.rs @@ -1,83 +1,38 @@ -/// Lists all registered repos with their status, description, and tags. +//! Lists registered repos grouped by workspace membership. +use crate::cli_args::ListArgumentsFilterParts; use crate::error::ReportalError; -use crate::reportal_config::{ReportalConfig, TagFilter}; -use crate::terminal_style; -use owo_colors::OwoColorize; +use crate::reportal_commands::repo_listing_renderer::RepoListingRenderer; +use crate::reportal_commands::repo_tree_grouping::{RepoTreeGrouping, RepoTreeGroupingParameters}; +use crate::reportal_config::ReportalConfig; -/// Prints a formatted list of all repos matching the given tag filter. +/// Prints a workspace-grouped listing of registered repos. /// -/// Each repo shows its alias as a bold header, followed by labeled -/// path, description, tags, and whether the directory exists on disk. -pub fn run_list(tag_filter: TagFilter) -> Result<(), ReportalError> { - let loaded_config = ReportalConfig::load_from_disk()?; - let matching_repos = loaded_config.repos_matching_tag_filter(&tag_filter); - - if matching_repos.is_empty() { - match tag_filter { - TagFilter::ByTag(target_tag) => println!("No repos found with tag '{target_tag}'"), - TagFilter::All => println!("No repos registered. Use 'reportal add ' to add one."), - } - return Ok(()); - } - - println!(); - println!( - " {}", - "RePortal".style(terminal_style::EMPHASIS_STYLE) - ); - println!(); - - for (alias, repo) in &matching_repos { - let directory_exists = repo.resolved_path().exists(); - - let uppercase_alias = alias.to_uppercase(); - println!( - " {} {}", - "██".style(terminal_style::ALIAS_STYLE), - uppercase_alias.style(terminal_style::ALIAS_STYLE), - ); - - println!( - " {} {}", - "Path:".style(terminal_style::LABEL_STYLE), - repo.raw_path().style(terminal_style::PATH_STYLE), - ); - - if !repo.description().is_empty() { - println!( - " {} {}", - "Desc:".style(terminal_style::LABEL_STYLE), - repo.description(), - ); - } - - if !repo.tags().is_empty() { - let formatted_tags = repo.tags().join(", "); - println!( - " {} {}", - "Tags:".style(terminal_style::LABEL_STYLE), - formatted_tags.style(terminal_style::TAG_STYLE), - ); - } - - let found_label = match directory_exists { - true => "yes".style(terminal_style::SUCCESS_STYLE).to_string(), - false => "no".style(terminal_style::FAILURE_STYLE).to_string(), - }; - println!( - " {} {}", - "Found:".style(terminal_style::LABEL_STYLE), - found_label, - ); - - println!(); - } - - println!( - " {} repos total", - matching_repos.len().style(terminal_style::EMPHASIS_STYLE), - ); - println!(); - Ok(()) +/// Workspaces become the tree root because they answer "what am +/// I working on" (an explicit user grouping), while tags answer +/// "what kind of repo is this" (a classification). The two axes +/// are orthogonal and compose as an AND intersection when both +/// filters are specified. +/// +/// Repos that belong to zero workspaces land in a synthetic +/// "(unassigned)" section at the bottom so no repo is hidden +/// from the listing. Multi-workspace repos render under every +/// containing workspace section because hiding multi-membership +/// would silently suppress user-declared relationships. +/// +/// # Errors +/// +/// Returns [`ReportalError::WorkspaceNotFound`] when the +/// workspace filter names an unknown workspace (a silent empty +/// output would mask typos), or config I/O errors from the +/// underlying load path. +pub fn run_list(filter_parts: &ListArgumentsFilterParts) -> Result<(), ReportalError> { + let loaded_config = ReportalConfig::load_or_initialize()?; + let tree_grouping = RepoTreeGrouping::build(&RepoTreeGroupingParameters { + loaded_config: &loaded_config, + tag_filter: filter_parts.tag_filter(), + workspace_filter: filter_parts.workspace_filter(), + })?; + let listing_renderer = RepoListingRenderer::for_tree(&tree_grouping); + listing_renderer.render(filter_parts) } diff --git a/src/reportal_commands/repo_listing_renderer.rs b/src/reportal_commands/repo_listing_renderer.rs new file mode 100644 index 0000000..27ae54b --- /dev/null +++ b/src/reportal_commands/repo_listing_renderer.rs @@ -0,0 +1,186 @@ +//! Rendering surface for the workspace-grouped `rep list` output. +//! +//! Grouping logic lives in `repo_tree_grouping.rs`; this file +//! owns the terminal output for an already-built tree. Splitting +//! the two responsibilities keeps the grouping unit-testable +//! without a terminal, and keeps the rendering free to change +//! its output format without touching the tree-building rules. + +use crate::cli_args::ListArgumentsFilterParts; +use crate::error::ReportalError; +use crate::reportal_commands::repo_tree_grouping::RepoTreeGrouping; +use crate::reportal_commands::repo_tree_workspace_section::WorkspaceSection; +use crate::reportal_config::{RepoEntry, TagFilter, WorkspaceFilter}; +use crate::terminal_style; +use owo_colors::OwoColorize; + +/// Renders a built `RepoTreeGrouping` to stdout in the +/// workspace-grouped layout. +/// +/// Holds a borrowed reference to the tree so rendering and tree +/// building are separable: tests can build trees without a +/// terminal, and the render format can evolve without altering +/// the invariants encoded in the tree-building pass. +pub struct RepoListingRenderer<'tree> { + /// The grouping to render; borrowed so ownership stays with + /// the caller and the renderer can be dropped as soon as the + /// render pass completes. + tree_grouping: &'tree RepoTreeGrouping<'tree>, +} + +/// Construction and rendering methods for `RepoListingRenderer`. +impl<'tree> RepoListingRenderer<'tree> { + /// Builds a renderer for the given tree. The tree must be + /// fully constructed before this call because the renderer + /// only reads from it. + #[must_use] + pub fn for_tree(tree_grouping: &'tree RepoTreeGrouping<'tree>) -> Self { + Self { tree_grouping } + } + + /// Renders the tree to stdout, including the empty-state + /// message when the tree has no sections and no unassigned + /// rows. The filter parts are consulted only for the + /// empty-state message so the user sees which filters are + /// active when the output is empty. + /// + /// # Errors + /// + /// Returns any error surfaced by the per-repo color swatch + /// resolution path. + pub fn render(&self, filter_parts: &ListArgumentsFilterParts) -> Result<(), ReportalError> { + if self.tree_grouping.is_empty() { + Self::render_empty_state(filter_parts); + return Ok(()); + } + + terminal_style::write_stdout("\n"); + terminal_style::write_stdout(&format!( + " {}\n\n", + "RePortal".style(terminal_style::EMPHASIS_STYLE), + )); + + for workspace_section in self.tree_grouping.workspace_sections() { + Self::render_workspace_section(workspace_section)?; + } + + if !self.tree_grouping.unassigned_repos().is_empty() { + terminal_style::write_stdout(&format!( + " {}\n\n", + "(unassigned)".style(terminal_style::EMPHASIS_STYLE), + )); + for repo_pair in self.tree_grouping.unassigned_repos() { + Self::render_repo_leaf(repo_pair)?; + } + } + + terminal_style::write_stdout(&format!( + " {} repos total\n\n", + self.tree_grouping + .distinct_repo_count() + .style(terminal_style::EMPHASIS_STYLE), + )); + Ok(()) + } + + /// Renders an empty-state message describing which filters + /// are active so the user can tell whether the config is + /// empty or the current filter combination rejects every + /// repo. Takes the filter parts directly (no `self`) because + /// the message content is derived entirely from the filters, + /// not from the tree the renderer wraps. + fn render_empty_state(filter_parts: &ListArgumentsFilterParts) { + let empty_state_message = match (filter_parts.tag_filter(), filter_parts.workspace_filter()) + { + (TagFilter::All, WorkspaceFilter::All) => { + "No repos registered. Use 'reportal add ' to add one.".to_owned() + } + (TagFilter::ByTag(target_tag), WorkspaceFilter::All) => { + format!("No repos found with tag '{target_tag}'") + } + (TagFilter::All, WorkspaceFilter::ByName(target_workspace)) => { + format!("No repos found in workspace '{target_workspace}'") + } + (TagFilter::ByTag(target_tag), WorkspaceFilter::ByName(target_workspace)) => { + format!("No repos found in workspace '{target_workspace}' with tag '{target_tag}'") + } + }; + terminal_style::write_stdout(&format!("{empty_state_message}\n")); + } + + /// Renders one workspace section header, description, and + /// the indented member rows. Takes the section as its only + /// parameter because the render is a pure transformation of + /// the section's own data. + fn render_workspace_section( + workspace_section: &WorkspaceSection<'_>, + ) -> Result<(), ReportalError> { + let uppercase_name = workspace_section.workspace_name().to_uppercase(); + terminal_style::write_stdout(&format!( + " {}\n", + uppercase_name.style(terminal_style::EMPHASIS_STYLE), + )); + let section_description = workspace_section.workspace_entry().description(); + if !section_description.is_empty() { + terminal_style::write_stdout(&format!( + " {}\n", + section_description.style(terminal_style::TAG_STYLE), + )); + } + terminal_style::write_stdout("\n"); + for repo_pair in workspace_section.member_repos() { + Self::render_repo_leaf(repo_pair)?; + } + Ok(()) + } + + /// Renders one repo row at leaf indentation level with its + /// alias, color swatch, path, description, tags, and + /// directory-exists marker. Takes the full + /// `(alias, entry)` tuple as one parameter because the + /// project's argument rules cap non-`self` parameters at one + /// per method. + fn render_repo_leaf(repo_pair: &(&str, &RepoEntry)) -> Result<(), ReportalError> { + let (repository_alias, repo_entry) = *repo_pair; + let directory_exists = repo_entry.resolved_path().exists(); + let swatch_style = terminal_style::swatch_style_for_repo_color(repo_entry.repo_color())?; + let uppercase_alias = repository_alias.to_uppercase(); + terminal_style::write_stdout(&format!( + " {} {}\n", + "██".style(swatch_style), + uppercase_alias.style(terminal_style::ALIAS_STYLE), + )); + terminal_style::write_stdout(&format!( + " {} {}\n", + "Path:".style(terminal_style::LABEL_STYLE), + repo_entry.raw_path().style(terminal_style::PATH_STYLE), + )); + if !repo_entry.description().is_empty() { + terminal_style::write_stdout(&format!( + " {} {}\n", + "Desc:".style(terminal_style::LABEL_STYLE), + repo_entry.description(), + )); + } + if !repo_entry.tags().is_empty() { + let formatted_tags = repo_entry.tags().join(", "); + terminal_style::write_stdout(&format!( + " {} {}\n", + "Tags:".style(terminal_style::LABEL_STYLE), + formatted_tags.style(terminal_style::TAG_STYLE), + )); + } + let found_label = if directory_exists { + "yes".style(terminal_style::SUCCESS_STYLE).to_string() + } else { + "no".style(terminal_style::FAILURE_STYLE).to_string() + }; + terminal_style::write_stdout(&format!( + " {} {}\n", + "Found:".style(terminal_style::LABEL_STYLE), + found_label, + )); + terminal_style::write_stdout("\n"); + Ok(()) + } +} diff --git a/src/reportal_commands/repo_open.rs b/src/reportal_commands/repo_open.rs index 97f6209..6248400 100644 --- a/src/reportal_commands/repo_open.rs +++ b/src/reportal_commands/repo_open.rs @@ -1,14 +1,18 @@ -/// Fuzzy-selects a repo and opens it in the configured editor. +//! Fuzzy-selects a repo and opens it in the configured editor. use crate::error::ReportalError; -use crate::reportal_config::{PathVisibility, RepoColor, ReportalConfig, TabTitle, TagFilter}; -use crate::terminal_style::{self, BackgroundAction, TerminalIdentity, TerminalIdentityParams}; -use dialoguer::{theme::ColorfulTheme, FuzzySelect}; +use crate::reportal_commands::direct_alias_router::{DirectAliasRouter, DirectAliasRouterOutcome}; +use crate::reportal_commands::repo_selection::{self, SelectedRepoParameters}; +use crate::reportal_commands::run_workspace_open; +use crate::reportal_commands::target_selection::{self, SelectedTarget, SelectedTargetParameters}; +use crate::reportal_commands::terminal_identity_emit::{self, TerminalIdentityEmitParameters}; +use crate::reportal_config::{PathVisibility, ReportalConfig, TagFilter}; +use crate::terminal_style; use owo_colors::OwoColorize; use std::process::Command; /// All parameters needed to run the open command. -pub struct OpenCommandParams<'a> { +pub struct OpenCommandParameters<'a> { /// Which repos to show in the fuzzy finder. pub tag_filter: TagFilter, /// If non-empty, skip the fuzzy finder and open this alias directly. @@ -19,84 +23,75 @@ pub struct OpenCommandParams<'a> { pub title_override: &'a str, } -/// Opens a repo in the configured editor (default: cursor). +/// Opens a repo — or a workspace — in the configured editor. /// -/// If `direct_alias` is provided, opens that repo directly without -/// prompting. Otherwise, presents a fuzzy finder for interactive selection. -/// The editor is launched by `cd`-ing into the repo directory first, -/// then running ` .` so the editor opens the folder correctly. -/// Also emits OSC escape sequences for tab title and background color. -pub fn run_open(open_params: OpenCommandParams<'_>) -> Result<(), ReportalError> { - let loaded_config = ReportalConfig::load_from_disk()?; - - let (selected_alias, selected_repo): (&str, &crate::reportal_config::RepoEntry) = - match open_params.direct_alias.is_empty() { - false => { - let found_repo = loaded_config.get_repo(open_params.direct_alias)?; - (open_params.direct_alias, found_repo) +/// If `direct_alias` is provided, repo resolution is tried first; +/// when no repo matches and the alias resolves to a registered +/// workspace, the workspace-open flow runs instead and launches +/// the editor against the `.code-workspace` file. Unknown aliases +/// surface as `RepoNotFound` so the error names the user-facing +/// concept the shell wrapper advertises. +/// +/// Without a direct alias, the fuzzy finder stays repo-only — use +/// `rep workspace list` / `row` for workspace selection. +/// +/// # Errors +/// +/// Returns [`ReportalError::RepoNotFound`] when the direct alias +/// resolves to neither a repo nor a workspace, +/// [`ReportalError::EditorLaunchFailure`] if the editor process +/// cannot be spawned, or any config / file I/O errors the +/// underlying paths surface. +pub fn run_open(open_params: &OpenCommandParameters<'_>) -> Result<(), ReportalError> { + let loaded_config = ReportalConfig::load_or_initialize()?; + + let resolved_repo_alias: String = if open_params.direct_alias.is_empty() { + let target_params = SelectedTargetParameters { + loaded_config: &loaded_config, + tag_filter: &open_params.tag_filter, + prompt_label: "Open repo or workspace", + }; + match target_selection::select_target(&target_params)? { + SelectedTarget::Repo(chosen_repo_alias) => chosen_repo_alias, + SelectedTarget::Workspace(canonical_workspace_name) => { + return run_workspace_open(&canonical_workspace_name); } - true => { - let matching_repos = - loaded_config.repos_matching_tag_filter(&open_params.tag_filter); - if matching_repos.is_empty() { - return Err(ReportalError::NoReposMatchFilter); - } - - let display_labels: Vec = matching_repos - .iter() - .map(|(alias, repo)| { - let description_suffix = match repo.description().is_empty() { - true => String::new(), - false => format!(" - {}", repo.description()), - }; - format!("{}{}", alias, description_suffix) - }) - .collect(); - - let selected_index = FuzzySelect::with_theme(&ColorfulTheme::default()) - .with_prompt("Open in editor") - .items(&display_labels) - .interact_opt() - .map_err(|select_error| ReportalError::ConfigIoFailure { - reason: select_error.to_string(), - })?; - - match selected_index { - Some(chosen_index) => match matching_repos.get(chosen_index) { - Some((chosen_alias, chosen_repo)) => (chosen_alias.as_str(), *chosen_repo), - None => return Err(ReportalError::SelectionCancelled), - }, - None => return Err(ReportalError::SelectionCancelled), - } + } + } else { + let router = DirectAliasRouter::for_config(&loaded_config); + match router.classify(open_params.direct_alias)? { + DirectAliasRouterOutcome::RegisteredRepo => open_params.direct_alias.to_owned(), + DirectAliasRouterOutcome::Workspace(canonical_workspace_name) => { + return run_workspace_open(&canonical_workspace_name); } - }; - - let resolved_title = match open_params.title_override.is_empty() { - false => open_params.title_override.to_string(), - true => match selected_repo.tab_title() { - TabTitle::Custom(custom_title) => custom_title.to_string(), - TabTitle::UseAlias => selected_alias.to_string(), - }, + DirectAliasRouterOutcome::Unknown => { + return Err(ReportalError::RepoNotFound { + alias: open_params.direct_alias.to_owned(), + }); + } + } }; - let background_action = match selected_repo.repo_color() { - RepoColor::Themed(hex_color) => { - BackgroundAction::SetColor(hex_color.as_osc_background_sequence()) - } - RepoColor::ResetToDefault => BackgroundAction::Reset, + let selection_params = SelectedRepoParameters { + loaded_config: &loaded_config, + direct_alias: &resolved_repo_alias, + tag_filter: &open_params.tag_filter, + prompt_label: "Open in editor", }; + let selected = repo_selection::select_repo(&selection_params)?; - let identity = TerminalIdentity::new(TerminalIdentityParams { - resolved_title, - background_action, + terminal_identity_emit::emit_repo_terminal_identity(&TerminalIdentityEmitParameters { + selected_alias: selected.repository_alias(), + selected_repo: selected.repo_config(), + title_override: open_params.title_override, }); - terminal_style::emit_terminal_identity_to_stderr(&identity); - let resolved_repo_path = selected_repo.resolved_path(); + let resolved_repo_path = selected.repo_config().resolved_path(); - let editor_command = match open_params.editor_override.is_empty() { - true => loaded_config.default_editor(), - false => open_params.editor_override, + let editor_command = if open_params.editor_override.is_empty() { + loaded_config.default_editor() + } else { + open_params.editor_override }; #[cfg(target_os = "windows")] @@ -117,8 +112,9 @@ pub fn run_open(open_params: OpenCommandParams<'_>) -> Result<(), ReportalError> match loaded_config.path_on_select() { PathVisibility::Show => { - let formatted_path = - loaded_config.path_display_format().format_path(&resolved_repo_path); + let formatted_path = loaded_config + .path_display_format() + .format_path(&resolved_repo_path); terminal_style::print_success(&format!( "Opened {} in {}", formatted_path.style(terminal_style::PATH_STYLE), @@ -128,5 +124,5 @@ pub fn run_open(open_params: OpenCommandParams<'_>) -> Result<(), ReportalError> PathVisibility::Hide => {} } - return Ok(()); + Ok(()) } diff --git a/src/reportal_commands/repo_remove.rs b/src/reportal_commands/repo_remove.rs index f1a9fe8..ee39871 100644 --- a/src/reportal_commands/repo_remove.rs +++ b/src/reportal_commands/repo_remove.rs @@ -1,17 +1,49 @@ -/// Removes a registered repo from the RePortal config by alias. +//! Removes a registered repo from the `RePortal` config by alias. use crate::error::ReportalError; use crate::reportal_config::ReportalConfig; use crate::terminal_style; -/// Unregisters a repo by its alias. Does not delete any files on disk. +/// Unregisters a repo by its primary alias key. Does not delete +/// any files on disk. Refuses the operation with +/// [`ReportalError::RepoIsWorkspaceMember`] when the repo is still +/// listed as a member of one or more workspaces, because silently +/// stripping the repo from every containing workspace would destroy +/// user-declared membership and silently cascade into either an +/// empty-workspace invariant violation or a hidden workspace +/// delete. Forcing the user to either `rep workspace remove-repo` +/// or `rep workspace delete` first keeps destructive changes +/// explicit. /// -/// Loads the config, removes the entry, and saves back to disk. -/// Prints confirmation with the path that was unregistered. -pub fn run_remove(repo_alias: &str) -> Result<(), ReportalError> { - let mut loaded_config = ReportalConfig::load_from_disk()?; - let removed_entry = loaded_config.remove_repo(repo_alias)?; +/// # Errors +/// +/// Returns [`ReportalError::RepoNotFound`] if the alias is not a +/// registered primary key, +/// [`ReportalError::RepoIsWorkspaceMember`] if the repo is still a +/// member of any workspace, or the config I/O errors that the load +/// and save paths surface. +pub fn run_remove(repository_alias: &str) -> Result<(), ReportalError> { + let mut loaded_config = ReportalConfig::load_or_initialize()?; + + let containing_workspaces = loaded_config.workspaces_containing_repo(repository_alias); + if !containing_workspaces.is_empty() { + let joined_workspace_names = containing_workspaces + .iter() + .map(|(workspace_name, _entry)| workspace_name.as_str()) + .collect::>() + .join(", "); + return Err(ReportalError::RepoIsWorkspaceMember { + alias: repository_alias.to_owned(), + affected_workspaces: joined_workspace_names, + }); + } + + let removed_entry = loaded_config.remove_repo(repository_alias)?; loaded_config.save_to_disk()?; - terminal_style::print_success(&format!("Removed '{}' ({})", repo_alias, removed_entry.raw_path())); + terminal_style::print_success(&format!( + "Removed '{}' ({})", + repository_alias, + removed_entry.raw_path() + )); Ok(()) } diff --git a/src/reportal_commands/repo_run.rs b/src/reportal_commands/repo_run.rs new file mode 100644 index 0000000..decd010 --- /dev/null +++ b/src/reportal_commands/repo_run.rs @@ -0,0 +1,235 @@ +//! Fuzzy-selects a repo and runs a user-defined command in it. + +use crate::error::ReportalError; +use crate::reportal_commands::repo_selection::{self, SelectedRepoParameters}; +use crate::reportal_commands::terminal_identity_emit::{self, TerminalIdentityEmitParameters}; +use crate::reportal_config::{CommandEntry, ReportalConfig, TagFilter}; +use crate::terminal_style; +use dialoguer::FuzzySelect; +use owo_colors::OwoColorize; +use std::collections::BTreeMap; +use std::process::Command; + +/// All parameters needed to run the run command. +pub struct RunCommandParameters<'a> { + /// Which repos to show in the fuzzy finder. + pub tag_filter: TagFilter, + /// If non-empty, skip the repo fuzzy finder and use this alias directly. + pub direct_alias: &'a str, + /// If non-empty, skip the command fuzzy finder and run this command directly. + pub direct_command: &'a str, +} + +/// A resolved command ready to execute: its name and the shell string. +struct ResolvedCommand { + command_name: String, + shell_command: String, +} + +/// A merged command entry combining global definition with optional repo override. +struct MergedCommandEntry<'a> { + name: &'a str, + shell_command: &'a str, + description: &'a str, +} + +/// Parameters for resolving which command to run. +struct ResolveCommandParameters<'a> { + /// Global commands from config. + global_commands: &'a BTreeMap, + /// Per-repo commands (override globals with same name, or add repo-specific ones). + repo_commands: &'a BTreeMap, + /// If non-empty, skip the fuzzy finder and use this command directly. + direct_command: &'a str, +} + +/// Merges global commands with repo-level commands, then resolves +/// which command to run (direct or fuzzy-selected). +/// +/// Repo-level commands override global commands with the same name. +fn resolve_command( + resolve_params: &ResolveCommandParameters<'_>, +) -> Result { + let mut merged: Vec> = Vec::new(); + + for (name, entry) in resolve_params.global_commands { + match resolve_params.repo_commands.get(name) { + Some(repo_override) => { + merged.push(MergedCommandEntry { + name, + shell_command: repo_override.shell_command(), + description: repo_override.description(), + }); + } + None => { + merged.push(MergedCommandEntry { + name, + shell_command: entry.shell_command(), + description: entry.description(), + }); + } + } + } + + for (name, entry) in resolve_params.repo_commands { + let already_merged = resolve_params.global_commands.contains_key(name); + if !already_merged { + merged.push(MergedCommandEntry { + name, + shell_command: entry.shell_command(), + description: entry.description(), + }); + } + } + + if merged.is_empty() { + return Err(ReportalError::NoCommandsAvailable); + } + + if resolve_params.direct_command.is_empty() { + let display_labels: Vec = merged + .iter() + .map(|entry| { + let mut label = entry.name.to_owned(); + let suffix = if entry.description.is_empty() { + entry.shell_command + } else { + entry.description + }; + label.push_str(" — "); + label.push_str(suffix); + label + }) + .collect(); + + let selected_index = FuzzySelect::with_theme(&terminal_style::reportal_prompt_theme()) + .with_prompt("Run command") + .items(&display_labels) + .interact_opt() + .map_err(|select_error| ReportalError::ConfigIoFailure { + reason: select_error.to_string(), + })?; + + let Some(chosen_index) = selected_index else { + return Err(ReportalError::SelectionCancelled); + }; + merged + .get(chosen_index) + .map_or(Err(ReportalError::SelectionCancelled), |entry| { + Ok(ResolvedCommand { + command_name: entry.name.to_owned(), + shell_command: entry.shell_command.to_owned(), + }) + }) + } else { + merged + .iter() + .find(|entry| entry.name == resolve_params.direct_command) + .map_or_else( + || { + Err(ReportalError::CommandNotFound { + command_name: resolve_params.direct_command.to_owned(), + }) + }, + |entry| { + Ok(ResolvedCommand { + command_name: entry.name.to_owned(), + shell_command: entry.shell_command.to_owned(), + }) + }, + ) + } +} + +/// Selects a repo, then selects a command, then runs it in the repo directory. +/// +/// Global commands from `[commands.*]` are merged with per-repo commands +/// from `[repos..commands]`. Repo-level commands override globals +/// with the same name. The command is spawned via the system shell with +/// inherited stdio for interactive passthrough. +pub fn run_run(run_params: &RunCommandParameters<'_>) -> Result<(), ReportalError> { + let loaded_config = ReportalConfig::load_or_initialize()?; + + let selection_params = SelectedRepoParameters { + loaded_config: &loaded_config, + direct_alias: run_params.direct_alias, + tag_filter: &run_params.tag_filter, + prompt_label: "Run command in", + }; + let selected = repo_selection::select_repo(&selection_params)?; + + terminal_identity_emit::emit_repo_terminal_identity(&TerminalIdentityEmitParameters { + selected_alias: selected.repository_alias(), + selected_repo: selected.repo_config(), + title_override: "", + }); + + let resolved_command = resolve_command(&ResolveCommandParameters { + global_commands: loaded_config.global_commands(), + repo_commands: selected.repo_config().repo_commands(), + direct_command: run_params.direct_command, + })?; + + let resolved_repo_path = selected.repo_config().resolved_path(); + + terminal_style::print_success(&format!( + "Running {} in {}", + resolved_command + .command_name + .style(terminal_style::ALIAS_STYLE), + loaded_config + .path_display_format() + .format_path(&resolved_repo_path) + .style(terminal_style::PATH_STYLE), + )); + + #[cfg(target_os = "windows")] + let mut spawned_process = Command::new("cmd") + .args(["/c", &resolved_command.shell_command]) + .current_dir(&resolved_repo_path) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .spawn() + .map_err(|spawn_error| ReportalError::CommandLaunchFailure { + reason: format!("{}: {spawn_error}", resolved_command.shell_command), + })?; + + #[cfg(not(target_os = "windows"))] + let mut spawned_process = Command::new("sh") + .args(["-c", &resolved_command.shell_command]) + .current_dir(&resolved_repo_path) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .spawn() + .map_err(|spawn_error| ReportalError::CommandLaunchFailure { + reason: format!("{}: {spawn_error}", resolved_command.shell_command), + })?; + + let exit_status = + spawned_process + .wait() + .map_err(|wait_error| ReportalError::CommandLaunchFailure { + reason: format!("process exited unexpectedly: {wait_error}"), + })?; + + if !exit_status.success() { + match exit_status.code() { + Some(exit_code) => { + terminal_style::print_error(&format!( + "Command '{}' exited with code {}", + resolved_command.command_name, exit_code + )); + } + None => { + terminal_style::print_error(&format!( + "Command '{}' was terminated by signal", + resolved_command.command_name + )); + } + } + } + + Ok(()) +} diff --git a/src/reportal_commands/repo_selection.rs b/src/reportal_commands/repo_selection.rs new file mode 100644 index 0000000..b98990b --- /dev/null +++ b/src/reportal_commands/repo_selection.rs @@ -0,0 +1,139 @@ +//! Shared repo fuzzy-selection helper used across jump, open, ai, edit, and web. + +use crate::error::ReportalError; +use crate::reportal_config::{HasAliases, RepoEntry, ReportalConfig, TagFilter}; +use crate::terminal_style; +use dialoguer::FuzzySelect; +use owo_colors::OwoColorize; +use std::fmt::Write; + +/// A repo that was resolved either by direct alias lookup or fuzzy selection. +/// Holds borrowed references into the loaded config so callers can read +/// the alias and entry without cloning. +pub struct SelectedRepo<'a> { + repository_alias: &'a str, + repo_config: &'a RepoEntry, +} + +/// Accessors for the selected repo's alias and config entry. +impl<'a> SelectedRepo<'a> { + /// The canonical alias used to identify this repo in the config + /// (either the direct alias passed in, or the one chosen from the fuzzy finder). + pub fn repository_alias(&self) -> &'a str { + self.repository_alias + } + + /// The full config entry for this repo, including path, tags, + /// description, color, and title. + pub fn repo_config(&self) -> &'a RepoEntry { + self.repo_config + } +} + +/// All inputs needed to resolve a repo — either by direct alias lookup +/// or interactive fuzzy selection with tag filtering and a labeled prompt. +pub struct SelectedRepoParameters<'a> { + /// The loaded config to search within. + pub loaded_config: &'a ReportalConfig, + /// If non-empty, skip the fuzzy finder and look up this alias directly. + pub direct_alias: &'a str, + /// Which repos to show in the fuzzy finder. + pub tag_filter: &'a TagFilter, + /// The prompt shown in the fuzzy finder (e.g. "Jump to repo"). + pub prompt_label: &'a str, +} + +/// Appends formatted text to a `String`, which is infallible. +/// Wraps `write!` so callers avoid discarding a `Result` that can never be `Err`. +fn push_formatted(target: &mut String, format_payload: std::fmt::Arguments<'_>) { + target.write_fmt(format_payload).unwrap_or(()); +} + +/// Resolves a repo by direct alias or interactive fuzzy selection. +/// +/// If `direct_alias` is non-empty, looks it up directly in the config. +/// Otherwise, presents a fuzzy finder filtered by `tag_filter` +/// with the given `prompt_label`. Repos are sorted by their first tag +/// so same-tag repos cluster visually. Each item shows a color swatch, +/// the alias, aliases, description, and tags. +pub fn select_repo<'a>( + selection_params: &'a SelectedRepoParameters<'a>, +) -> Result, ReportalError> { + if !selection_params.direct_alias.is_empty() { + let found_repo = selection_params + .loaded_config + .get_repo(selection_params.direct_alias)?; + return Ok(SelectedRepo { + repository_alias: selection_params.direct_alias, + repo_config: found_repo, + }); + } + + { + let mut matching_repos = selection_params + .loaded_config + .repos_matching_tag_filter(selection_params.tag_filter); + if matching_repos.is_empty() { + return Err(ReportalError::NoReposMatchFilter); + } + + matching_repos.sort_by(|(alias_a, repo_a), (alias_b, repo_b)| { + let first_tag_a = repo_a.tags().first().map(String::as_str); + let first_tag_b = repo_b.tags().first().map(String::as_str); + first_tag_a.cmp(&first_tag_b).then(alias_a.cmp(alias_b)) + }); + + let display_labels: Vec = matching_repos + .iter() + .map(|(alias, repo)| { + let swatch_style = + match terminal_style::swatch_style_for_repo_color(repo.repo_color()) { + Ok(resolved_style) => resolved_style, + Err(_color_error) => terminal_style::DEFAULT_SWATCH_STYLE, + }; + let swatch = "██".style(swatch_style); + + let mut label = format!("{swatch} {alias}"); + + if !repo.aliases().is_empty() { + let aliases_joined = repo.aliases().join(", "); + push_formatted(&mut label, format_args!(" ({aliases_joined})")); + } + + if !repo.description().is_empty() { + push_formatted(&mut label, format_args!(" — {}", repo.description())); + } + + if !repo.tags().is_empty() { + let tags_display = repo.tags().join(", "); + push_formatted(&mut label, format_args!(" [{tags_display}]")); + } + + label + }) + .collect(); + + let prompt_theme = terminal_style::reportal_prompt_theme(); + let selected_index = FuzzySelect::with_theme(&prompt_theme) + .with_prompt(selection_params.prompt_label) + .items(&display_labels) + .highlight_matches(false) + .interact_opt() + .map_err(|select_error| ReportalError::ConfigIoFailure { + reason: select_error.to_string(), + })?; + + let Some(chosen_index) = selected_index else { + return Err(ReportalError::SelectionCancelled); + }; + matching_repos.get(chosen_index).map_or( + Err(ReportalError::SelectionCancelled), + |(chosen_alias, chosen_repo)| { + Ok(SelectedRepo { + repository_alias: chosen_alias.as_str(), + repo_config: chosen_repo, + }) + }, + ) + } +} diff --git a/src/reportal_commands/repo_status.rs b/src/reportal_commands/repo_status.rs index 8f10ec0..af1792a 100644 --- a/src/reportal_commands/repo_status.rs +++ b/src/reportal_commands/repo_status.rs @@ -1,12 +1,12 @@ //! Shows git status across all registered repos in a table. use crate::error::ReportalError; +use crate::reportal_commands::git_commands::{self, GitCommandOutcome, GitCommandParameters}; use crate::reportal_config::{ReportalConfig, TagFilter}; use crate::terminal_style; use comfy_table::{Cell, Table}; use owo_colors::OwoColorize; use std::path::PathBuf; -use std::process::Command; /// The git state of a single repository. struct RepoGitStatus { @@ -60,16 +60,6 @@ struct AheadBehindCounts { behind: usize, } -/// Whether a git command produced usable output or failed. -enum GitCommandOutcome { - /// The command ran and produced stdout output. - Output(String), - /// The command failed to run (git not installed, not a repo, etc). - SpawnFailed, - /// The command ran but returned a non-zero exit code. - NonZeroExit, -} - /// Whether the rev-list count line could be split into ahead/behind. enum RevListParse { /// Both counts were successfully parsed. @@ -86,16 +76,8 @@ enum UsizeParse { Invalid, } -/// Parameters for running a git command inside a repo directory. -struct GitCommandSpec<'a> { - /// The resolved filesystem path to run git in. - repo_path: &'a PathBuf, - /// The git subcommand and its arguments (e.g. ["status", "--porcelain"]). - git_subcommand_args: &'a [&'a str], -} - /// Parameters for collecting git status from a single repo. -struct StatusCollectionParams<'a> { +struct StatusCollectionParameters<'a> { /// The alias of the repo being checked. alias: &'a str, /// The resolved filesystem path to the repo. @@ -110,61 +92,45 @@ fn parse_usize(raw_text: &str) -> UsizeParse { } } -/// Runs a git command in a directory and returns the trimmed stdout. -fn run_git_command(git_command_spec: GitCommandSpec<'_>) -> GitCommandOutcome { - let command_result = Command::new("git") - .args(git_command_spec.git_subcommand_args) - .current_dir(git_command_spec.repo_path) - .output(); - - match command_result { - Ok(output) => match output.status.success() { - true => GitCommandOutcome::Output( - String::from_utf8_lossy(&output.stdout).trim().to_string(), - ), - false => GitCommandOutcome::NonZeroExit, - }, - Err(_git_spawn_error) => GitCommandOutcome::SpawnFailed, - } -} - /// Reads the current branch name from a repo directory. fn read_branch_name(repo_path: &PathBuf) -> String { - match run_git_command(GitCommandSpec { + match git_commands::run_git_command(&GitCommandParameters { repo_path, git_subcommand_args: &["rev-parse", "--abbrev-ref", "HEAD"], }) { - GitCommandOutcome::Output(branch) => match branch.is_empty() { - true => "detached".to_string(), - false => match branch.eq("HEAD") { - true => "detached".to_string(), - false => branch, - }, - }, - GitCommandOutcome::NonZeroExit => "unknown".to_string(), - GitCommandOutcome::SpawnFailed => "no-git".to_string(), + GitCommandOutcome::Output(branch) => { + if branch.is_empty() || branch == "HEAD" { + "detached".to_owned() + } else { + branch + } + } + GitCommandOutcome::NonZeroExit => "unknown".to_owned(), + GitCommandOutcome::SpawnFailed => "no-git".to_owned(), } } /// Checks whether the working tree has uncommitted changes. fn read_working_tree_state(repo_path: &PathBuf) -> WorkingTreeState { - match run_git_command(GitCommandSpec { + match git_commands::run_git_command(&GitCommandParameters { repo_path, git_subcommand_args: &["status", "--porcelain"], }) { - GitCommandOutcome::Output(output) => match output.is_empty() { - true => WorkingTreeState::Clean, - false => WorkingTreeState::Dirty, - }, - GitCommandOutcome::NonZeroExit => WorkingTreeState::Dirty, - GitCommandOutcome::SpawnFailed => WorkingTreeState::Dirty, + GitCommandOutcome::Output(output) => { + if output.is_empty() { + WorkingTreeState::Clean + } else { + WorkingTreeState::Dirty + } + } + GitCommandOutcome::NonZeroExit | GitCommandOutcome::SpawnFailed => WorkingTreeState::Dirty, } } /// Parses a "N\tM" rev-list count string into ahead/behind counts. fn parse_rev_list_counts(raw_counts: &str) -> RevListParse { let parts: Vec<&str> = raw_counts.split('\t').collect(); - match (parts.get(0), parts.get(1)) { + match (parts.first(), parts.get(1)) { (Some(ahead_str), Some(behind_str)) => { match (parse_usize(ahead_str), parse_usize(behind_str)) { (UsizeParse::Valid(ahead), UsizeParse::Valid(behind)) => { @@ -179,7 +145,7 @@ fn parse_rev_list_counts(raw_counts: &str) -> RevListParse { /// Reads ahead/behind counts relative to the upstream tracking branch. fn read_upstream_delta(repo_path: &PathBuf) -> UpstreamDelta { - match run_git_command(GitCommandSpec { + match git_commands::run_git_command(&GitCommandParameters { repo_path, git_subcommand_args: &["rev-list", "--left-right", "--count", "HEAD...@{upstream}"], }) { @@ -187,46 +153,46 @@ fn read_upstream_delta(repo_path: &PathBuf) -> UpstreamDelta { RevListParse::Parsed(counts) => UpstreamDelta::Tracked(counts), RevListParse::Malformed => UpstreamDelta::NoUpstream, }, - GitCommandOutcome::NonZeroExit => UpstreamDelta::NoUpstream, - GitCommandOutcome::SpawnFailed => UpstreamDelta::NoUpstream, + GitCommandOutcome::NonZeroExit | GitCommandOutcome::SpawnFailed => { + UpstreamDelta::NoUpstream + } } } /// Reads the relative age of the last commit (e.g. "3 days ago"). fn read_last_commit_age(repo_path: &PathBuf) -> String { - match run_git_command(GitCommandSpec { + match git_commands::run_git_command(&GitCommandParameters { repo_path, git_subcommand_args: &["log", "-1", "--format=%cr"], }) { GitCommandOutcome::Output(age) => age, - GitCommandOutcome::NonZeroExit => "no commits".to_string(), - GitCommandOutcome::SpawnFailed => "no-git".to_string(), + GitCommandOutcome::NonZeroExit => "no commits".to_owned(), + GitCommandOutcome::SpawnFailed => "no-git".to_owned(), } } /// Collects git status for a single repo by running git commands in its directory. -fn collect_repo_status(status_collection_params: StatusCollectionParams<'_>) -> RepoGitStatus { - match status_collection_params.repo_path.exists() { - true => { - let branch = read_branch_name(status_collection_params.repo_path); - let working_tree = read_working_tree_state(status_collection_params.repo_path); - let upstream_delta = read_upstream_delta(status_collection_params.repo_path); - let last_commit_age = read_last_commit_age(status_collection_params.repo_path); - - RepoGitStatus { - alias: status_collection_params.alias.to_string(), - presence: RepoPresence::Present(GitInfo { - branch, - working_tree, - upstream_delta, - last_commit_age, - }), - } +fn collect_repo_status(status_collection_params: &StatusCollectionParameters<'_>) -> RepoGitStatus { + if status_collection_params.repo_path.exists() { + let branch = read_branch_name(status_collection_params.repo_path); + let working_tree = read_working_tree_state(status_collection_params.repo_path); + let upstream_delta = read_upstream_delta(status_collection_params.repo_path); + let last_commit_age = read_last_commit_age(status_collection_params.repo_path); + + RepoGitStatus { + alias: status_collection_params.alias.to_owned(), + presence: RepoPresence::Present(GitInfo { + branch, + working_tree, + upstream_delta, + last_commit_age, + }), } - false => RepoGitStatus { - alias: status_collection_params.alias.to_string(), + } else { + RepoGitStatus { + alias: status_collection_params.alias.to_owned(), presence: RepoPresence::Missing, - }, + } } } @@ -243,21 +209,22 @@ fn format_upstream_delta(upstream_delta: &UpstreamDelta) -> String { 0 => {} behind_count => parts.push(format!("{behind_count}↓")), } - match parts.is_empty() { - true => "synced".to_string(), - false => parts.join(" "), + if parts.is_empty() { + "synced".to_owned() + } else { + parts.join(" ") } } - UpstreamDelta::NoUpstream => "no upstream".to_string(), + UpstreamDelta::NoUpstream => "no upstream".to_owned(), } } /// Collects git metadata (branch, dirty state, upstream delta, last commit) /// from every repo matching the tag filter and prints a summary table. /// Reports dirty and missing repo counts in a footer on stderr. -pub fn run_status(tag_filter: TagFilter) -> Result<(), ReportalError> { - let loaded_config = ReportalConfig::load_from_disk()?; - let matching_repos = loaded_config.repos_matching_tag_filter(&tag_filter); +pub fn run_status(tag_filter: &TagFilter) -> Result<(), ReportalError> { + let loaded_config = ReportalConfig::load_or_initialize()?; + let matching_repos = loaded_config.repos_matching_tag_filter(tag_filter); if matching_repos.is_empty() { return Err(ReportalError::NoReposMatchFilter); @@ -267,7 +234,7 @@ pub fn run_status(tag_filter: TagFilter) -> Result<(), ReportalError> { .iter() .map(|(alias, repo)| { let resolved = repo.resolved_path(); - collect_repo_status(StatusCollectionParams { + collect_repo_status(&StatusCollectionParameters { alias, repo_path: &resolved, }) @@ -303,11 +270,7 @@ pub fn run_status(tag_filter: TagFilter) -> Result<(), ReportalError> { table.add_row(vec![ Cell::new(&repo_status.alias), Cell::new("—"), - Cell::new( - "missing" - .style(terminal_style::FAILURE_STYLE) - .to_string(), - ), + Cell::new("missing".style(terminal_style::FAILURE_STYLE).to_string()), Cell::new("—"), Cell::new("—"), ]); @@ -315,7 +278,7 @@ pub fn run_status(tag_filter: TagFilter) -> Result<(), ReportalError> { } } - println!("{table}"); + terminal_style::write_stdout(&format!("{table}\n")); let dirty_count = statuses .iter() @@ -336,35 +299,35 @@ pub fn run_status(tag_filter: TagFilter) -> Result<(), ReportalError> { }) .count(); - println!(); + terminal_style::write_stdout("\n"); match dirty_count { 0 => {} count => { - eprintln!( - " {} {} {} with uncommitted changes", + terminal_style::write_stderr(&format!( + " {} {} {} with uncommitted changes\n", "!".style(terminal_style::FAILURE_STYLE), count, match count { 1 => "repo", _ => "repos", }, - ); + )); } } match missing_count { 0 => {} count => { - eprintln!( - " {} {} {} not found on disk", + terminal_style::write_stderr(&format!( + " {} {} {} not found on disk\n", "!".style(terminal_style::FAILURE_STYLE), count, match count { 1 => "repo", _ => "repos", }, - ); + )); } } - return Ok(()); + Ok(()) } diff --git a/src/reportal_commands/repo_sync.rs b/src/reportal_commands/repo_sync.rs index 492414e..a6419f5 100644 --- a/src/reportal_commands/repo_sync.rs +++ b/src/reportal_commands/repo_sync.rs @@ -1,22 +1,13 @@ //! Pulls latest changes across all registered repos. use crate::error::ReportalError; +use crate::reportal_commands::git_commands::{self, GitCommandOutcome, GitCommandParameters}; use crate::reportal_config::{ReportalConfig, TagFilter}; use crate::terminal_style; use owo_colors::OwoColorize; use std::path::PathBuf; use std::process::Command; -/// Whether a git command produced usable output or failed. -enum GitCommandOutcome { - /// The command ran and produced stdout output. - Output(String), - /// The command failed to run (git not installed, not a repo, etc). - SpawnFailed, - /// The command ran but returned a non-zero exit code. - NonZeroExit, -} - /// Whether a repo's working tree is clean enough to pull. enum PullReadiness { /// The working tree is clean; safe to pull. @@ -47,44 +38,20 @@ struct RepoSyncResult { outcome: SyncOutcome, } -/// Parameters for running a git command inside a repo directory. -struct GitCommandSpec<'a> { - /// The resolved filesystem path to run git in. - repo_path: &'a PathBuf, - /// The git subcommand and its arguments. - git_subcommand_args: &'a [&'a str], -} - -/// Runs a git command in a directory and returns the trimmed stdout. -fn run_git_command(git_command_spec: GitCommandSpec<'_>) -> GitCommandOutcome { - let command_result = Command::new("git") - .args(git_command_spec.git_subcommand_args) - .current_dir(git_command_spec.repo_path) - .output(); - - match command_result { - Ok(output) => match output.status.success() { - true => GitCommandOutcome::Output( - String::from_utf8_lossy(&output.stdout).trim().to_string(), - ), - false => GitCommandOutcome::NonZeroExit, - }, - Err(_git_spawn_error) => GitCommandOutcome::SpawnFailed, - } -} - /// Checks whether the working tree is clean enough to safely pull. fn check_pull_readiness(repo_path: &PathBuf) -> PullReadiness { - match run_git_command(GitCommandSpec { + match git_commands::run_git_command(&GitCommandParameters { repo_path, git_subcommand_args: &["status", "--porcelain"], }) { - GitCommandOutcome::Output(output) => match output.is_empty() { - true => PullReadiness::Ready, - false => PullReadiness::DirtyWorkingTree, - }, - GitCommandOutcome::NonZeroExit => PullReadiness::Unknown, - GitCommandOutcome::SpawnFailed => PullReadiness::Unknown, + GitCommandOutcome::Output(output) => { + if output.is_empty() { + PullReadiness::Ready + } else { + PullReadiness::DirtyWorkingTree + } + } + GitCommandOutcome::NonZeroExit | GitCommandOutcome::SpawnFailed => PullReadiness::Unknown, } } @@ -96,45 +63,86 @@ fn pull_repo(repo_path: &PathBuf) -> SyncOutcome { .output(); match pull_result { - Ok(output) => match output.status.success() { - true => { - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + Ok(output) => { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned(); SyncOutcome::Pulled(stdout) - } - false => { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); SyncOutcome::PullFailed(stderr) } - }, + } Err(spawn_error) => SyncOutcome::PullFailed(spawn_error.to_string()), } } /// Syncs a single repo: checks if clean, then pulls. fn sync_single_repo(alias: &str, repo_path: &PathBuf) -> RepoSyncResult { - match repo_path.exists() { - false => RepoSyncResult { - alias: alias.to_string(), + if !repo_path.exists() { + return RepoSyncResult { + alias: alias.to_owned(), outcome: SyncOutcome::Missing, - }, - true => { - match check_pull_readiness(repo_path) { - PullReadiness::Ready => { - let outcome = pull_repo(repo_path); - RepoSyncResult { - alias: alias.to_string(), - outcome, - } - } - PullReadiness::DirtyWorkingTree => RepoSyncResult { - alias: alias.to_string(), - outcome: SyncOutcome::SkippedDirty, - }, - PullReadiness::Unknown => RepoSyncResult { - alias: alias.to_string(), - outcome: SyncOutcome::PullFailed("could not determine working tree state".to_string()), - }, - } + }; + } + let outcome = match check_pull_readiness(repo_path) { + PullReadiness::Ready => pull_repo(repo_path), + PullReadiness::DirtyWorkingTree => SyncOutcome::SkippedDirty, + PullReadiness::Unknown => { + SyncOutcome::PullFailed("could not determine working tree state".to_owned()) + } + }; + RepoSyncResult { + alias: alias.to_owned(), + outcome, + } +} + +/// Prints a styled status line for a single repo sync result. +fn print_sync_result(sync_result: &RepoSyncResult) { + match &sync_result.outcome { + SyncOutcome::Pulled(output) => { + let marker = if output.contains("Already up to date") { + "✓" + } else { + "↓" + }; + let suffix = if output.contains("Already up to date") { + "up to date" + } else { + "pulled" + }; + terminal_style::write_stdout(&format!( + " {} {} {} +", + sync_result.alias.style(terminal_style::ALIAS_STYLE), + marker.style(terminal_style::SUCCESS_STYLE), + suffix, + )); + } + SyncOutcome::SkippedDirty => { + terminal_style::write_stderr(&format!( + " {} {} skipped (dirty working tree) +", + sync_result.alias.style(terminal_style::ALIAS_STYLE), + "!".style(terminal_style::FAILURE_STYLE), + )); + } + SyncOutcome::Missing => { + terminal_style::write_stderr(&format!( + " {} {} missing on disk +", + sync_result.alias.style(terminal_style::ALIAS_STYLE), + "✗".style(terminal_style::FAILURE_STYLE), + )); + } + SyncOutcome::PullFailed(error_message) => { + terminal_style::write_stderr(&format!( + " {} {} pull failed: {} +", + sync_result.alias.style(terminal_style::ALIAS_STYLE), + "✗".style(terminal_style::FAILURE_STYLE), + error_message, + )); } } } @@ -142,9 +150,9 @@ fn sync_single_repo(alias: &str, repo_path: &PathBuf) -> RepoSyncResult { /// Pulls latest changes for every repo matching the tag filter. /// Skips repos with uncommitted changes (with a warning). /// Prints a per-repo summary showing what happened. -pub fn run_sync(tag_filter: TagFilter) -> Result<(), ReportalError> { - let loaded_config = ReportalConfig::load_from_disk()?; - let matching_repos = loaded_config.repos_matching_tag_filter(&tag_filter); +pub fn run_sync(tag_filter: &TagFilter) -> Result<(), ReportalError> { + let loaded_config = ReportalConfig::load_or_initialize()?; + let matching_repos = loaded_config.repos_matching_tag_filter(tag_filter); if matching_repos.is_empty() { return Err(ReportalError::NoReposMatchFilter); @@ -156,63 +164,27 @@ pub fn run_sync(tag_filter: TagFilter) -> Result<(), ReportalError> { for (alias, repo) in &matching_repos { let resolved = repo.resolved_path(); - let result = sync_single_repo(alias, &resolved); - - match &result.outcome { - SyncOutcome::Pulled(output) => { - pulled_count += 1; - match output.contains("Already up to date") { - true => { - println!( - " {} {} up to date", - result.alias.style(terminal_style::ALIAS_STYLE), - "✓".style(terminal_style::SUCCESS_STYLE), - ); - } - false => { - println!( - " {} {} pulled", - result.alias.style(terminal_style::ALIAS_STYLE), - "↓".style(terminal_style::SUCCESS_STYLE), - ); - } - } - } - SyncOutcome::SkippedDirty => { - skipped_count += 1; - eprintln!( - " {} {} skipped (dirty working tree)", - result.alias.style(terminal_style::ALIAS_STYLE), - "!".style(terminal_style::FAILURE_STYLE), - ); - } - SyncOutcome::Missing => { - failed_count += 1; - eprintln!( - " {} {} missing on disk", - result.alias.style(terminal_style::ALIAS_STYLE), - "✗".style(terminal_style::FAILURE_STYLE), - ); - } - SyncOutcome::PullFailed(error_message) => { - failed_count += 1; - eprintln!( - " {} {} pull failed: {}", - result.alias.style(terminal_style::ALIAS_STYLE), - "✗".style(terminal_style::FAILURE_STYLE), - error_message, - ); - } + let sync_result = sync_single_repo(alias, &resolved); + print_sync_result(&sync_result); + + match &sync_result.outcome { + SyncOutcome::Pulled(_) => pulled_count += 1, + SyncOutcome::SkippedDirty => skipped_count += 1, + SyncOutcome::Missing | SyncOutcome::PullFailed(_) => failed_count += 1, } } - println!(); - println!( - " {} pulled, {} skipped, {} failed", - pulled_count.to_string().style(terminal_style::SUCCESS_STYLE), + terminal_style::write_stdout("\n"); + terminal_style::write_stdout(&format!( + " {} pulled, {} skipped, {} failed\n", + pulled_count + .to_string() + .style(terminal_style::SUCCESS_STYLE), skipped_count.to_string().style(terminal_style::TAG_STYLE), - failed_count.to_string().style(terminal_style::FAILURE_STYLE), - ); + failed_count + .to_string() + .style(terminal_style::FAILURE_STYLE), + )); - return Ok(()); + Ok(()) } diff --git a/src/reportal_commands/repo_tree_grouping.rs b/src/reportal_commands/repo_tree_grouping.rs new file mode 100644 index 0000000..fad0c5e --- /dev/null +++ b/src/reportal_commands/repo_tree_grouping.rs @@ -0,0 +1,525 @@ +//! Builds the workspace-rooted tree structure rendered by `rep list`. +//! +//! The tree groups registered repos by workspace membership: +//! each workspace becomes a section whose members are the repos +//! listed in its `repos` field, preserving declared order. Repos +//! that belong to no workspace land in a synthetic "unassigned" +//! section at the bottom so no repo is ever hidden from the +//! listing output. + +use crate::error::ReportalError; +use crate::reportal_commands::repo_tree_workspace_section::WorkspaceSection; +use crate::reportal_config::{RepoEntry, ReportalConfig, TagFilter, WorkspaceFilter}; +use std::collections::BTreeSet; + +/// Workspace-rooted grouping of registered repos, filtered by +/// the active `--tag` and `--workspace` selections. +/// +/// Built once per `rep list` invocation, rendered to stdout by +/// `repo_listing::run_list`, and dropped when the handler +/// returns. Holding borrowed references to the backing config +/// avoids cloning repo entries into the tree. +/// +/// Multi-workspace repos (repos listed as members of more than +/// one workspace) render under every containing workspace rather +/// than being deduped into a single section. Deduping would +/// silently hide user-declared multi-membership, which is a +/// legitimate relationship the user must see. The +/// `distinct_repo_count` field reports unique repos so the +/// summary line at the bottom of the output does not exaggerate +/// the total when a repo appears in multiple sections. +#[derive(Debug)] +pub struct RepoTreeGrouping<'config> { + /// Workspace sections that have at least one member surviving + /// filtering, in the iteration order of `workspaces_with_names` + /// (which is `BTreeMap` order — deterministic across runs). + workspace_sections: Vec>, + /// Repos that belong to zero workspaces in the full config, + /// populated only when the workspace filter is `All`. When a + /// specific workspace is requested this section is empty + /// because the query is scoped to one workspace and + /// non-members are off-topic. + unassigned_repos: Vec<(&'config str, &'config RepoEntry)>, + /// Count of distinct repo aliases visible anywhere in the + /// tree, used by the summary line at the bottom of `rep list`. + distinct_repo_count: usize, +} + +/// Accessors for a built `RepoTreeGrouping`. +impl<'config> RepoTreeGrouping<'config> { + /// Builds the grouping from a loaded config and the active + /// filters by delegating to methods on the parameter struct. + /// Splitting the collection logic across per-section methods + /// keeps each one within the project's nesting budget — a + /// single inlined `build` body exceeded the limit because + /// `match` inside `for` inside `if` pushed the depth over. + /// + /// # Errors + /// + /// Returns [`ReportalError::WorkspaceNotFound`] when the + /// workspace filter is `ByName(name)` and no workspace with + /// that name is registered, or + /// [`ReportalError::RepoNotFound`] if a workspace's member + /// alias fails post-load resolution (which should not happen + /// if `validate_workspace_references` succeeded). + pub fn build( + build_params: &RepoTreeGroupingParameters<'config>, + ) -> Result { + build_params.validate_workspace_target()?; + let workspace_sections = build_params.collect_workspace_sections()?; + let unassigned_repos = build_params.collect_unassigned_repos(); + let mut distinct_aliases: BTreeSet<&str> = BTreeSet::new(); + for section in &workspace_sections { + for (member_alias, _member_entry) in section.member_repos() { + distinct_aliases.insert(*member_alias); + } + } + for (orphan_alias, _orphan_entry) in &unassigned_repos { + distinct_aliases.insert(*orphan_alias); + } + let distinct_repo_count = distinct_aliases.len(); + Ok(Self { + workspace_sections, + unassigned_repos, + distinct_repo_count, + }) + } + + /// The workspace sections in iteration order, each with its + /// ordered member list. + #[must_use] + pub fn workspace_sections(&self) -> &[WorkspaceSection<'config>] { + &self.workspace_sections + } + + /// The repos that belong to zero workspaces. Empty when the + /// workspace filter is scoped to a specific workspace. + #[must_use] + pub fn unassigned_repos(&self) -> &[(&'config str, &'config RepoEntry)] { + &self.unassigned_repos + } + + /// Count of distinct repos visible anywhere in the tree. + #[must_use] + pub fn distinct_repo_count(&self) -> usize { + self.distinct_repo_count + } + + /// Whether the tree has zero visible sections and zero + /// unassigned repos, used by the caller to decide whether to + /// render an empty-state message instead of the tree. + #[must_use] + pub fn is_empty(&self) -> bool { + self.workspace_sections.is_empty() && self.unassigned_repos.is_empty() + } +} + +/// Borrowed inputs required to build a [`RepoTreeGrouping`]. +/// +/// Bundling the config and filter references into a single +/// parameter struct satisfies the project rule that constructors +/// take at most one non-`self` argument, and the named fields +/// prevent call sites from swapping the two filter refs (which +/// share the same enum taxonomy at a glance). +pub struct RepoTreeGroupingParameters<'config> { + /// The loaded config whose workspaces and repos are grouped. + pub(super) loaded_config: &'config ReportalConfig, + /// The tag filter applied to every member before inclusion. + pub(super) tag_filter: &'config TagFilter, + /// The workspace filter selecting which sections render. + pub(super) workspace_filter: &'config WorkspaceFilter, +} + +/// Collection methods for `RepoTreeGroupingParameters`, each written +/// as a separate method so nesting starts fresh inside each body +/// and no single method exceeds the project's depth budget. +impl<'config> RepoTreeGroupingParameters<'config> { + /// Ensures the workspace filter, when set to `ByName`, + /// references a workspace that exists. A silent empty tree + /// for a typo would mask the mistake, so the early check + /// fails loudly with [`ReportalError::WorkspaceNotFound`]. + /// + /// # Errors + /// + /// Returns [`ReportalError::WorkspaceNotFound`] when the + /// filter is `ByName(name)` and no workspace with that name + /// is registered. + fn validate_workspace_target(&self) -> Result<(), ReportalError> { + match *self.workspace_filter { + WorkspaceFilter::All => Ok(()), + WorkspaceFilter::ByName(ref requested_name) => { + self.loaded_config.get_workspace(requested_name)?; + Ok(()) + } + } + } + + /// Walks every registered workspace in deterministic order, + /// filters by the workspace-filter selection, resolves each + /// member alias to its backing [`RepoEntry`], and keeps only + /// the members that survive the tag filter. Workspaces that + /// end up with zero surviving members are omitted because + /// rendering an empty section adds no information to the + /// listing. + /// + /// # Errors + /// + /// Returns [`ReportalError::RepoNotFound`] when a workspace + /// member alias does not resolve, which should not happen + /// after a successful `validate_workspace_references` pass. + fn collect_workspace_sections(&self) -> Result>, ReportalError> { + let workspace_restriction_target = self.workspace_restriction_target(); + let workspace_restriction_active = !workspace_restriction_target.is_empty(); + let mut workspace_sections: Vec> = Vec::new(); + for (workspace_name, workspace_entry) in self.loaded_config.workspaces_with_names() { + if workspace_restriction_active && workspace_name != workspace_restriction_target { + continue; + } + let surviving_members = self.resolve_surviving_members(workspace_entry)?; + if surviving_members.is_empty() { + continue; + } + workspace_sections.push(WorkspaceSection { + workspace_name, + workspace_entry, + member_repos: surviving_members, + }); + } + Ok(workspace_sections) + } + + /// Resolves every member alias in the given workspace to its + /// backing [`RepoEntry`] and keeps only the members that + /// survive the tag filter. Extracted as its own method so + /// the inner iteration starts its nesting count fresh, + /// which keeps `collect_workspace_sections` within the + /// project's depth budget. + /// + /// # Errors + /// + /// Returns [`ReportalError::RepoNotFound`] when a member + /// alias does not resolve. + fn resolve_surviving_members( + &self, + workspace_entry: &'config crate::reportal_config::WorkspaceEntry, + ) -> Result, ReportalError> { + let tag_restriction_target = self.tag_restriction_target(); + let tag_restriction_active = !tag_restriction_target.is_empty(); + let mut surviving_members: Vec<(&'config str, &'config RepoEntry)> = Vec::new(); + for member_alias in workspace_entry.repo_aliases() { + let member_repo = self.loaded_config.get_repo(member_alias)?; + if tag_restriction_active + && !member_repo + .tags() + .iter() + .any(|existing_tag| existing_tag == tag_restriction_target) + { + continue; + } + surviving_members.push((member_alias, member_repo)); + } + Ok(surviving_members) + } + + /// Walks every registered repo and collects the ones that + /// belong to zero workspaces AND survive the tag filter. + /// Returns an empty vector when the workspace filter is + /// scoped to a specific workspace because the unassigned + /// section is suppressed in that case — the query is about + /// one workspace, so non-members are off-topic. + fn collect_unassigned_repos(&self) -> Vec<(&'config str, &'config RepoEntry)> { + let workspace_restriction_target = self.workspace_restriction_target(); + let workspace_restriction_active = !workspace_restriction_target.is_empty(); + let tag_restriction_target = self.tag_restriction_target(); + let tag_restriction_active = !tag_restriction_target.is_empty(); + let mut unassigned_repos: Vec<(&'config str, &'config RepoEntry)> = Vec::new(); + if workspace_restriction_active { + return unassigned_repos; + } + for (repository_alias, repo_entry) in self.loaded_config.repos_with_aliases() { + let repo_survives_tag = !tag_restriction_active + || repo_entry + .tags() + .iter() + .any(|existing_tag| existing_tag == tag_restriction_target); + if !repo_survives_tag { + continue; + } + let containing_workspaces = self + .loaded_config + .workspaces_containing_repo(repository_alias); + if containing_workspaces.is_empty() { + unassigned_repos.push((repository_alias.as_str(), repo_entry)); + } + } + unassigned_repos + } + + /// Returns the target workspace name as a borrowed string + /// slice, or an empty string when the filter is `All`. The + /// empty-string sentinel is safe because + /// `WorkspaceFilterArguments::into_workspace_filter` rejects + /// empty input as the `All` variant, so `ByName` never + /// carries an empty string in production. + fn workspace_restriction_target(&self) -> &'config str { + match *self.workspace_filter { + WorkspaceFilter::All => "", + WorkspaceFilter::ByName(ref requested_name) => requested_name.as_str(), + } + } + + /// Returns the target tag as a borrowed string slice, or an + /// empty string when the filter is `All`. The empty-string + /// sentinel is safe because `TagFilterArguments::into_tag_filter` + /// rejects empty input as the `All` variant. + fn tag_restriction_target(&self) -> &'config str { + match *self.tag_filter { + TagFilter::All => "", + TagFilter::ByTag(ref target_tag) => target_tag.as_str(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_test_config(toml_text: &str) -> ReportalConfig { + toml::from_str(toml_text).expect("test fixture must parse as valid config") + } + + fn build_tree<'config>( + test_config: &'config ReportalConfig, + tag_filter: &'config TagFilter, + workspace_filter: &'config WorkspaceFilter, + ) -> Result, ReportalError> { + RepoTreeGrouping::build(&RepoTreeGroupingParameters { + loaded_config: test_config, + tag_filter, + workspace_filter, + }) + } + + #[test] + fn unassigned_repo_lands_in_unassigned_section() { + let test_config = parse_test_config( + r#" + [repos.lonely] + path = "/tmp/lonely" + "#, + ); + let tag_filter = TagFilter::All; + let workspace_filter = WorkspaceFilter::All; + let tree_grouping = + build_tree(&test_config, &tag_filter, &workspace_filter).expect("build must succeed"); + assert_eq!(tree_grouping.unassigned_repos().len(), 1); + assert_eq!(tree_grouping.unassigned_repos()[0].0, "lonely"); + assert!(tree_grouping.workspace_sections().is_empty()); + assert_eq!(tree_grouping.distinct_repo_count(), 1); + } + + #[test] + fn multi_workspace_repo_renders_in_every_section() { + let test_config = parse_test_config( + r#" + [repos.shared] + path = "/tmp/shared" + + [workspaces.frontend] + repos = ["shared"] + + [workspaces.backend] + repos = ["shared"] + "#, + ); + let tag_filter = TagFilter::All; + let workspace_filter = WorkspaceFilter::All; + let tree_grouping = + build_tree(&test_config, &tag_filter, &workspace_filter).expect("build must succeed"); + assert_eq!( + tree_grouping.workspace_sections().len(), + 2, + "repo should render under BOTH containing workspaces", + ); + assert_eq!( + tree_grouping.distinct_repo_count(), + 1, + "distinct count must dedupe the multi-workspace repo", + ); + assert!( + tree_grouping.unassigned_repos().is_empty(), + "a repo that IS in workspaces must not appear as unassigned", + ); + } + + #[test] + fn workspace_filter_by_name_scopes_to_one_section() { + let test_config = parse_test_config( + r#" + [repos.alpha] + path = "/tmp/alpha" + [repos.bravo] + path = "/tmp/bravo" + + [workspaces.frontend] + repos = ["alpha"] + + [workspaces.backend] + repos = ["bravo"] + "#, + ); + let tag_filter = TagFilter::All; + let workspace_filter = WorkspaceFilter::ByName("frontend".to_owned()); + let tree_grouping = + build_tree(&test_config, &tag_filter, &workspace_filter).expect("build must succeed"); + assert_eq!(tree_grouping.workspace_sections().len(), 1); + assert_eq!( + tree_grouping.workspace_sections()[0].workspace_name(), + "frontend", + ); + assert!( + tree_grouping.unassigned_repos().is_empty(), + "unassigned section must be suppressed when workspace filter is scoped", + ); + } + + #[test] + fn workspace_filter_unknown_name_errors() { + let test_config = parse_test_config( + r#" + [repos.alpha] + path = "/tmp/alpha" + "#, + ); + let tag_filter = TagFilter::All; + let workspace_filter = WorkspaceFilter::ByName("nonexistent".to_owned()); + let build_outcome = build_tree(&test_config, &tag_filter, &workspace_filter); + assert!( + matches!(build_outcome, Err(ReportalError::WorkspaceNotFound { .. })), + "unknown workspace name must error loudly, not yield empty output: {build_outcome:?}", + ); + } + + #[test] + fn tag_filter_restricts_members_within_workspaces() { + let test_config = parse_test_config( + r#" + [repos.api] + path = "/tmp/api" + tags = ["work"] + + [repos.hobby] + path = "/tmp/hobby" + tags = ["personal"] + + [workspaces.combined] + repos = ["api", "hobby"] + "#, + ); + let tag_filter = TagFilter::ByTag("work".to_owned()); + let workspace_filter = WorkspaceFilter::All; + let tree_grouping = + build_tree(&test_config, &tag_filter, &workspace_filter).expect("build must succeed"); + assert_eq!(tree_grouping.workspace_sections().len(), 1); + assert_eq!( + tree_grouping.workspace_sections()[0].member_repos().len(), + 1 + ); + assert_eq!( + tree_grouping.workspace_sections()[0].member_repos()[0].0, + "api", + ); + } + + #[test] + fn workspace_with_no_surviving_members_is_omitted() { + let test_config = parse_test_config( + r#" + [repos.personal_only] + path = "/tmp/personal" + tags = ["personal"] + + [workspaces.personal_ws] + repos = ["personal_only"] + "#, + ); + let tag_filter = TagFilter::ByTag("work".to_owned()); + let workspace_filter = WorkspaceFilter::All; + let tree_grouping = + build_tree(&test_config, &tag_filter, &workspace_filter).expect("build must succeed"); + assert!( + tree_grouping.workspace_sections().is_empty(), + "workspaces with zero surviving members must be hidden", + ); + assert!( + tree_grouping.unassigned_repos().is_empty(), + "the only repo was filtered out by tag", + ); + assert!(tree_grouping.is_empty()); + } + + #[test] + fn member_order_matches_declared_order() { + let test_config = parse_test_config( + r#" + [repos.alpha] + path = "/tmp/alpha" + [repos.bravo] + path = "/tmp/bravo" + [repos.charlie] + path = "/tmp/charlie" + + [workspaces.ordered] + repos = ["charlie", "alpha", "bravo"] + "#, + ); + let tag_filter = TagFilter::All; + let workspace_filter = WorkspaceFilter::All; + let tree_grouping = + build_tree(&test_config, &tag_filter, &workspace_filter).expect("build must succeed"); + let observed_member_order: Vec<&str> = tree_grouping.workspace_sections()[0] + .member_repos() + .iter() + .map(|(repository_alias, _repo_entry)| *repository_alias) + .collect(); + assert_eq!( + observed_member_order, + vec!["charlie", "alpha", "bravo"], + "sidebar ordering from declared `repos` field must round-trip into the tree", + ); + } + + #[test] + fn tag_and_workspace_filters_intersect() { + let test_config = parse_test_config( + r#" + [repos.api] + path = "/tmp/api" + tags = ["work"] + [repos.tool] + path = "/tmp/tool" + tags = ["work"] + [repos.game] + path = "/tmp/game" + tags = ["fun"] + + [workspaces.backend] + repos = ["api", "tool"] + + [workspaces.play] + repos = ["game"] + "#, + ); + let tag_filter = TagFilter::ByTag("work".to_owned()); + let workspace_filter = WorkspaceFilter::ByName("backend".to_owned()); + let tree_grouping = + build_tree(&test_config, &tag_filter, &workspace_filter).expect("build must succeed"); + assert_eq!(tree_grouping.workspace_sections().len(), 1); + assert_eq!( + tree_grouping.workspace_sections()[0].member_repos().len(), + 2, + "both backend members carry the work tag", + ); + assert_eq!(tree_grouping.distinct_repo_count(), 2); + } +} diff --git a/src/reportal_commands/repo_tree_workspace_section.rs b/src/reportal_commands/repo_tree_workspace_section.rs new file mode 100644 index 0000000..6bbcd4d --- /dev/null +++ b/src/reportal_commands/repo_tree_workspace_section.rs @@ -0,0 +1,47 @@ +//! One workspace section of the `rep list` tree-grouped render. + +use crate::reportal_config::{RepoEntry, WorkspaceEntry}; + +/// One workspace section of the `rep list` tree-grouped render. +/// +/// Holds borrowed references to the backing config so no cloning +/// happens during tree construction — the grouping is built, +/// rendered, and dropped within a single handler invocation, so +/// the borrow is always shorter than the config load. Member +/// order mirrors the workspace's `repos` field order because +/// that is the sidebar order the user explicitly declared and +/// the listing output must match it. +#[derive(Debug)] +pub struct WorkspaceSection<'config> { + /// The workspace this section represents. + pub(super) workspace_name: &'config String, + /// The full workspace entry, used for description display. + pub(super) workspace_entry: &'config WorkspaceEntry, + /// Ordered list of member repos that survived filtering, in + /// the same order their aliases appear in + /// `WorkspaceEntry::repo_aliases`. + pub(super) member_repos: Vec<(&'config str, &'config RepoEntry)>, +} + +/// Accessors for a workspace section. +impl<'config> WorkspaceSection<'config> { + /// The workspace name this section groups under. + #[must_use] + pub fn workspace_name(&self) -> &'config str { + self.workspace_name + } + + /// The workspace entry, used to read the description for + /// display purposes. + #[must_use] + pub fn workspace_entry(&self) -> &'config WorkspaceEntry { + self.workspace_entry + } + + /// The ordered list of member repos that survived the active + /// filters for this listing. + #[must_use] + pub fn member_repos(&self) -> &[(&'config str, &'config RepoEntry)] { + &self.member_repos + } +} diff --git a/src/reportal_commands/repo_web.rs b/src/reportal_commands/repo_web.rs new file mode 100644 index 0000000..046e753 --- /dev/null +++ b/src/reportal_commands/repo_web.rs @@ -0,0 +1,175 @@ +//! Fuzzy-selects a repo and opens its remote URL in the default browser. + +use crate::error::ReportalError; +use crate::reportal_commands::repo_selection::{self, SelectedRepoParameters}; +use crate::reportal_commands::terminal_identity_emit::{self, TerminalIdentityEmitParameters}; +use crate::reportal_config::{ReportalConfig, TagFilter}; +use crate::terminal_style; +use owo_colors::OwoColorize; +use std::process::Command; + +/// All parameters needed to run the web command. +pub struct WebCommandParameters<'a> { + /// Which repos to show in the fuzzy finder. + pub tag_filter: TagFilter, + /// If non-empty, skip the fuzzy finder and open this alias directly. + pub direct_alias: &'a str, +} + +/// The result of resolving a git remote URL to a browser-friendly HTTPS URL. +enum RemoteResolution { + /// A browsable HTTPS URL was resolved. + Resolved(String), + /// No remote URL could be found from any source. + NotFound, +} + +/// Converts a git remote URL (SSH or HTTPS) into a browser-friendly HTTPS URL. +/// +/// Handles these formats: +/// - `git@github.com:org/repo.git` → `https://github.com/org/repo` +/// - `ssh://git@github.com/org/repo.git` → `https://github.com/org/repo` +/// - `https://github.com/org/repo.git` → `https://github.com/org/repo` +/// +/// Strips the trailing `.git` suffix for a cleaner browser URL. +fn remote_url_to_browser_url(remote_url: &str) -> String { + let https_url = remote_url.strip_prefix("git@").map_or_else( + || { + remote_url.strip_prefix("ssh://").map_or_else( + || remote_url.to_owned(), + |without_scheme| { + let without_user = without_scheme + .find('@') + .map_or(without_scheme, |at_position| { + &without_scheme[at_position + 1..] + }); + format!("https://{without_user}") + }, + ) + }, + |without_prefix| { + let normalized = without_prefix.replacen(':', "/", 1); + format!("https://{normalized}") + }, + ); + + if std::path::Path::new(&https_url) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("git")) + { + https_url[..https_url.len() - 4].to_owned() + } else { + https_url + } +} + +/// Attempts to read the git origin remote URL from a repo directory. +fn detect_git_remote(repo_path: &std::path::Path) -> RemoteResolution { + let git_output = Command::new("git") + .args(["remote", "get-url", "origin"]) + .current_dir(repo_path) + .output(); + + match git_output { + Ok(output) => { + if output.status.success() { + let trimmed = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + if trimmed.is_empty() { + RemoteResolution::NotFound + } else { + RemoteResolution::Resolved(trimmed) + } + } else { + RemoteResolution::NotFound + } + } + Err(_git_error) => RemoteResolution::NotFound, + } +} + +/// Opens a repo's remote URL in the default browser. +/// +/// Resolves the remote URL from the config's `remote` field first, +/// falling back to `git remote get-url origin` in the repo directory. +/// Converts SSH remotes to HTTPS URLs for browser compatibility. +/// Emits tab title and color before opening the browser. +pub fn run_web(web_params: &WebCommandParameters<'_>) -> Result<(), ReportalError> { + let loaded_config = ReportalConfig::load_or_initialize()?; + + let selection_params = SelectedRepoParameters { + loaded_config: &loaded_config, + direct_alias: web_params.direct_alias, + tag_filter: &web_params.tag_filter, + prompt_label: "Open in browser", + }; + let selected = repo_selection::select_repo(&selection_params)?; + + terminal_identity_emit::emit_repo_terminal_identity(&TerminalIdentityEmitParameters { + selected_alias: selected.repository_alias(), + selected_repo: selected.repo_config(), + title_override: "", + }); + + let raw_remote_url = if selected.repo_config().remote().is_empty() { + match detect_git_remote(&selected.repo_config().resolved_path()) { + RemoteResolution::Resolved(detected_url) => detected_url, + RemoteResolution::NotFound => { + return Err(ReportalError::NoRemoteUrl { + alias: selected.repository_alias().to_owned(), + }); + } + } + } else { + selected.repo_config().remote().to_owned() + }; + + let browser_url = remote_url_to_browser_url(&raw_remote_url); + + open::that(&browser_url).map_err(|open_error| ReportalError::BrowserLaunchFailure { + reason: open_error.to_string(), + })?; + + terminal_style::print_success(&format!( + "Opened {}", + browser_url.style(terminal_style::PATH_STYLE), + )); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn converts_ssh_remote_to_https() { + assert_eq!( + remote_url_to_browser_url("git@github.com:org/repo.git"), + "https://github.com/org/repo" + ); + } + + #[test] + fn converts_ssh_scheme_remote_to_https() { + assert_eq!( + remote_url_to_browser_url("ssh://git@github.com/org/repo.git"), + "https://github.com/org/repo" + ); + } + + #[test] + fn passes_through_https_remote() { + assert_eq!( + remote_url_to_browser_url("https://github.com/org/repo.git"), + "https://github.com/org/repo" + ); + } + + #[test] + fn handles_https_without_git_suffix() { + assert_eq!( + remote_url_to_browser_url("https://github.com/org/repo"), + "https://github.com/org/repo" + ); + } +} diff --git a/src/reportal_commands/repository_color.rs b/src/reportal_commands/repository_color.rs new file mode 100644 index 0000000..4e52e00 --- /dev/null +++ b/src/reportal_commands/repository_color.rs @@ -0,0 +1,154 @@ +//! Emits OSC terminal personalization sequences for the current +//! working directory or a specified repository. +//! +//! Used as a shell prompt hook so terminals that open directly +//! into a repository or workspace (e.g. the VS Code integrated +//! terminal) get the right tab title and background color +//! without going through `rj` / `rjw`. +//! +//! When called without `--repo`, the CWD is resolved via +//! [`PromptIdentityResolver`], which prefers workspace +//! directories over repository paths so a user inside +//! `~/dev/workspaces/venoble/` identifies with the venoble +//! workspace's color rather than the first member's. + +use crate::error::ReportalError; +use crate::reportal_config::{HexColor, ReportalConfig, TabTitle}; +use crate::terminal_style::{self, TabColorAction, TerminalIdentity, TerminalIdentityParameters}; + +use super::color_command_mode::ColorCommandMode; +use super::prompt_identity::PromptIdentityResolver; + +/// Inputs the `rep color` subcommand needs to resolve which +/// terminal identity to paint. +/// +/// Bundled on a named struct so future knobs (e.g. a `--title` +/// override) can be added without changing every call site, +/// matching the project-wide params-bundle convention used by +/// `JumpCommandParameters` and siblings. +pub struct ColorCommandParameters<'command_lifetime> { + /// Alias to paint instead of the CWD-derived identity. + /// + /// An empty string means "use the CWD": the resolver then + /// tries workspace directories first and falls back to + /// registered repositories. A non-empty alias bypasses the + /// resolver entirely and looks up the repository registry + /// directly, which is the path used by the `rj` / `ro` + /// shell helpers that already know which alias was selected. + pub repository_alias: &'command_lifetime str, + /// How the command was invoked, controlling stdout output + /// and what happens when no identity matches. + /// + /// `PromptHook` prints the title to stdout (so shell + /// integrations can set the OS window title via native + /// APIs) and leaves the previous identity intact on + /// no-match. `Explicit` stays silent on stdout and resets + /// the tab strip to the terminal default on no-match. + pub mode: ColorCommandMode, +} + +/// Pre-OSC-emit view of a terminal identity: a title and an +/// accent color, or no accent for the default-reset case. +/// +/// Bundled on a named struct so the two lookup paths (CWD match +/// and explicit repository alias) each return the same shape, +/// and the single OSC-emission block downstream handles both +/// uniformly. Private to this file because it only bridges the +/// pre-existing [`TabColorAction`] / [`TerminalIdentity`] pair. +struct ResolvedTerminalIdentity { + /// The tab / window title after resolving title > alias. + display_title: String, + /// The accent color, or `None` when the identity should + /// reset the tab strip to the terminal's default. + accent_color: Option, +} + +/// Construction and OSC emission for the pre-emit identity. +impl ResolvedTerminalIdentity { + /// @why Builds the identity for the `--repo ` path so + /// the explicit-alias branch produces the same shape as the + /// workspace / repository CWD resolver and a single emit + /// block downstream handles both inputs without a second + /// branching match. + fn from_repository_alias( + loaded_configuration: &ReportalConfig, + requested_alias: &str, + ) -> Result { + let found_repository = loaded_configuration.get_repo(requested_alias)?; + let display_title = match found_repository.tab_title() { + TabTitle::Custom(custom_title) => custom_title.clone(), + TabTitle::UseAlias => requested_alias.to_owned(), + }; + let accent_color = found_repository.repo_color().themed_hex_color().cloned(); + Ok(Self { + display_title, + accent_color, + }) + } + + /// @why Writes the OSC tab-title and tab-color sequences for + /// this identity through the shared console writer so the + /// shell prompt hook and the explicit-alias call share the + /// same output path and cannot drift in what they send to + /// the terminal. + fn emit_to_console(&self, invocation_mode: &ColorCommandMode) { + if matches!(invocation_mode, ColorCommandMode::PromptHook) { + terminal_style::write_stdout(&self.display_title); + } + let tab_color_action = self + .accent_color + .as_ref() + .map_or(TabColorAction::Reset, |hex_color| { + TabColorAction::SetColor(hex_color.as_osc_tab_color_sequence()) + }); + let terminal_identity = TerminalIdentity::new(TerminalIdentityParameters { + resolved_title: self.display_title.clone(), + tab_color_action, + }); + terminal_style::emit_terminal_identity_to_console(&terminal_identity); + } +} + +/// @why Emits the terminal's tab-strip color and title for the +/// CWD (workspace-first, repository-second) or for an explicit +/// `--repo ` override, so a terminal that opened directly +/// inside a repository gets the right identity without going +/// through the `rj` / `rjw` shell helpers. A CWD that matches +/// neither a workspace nor a repository resets the tab strip +/// (explicit call) or leaves the previous identity intact +/// (prompt hook). +/// +/// # Errors +/// +/// Returns any error from configuration load, CWD read, or +/// repository lookup. +pub fn run_color(color_params: &ColorCommandParameters<'_>) -> Result<(), ReportalError> { + let loaded_configuration = ReportalConfig::load_or_initialize()?; + + let resolved_identity = if color_params.repository_alias.is_empty() { + let identity_resolver = PromptIdentityResolver::for_configuration(&loaded_configuration); + identity_resolver + .resolve_from_current_directory()? + .map(|prompt_identity| ResolvedTerminalIdentity { + display_title: prompt_identity.display_label, + accent_color: prompt_identity.accent_color, + }) + } else { + Some(ResolvedTerminalIdentity::from_repository_alias( + &loaded_configuration, + color_params.repository_alias, + )?) + }; + + match resolved_identity { + Some(identity) => identity.emit_to_console(&color_params.mode), + None => match color_params.mode { + ColorCommandMode::Explicit => { + terminal_style::write_to_console(terminal_style::osc_reset_tab_color_sequence()); + } + ColorCommandMode::PromptHook => {} + }, + } + + Ok(()) +} diff --git a/src/reportal_commands/shell_alias_emit.rs b/src/reportal_commands/shell_alias_emit.rs new file mode 100644 index 0000000..5a5d315 --- /dev/null +++ b/src/reportal_commands/shell_alias_emit.rs @@ -0,0 +1,414 @@ +//! Shell-function snippet generator for opted-in config entries. +//! +//! `rep init ` calls into this module after emitting the +//! base integration script so users who set `shell_alias = true` +//! on a command, repo, or workspace get a top-level shell +//! function for that entry — `vna` instead of `rj vna`, `uc` +//! instead of `rep run --cmd uc` — without further setup. +//! +//! The emission is layered in its own module rather than folded +//! into `shell_integration` so the integration block (built-in +//! wrappers + auto-wire bookkeeping) and the per-config alias +//! block (driven by user opt-ins) can evolve on independent +//! release cadences. + +use std::collections::HashSet; + +use crate::cli_args::InitializeShell; +use crate::reportal_config::{ + CommandEntry, RepoEntry, ReportalConfig, ShellAliasExport, WorkspaceEntry, +}; + +/// Parameters for the shell-alias emission entry point. +/// +/// Held as a struct rather than two positional arguments so the +/// call site at `run_initialize` reads as a single named binding +/// instead of an ambiguous `(shell, configuration)` pair. +pub struct ShellAliasEmissionParameters<'configuration> { + /// Target shell whose function syntax to emit. + pub target_shell: InitializeShell, + /// Loaded configuration to scan for opted-in entries. + pub configuration: &'configuration ReportalConfig, +} + +/// Built-in shell function names emitted by the base integration +/// script. Listed here so opted-in user entries cannot clobber +/// the built-ins by reusing the same name — collisions are +/// silently skipped at emission time and the user keeps the +/// expected `rj`/`ro`/`rjw`/`row`/`rw`/`rr` behavior. +const BUILTIN_SHELL_FUNCTION_NAMES: &[&str] = &["rj", "ro", "rjw", "row", "rw", "rr"]; + +/// Builds the shell code for opted-in aliases declared in the +/// `RePortal` config. +/// +/// Returns an empty string when no repo, workspace, or command has +/// `shell_alias = true`; otherwise returns function definitions for +/// the requested shell. The generated jump functions stop before +/// changing directory if the underlying `rep` command cannot resolve +/// a target path. +/// @why Builds the per-shell snippet that defines a top-level +/// function for every opted-in repo, workspace, and command so +/// users invoke their config entries directly from the prompt +/// without typing `rj`, `rjw`, or `rep run --cmd` first. +#[must_use] +pub fn shell_alias_export_snippet( + shell_alias_parameters: &ShellAliasEmissionParameters<'_>, +) -> String { + let exported_dispatch_entries = + collect_exported_dispatch_entries(shell_alias_parameters.configuration); + if exported_dispatch_entries.is_empty() { + return String::new(); + } + match shell_alias_parameters.target_shell { + InitializeShell::Zsh | InitializeShell::Bash => { + render_bash_snippet(&exported_dispatch_entries) + } + InitializeShell::Powershell => render_powershell_snippet(&exported_dispatch_entries), + } +} + +/// What the emitted shell function should dispatch to. Three +/// variants because each one expands to a different built-in +/// (`rep jump`, `rep workspace jump`, `rep run --cmd`) and the +/// emission helpers branch on this rather than re-deriving the +/// dispatch shape from a string tag. +enum ExportedDispatchKind { + /// Repo jump: shell function runs `rep jump ` and cd's + /// into the resolved path, applying repo color. + RepositoryJump, + /// Workspace jump: shell function runs `rep workspace jump + /// ` and cd's into the workspace directory. + WorkspaceJump, + /// Configured command run: shell function runs `rep run + /// --cmd ` after the user's repo selection prompt. + ConfiguredCommandRun, +} + +/// One emitted shell function: the name as the user typed it +/// (canonical key or an alias) and the dispatch kind that decides +/// which `rep` subcommand the function body invokes. +struct ExportedDispatchEntry { + /// The shell-function name to define — exactly the canonical + /// key or declared alias from config, validated to look like + /// a usable shell identifier before reaching here. + function_name: String, + /// The argument value passed to the underlying `rep` command + /// (`rep jump `, `rep workspace jump `, + /// `rep run --cmd `). Always the exact key/alias + /// the user typed, so dispatch resolves through the same + /// alias machinery the rest of the CLI already uses. + dispatch_argument: String, + /// Which `rep` subcommand the emitted function body invokes. + dispatch_kind: ExportedDispatchKind, +} + +/// Collects every opted-in entry from the configuration and +/// converts each (canonical key + each declared alias) into one +/// `ExportedDispatchEntry`. Skips names that collide with the +/// base integration's built-in functions and names that are not +/// usable as bare shell identifiers. +fn collect_exported_dispatch_entries( + reportal_configuration: &ReportalConfig, +) -> Vec { + let mut collected: Vec = Vec::new(); + for (command_key, command_entry) in reportal_configuration.global_commands() { + append_command_dispatch_entry(&mut collected, command_key, command_entry); + } + for (repository_canonical_key, repository_entry) in reportal_configuration.repos_with_aliases() + { + append_repository_dispatch_entries( + &mut collected, + repository_canonical_key, + repository_entry, + ); + } + for (workspace_canonical_name, workspace_entry) in + reportal_configuration.workspaces_with_names() + { + append_workspace_dispatch_entries( + &mut collected, + workspace_canonical_name, + workspace_entry, + ); + } + deduplicate_by_function_name(collected) +} + +/// Removes second-and-later occurrences of any function name so +/// each emitted shell function gets defined exactly once. Repos +/// can list their canonical key inside their own `aliases` array +/// (a no-op alias the rest of the CLI tolerates), and two +/// distinct entries opted-in with the same shell-function name +/// would otherwise produce two competing definitions; the first +/// wins so the iteration order — commands, repos, workspaces — +/// remains the published precedence. +fn deduplicate_by_function_name( + raw_entries: Vec, +) -> Vec { + let mut already_emitted_names: HashSet = HashSet::new(); + let mut deduplicated: Vec = Vec::with_capacity(raw_entries.len()); + for raw_entry in raw_entries { + if already_emitted_names.contains(&raw_entry.function_name) { + continue; + } + already_emitted_names.insert(raw_entry.function_name.clone()); + deduplicated.push(raw_entry); + } + deduplicated +} + +/// Pushes a command's dispatch entry onto the collector when the +/// command is opted-in and its key is a usable shell identifier. +fn append_command_dispatch_entry( + collector: &mut Vec, + command_key: &str, + command_entry: &CommandEntry, +) { + match command_entry.shell_alias_export() { + ShellAliasExport::Disabled => {} + ShellAliasExport::Enabled => { + if is_emittable_function_name(command_key) { + collector.push(ExportedDispatchEntry { + function_name: command_key.to_owned(), + dispatch_argument: command_key.to_owned(), + dispatch_kind: ExportedDispatchKind::ConfiguredCommandRun, + }); + } + } + } +} + +/// Pushes one dispatch entry per (canonical key + each declared +/// alias) for an opted-in repo, skipping names that are not +/// emittable shell identifiers. +fn append_repository_dispatch_entries( + collector: &mut Vec, + repository_canonical_key: &str, + repository_entry: &RepoEntry, +) { + match repository_entry.shell_alias_export() { + ShellAliasExport::Disabled => {} + ShellAliasExport::Enabled => { + push_one_dispatch_entry_if_emittable( + collector, + repository_canonical_key, + repository_canonical_key, + ExportedDispatchKind::RepositoryJump, + ); + for declared_alias in crate::reportal_config::HasAliases::aliases(repository_entry) { + push_one_dispatch_entry_if_emittable( + collector, + declared_alias, + declared_alias, + ExportedDispatchKind::RepositoryJump, + ); + } + } + } +} + +/// Pushes one dispatch entry per (canonical name + each declared +/// alias) for an opted-in workspace, skipping names that are not +/// emittable shell identifiers. +fn append_workspace_dispatch_entries( + collector: &mut Vec, + workspace_canonical_name: &str, + workspace_entry: &WorkspaceEntry, +) { + match workspace_entry.shell_alias_export() { + ShellAliasExport::Disabled => {} + ShellAliasExport::Enabled => { + push_one_dispatch_entry_if_emittable( + collector, + workspace_canonical_name, + workspace_canonical_name, + ExportedDispatchKind::WorkspaceJump, + ); + for declared_alias in crate::reportal_config::HasAliases::aliases(workspace_entry) { + push_one_dispatch_entry_if_emittable( + collector, + declared_alias, + declared_alias, + ExportedDispatchKind::WorkspaceJump, + ); + } + } + } +} + +/// Pushes a single dispatch entry onto the collector iff the +/// candidate function name is a usable shell identifier and does +/// not collide with the base integration's built-in functions. +fn push_one_dispatch_entry_if_emittable( + collector: &mut Vec, + candidate_function_name: &str, + dispatch_argument: &str, + dispatch_kind: ExportedDispatchKind, +) { + if !is_emittable_function_name(candidate_function_name) { + return; + } + collector.push(ExportedDispatchEntry { + function_name: candidate_function_name.to_owned(), + dispatch_argument: dispatch_argument.to_owned(), + dispatch_kind, + }); +} + +/// Whether a candidate name is safe to emit as a bare shell +/// function identifier on every supported shell. +/// +/// Allows ASCII letters, digits, the `_` character, and the `-` +/// character. zsh, bash, and `PowerShell` all accept this set as +/// function-name characters. The first character must not be a +/// digit. Names that collide with the base integration's +/// reserved built-ins are also rejected so user opt-ins cannot +/// accidentally shadow `rj`, `ro`, etc. +fn is_emittable_function_name(candidate_name: &str) -> bool { + if candidate_name.is_empty() { + return false; + } + if BUILTIN_SHELL_FUNCTION_NAMES.contains(&candidate_name) { + return false; + } + let mut character_iterator = candidate_name.chars(); + let Some(first_character) = character_iterator.next() else { + return false; + }; + if !first_character.is_ascii_alphabetic() && first_character != '_' { + return false; + } + for following_character in character_iterator { + let is_allowed = following_character.is_ascii_alphanumeric() + || following_character == '_' + || following_character == '-'; + if !is_allowed { + return false; + } + } + true +} + +/// Builds the bash/zsh emission block for a non-empty list of +/// dispatch entries. +/// +/// `unalias ` is emitted on its own line BEFORE the +/// function definition (not chained with `;`) because zsh +/// expands aliases at parse time, before any preceding +/// statement on the same compound line has run. Putting +/// `unalias` and the `name() { ... }` definition on one line +/// would still trigger zsh's "defining function based on alias" +/// parse error and abort the whole `eval` block. Splitting them +/// onto two physical lines runs the unalias to completion before +/// the parser ever sees the function-definition token. +fn render_bash_snippet(exported_entries: &[ExportedDispatchEntry]) -> String { + let mut rendered_block = String::from( + "# RePortal user-config shell aliases — generated from `shell_alias = true` entries.\n", + ); + for exported_entry in exported_entries { + rendered_block.push_str(&format!( + "unalias {name} 2>/dev/null\n", + name = exported_entry.function_name, + )); + match exported_entry.dispatch_kind { + ExportedDispatchKind::RepositoryJump => { + rendered_block.push_str(&format!( + "function {name}() {{ local _target; _target=$(rep jump {arg}) || return; cd \"$_target\" || return; rep color; }}\n", + name = exported_entry.function_name, + arg = exported_entry.dispatch_argument, + )); + } + ExportedDispatchKind::WorkspaceJump => { + rendered_block.push_str(&format!( + "function {name}() {{ local _target; _target=$(rep workspace jump {arg}) || return; cd \"$_target\" || return; rep color; }}\n", + name = exported_entry.function_name, + arg = exported_entry.dispatch_argument, + )); + } + ExportedDispatchKind::ConfiguredCommandRun => { + rendered_block.push_str(&format!( + "function {name}() {{ rep run --cmd {arg} \"$@\"; }}\n", + name = exported_entry.function_name, + arg = exported_entry.dispatch_argument, + )); + } + } + } + rendered_block +} + +/// Builds the `PowerShell` emission block for a non-empty list of +/// dispatch entries. Uses `global:` scope so the functions remain +/// callable from interactive sessions regardless of how the +/// integration script was loaded. +/// +/// Each function definition is preceded by a silent +/// `Remove-Item Alias:` so a pre-existing `PowerShell` alias +/// (auto-loaded modules, the user's profile, etc.) does not +/// shadow the reportal function that the user explicitly opted +/// into. +fn render_powershell_snippet(exported_entries: &[ExportedDispatchEntry]) -> String { + let mut rendered_block = String::from( + "# RePortal user-config shell aliases — generated from `shell_alias = true` entries.\n", + ); + for exported_entry in exported_entries { + match exported_entry.dispatch_kind { + ExportedDispatchKind::RepositoryJump => { + rendered_block.push_str(&format!( + "Remove-Item Alias:{name} -ErrorAction SilentlyContinue; function global:{name} {{ $target = rep jump {arg}; if ($target) {{ Set-Location $target; rep color }} }}\n", + name = exported_entry.function_name, + arg = exported_entry.dispatch_argument, + )); + } + ExportedDispatchKind::WorkspaceJump => { + rendered_block.push_str(&format!( + "Remove-Item Alias:{name} -ErrorAction SilentlyContinue; function global:{name} {{ $target = rep workspace jump {arg}; if ($target) {{ Set-Location $target; rep color }} }}\n", + name = exported_entry.function_name, + arg = exported_entry.dispatch_argument, + )); + } + ExportedDispatchKind::ConfiguredCommandRun => { + rendered_block.push_str(&format!( + "Remove-Item Alias:{name} -ErrorAction SilentlyContinue; function global:{name} {{ rep run --cmd {arg} @args }}\n", + name = exported_entry.function_name, + arg = exported_entry.dispatch_argument, + )); + } + } + } + rendered_block +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_empty_function_name() { + assert!(!is_emittable_function_name("")); + } + + #[test] + fn rejects_builtin_collision() { + assert!(!is_emittable_function_name("rj")); + assert!(!is_emittable_function_name("rjw")); + assert!(!is_emittable_function_name("rr")); + } + + #[test] + fn allows_hyphenated_repository_alias() { + assert!(is_emittable_function_name("venoble-app")); + } + + #[test] + fn rejects_leading_digit() { + assert!(!is_emittable_function_name("4chan")); + } + + #[test] + fn rejects_special_characters() { + assert!(!is_emittable_function_name("foo bar")); + assert!(!is_emittable_function_name("foo.bar")); + assert!(!is_emittable_function_name("foo/bar")); + } +} diff --git a/src/reportal_commands/shell_integration.rs b/src/reportal_commands/shell_integration.rs new file mode 100644 index 0000000..cf82fb4 --- /dev/null +++ b/src/reportal_commands/shell_integration.rs @@ -0,0 +1,95 @@ +//! Shell integration script content, stamped with the running +//! binary's version. +//! +//! The content is rendered to stdout by `rep init ` and +//! consumed by the user's rc file through a guarded eval block. +//! There is no on-disk integration file — the script is regenerated +//! every shell session so a binary upgrade takes effect the next +//! time the user opens a terminal, with zero manual steps. +//! +//! `REPORTAL_LOADED` is stamped with the shell's own PID (`$$` in +//! bash/zsh, `$PID` in `PowerShell`) rather than the literal `1`, +//! so child processes which inherit the env var without sourcing +//! the integration become detectable: `rep` compares the value +//! against its parent process id at startup and treats a mismatch +//! as "marker inherited, integration not actually loaded in this +//! shell" — see `shell_integration_autowire`. + +const POSIX_REINSTALL_COMMAND: &str = "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/CheckPickerUpper/reportal/releases/latest/download/reportal-installer.sh | sh"; + +const POWERSHELL_REINSTALL_COMMAND: &str = "powershell -ExecutionPolicy Bypass -c \"irm https://github.com/CheckPickerUpper/reportal/releases/latest/download/reportal-installer.ps1 | iex\""; + +/// @why Renders the bash/zsh integration block so shell startup can +/// regenerate wrappers from the current binary while preserving a +/// clear recovery path when the executable is missing. +#[must_use] +pub fn bash_integration_content() -> String { + let null_device = std::path::Path::new("/dev").join("null"); + format!( + r#"# RePortal shell integration — v{version} +# Generated by 'rep init bash|zsh'. +export REPORTAL_LOADED=$$ +_reportal_reinstall_command="{reinstall_command}" +_reportal_missing_rep() {{ printf '%s\n' "RePortal: rep is not on PATH. Reinstall with: $_reportal_reinstall_command" >&2; }} +_reportal_rep_command() {{ + local _rep_path + if [ -n "${{BASH_VERSION:-}}" ]; then + _rep_path=$(type -P rep 2>{null_device}); [ -n "$_rep_path" ] && {{ printf '%s\n' "$_rep_path"; return 0; }} + fi + if [ -n "${{ZSH_VERSION:-}}" ]; then + _rep_path=$(whence -p rep 2>{null_device}); [ -n "$_rep_path" ] && {{ printf '%s\n' "$_rep_path"; return 0; }} + fi + _rep_path=$(command -v reportal 2>{null_device}); [ -n "$_rep_path" ] && {{ printf '%s\n' "$_rep_path"; return 0; }} + return 1 +}} +rep() {{ local _rep; if _rep=$(_reportal_rep_command); then "$_rep" "$@"; else _reportal_missing_rep; return 127; fi; }} +rj() {{ local _target; _target=$(rep jump "$@") || return; cd "$_target" || return; rep color; }} +ro() {{ rep open "$@" && rep color; }} +rjw() {{ local _target; _target=$(rep workspace jump "$@") || return; cd "$_target" || return; rep color; }} +row() {{ rep workspace open "$@" && rep color; }} +rw() {{ rep web "$@"; }} +rr() {{ rep run "$@"; }} +_reportal_hook() {{ local _t; _t=$(rep color --mode prompt-hook 2>{null_device}); [ -n "$_t" ] && printf '\033]2;%s\007' "$_t"; }} +PROMPT_COMMAND="${{PROMPT_COMMAND:+$PROMPT_COMMAND;}}_reportal_hook" +"#, + version = env!("CARGO_PKG_VERSION"), + reinstall_command = POSIX_REINSTALL_COMMAND, + null_device = null_device.display(), + ) +} + +/// @why Renders the `PowerShell` integration block so profile +/// startup can refresh global wrappers from the current binary +/// while preserving a clear recovery path when `rep.exe` is +/// missing from the user PATH. +#[must_use] +pub fn powershell_integration_content() -> String { + format!( + r#"# RePortal shell integration — v{version} +# Generated by 'rep init powershell'. +$env:REPORTAL_LOADED = "$PID" +function global:rep {{ + $repApplication = Get-Command rep -CommandType Application -ErrorAction SilentlyContinue + if (-not $repApplication) {{ + $repApplication = Get-Command reportal -CommandType Application -ErrorAction SilentlyContinue + }} + if ($repApplication) {{ + & $repApplication.Source @args + return + }} + Write-Error 'RePortal: rep.exe is not on PATH. Reinstall with: {reinstall_command}' + $global:LASTEXITCODE = 127 +}} +function global:rj {{ $target = rep jump @args; if ($target) {{ Set-Location $target; rep color }} }} +function global:ro {{ rep open @args; if ($?) {{ rep color }} }} +function global:rjw {{ $target = rep workspace jump @args; if ($target) {{ Set-Location $target; rep color }} }} +function global:row {{ rep workspace open @args; if ($?) {{ rep color }} }} +function global:rw {{ rep web @args }} +function global:rr {{ rep run @args }} +$script:_reportal_original_prompt = $function:global:prompt +function global:prompt {{ $p = & $script:_reportal_original_prompt; $t = rep color --mode prompt-hook 2>$null; if ($t) {{ $Host.UI.RawUI.WindowTitle = $t }}; $p }} +"#, + version = env!("CARGO_PKG_VERSION"), + reinstall_command = POWERSHELL_REINSTALL_COMMAND, + ) +} diff --git a/src/reportal_commands/shell_integration_autowire.rs b/src/reportal_commands/shell_integration_autowire.rs new file mode 100644 index 0000000..84ec798 --- /dev/null +++ b/src/reportal_commands/shell_integration_autowire.rs @@ -0,0 +1,524 @@ +//! Auto-wire that appends the `eval "$(rep init )"` block to +//! the user's shell rc file on first `rep` invocation, so users who +//! installed via `cargo install reportal` (or any route that bypasses +//! the shell installer in `installer/`) don't have to edit their rc +//! file by hand. +//! +//! This module also owns the load-check that distinguishes between +//! "the integration script ran in this exact shell" and "the +//! `REPORTAL_LOADED` env var was inherited from a parent shell that +//! had it loaded but I'm a child shell that never sourced an rc +//! file." Without that distinction, child shells (non-interactive +//! `zsh -c`, certain terminal-host setups, scripts) inherit the +//! marker, the binary thinks the user is fine, the auto-wire skips, +//! and the user types `rj` only to get an unexplained `command not +//! found`. The marker is now stamped with the shell's own PID; the +//! binary compares it against its parent process id and only treats +//! the integration as "loaded" when they match. + +use std::env as environment; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use dirs as directories; + +use crate::cli_args::InitializeShell; + +/// Fenced marker opening the auto-managed integration block. Byte- +/// for-byte identical to the markers used by the shell installer +/// scripts in `installer/install.sh` and `installer/install.ps1`, +/// which makes both code paths interoperable: whichever runs first +/// claims the block, and the other becomes a no-op. +const MARKER_START: &str = "# >>> reportal shell integration (do not edit) >>>"; + +/// Fenced marker closing the auto-managed integration block. +const MARKER_END: &str = "# <<< reportal shell integration <<<"; + +/// One auto-wire attempt against a specific shell + rc-file pair. +/// Holds the shell variant and resolved rc file path so the helper +/// methods don't have to re-derive them on every call. +struct AutoWireOperation { + /// Target shell whose `eval` line we'd append. + shell: InitializeShell, + /// Resolved rc file path the eval line should land in. + rc_file: PathBuf, +} + +/// Outcome of a single `AutoWireOperation::install_if_missing` call. +/// The caller branches on this to decide whether to print an +/// activation hint to stderr — only `Appended` earns the hint. +enum AutoWireOperationOutcome { + /// Marker already present — no change made. + AlreadyPresent, + /// Fenced block appended to the rc file. + Appended, +} + +/// Whether the fenced block should be preceded by a separator +/// newline. Replaces a `bool` parameter so each call site reads +/// the policy at the call instead of decoding which polarity of +/// `true` / `false` corresponds to "needs a newline." +#[derive(Clone, Copy)] +enum AutoWireOperationLeadingNewlinePolicy { + /// File already ends mid-line — emit a separator newline so + /// the marker starts on its own line. + InsertSeparator, + /// File is empty or already ends with a newline — no separator + /// needed. + NoSeparator, +} + +/// Which rc-file I/O step failed. Lifted out of the error enum's +/// variants into a discriminant so `AutoWireOperationRcFileError` +/// is a single struct that names its three shared fields once. +#[derive(Debug)] +enum AutoWireOperationRcFileOperationKind { + /// `read_to_string` of an existing rc file. + Read, + /// `create_dir_all` of the rc file's parent directory. + CreateParentDirectory, + /// `OpenOptions::open` in append mode on the rc file. + Open, + /// `write_all` of the fenced block bytes. + Write, +} + +/// Typed I/O failure produced by `AutoWireOperation` while reading +/// or writing the user's rc file. Carries the failed step plus the +/// path it was operating on so the caller's stderr hint can quote +/// the exact file the auto-wire stumbled on. +#[derive(Debug, thiserror::Error)] +#[error("auto-wire rc-file {operation_kind:?} on {rc_file_path}: {source}")] +struct AutoWireOperationRcFileError { + /// Which rc-file I/O step failed. + operation_kind: AutoWireOperationRcFileOperationKind, + /// Display path of the file or directory we were operating on. + rc_file_path: String, + /// Underlying I/O error from the standard library. + #[source] + source: std::io::Error, +} + +/// Result of comparing the `REPORTAL_LOADED` marker against the +/// actual parent process id. Replaces a `bool` return so call sites +/// at `ensure_shell_integration_installed` and `rep doctor` carry +/// the meaning of each branch in the type, not in a hand-decoded +/// `true` / `false`. +pub enum AutoWireOperationLoadedState { + /// The integration script ran in the shell that launched this + /// `rep` process — `rj` and friends are defined. + LoadedInCurrentShell, + /// The marker is unset, set to a stale value, or inherited from + /// a grandparent — the user's current shell almost certainly + /// does NOT have `rj` defined. + NotLoadedInCurrentShell, +} + +/// PID-marker comparison helpers on `AutoWireOperationLoadedState`, +/// kept as associated functions so the platform-gated branching +/// lives on the type that names the answer instead of as free +/// helpers crowding the module. +impl AutoWireOperationLoadedState { + /// Compares a PID-stamped `REPORTAL_LOADED` value against the + /// running process's actual parent PID on Unix; returns + /// `NotLoadedInCurrentShell` on mismatch or when the parent + /// PID can't be read. + #[cfg(unix)] + fn classify_against_actual_parent_pid(stamped_marker_value: &str) -> Self { + let Some(parent_process_id) = rustix::process::getppid() else { + return Self::NotLoadedInCurrentShell; + }; + let parent_pid_string = parent_process_id.as_raw_nonzero().get().to_string(); + if parent_pid_string == stamped_marker_value { + Self::LoadedInCurrentShell + } else { + Self::NotLoadedInCurrentShell + } + } + + /// Windows-side parent-PID lookup via `sysinfo`. Mirrors the unix + /// branch's semantics: returns `NotLoadedInCurrentShell` on + /// mismatch, when the running process can't be located in the + /// snapshot, or when the process has no parent. `sysinfo` is the + /// only safe-API parent-PID source under the project's + /// `forbid(unsafe_code)` constraint; `winsafe` would push raw + /// `ToolHelp32` boilerplate into our tree and `windows-sys` is + /// straight FFI. + #[cfg(windows)] + fn classify_against_actual_parent_pid(stamped_marker_value: &str) -> Self { + let mut system_snapshot = sysinfo::System::new(); + let self_process_id = sysinfo::Pid::from_u32(std::process::id()); + system_snapshot + .refresh_processes(sysinfo::ProcessesToUpdate::Some(&[self_process_id]), true); + let Some(self_process) = system_snapshot.process(self_process_id) else { + return Self::NotLoadedInCurrentShell; + }; + let Some(parent_process_id) = self_process.parent() else { + return Self::NotLoadedInCurrentShell; + }; + let parent_pid_string = parent_process_id.as_u32().to_string(); + if parent_pid_string == stamped_marker_value { + Self::LoadedInCurrentShell + } else { + Self::NotLoadedInCurrentShell + } + } +} + +#[cfg(not(any(unix, windows)))] +compile_error!( + "RePortal needs platform-specific parent-PID lookup for the \ + REPORTAL_LOADED-inheritance check; only unix and windows are \ + wired up. Add a branch in classify_against_actual_parent_pid for \ + this target before building." +); + +/// Auto-wire entry point + private helpers. Grouped here so the +/// shell-detection / rc-file-resolution / fenced-block-write logic +/// lives next to the data it operates on, instead of leaking out +/// as a pile of free helper functions sharing implicit invariants. +impl AutoWireOperation { + /// Resolves the user's shell + rc file from the environment. + /// Returns `None` for fish, tcsh, csh, or any shell we don't + /// generate integration code for — those users can run + /// `rep init ` themselves and will get a clear rejection + /// from the CLI parser. + fn detect_from_environment() -> Option { + let shell = Self::shell_from_environment()?; + let rc_file = Self::rc_file_for(shell)?; + Some(Self { shell, rc_file }) + } + + /// Performs a single auto-wire attempt: detects the shell + rc + /// file, installs the fenced block if missing, and emits a + /// stderr hint for the appended / failed cases. Lives on the + /// type so `ensure_shell_integration_installed` reads as the + /// policy ("if not loaded, attempt") and the mechanics travel + /// with the data they operate on. + fn run_one_auto_wire_attempt() { + let Some(operation) = Self::detect_from_environment() else { + return; + }; + operation.report_install_outcome(); + } + + /// Runs `install_if_missing` and routes each outcome to the + /// appropriate stderr hint. Split out from + /// `run_one_auto_wire_attempt` so the policy branch ("if no + /// operation could be detected, do nothing") and the I/O + /// branch ("install + report") read independently. + fn report_install_outcome(&self) { + match self.install_if_missing() { + Ok(AutoWireOperationOutcome::AlreadyPresent) => {} + Ok(AutoWireOperationOutcome::Appended) => { + eprintln!( + "RePortal: added shell integration to {rc} — run 'source {rc}' or open a new shell to activate.", + rc = self.rc_file.display(), + ); + } + Err(reason) => { + eprintln!( + "RePortal: note: could not auto-wire shell integration ({reason}); add `{eval_line}` to your rc file manually.", + eval_line = self.eval_line(), + ); + } + } + } + + /// Parses `$SHELL`'s basename and returns the matching + /// `InitializeShell` variant; falls back to `PowerShell` on + /// Windows when `$SHELL` is unset so the binary still picks + /// the right rc file when invoked from `cmd.exe`. + fn shell_from_environment() -> Option { + Self::shell_basename_from_environment_variable() + .map_or_else(Self::default_shell_when_environment_unset, |name| { + Self::interpret_shell_basename(&name) + }) + } + + /// Reads `$SHELL` and returns its lowercase basename, or `None` + /// if the env var is unset or has no usable basename. + fn shell_basename_from_environment_variable() -> Option { + let shell_executable_path_value = environment::var_os("SHELL")?; + Path::new(&shell_executable_path_value) + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_ascii_lowercase) + } + + /// Maps a lowercase shell basename to the `InitializeShell` + /// variant we generate integration code for. Returns `None` + /// for shells we don't support yet (fish, tcsh, csh, ...). + fn interpret_shell_basename(basename: &str) -> Option { + match basename { + "zsh" => Some(InitializeShell::Zsh), + "bash" => Some(InitializeShell::Bash), + "pwsh" | "powershell" => Some(InitializeShell::Powershell), + _ => None, + } + } + + /// Picks a default shell when `$SHELL` is unset — `PowerShell` + /// on Windows so `cmd.exe`-launched invocations still find a + /// reasonable rc file, `None` everywhere else. + fn default_shell_when_environment_unset() -> Option { + if cfg!(windows) { + Some(InitializeShell::Powershell) + } else { + None + } + } + + /// Resolves the rc file we should append to for the given + /// shell. + fn rc_file_for(shell: InitializeShell) -> Option { + match shell { + InitializeShell::Zsh => directories::home_dir().map(|home| home.join(".zshrc")), + InitializeShell::Bash => directories::home_dir().map(|home| home.join(".bashrc")), + InitializeShell::Powershell => Self::powershell_profile_path(), + } + } + + /// Resolves the `PowerShell` `$PROFILE` path — prefers the + /// host-provided env var, then falls back to the conventional + /// `Documents\PowerShell\…` layout under the user's home. + fn powershell_profile_path() -> Option { + environment::var_os("PROFILE").map_or_else( + Self::default_powershell_profile_path, + Self::interpret_powershell_profile_environment_variable, + ) + } + + /// Wraps a non-empty `$PROFILE` value into a `PathBuf`; treats + /// the empty string as "unset" so the conventional default + /// path takes over. + fn interpret_powershell_profile_environment_variable( + profile: std::ffi::OsString, + ) -> Option { + let powershell_profile_path = PathBuf::from(profile); + if powershell_profile_path.as_os_str().is_empty() { + return Self::default_powershell_profile_path(); + } + Some(powershell_profile_path) + } + + /// Conventional `Documents\PowerShell\Microsoft.PowerShell_profile.ps1` + /// rooted at the user's home directory. + fn default_powershell_profile_path() -> Option { + directories::home_dir().map(|home| { + home.join("Documents") + .join("PowerShell") + .join("Microsoft.PowerShell_profile.ps1") + }) + } + + /// Returns the single line that goes inside the fenced block + /// for this shell. Byte-for-byte identical to what the shell + /// installer scripts write. + fn eval_line(&self) -> &'static str { + match self.shell { + InitializeShell::Zsh => "eval \"$(rep init zsh)\"", + InitializeShell::Bash => "eval \"$(rep init bash)\"", + InitializeShell::Powershell => "Invoke-Expression (& rep init powershell | Out-String)", + } + } + + /// Appends the fenced integration block to the rc file iff the + /// marker is absent. Branches on whether the file already + /// exists so each branch handles one focused concern. + fn install_if_missing(&self) -> Result { + if self.rc_file.exists() { + return self.install_into_existing_rc_file(); + } + self.install_into_fresh_rc_file() + } + + /// Reads the existing rc file, decides whether the marker is + /// already present, and appends the block with the correct + /// leading-newline policy for whatever the file's final byte + /// is. + fn install_into_existing_rc_file( + &self, + ) -> Result { + let existing_rc_file_content = self.read_rc_file_content()?; + if existing_rc_file_content.contains(MARKER_START) { + return Ok(AutoWireOperationOutcome::AlreadyPresent); + } + let leading_newline_policy = Self::leading_newline_policy_for(&existing_rc_file_content); + self.write_fenced_block(leading_newline_policy)?; + Ok(AutoWireOperationOutcome::Appended) + } + + /// Creates the parent directory if needed, then writes the + /// fenced block as the file's only content. No leading + /// separator since the file is empty. + fn install_into_fresh_rc_file( + &self, + ) -> Result { + self.create_parent_directory_if_needed()?; + self.write_fenced_block(AutoWireOperationLeadingNewlinePolicy::NoSeparator)?; + Ok(AutoWireOperationOutcome::Appended) + } + + /// Reads the rc file as UTF-8, mapping any I/O failure into the + /// typed error so callers don't have to handle + /// `std::io::Error` directly. + fn read_rc_file_content(&self) -> Result { + std::fs::read_to_string(&self.rc_file).map_err(|source| AutoWireOperationRcFileError { + operation_kind: AutoWireOperationRcFileOperationKind::Read, + rc_file_path: self.rc_file.display().to_string(), + source, + }) + } + + /// Decides whether the appended block needs a leading newline + /// separator based on whether the existing content already ends + /// with one. Empty files don't need a separator either — they + /// have no preceding content for the marker to bump up against. + fn leading_newline_policy_for( + existing_rc_file_content: &str, + ) -> AutoWireOperationLeadingNewlinePolicy { + if existing_rc_file_content.is_empty() || existing_rc_file_content.ends_with('\n') { + AutoWireOperationLeadingNewlinePolicy::NoSeparator + } else { + AutoWireOperationLeadingNewlinePolicy::InsertSeparator + } + } + + /// Creates the rc file's parent directory tree if it doesn't + /// already exist. No-op when the rc file's path has no parent + /// component or the parent already exists. + fn create_parent_directory_if_needed(&self) -> Result<(), AutoWireOperationRcFileError> { + let Some(rc_file_parent_directory) = self.rc_file.parent() else { + return Ok(()); + }; + if rc_file_parent_directory.as_os_str().is_empty() || rc_file_parent_directory.exists() { + return Ok(()); + } + std::fs::create_dir_all(rc_file_parent_directory).map_err(|source| { + AutoWireOperationRcFileError { + operation_kind: AutoWireOperationRcFileOperationKind::CreateParentDirectory, + rc_file_path: rc_file_parent_directory.display().to_string(), + source, + } + }) + } + + /// Opens the rc file in append mode and writes the fenced + /// block. Composition of the block bytes lives in + /// `compose_fenced_block` so this method stays focused on I/O. + fn write_fenced_block( + &self, + leading_newline_policy: AutoWireOperationLeadingNewlinePolicy, + ) -> Result<(), AutoWireOperationRcFileError> { + let mut rc_file_handle = OpenOptions::new() + .create(true) + .append(true) + .open(&self.rc_file) + .map_err(|source| AutoWireOperationRcFileError { + operation_kind: AutoWireOperationRcFileOperationKind::Open, + rc_file_path: self.rc_file.display().to_string(), + source, + })?; + let fenced_block_bytes = self.compose_fenced_block(leading_newline_policy); + rc_file_handle + .write_all(fenced_block_bytes.as_bytes()) + .map_err(|source| AutoWireOperationRcFileError { + operation_kind: AutoWireOperationRcFileOperationKind::Write, + rc_file_path: self.rc_file.display().to_string(), + source, + }) + } + + /// Builds the fenced block as a single string. `PowerShell` on + /// Windows uses CRLF; everything else uses LF. + fn compose_fenced_block( + &self, + leading_newline_policy: AutoWireOperationLeadingNewlinePolicy, + ) -> String { + let eol = self.line_ending(); + let mut fenced_block_buffer = String::new(); + match leading_newline_policy { + AutoWireOperationLeadingNewlinePolicy::InsertSeparator => { + fenced_block_buffer.push_str(eol); + } + AutoWireOperationLeadingNewlinePolicy::NoSeparator => {} + } + fenced_block_buffer.push_str(eol); + fenced_block_buffer.push_str(MARKER_START); + fenced_block_buffer.push_str(eol); + fenced_block_buffer.push_str(self.eval_line()); + fenced_block_buffer.push_str(eol); + fenced_block_buffer.push_str(MARKER_END); + fenced_block_buffer.push_str(eol); + fenced_block_buffer + } + + /// Picks the line-ending bytes for this shell. CRLF only for + /// `PowerShell` on Windows; LF for everything else (including + /// `PowerShell` on macOS / Linux via `pwsh`). + fn line_ending(&self) -> &'static str { + match self.shell { + InitializeShell::Powershell => { + #[cfg(windows)] + { + "\r\n" + } + #[cfg(not(windows))] + { + "\n" + } + } + InitializeShell::Zsh | InitializeShell::Bash => "\n", + } + } +} + +/// @why Best-effort first-run hook that ensures the user's shell rc +/// file sources the `RePortal` integration, so users who installed +/// via `cargo install reportal` (or any route that skips the shell +/// installer) don't have to edit their rc file by hand. Called from +/// `main()` before subcommand dispatch and skipped for `rep init` +/// itself, since that subcommand's stdout is consumed via +/// `eval "$(rep init zsh)"` where any stderr noise would surface on +/// every shell startup. The function never returns an error to its +/// caller — auto-setup is a convenience and must never break the +/// user's actual command. +pub fn ensure_shell_integration_installed() { + match current_shell_integration_loaded_state() { + AutoWireOperationLoadedState::LoadedInCurrentShell => {} + AutoWireOperationLoadedState::NotLoadedInCurrentShell => { + AutoWireOperation::run_one_auto_wire_attempt(); + } + } +} + +/// @why Reports whether the integration script actually ran in the +/// shell that launched this `rep` process, so callers +/// (`ensure_shell_integration_installed`, `rep doctor`) can +/// distinguish between a real loaded session and a child process +/// that merely inherited the `REPORTAL_LOADED` env var from a +/// grandparent without sourcing the integration itself. The shell +/// stamps the marker with its own `$$` (or `$PID` on `PowerShell`) +/// at integration time; we read that stamp and compare it against +/// our parent process id. A match means the parent shell IS the +/// one that set the marker; a mismatch means the marker was +/// inherited across one or more process boundaries and the actual +/// functions (`rj`, `ro`, ...) probably aren't defined in the +/// user's prompt. +/// +/// The legacy literal value `"1"` (used by versions ≤ 0.18.1) is +/// treated as "loaded" so users with an older shell session still +/// open during an upgrade don't see spurious warnings on every +/// `rep` invocation until they re-source their rc file. +#[must_use] +pub fn current_shell_integration_loaded_state() -> AutoWireOperationLoadedState { + let Ok(stamped_marker_value) = environment::var("REPORTAL_LOADED") else { + return AutoWireOperationLoadedState::NotLoadedInCurrentShell; + }; + if stamped_marker_value == "1" { + return AutoWireOperationLoadedState::LoadedInCurrentShell; + } + AutoWireOperationLoadedState::classify_against_actual_parent_pid(&stamped_marker_value) +} diff --git a/src/reportal_commands/shell_prompt_badge.rs b/src/reportal_commands/shell_prompt_badge.rs new file mode 100644 index 0000000..48a794b --- /dev/null +++ b/src/reportal_commands/shell_prompt_badge.rs @@ -0,0 +1,107 @@ +//! Shell-integration snippet that prepends the workspace / +//! repository prompt badge to the user's existing `PS1` / +//! `PROMPT` / `prompt` function. +//! +//! Lives in a separate file from `shell_integration` so the +//! existing shell-integration script can stay untouched by the +//! badge feature — `run_initialize` concatenates the snippet +//! produced here onto the integration script for its shell, and +//! the two halves can evolve independently. +//! +//! # Coexistence with prompt frameworks +//! +//! Frameworks like powerlevel10k, starship, and oh-my-zsh themes +//! own `PROMPT` / `PS1` and rewrite it on every prompt redraw. +//! Two strategies together make rep's badge survive alongside +//! any framework, regardless of load order: +//! +//! 1. **Idempotent prepend.** A sentinel variable tracks the +//! exact badge string we added last time; on each redraw we +//! strip that sentinel from the current prompt (no-op if the +//! framework already rewrote it) and prepend a freshly +//! resolved badge. +//! +//! 2. **Run-last registration.** Bash rewrites `PROMPT_COMMAND` +//! on every hook run so our entry stays last. Zsh registers +//! the hook once through `add-zsh-hook`; mutating +//! `precmd_functions` from inside a `precmd` hook can make +//! zsh keep invoking the same hook before it ever draws the +//! prompt. +//! +//! For `PowerShell`, we wrap the `prompt` function. Wrapping +//! composes: if a framework also wraps the function after us, +//! its wrapper calls into ours, which calls into the original. +//! No strip-and-prepend needed because each call computes fresh +//! output. + +use crate::cli_args::InitializeShell; + +/// @why Builds the badge-prepending snippet for the target +/// shell so `run_initialize`'s output wires an inline colored +/// badge into the prompt on every redraw and coexists with any +/// prompt framework — the emitted hook strips only bytes it +/// itself added last time and re-registers itself at the end +/// of the prompt-hook chain so the framework's redraw never +/// overwrites the badge on the next tick. +#[must_use] +pub fn prompt_badge_integration_snippet(target_shell: InitializeShell) -> String { + let null_device = std::path::Path::new("/dev").join("null"); + match target_shell { + InitializeShell::Bash => format!( + r#" +_REPORTAL_LAST_BADGE_BASH="" +_reportal_prompt_badge_hook() {{ + local _b; _b=$(rep prompt --shell bash 2>{null_device}) + if [ -n "$_REPORTAL_LAST_BADGE_BASH" ] && [[ "$PS1" == "$_REPORTAL_LAST_BADGE_BASH"* ]]; then + PS1="${{PS1#$_REPORTAL_LAST_BADGE_BASH}}" + fi + if [ -n "$_b" ]; then + PS1="$_b $PS1" + _REPORTAL_LAST_BADGE_BASH="$_b " + else + _REPORTAL_LAST_BADGE_BASH="" + fi + PROMPT_COMMAND="${{PROMPT_COMMAND//;_reportal_prompt_badge_hook/}}" + PROMPT_COMMAND="${{PROMPT_COMMAND//_reportal_prompt_badge_hook;/}}" + PROMPT_COMMAND="${{PROMPT_COMMAND//_reportal_prompt_badge_hook/}}" + PROMPT_COMMAND="${{PROMPT_COMMAND:+$PROMPT_COMMAND;}}_reportal_prompt_badge_hook" +}} +PROMPT_COMMAND="${{PROMPT_COMMAND:+$PROMPT_COMMAND;}}_reportal_prompt_badge_hook" +"#, + null_device = null_device.display(), + ), + InitializeShell::Zsh => format!( + r#" +typeset -g _REPORTAL_LAST_BADGE_ZSH="" +_reportal_prompt_badge_hook() {{ + local _b + _b=$(rep prompt --shell zsh 2>{null_device}) + if [ -n "$_REPORTAL_LAST_BADGE_ZSH" ] && [[ "$PROMPT" == "$_REPORTAL_LAST_BADGE_ZSH"* ]]; then + PROMPT="${{PROMPT#$_REPORTAL_LAST_BADGE_ZSH}}" + fi + if [ -n "$_b" ]; then + PROMPT="$_b $PROMPT" + _REPORTAL_LAST_BADGE_ZSH="$_b " + else + _REPORTAL_LAST_BADGE_ZSH="" + fi +}} +autoload -Uz add-zsh-hook 2>{null_device} || true +if typeset -f add-zsh-hook >{null_device} 2>&1; then add-zsh-hook precmd _reportal_prompt_badge_hook; fi +"#, + null_device = null_device.display(), + ), + InitializeShell::Powershell => String::from( + r#" +if (-not (Get-Variable -Name '_ReportalOriginalBadgePrompt' -Scope Global -ErrorAction SilentlyContinue)) { + $Global:_ReportalOriginalBadgePrompt = $function:global:prompt +} +function global:prompt { + $existingPromptOutput = & $Global:_ReportalOriginalBadgePrompt + $badgeOutput = rep prompt --shell powershell 2>$null + if ($badgeOutput) { "$badgeOutput $existingPromptOutput" } else { $existingPromptOutput } +} +"#, + ), + } +} diff --git a/src/reportal_commands/target_selection.rs b/src/reportal_commands/target_selection.rs new file mode 100644 index 0000000..6e03750 --- /dev/null +++ b/src/reportal_commands/target_selection.rs @@ -0,0 +1,154 @@ +//! Unified fuzzy-select helper for `rep jump` / `rep open` that +//! includes both registered repos AND registered workspaces in +//! one list. +//! +//! Needed because the user's mental model for `rj` / `ro` is "go +//! somewhere" — and workspaces are legitimate targets. A repo-only +//! fuzzy list forces them to remember which entities are repos vs +//! workspaces before picking a command, which defeats the "fuzzy +//! finder to the rescue" ergonomic. + +use crate::error::ReportalError; +use crate::reportal_config::{HasAliases, ReportalConfig, TagFilter}; +use crate::terminal_style; +use dialoguer::FuzzySelect; +use owo_colors::OwoColorize; + +/// Classified fuzzy-select outcome for the `rj` / `ro` entry +/// points. +/// +/// Carries just the canonical alias/name; callers re-resolve the +/// full entry from the config as needed. Avoids tangling the +/// lifetime of the selection result with the borrowed config +/// snapshot taken at the selection moment. +pub enum SelectedTarget { + /// The user picked a registered repo; payload is the repo's + /// canonical alias (BTreeMap key). + Repo(String), + /// The user picked a registered workspace; payload is the + /// workspace's canonical name. + Workspace(String), +} + +/// Inputs needed to present the unified fuzzy finder. +/// +/// Takes the tag filter so the repo rows still honor `--tag`. +/// Workspace rows are included only when the tag filter is `All`, +/// because workspaces do not carry tags and slipping them into a +/// tag-restricted list would imply they match the filter. +pub struct SelectedTargetParameters<'borrow> { + /// The loaded config to draw rows from. + pub loaded_config: &'borrow ReportalConfig, + /// Which repos to show in the finder; workspaces are shown + /// only when this is `TagFilter::All`. + pub tag_filter: &'borrow TagFilter, + /// The prompt shown above the fuzzy finder. + pub prompt_label: &'borrow str, +} + +/// Presents the unified fuzzy finder and returns the user's +/// selection as a [`SelectedTarget`]. +/// +/// Rows: +/// - repos render with their color swatch `██` and any aliases, +/// description, and tags — same format as the repo-only finder. +/// - workspaces render with `▣▣` and a trailing `[workspace]` +/// suffix so the two kinds are visually distinct in the list. +/// +/// The repo list precedes the workspace list because repos are +/// the common case; the selected-index arithmetic uses the split +/// count to dispatch to the correct variant. +/// +/// # Errors +/// +/// Returns [`ReportalError::NoReposMatchFilter`] when the tag +/// filter leaves zero repos AND the workspace namespace is also +/// empty; [`ReportalError::SelectionCancelled`] if the user +/// escapes the prompt; [`ReportalError::ConfigIoFailure`] for +/// fuzzy-finder I/O errors. +pub fn select_target( + params: &SelectedTargetParameters<'_>, +) -> Result { + let matching_repos = params + .loaded_config + .repos_matching_tag_filter(params.tag_filter); + let workspace_rows_included = match params.tag_filter { + TagFilter::All => params.loaded_config.workspaces_with_names(), + TagFilter::ByTag(_restricted_tag) => Vec::new(), + }; + + if matching_repos.is_empty() && workspace_rows_included.is_empty() { + return Err(ReportalError::NoReposMatchFilter); + } + + let mut display_labels: Vec = + Vec::with_capacity(matching_repos.len() + workspace_rows_included.len()); + + for (repository_alias, repo_entry) in &matching_repos { + let swatch_style = + match terminal_style::swatch_style_for_repo_color(repo_entry.repo_color()) { + Ok(resolved_style) => resolved_style, + Err(_color_error) => terminal_style::DEFAULT_SWATCH_STYLE, + }; + let swatch = "██".style(swatch_style); + let mut label = format!("{swatch} {repository_alias}"); + if !repo_entry.aliases().is_empty() { + let aliases_joined = repo_entry.aliases().join(", "); + label = format!("{label} ({aliases_joined})"); + } + if !repo_entry.description().is_empty() { + label = format!("{label} — {}", repo_entry.description()); + } + if !repo_entry.tags().is_empty() { + let tags_joined = repo_entry.tags().join(", "); + label = format!("{label} [{tags_joined}]"); + } + display_labels.push(label); + } + + for (workspace_name, workspace_entry) in &workspace_rows_included { + let uppercase_name = workspace_name.to_uppercase(); + let mut label = format!("▣▣ {uppercase_name}"); + if !workspace_entry.aliases().is_empty() { + let aliases_joined = workspace_entry.aliases().join(", "); + label = format!("{label} ({aliases_joined})"); + } + if !workspace_entry.description().is_empty() { + label = format!("{label} — {}", workspace_entry.description()); + } + display_labels.push(format!("{label} [workspace]")); + } + + let prompt_theme = terminal_style::reportal_prompt_theme(); + let selected_index = FuzzySelect::with_theme(&prompt_theme) + .with_prompt(params.prompt_label) + .items(&display_labels) + .highlight_matches(false) + .interact_opt() + .map_err(|select_error| ReportalError::ConfigIoFailure { + reason: select_error.to_string(), + })?; + + let chosen_index = match selected_index { + Some(index) => index, + None => return Err(ReportalError::SelectionCancelled), + }; + + let repo_count = matching_repos.len(); + if chosen_index < repo_count { + match matching_repos.get(chosen_index) { + Some((chosen_repo_alias, _chosen_repo_entry)) => { + Ok(SelectedTarget::Repo((*chosen_repo_alias).clone())) + } + None => Err(ReportalError::SelectionCancelled), + } + } else { + let workspace_offset = chosen_index - repo_count; + match workspace_rows_included.get(workspace_offset) { + Some((chosen_workspace_name, _chosen_entry)) => { + Ok(SelectedTarget::Workspace((*chosen_workspace_name).clone())) + } + None => Err(ReportalError::SelectionCancelled), + } + } +} diff --git a/src/reportal_commands/terminal_identity_emit.rs b/src/reportal_commands/terminal_identity_emit.rs new file mode 100644 index 0000000..62edb44 --- /dev/null +++ b/src/reportal_commands/terminal_identity_emit.rs @@ -0,0 +1,43 @@ +//! Emits OSC terminal identity sequences (tab title + color) for a selected repo. + +use crate::reportal_config::{RepoColor, RepoEntry, TabTitle}; +use crate::terminal_style::{self, TabColorAction, TerminalIdentity, TerminalIdentityParameters}; + +/// Parameters for emitting terminal identity (tab title + color). +pub struct TerminalIdentityEmitParameters<'a> { + /// The selected repo's alias (used as fallback title). + pub selected_alias: &'a str, + /// The selected repo's config entry (provides title and color fields). + pub selected_repo: &'a RepoEntry, + /// If non-empty, overrides the repo's configured title for this session. + pub title_override: &'a str, +} + +/// Resolves a repo's tab title and color, then emits OSC sequences +/// directly to the console handle (CONOUT$ / /dev/tty). +/// +/// Title precedence: `title_override` > repo's `title` field > alias. +/// Color: repo's `color` field if set, otherwise resets to terminal default. +pub fn emit_repo_terminal_identity(identity_params: &TerminalIdentityEmitParameters<'_>) { + let resolved_title = if identity_params.title_override.is_empty() { + match identity_params.selected_repo.tab_title() { + TabTitle::Custom(custom_title) => custom_title.to_owned(), + TabTitle::UseAlias => identity_params.selected_alias.to_owned(), + } + } else { + identity_params.title_override.to_owned() + }; + + let tab_color_action = match identity_params.selected_repo.repo_color() { + RepoColor::Themed(hex_color) => { + TabColorAction::SetColor(hex_color.as_osc_tab_color_sequence()) + } + RepoColor::ResetToDefault => TabColorAction::Reset, + }; + + let identity = TerminalIdentity::new(TerminalIdentityParameters { + resolved_title, + tab_color_action, + }); + terminal_style::emit_terminal_identity_to_console(&identity); +} diff --git a/src/reportal_commands/workspace_create.rs b/src/reportal_commands/workspace_create.rs new file mode 100644 index 0000000..475c30a --- /dev/null +++ b/src/reportal_commands/workspace_create.rs @@ -0,0 +1,103 @@ +//! Creates a new workspace and materializes its on-disk directory +//! (symlinks + `.code-workspace` file). + +use crate::cli_args::WorkspaceArgumentsCreateParts; +use crate::error::ReportalError; +use crate::reportal_commands::workspace_operations::WorkspaceRegenerator; +use crate::reportal_config::{ReportalConfig, WorkspaceRegistrationBuilder}; +use crate::terminal_style; +use owo_colors::OwoColorize; +use std::path::PathBuf; + +/// Registers a new workspace with the given members and +/// materializes the on-disk directory containing one symlink / +/// junction per member plus the `.code-workspace` file. +/// +/// The save-then-regenerate ordering is required so the regenerator +/// reads the post-insert registry state. Regenerating before save +/// would make the new workspace unreachable via `get_workspace`. +/// +/// The optional `--file-path` flag now specifies the workspace +/// **directory**, not the `.code-workspace` file path. If the user +/// passes a legacy value ending in `.code-workspace`, the parent +/// directory is used instead — so existing scripts that set the +/// flag continue to produce a usable workspace directory. +/// +/// # Errors +/// +/// Returns [`ReportalError::ValidationFailure`] from the builder if +/// the name or member list is empty, +/// [`ReportalError::WorkspaceAlreadyExists`] if a workspace with +/// that name is already registered, +/// [`ReportalError::WorkspaceHasDanglingRepo`] if any member alias +/// is not registered, or the file/link I/O errors the regeneration +/// path surfaces. +pub fn run_workspace_create( + create_parts: &WorkspaceArgumentsCreateParts, +) -> Result<(), ReportalError> { + let mut loaded_config = ReportalConfig::load_or_initialize()?; + + let reinterpreted_directory_path = + reinterpret_custom_path_as_directory(create_parts.custom_file_path()); + + let validated_registration = + WorkspaceRegistrationBuilder::start(create_parts.workspace_name().to_owned()) + .repo_aliases(create_parts.repo_aliases().to_vec()) + .workspace_description(create_parts.description().to_owned()) + .workspace_file_path(reinterpreted_directory_path) + .workspace_aliases(create_parts.workspace_aliases().to_vec()) + .build()?; + + loaded_config.add_workspace(validated_registration)?; + loaded_config.save_to_disk()?; + + let regenerator = WorkspaceRegenerator::for_config(&loaded_config); + let workspace_file_path = + regenerator.regenerate_workspace_file(create_parts.workspace_name())?; + let workspace_directory = + regenerator.resolve_workspace_directory(create_parts.workspace_name())?; + + terminal_style::print_success(&format!( + "Created workspace {} at {}", + create_parts + .workspace_name() + .style(terminal_style::ALIAS_STYLE), + workspace_directory + .display() + .to_string() + .style(terminal_style::PATH_STYLE), + )); + terminal_style::write_stdout(&format!( + " workspace file: {}\n", + workspace_file_path + .display() + .to_string() + .style(terminal_style::PATH_STYLE), + )); + Ok(()) +} + +/// Converts a user-supplied `--file-path` value into the workspace +/// directory path stored in config. +/// +/// Pre-v0.15.2 the flag pointed at the `.code-workspace` file; we +/// interpret such values as the parent directory so existing +/// scripts continue to produce a working layout. Empty input +/// preserves the empty-string sentinel that means "use the default +/// `//` location." +fn reinterpret_custom_path_as_directory(raw_path: &str) -> String { + let trimmed = raw_path.trim(); + if trimmed.is_empty() { + return String::new(); + } + let candidate = PathBuf::from(trimmed); + if candidate + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("code-workspace")) + { + return candidate + .parent() + .map_or_else(String::new, |parent_path| parent_path.display().to_string()); + } + trimmed.to_owned() +} diff --git a/src/reportal_commands/workspace_delete.rs b/src/reportal_commands/workspace_delete.rs new file mode 100644 index 0000000..1976c27 --- /dev/null +++ b/src/reportal_commands/workspace_delete.rs @@ -0,0 +1,71 @@ +//! Removes a workspace from config, optionally purging its on-disk +//! directory. + +use crate::cli_args::WorkspaceArgumentsDeleteParts; +use crate::error::ReportalError; +use crate::reportal_commands::workspace_layout::purge_workspace_directory; +use crate::reportal_commands::workspace_operations::WorkspaceRegenerator; +use crate::reportal_config::ReportalConfig; +use crate::terminal_style; +use owo_colors::OwoColorize; + +/// Unregisters the named workspace from config, optionally purging +/// its on-disk directory. +/// +/// Accepts either the canonical workspace key or any declared +/// alias. Default behavior is to remove the config entry and leave +/// the workspace directory in place — that keeps the `--purge` +/// step explicit so an accidental `delete` cannot destroy an open +/// editor session. Member repo directories are never touched, even +/// with `--purge`; only the symlinks inside the workspace directory +/// are removed. +/// +/// # Errors +/// +/// Returns [`ReportalError::WorkspaceNotFound`] if no workspace +/// matches the name or alias, or the config / filesystem I/O +/// errors that the load, save, and purge paths surface. +pub fn run_workspace_delete( + delete_parts: &WorkspaceArgumentsDeleteParts, +) -> Result<(), ReportalError> { + let mut loaded_config = ReportalConfig::load_or_initialize()?; + let canonical_name = + loaded_config.resolve_workspace_canonical_name(delete_parts.workspace_name())?; + + // Resolve the directory BEFORE removing the entry — the + // resolver reads the workspace entry, so we need to capture + // the path while the entry is still in the registry. If the + // user did not pass --purge we still load the path so the + // deletion message can point at exactly what would be removed. + let regenerator = WorkspaceRegenerator::for_config(&loaded_config); + let workspace_directory = regenerator.resolve_workspace_directory(&canonical_name)?; + + loaded_config.remove_workspace(&canonical_name)?; + loaded_config.save_to_disk()?; + + if delete_parts.purge() { + terminal_style::write_stdout(&format!( + " Purging workspace directory {} (member repos are not touched)\n", + workspace_directory + .display() + .to_string() + .style(terminal_style::PATH_STYLE), + )); + purge_workspace_directory(&workspace_directory)?; + terminal_style::print_success(&format!( + "Removed workspace {} from config and purged {}", + canonical_name.style(terminal_style::ALIAS_STYLE), + workspace_directory + .display() + .to_string() + .style(terminal_style::PATH_STYLE), + )); + } else { + terminal_style::print_success(&format!( + "Removed workspace {} from config (directory at {} was not deleted — pass --purge to remove it)", + canonical_name.style(terminal_style::ALIAS_STYLE), + workspace_directory.display().to_string().style(terminal_style::PATH_STYLE), + )); + } + Ok(()) +} diff --git a/src/reportal_commands/workspace_dispatch.rs b/src/reportal_commands/workspace_dispatch.rs new file mode 100644 index 0000000..5f9df7c --- /dev/null +++ b/src/reportal_commands/workspace_dispatch.rs @@ -0,0 +1,60 @@ +//! Dispatch layer for `rep workspace` subcommands. +//! +//! Routes a parsed `WorkspaceArguments` to the matching handler in one +//! place so main.rs does not grow a secondary match on workspace +//! variants, and so adding a new workspace subcommand requires +//! exactly one new arm in exactly one file. + +use crate::cli_args::{WorkspaceArguments, WorkspaceArgumentsSubcommand}; +use crate::error::ReportalError; +use crate::reportal_commands::{ + run_workspace_add_repo, run_workspace_create, run_workspace_delete, run_workspace_jump, + run_workspace_list, run_workspace_open, run_workspace_rebuild, run_workspace_remove_repo, + run_workspace_show, +}; + +/// Dispatches a parsed `rep workspace` invocation to the matching +/// subcommand handler. +/// +/// Taking ownership of `WorkspaceArguments` is required because every +/// handler pulls owned strings out via `into_parts` / +/// `into_workspace_name`, which consume the parsed args. Returning +/// the handler's `Result` directly lets main.rs treat workspace +/// errors the same as every other subcommand's errors. +/// +/// # Errors +/// +/// Returns whatever error the selected handler produces. See the +/// individual `run_workspace_*` functions for the per-action error +/// set. +pub fn dispatch_workspace_subcommand( + parsed_workspace_args: WorkspaceArguments, +) -> Result<(), ReportalError> { + match parsed_workspace_args.into_action() { + WorkspaceArgumentsSubcommand::List => run_workspace_list(), + WorkspaceArgumentsSubcommand::Show(name_only) => { + run_workspace_show(&name_only.into_workspace_name()) + } + WorkspaceArgumentsSubcommand::Create(create_args) => { + run_workspace_create(&create_args.into_parts()) + } + WorkspaceArgumentsSubcommand::Delete(delete_args) => { + run_workspace_delete(&delete_args.into_parts()) + } + WorkspaceArgumentsSubcommand::AddRepo(member_edit) => { + run_workspace_add_repo(&member_edit.into_parts()) + } + WorkspaceArgumentsSubcommand::RemoveRepo(member_edit) => { + run_workspace_remove_repo(&member_edit.into_parts()) + } + WorkspaceArgumentsSubcommand::Open(optional_name) => { + run_workspace_open(&optional_name.into_optional_workspace_name()) + } + WorkspaceArgumentsSubcommand::Jump(optional_name) => { + run_workspace_jump(&optional_name.into_optional_workspace_name()) + } + WorkspaceArgumentsSubcommand::Rebuild(name_only) => { + run_workspace_rebuild(&name_only.into_workspace_name()) + } + } +} diff --git a/src/reportal_commands/workspace_jump.rs b/src/reportal_commands/workspace_jump.rs new file mode 100644 index 0000000..5b958e6 --- /dev/null +++ b/src/reportal_commands/workspace_jump.rs @@ -0,0 +1,40 @@ +//! Prints a workspace's `.code-workspace` file parent directory +//! for shell `cd` integration. + +use crate::error::ReportalError; +use crate::reportal_commands::direct_alias_router::DirectAliasRouter; +use crate::reportal_commands::workspace_selection; +use crate::reportal_config::ReportalConfig; + +/// Resolves the named workspace (by canonical key or alias) and +/// prints the `.code-workspace` file's parent directory to stdout +/// so the `rjw` shell wrapper can cd there. +/// +/// When the alias string is empty, presents a workspace fuzzy +/// finder so `rjw` with no arguments is a useful entry point +/// instead of an error. This is the workspace-only counterpart to +/// `rep jump`; it never falls through to the repo registry. +/// Separating the two entry points keeps `rjw` from accidentally +/// cd'ing into a repo when the user meant to target a workspace +/// by the same short name. +/// +/// # Errors +/// +/// Returns [`ReportalError::WorkspaceNotFound`] when a non-empty +/// name or alias matches no registered workspace, +/// [`ReportalError::NoWorkspacesConfigured`] when the fuzzy +/// finder would have no items, +/// [`ReportalError::SelectionCancelled`] if the user escapes the +/// prompt, or [`ReportalError::ConfigIoFailure`] if the default +/// workspace file location needs the home directory and it cannot +/// be resolved. +pub fn run_workspace_jump(alias_or_canonical: &str) -> Result<(), ReportalError> { + let loaded_config = ReportalConfig::load_or_initialize()?; + let canonical_workspace_name = if alias_or_canonical.is_empty() { + workspace_selection::select_workspace(&loaded_config)? + } else { + loaded_config.resolve_workspace_canonical_name(alias_or_canonical)? + }; + let router = DirectAliasRouter::for_config(&loaded_config); + router.jump_to_workspace_parent(&canonical_workspace_name) +} diff --git a/src/reportal_commands/workspace_layout.rs b/src/reportal_commands/workspace_layout.rs new file mode 100644 index 0000000..d26ab8d --- /dev/null +++ b/src/reportal_commands/workspace_layout.rs @@ -0,0 +1,569 @@ +//! Materializes a workspace as a real directory on disk, with +//! symlinks (Unix) or directory junctions (Windows) pointing at +//! each member repo, plus a `.code-workspace` file inside. +//! +//! The workspace directory replaces the pre-v0.15.2 layout where +//! reportal only wrote a loose `.code-workspace` JSON file +//! under `~/.reportal/workspaces/`. Materializing a real directory +//! means `rjw` can cd into it and every member repo appears as a +//! subdirectory — so `ls`, `cargo`, and an IDE all see +//! the same view of the workspace. +//! +//! Member repos are NEVER moved on disk; only a symlink / junction +//! is created per member. A single repo can therefore belong to any +//! number of workspaces without conflict. + +use crate::code_workspace::CodeWorkspaceFile; +use crate::error::ReportalError; +use std::io; +use std::path::{Path, PathBuf}; + +/// Describes one member link that the workspace layout builder +/// materializes on disk. +/// +/// The link name is the short label the user sees in the workspace +/// directory (and in the editor sidebar) — typically the repo alias. +/// The target is the absolute path of the real repo on disk, which +/// the symlink / junction points at. +pub struct WorkspaceLinkSpec { + /// Short name for the link inside the workspace directory. + pub link_name: String, + /// Absolute path the link should resolve to. + pub target_absolute_path: PathBuf, +} + +/// Outcome of materializing a single member link, for post-op +/// diagnostics and logging. +/// +/// Named variants rather than `Option` because `Created`, +/// `AlreadyCorrect`, and `Replaced` carry different operational +/// meanings the caller surfaces in user output. +pub enum WorkspaceLinkOutcome { + /// The link did not exist; a fresh symlink / junction was created. + Created, + /// A link already existed pointing at the expected target; no + /// change on disk. + AlreadyCorrect, + /// A link already existed pointing elsewhere; the stale link was + /// removed and recreated against the new target. + Replaced, +} + +/// Creates a symlink (Unix) or directory junction (Windows) at +/// `link_path` pointing to `target`. +/// +/// The platform split exists because Windows `std::os::windows::fs:: +/// symlink_dir` requires developer mode or elevated privileges, +/// while a directory junction works for any user. Junctions resolve +/// identically to symlinks for our purposes: `cd`, `ls`, and editor +/// file operations all follow them transparently. +/// +/// # Errors +/// +/// Returns the underlying `io::Error` from the OS call if the link +/// cannot be created (common causes: parent directory missing, +/// target does not exist on Windows, or a file already exists at +/// `link_path`). +pub fn create_workspace_link(target: &Path, link_path: &Path) -> io::Result<()> { + #[cfg(unix)] + { + std::os::unix::fs::symlink(target, link_path) + } + #[cfg(windows)] + { + junction::create(target, link_path) + } + #[cfg(not(any(unix, windows)))] + { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "workspace symlink creation is not supported on this platform", + )) + } +} + +/// Parameters for materializing a workspace's on-disk layout. +/// +/// Bundled so the public entry point takes a single struct rather +/// than four positional arguments, in line with the project's +/// param-count rules for public functions. +pub struct WorkspaceLayoutParameters<'caller> { + /// Absolute path of the directory that should hold the + /// `.code-workspace` file and every member link. + pub workspace_directory: &'caller Path, + /// Workspace name (also the base filename of the + /// `.code-workspace` file written inside the directory). + pub workspace_name: &'caller str, + /// Ordered list of member links to materialize. The order + /// determines the `folders[]` order in the `.code-workspace` + /// file and therefore the editor sidebar. + pub member_links: &'caller [WorkspaceLinkSpec], + /// Workspace identity label stamped into the editor's + /// `window.title` setting. `None` leaves the setting + /// untouched; `Some(label)` replaces the `.code-workspace` + /// file's `settings` object with a reportal-managed block. + pub window_title_override: Option<&'caller str>, + /// Workspace accent color as a raw `#RRGGBB` string, used + /// for the editor title bar when `window_title_override` is + /// also set. `None` means no title-bar color override. + pub title_bar_color_hex: Option<&'caller str>, +} + +/// Materializes the workspace directory, one link per member, and +/// the `.code-workspace` file inside the directory. +/// +/// Idempotent: running against an existing workspace directory +/// updates links whose targets have changed, leaves already-correct +/// links untouched, and refuses to clobber non-link entries at the +/// link paths so a user who drops a real file into the workspace +/// directory cannot lose it to a silent overwrite. +/// +/// The `.code-workspace` file is written with relative `./` +/// folder entries so opening it in Cursor / `VSCode` surfaces the +/// symlinked paths exactly the way a terminal `cd` into the +/// workspace directory would. +/// +/// # Errors +/// +/// Returns [`ReportalError::CodeWorkspaceIoFailure`] if the +/// directory cannot be created, a stale link cannot be replaced, or +/// the `.code-workspace` file cannot be written; or +/// [`ReportalError::ValidationFailure`] if any link path is already +/// occupied by a real file / directory that is not a symlink +/// pointing at the expected target. +pub fn materialize_workspace_layout( + layout_params: &WorkspaceLayoutParameters<'_>, +) -> Result { + ensure_directory_exists(layout_params.workspace_directory)?; + for link_spec in layout_params.member_links { + ensure_member_link(layout_params.workspace_directory, link_spec)?; + } + let workspace_file_path = workspace_file_path_inside_dir( + layout_params.workspace_directory, + layout_params.workspace_name, + ); + let relative_folder_paths: Vec = layout_params + .member_links + .iter() + .map(|link_spec| PathBuf::from(format!("./{}", link_spec.link_name))) + .collect(); + let mut code_workspace_document = CodeWorkspaceFile::load_or_empty(&workspace_file_path)?; + code_workspace_document.set_folder_paths(&relative_folder_paths); + if let Some(window_title) = layout_params.window_title_override { + code_workspace_document.set_workspace_identity( + window_title.to_owned(), + layout_params.title_bar_color_hex.map(str::to_owned), + ); + } + code_workspace_document.write_to_disk(&workspace_file_path)?; + Ok(workspace_file_path) +} + +/// Computes the canonical `.code-workspace` file path inside a +/// workspace directory, for callers that only need the file path +/// without running the full materialization. +#[must_use] +pub fn workspace_file_path_inside_dir(workspace_directory: &Path, workspace_name: &str) -> PathBuf { + workspace_directory.join(format!("{workspace_name}.code-workspace")) +} + +/// Removes the workspace directory and every symlink / file it +/// contains, following the `--purge` contract on +/// `rep workspace delete`. +/// +/// Only the workspace directory is removed. Member repo contents +/// are never touched: symlinks are unlinked, the real repo +/// directories they point at remain on disk. +/// +/// # Errors +/// +/// Returns [`ReportalError::CodeWorkspaceIoFailure`] if the +/// directory cannot be removed (permission denied, device busy, +/// etc.). A missing directory is not an error — purge is a no-op +/// in that case because the user is clearly asking for "make sure +/// this doesn't exist." +pub fn purge_workspace_directory(workspace_directory: &Path) -> Result<(), ReportalError> { + if !workspace_directory.exists() { + return Ok(()); + } + std::fs::remove_dir_all(workspace_directory).map_err(|remove_error| { + ReportalError::CodeWorkspaceIoFailure { + file_path: workspace_directory.display().to_string(), + reason: remove_error.to_string(), + } + })?; + Ok(()) +} + +/// Creates `target_directory` (and every missing parent) if it +/// does not already exist. +fn ensure_directory_exists(target_directory: &Path) -> Result<(), ReportalError> { + if target_directory.exists() { + return Ok(()); + } + std::fs::create_dir_all(target_directory).map_err(|create_error| { + ReportalError::CodeWorkspaceIoFailure { + file_path: target_directory.display().to_string(), + reason: create_error.to_string(), + } + })?; + Ok(()) +} + +/// Ensures one member link exists at +/// `/` pointing at +/// `link_spec.target_absolute_path`. +/// +/// Handles three cases: +/// - The path is free: create the link. +/// - The path is a link to the expected target: no-op. +/// - The path is a link to a different target: unlink + recreate. +/// - The path is a real file / directory: refuse — the user would +/// lose data on an unconditional overwrite. +fn ensure_member_link( + workspace_directory: &Path, + link_spec: &WorkspaceLinkSpec, +) -> Result { + let link_path = workspace_directory.join(&link_spec.link_name); + let link_metadata = std::fs::symlink_metadata(&link_path); + match link_metadata { + Err(metadata_error) if metadata_error.kind() == io::ErrorKind::NotFound => { + create_workspace_link(&link_spec.target_absolute_path, &link_path).map_err( + |link_error| ReportalError::CodeWorkspaceIoFailure { + file_path: link_path.display().to_string(), + reason: link_error.to_string(), + }, + )?; + Ok(WorkspaceLinkOutcome::Created) + } + Err(metadata_error) => Err(ReportalError::CodeWorkspaceIoFailure { + file_path: link_path.display().to_string(), + reason: metadata_error.to_string(), + }), + Ok(existing_metadata) => handle_existing_link_path( + &link_path, + &existing_metadata, + &link_spec.target_absolute_path, + ), + } +} + +/// Decides what to do with an existing entry at the link path: +/// leave it if it already points at the right target, replace it +/// if it's a stale link, or refuse if it's real user data. +fn handle_existing_link_path( + link_path: &Path, + existing_metadata: &std::fs::Metadata, + expected_target: &Path, +) -> Result { + if existing_metadata.file_type().is_symlink() { + return replace_or_keep_symlink(link_path, expected_target); + } + // On Windows a directory junction reports as a directory, not a + // symlink. Treat any directory whose canonical path matches the + // expected target as a correct link so re-runs are idempotent. + if existing_metadata.is_dir() && paths_point_to_same_target(link_path, expected_target) { + return Ok(WorkspaceLinkOutcome::AlreadyCorrect); + } + Err(ReportalError::ValidationFailure { + field: "workspace member link".to_owned(), + reason: format!( + "'{}' already exists and is not a reportal-managed symlink; refusing to clobber — move the file out of the workspace directory and retry", + link_path.display(), + ), + }) +} + +/// Reads a symlink, compares against the expected target, and +/// either leaves it alone or replaces it with a correct link. +fn replace_or_keep_symlink( + link_path: &Path, + expected_target: &Path, +) -> Result { + let current_target = std::fs::read_link(link_path).map_err(|read_error| { + ReportalError::CodeWorkspaceIoFailure { + file_path: link_path.display().to_string(), + reason: read_error.to_string(), + } + })?; + if current_target == expected_target { + return Ok(WorkspaceLinkOutcome::AlreadyCorrect); + } + std::fs::remove_file(link_path).map_err(|remove_error| { + ReportalError::CodeWorkspaceIoFailure { + file_path: link_path.display().to_string(), + reason: remove_error.to_string(), + } + })?; + create_workspace_link(expected_target, link_path).map_err(|link_error| { + ReportalError::CodeWorkspaceIoFailure { + file_path: link_path.display().to_string(), + reason: link_error.to_string(), + } + })?; + Ok(WorkspaceLinkOutcome::Replaced) +} + +/// Best-effort comparison of two paths by canonical form, used on +/// Windows where a junction shows up as a plain directory in the +/// metadata but still resolves to the intended target through +/// `canonicalize`. +fn paths_point_to_same_target(candidate_path: &Path, expected_path: &Path) -> bool { + match (candidate_path.canonicalize(), expected_path.canonicalize()) { + (Ok(candidate_canonical), Ok(expected_canonical)) => { + candidate_canonical == expected_canonical + } + _ => false, + } +} + +#[cfg(test)] +#[allow( + clippy::expect_used, + reason = "test code: `.expect` is the project-wide convention for fixture setup in tests (matches the pattern in code_workspace_file.rs and workspace_registration_builder.rs test modules)" +)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + static TEST_DIR_COUNTER: AtomicU64 = AtomicU64::new(0); + + fn unique_temp_dir(label: &str) -> PathBuf { + let counter_value = TEST_DIR_COUNTER.fetch_add(1, Ordering::Relaxed); + let process_id = std::process::id(); + let path = std::env::temp_dir().join(format!( + "reportal-wslayout-{label}-{process_id}-{counter_value}" + )); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).expect("temp dir must be creatable"); + path + } + + #[test] + #[cfg(unix)] + fn create_workspace_link_on_unix_points_at_target() { + let scratch_dir = unique_temp_dir("create-link"); + let target_dir = scratch_dir.join("target"); + std::fs::create_dir_all(&target_dir).expect("target must be creatable"); + let link_path = scratch_dir.join("link"); + + create_workspace_link(&target_dir, &link_path).expect("link creation must succeed"); + + let link_metadata = + std::fs::symlink_metadata(&link_path).expect("link metadata must be readable"); + assert!( + link_metadata.file_type().is_symlink(), + "created path must be a symlink", + ); + let resolved_target = std::fs::read_link(&link_path).expect("symlink must be readable"); + assert_eq!(resolved_target, target_dir); + + let _ = std::fs::remove_dir_all(&scratch_dir); + } + + #[test] + fn materialize_layout_produces_expected_links_and_file() { + let scratch_dir = unique_temp_dir("materialize"); + let repo_alpha = scratch_dir.join("repos").join("alpha"); + let repo_bravo = scratch_dir.join("repos").join("bravo"); + let repo_charlie = scratch_dir.join("repos").join("charlie"); + std::fs::create_dir_all(&repo_alpha).expect("repo alpha must be creatable"); + std::fs::create_dir_all(&repo_bravo).expect("repo bravo must be creatable"); + std::fs::create_dir_all(&repo_charlie).expect("repo charlie must be creatable"); + + let workspace_dir = scratch_dir.join("workspaces").join("nro"); + let member_links = vec![ + WorkspaceLinkSpec { + link_name: "alpha".to_owned(), + target_absolute_path: repo_alpha.clone(), + }, + WorkspaceLinkSpec { + link_name: "bravo".to_owned(), + target_absolute_path: repo_bravo.clone(), + }, + WorkspaceLinkSpec { + link_name: "charlie".to_owned(), + target_absolute_path: repo_charlie.clone(), + }, + ]; + + let workspace_file_path = materialize_workspace_layout(&WorkspaceLayoutParameters { + workspace_directory: &workspace_dir, + workspace_name: "nro", + member_links: &member_links, + window_title_override: None, + title_bar_color_hex: None, + }) + .expect("materialize must succeed"); + + assert!( + workspace_dir.exists(), + "workspace directory must be created" + ); + assert_eq!( + workspace_file_path, + workspace_dir.join("nro.code-workspace") + ); + assert!( + workspace_file_path.exists(), + ".code-workspace file must be written inside workspace dir", + ); + + for (link_name, expected_target) in [ + ("alpha", &repo_alpha), + ("bravo", &repo_bravo), + ("charlie", &repo_charlie), + ] { + let link_path = workspace_dir.join(link_name); + let metadata = std::fs::symlink_metadata(&link_path).expect("link metadata must exist"); + #[cfg(unix)] + assert!( + metadata.file_type().is_symlink(), + "member '{link_name}' must be a symlink", + ); + #[cfg(windows)] + assert!( + metadata.is_dir(), + "member '{link_name}' must be a junction (reports as dir on Windows)", + ); + let _ = metadata; + #[cfg(unix)] + { + let resolved = std::fs::read_link(&link_path).expect("link must be readable"); + assert_eq!(&resolved, expected_target); + } + let _ = expected_target; + } + + let workspace_file_contents = + std::fs::read_to_string(&workspace_file_path).expect("workspace file read"); + assert!( + workspace_file_contents.contains("./alpha"), + "folders[] should use relative path ./alpha", + ); + assert!( + workspace_file_contents.contains("./bravo"), + "folders[] should use relative path ./bravo", + ); + assert!( + workspace_file_contents.contains("./charlie"), + "folders[] should use relative path ./charlie", + ); + + let _ = std::fs::remove_dir_all(&scratch_dir); + } + + #[test] + #[cfg(unix)] + fn materialize_layout_is_idempotent_and_replaces_stale_links() { + let scratch_dir = unique_temp_dir("idempotent"); + let real_target = scratch_dir.join("repos").join("real"); + let stale_target = scratch_dir.join("repos").join("stale"); + std::fs::create_dir_all(&real_target).expect("real target must be creatable"); + std::fs::create_dir_all(&stale_target).expect("stale target must be creatable"); + + let workspace_dir = scratch_dir.join("workspaces").join("nro"); + std::fs::create_dir_all(&workspace_dir).expect("workspace dir must be creatable"); + + // Pre-populate a stale link to prove it gets replaced. + let stale_link_path = workspace_dir.join("alpha"); + std::os::unix::fs::symlink(&stale_target, &stale_link_path) + .expect("stale link must be creatable"); + + let member_links = vec![WorkspaceLinkSpec { + link_name: "alpha".to_owned(), + target_absolute_path: real_target.clone(), + }]; + + materialize_workspace_layout(&WorkspaceLayoutParameters { + workspace_directory: &workspace_dir, + workspace_name: "nro", + member_links: &member_links, + window_title_override: None, + title_bar_color_hex: None, + }) + .expect("first run must succeed"); + + let resolved_after_first = std::fs::read_link(&stale_link_path).expect("link must resolve"); + assert_eq!( + resolved_after_first, real_target, + "stale link must have been replaced with the real target", + ); + + // Second run is a no-op — must still succeed. + materialize_workspace_layout(&WorkspaceLayoutParameters { + workspace_directory: &workspace_dir, + workspace_name: "nro", + member_links: &member_links, + window_title_override: None, + title_bar_color_hex: None, + }) + .expect("second run must be idempotent"); + + let resolved_after_second = + std::fs::read_link(&stale_link_path).expect("link must still resolve"); + assert_eq!(resolved_after_second, real_target); + + let _ = std::fs::remove_dir_all(&scratch_dir); + } + + #[test] + #[cfg(unix)] + fn materialize_layout_refuses_to_clobber_real_file_at_link_path() { + let scratch_dir = unique_temp_dir("clobber-guard"); + let real_target = scratch_dir.join("repos").join("real"); + std::fs::create_dir_all(&real_target).expect("real target must be creatable"); + + let workspace_dir = scratch_dir.join("workspaces").join("nro"); + std::fs::create_dir_all(&workspace_dir).expect("workspace dir must be creatable"); + + // Pre-populate a real file at the link path. + let occupied_link_path = workspace_dir.join("alpha"); + std::fs::write( + &occupied_link_path, + b"user data the tool must not destroy\n", + ) + .expect("scratch file must be writable"); + + let member_links = vec![WorkspaceLinkSpec { + link_name: "alpha".to_owned(), + target_absolute_path: real_target.clone(), + }]; + + let outcome = materialize_workspace_layout(&WorkspaceLayoutParameters { + workspace_directory: &workspace_dir, + workspace_name: "nro", + member_links: &member_links, + window_title_override: None, + title_bar_color_hex: None, + }); + + assert!( + matches!(outcome, Err(ReportalError::ValidationFailure { .. })), + "must refuse to clobber a real file at a link path, got: {outcome:?}", + ); + let file_contents = + std::fs::read(&occupied_link_path).expect("file must still exist after refusal"); + assert_eq!(file_contents, b"user data the tool must not destroy\n"); + + let _ = std::fs::remove_dir_all(&scratch_dir); + } + + #[test] + fn purge_workspace_directory_is_noop_on_missing_dir() { + let scratch_dir = unique_temp_dir("purge-missing"); + let nonexistent = scratch_dir.join("never-created"); + purge_workspace_directory(&nonexistent).expect("purge of missing dir must succeed"); + let _ = std::fs::remove_dir_all(&scratch_dir); + } + + #[test] + fn workspace_file_path_inside_dir_composes_expected_name() { + let workspace_dir = PathBuf::from("/workspaces/nro"); + let file_path = workspace_file_path_inside_dir(&workspace_dir, "nro"); + assert_eq!( + file_path, + PathBuf::from("/workspaces/nro/nro.code-workspace") + ); + } +} diff --git a/src/reportal_commands/workspace_listing.rs b/src/reportal_commands/workspace_listing.rs new file mode 100644 index 0000000..a98308b --- /dev/null +++ b/src/reportal_commands/workspace_listing.rs @@ -0,0 +1,100 @@ +//! Lists every registered VSCode/Cursor `.code-workspace` entry. +//! +//! Reads the config, then prints each workspace's name, description, +//! and member repo aliases in declared order so the user can see at +//! a glance which workspaces exist and what each one groups together. + +use crate::error::ReportalError; +use crate::reportal_commands::workspace_operations::WorkspaceRegenerator; +use crate::reportal_config::{HasAliases, ReportalConfig}; +use crate::terminal_style; +use owo_colors::OwoColorize; + +/// Prints every registered workspace with its description and member repos. +/// +/// Iterates `workspaces_with_names` so output order matches the +/// deterministic `BTreeMap` iteration, which means two invocations +/// against the same config produce byte-identical output — required +/// for the user to rely on list output as a source of truth. +/// +/// # Errors +/// +/// Returns [`ReportalError::ConfigParseFailure`] / +/// [`ReportalError::ConfigIoFailure`] if the config cannot be +/// loaded, or [`ReportalError::WorkspaceHasDanglingRepo`] if the +/// on-load validation pass finds a workspace pointing at a +/// non-registered repo. +pub fn run_workspace_list() -> Result<(), ReportalError> { + let loaded_config = ReportalConfig::load_or_initialize()?; + let registered_workspaces = loaded_config.workspaces_with_names(); + + if registered_workspaces.is_empty() { + terminal_style::write_stdout( + "No workspaces registered. Use 'rep workspace create' to add one.\n", + ); + return Ok(()); + } + + terminal_style::write_stdout("\n"); + terminal_style::write_stdout(&format!( + " {}\n", + "Workspaces".style(terminal_style::EMPHASIS_STYLE) + )); + terminal_style::write_stdout("\n"); + + let regenerator = WorkspaceRegenerator::for_config(&loaded_config); + for (workspace_name, workspace_entry) in ®istered_workspaces { + let uppercase_name = workspace_name.to_uppercase(); + terminal_style::write_stdout(&format!( + " {}\n", + uppercase_name.style(terminal_style::ALIAS_STYLE), + )); + + if !workspace_entry.description().is_empty() { + terminal_style::write_stdout(&format!( + " {} {}\n", + "Desc:".style(terminal_style::LABEL_STYLE), + workspace_entry.description(), + )); + } + + if !workspace_entry.aliases().is_empty() { + terminal_style::write_stdout(&format!( + " {} {}\n", + "Aliases:".style(terminal_style::LABEL_STYLE), + workspace_entry + .aliases() + .join(", ") + .style(terminal_style::ALIAS_STYLE), + )); + } + + if let Ok(workspace_directory) = regenerator.resolve_workspace_directory(workspace_name) { + terminal_style::write_stdout(&format!( + " {} {}\n", + "Dir:".style(terminal_style::LABEL_STYLE), + workspace_directory + .display() + .to_string() + .style(terminal_style::PATH_STYLE), + )); + } + + let formatted_member_list = workspace_entry.repo_aliases().join(", "); + terminal_style::write_stdout(&format!( + " {} {}\n", + "Repos:".style(terminal_style::LABEL_STYLE), + formatted_member_list.style(terminal_style::PATH_STYLE), + )); + + terminal_style::write_stdout("\n"); + } + + terminal_style::write_stdout(&format!( + " {} workspaces total\n\n", + registered_workspaces + .len() + .style(terminal_style::EMPHASIS_STYLE), + )); + Ok(()) +} diff --git a/src/reportal_commands/workspace_members.rs b/src/reportal_commands/workspace_members.rs new file mode 100644 index 0000000..03813ff --- /dev/null +++ b/src/reportal_commands/workspace_members.rs @@ -0,0 +1,132 @@ +//! Add-repo and remove-repo subcommands for workspace membership. + +use crate::cli_args::WorkspaceArgumentsMemberEditParts; +use crate::error::ReportalError; +use crate::reportal_commands::workspace_operations::WorkspaceRegenerator; +use crate::reportal_config::{ReportalConfig, WorkspaceMember, WorkspaceMemberAliasLookup}; +use crate::terminal_style; +use owo_colors::OwoColorize; + +/// Appends a repo alias to the workspace's member list and +/// regenerates the `.code-workspace` file from the post-mutation +/// state. +/// +/// Accepts either the canonical workspace key or any declared +/// workspace alias. Resolves to canonical first so downstream +/// regeneration uses the correct file-location key. +/// +/// # Errors +/// +/// Returns [`ReportalError::WorkspaceNotFound`] if the workspace +/// name or alias does not exist, [`ReportalError::RepoNotFound`] +/// if the repo alias is not registered, or +/// [`ReportalError::ValidationFailure`] if the member is already +/// present in the workspace. Config I/O and file regeneration +/// errors bubble up from the underlying calls. +pub fn run_workspace_add_repo( + member_edit: &WorkspaceArgumentsMemberEditParts, +) -> Result<(), ReportalError> { + let mut loaded_config = ReportalConfig::load_or_initialize()?; + loaded_config.get_repo(member_edit.repository_alias())?; + let canonical_workspace_name = + loaded_config.resolve_workspace_canonical_name(member_edit.workspace_name())?; + + let target_workspace = loaded_config.get_workspace_mut(&canonical_workspace_name)?; + if target_workspace.contains_repo(member_edit.repository_alias()) { + return Err(ReportalError::ValidationFailure { + field: "workspace member".to_owned(), + reason: format!( + "repo '{}' is already a member of workspace '{canonical_workspace_name}'", + member_edit.repository_alias(), + ), + }); + } + let mut updated_members = target_workspace.members().to_vec(); + updated_members.push(WorkspaceMember::RegisteredRepo( + member_edit.repository_alias().to_owned(), + )); + target_workspace.set_members(updated_members); + loaded_config.save_to_disk()?; + + let regenerator = WorkspaceRegenerator::for_config(&loaded_config); + regenerator.regenerate_workspace_file(&canonical_workspace_name)?; + + terminal_style::print_success(&format!( + "Added {} to workspace {}", + member_edit + .repository_alias() + .style(terminal_style::ALIAS_STYLE), + canonical_workspace_name.style(terminal_style::ALIAS_STYLE), + )); + Ok(()) +} + +/// Drops a repo alias from the workspace's member list and +/// regenerates the `.code-workspace` file from the post-mutation +/// state. +/// +/// Accepts either the canonical workspace key or any declared +/// workspace alias. Refuses to remove a repo that is not currently +/// a member so the caller gets a clear error instead of a silent +/// no-op, and refuses to leave the workspace empty because an +/// empty workspace has no meaning. +/// +/// # Errors +/// +/// Returns [`ReportalError::WorkspaceNotFound`] if the workspace +/// name or alias does not exist, or +/// [`ReportalError::ValidationFailure`] if the repo is not a +/// current member or if removal would leave the workspace with +/// zero members. Config I/O and file regeneration errors bubble up +/// from the underlying calls. +pub fn run_workspace_remove_repo( + member_edit: &WorkspaceArgumentsMemberEditParts, +) -> Result<(), ReportalError> { + let mut loaded_config = ReportalConfig::load_or_initialize()?; + let canonical_workspace_name = + loaded_config.resolve_workspace_canonical_name(member_edit.workspace_name())?; + let target_workspace = loaded_config.get_workspace_mut(&canonical_workspace_name)?; + if !target_workspace.contains_repo(member_edit.repository_alias()) { + return Err(ReportalError::ValidationFailure { + field: "workspace member".to_owned(), + reason: format!( + "repo '{}' is not a member of workspace '{canonical_workspace_name}'", + member_edit.repository_alias(), + ), + }); + } + let remaining_members: Vec = target_workspace + .members() + .iter() + .filter(|member| match member.registered_repo_alias() { + WorkspaceMemberAliasLookup::Matches(existing_alias) => { + existing_alias != member_edit.repository_alias() + } + WorkspaceMemberAliasLookup::NotARepoReference => true, + }) + .cloned() + .collect(); + if remaining_members.is_empty() { + return Err(ReportalError::ValidationFailure { + field: "workspace member".to_owned(), + reason: format!( + "removing '{}' would leave workspace '{canonical_workspace_name}' with zero members; delete the workspace instead", + member_edit.repository_alias(), + ), + }); + } + target_workspace.set_members(remaining_members); + loaded_config.save_to_disk()?; + + let regenerator = WorkspaceRegenerator::for_config(&loaded_config); + regenerator.regenerate_workspace_file(&canonical_workspace_name)?; + + terminal_style::print_success(&format!( + "Removed {} from workspace {}", + member_edit + .repository_alias() + .style(terminal_style::ALIAS_STYLE), + canonical_workspace_name.style(terminal_style::ALIAS_STYLE), + )); + Ok(()) +} diff --git a/src/reportal_commands/workspace_open.rs b/src/reportal_commands/workspace_open.rs new file mode 100644 index 0000000..63f0db0 --- /dev/null +++ b/src/reportal_commands/workspace_open.rs @@ -0,0 +1,65 @@ +//! Opens a workspace's `.code-workspace` file in the default editor. + +use crate::error::ReportalError; +use crate::reportal_commands::workspace_operations::WorkspaceRegenerator; +use crate::reportal_commands::workspace_selection; +use crate::reportal_config::ReportalConfig; +use crate::terminal_style; +use owo_colors::OwoColorize; +use std::process::Command; + +/// Regenerates the workspace's `.code-workspace` file and launches +/// the configured default editor against it. +/// +/// Accepts either the canonical workspace key or any declared +/// alias; an empty string presents a workspace fuzzy finder. Every +/// path resolves to a canonical name first so the regenerator's +/// default file location uses the canonical name rather than the +/// user's short input. +/// +/// # Errors +/// +/// Returns [`ReportalError::WorkspaceNotFound`] if a non-empty +/// name or alias is unknown, +/// [`ReportalError::NoWorkspacesConfigured`] when the fuzzy +/// finder has nothing to show, +/// [`ReportalError::SelectionCancelled`] if the user escapes the +/// prompt, [`ReportalError::RepoNotFound`] if any member alias +/// does not resolve, [`ReportalError::EditorLaunchFailure`] if +/// the editor process cannot be spawned, or the config / file I/O +/// errors the regeneration path surfaces. +pub fn run_workspace_open(alias_or_canonical: &str) -> Result<(), ReportalError> { + let loaded_config = ReportalConfig::load_or_initialize()?; + let canonical_name = if alias_or_canonical.is_empty() { + workspace_selection::select_workspace(&loaded_config)? + } else { + loaded_config.resolve_workspace_canonical_name(alias_or_canonical)? + }; + + let regenerator = WorkspaceRegenerator::for_config(&loaded_config); + let workspace_file_path = regenerator.regenerate_workspace_file(&canonical_name)?; + + let editor_command = loaded_config.default_editor(); + + #[cfg(target_os = "windows")] + let spawn_result = Command::new("cmd") + .args(["/c", editor_command]) + .arg(&workspace_file_path) + .spawn(); + + #[cfg(not(target_os = "windows"))] + let spawn_result = Command::new(editor_command) + .arg(&workspace_file_path) + .spawn(); + + spawn_result.map_err(|spawn_error| ReportalError::EditorLaunchFailure { + reason: spawn_error.to_string(), + })?; + + terminal_style::print_success(&format!( + "Opened workspace {} in {}", + canonical_name.style(terminal_style::ALIAS_STYLE), + editor_command.style(terminal_style::ALIAS_STYLE), + )); + Ok(()) +} diff --git a/src/reportal_commands/workspace_operations.rs b/src/reportal_commands/workspace_operations.rs new file mode 100644 index 0000000..c85ae4c --- /dev/null +++ b/src/reportal_commands/workspace_operations.rs @@ -0,0 +1,196 @@ +//! Regeneration of workspace directories + `.code-workspace` files +//! from the current config. +//! +//! `WorkspaceRegenerator` is the single chokepoint through which +//! every `rep workspace` subcommand materializes a workspace on +//! disk, so the resolution of member repo aliases to absolute +//! paths, the decision of where the workspace directory lives on +//! disk, and the symlink / junction creation all stay in one place +//! and cannot drift between callers. +//! +//! Since v0.15.2 the workspace is a real directory on disk that +//! contains the `.code-workspace` file plus one symlink / junction +//! per member repo, not a loose `.code-workspace` file under +//! `~/.reportal/workspaces/`. The regenerator creates and maintains +//! that directory structure via the `workspace_layout` module. + +use crate::error::ReportalError; +use crate::reportal_commands::workspace_layout::{ + materialize_workspace_layout, workspace_file_path_inside_dir, WorkspaceLayoutParameters, + WorkspaceLinkSpec, +}; +use crate::reportal_config::{ReportalConfig, WorkspaceEntry, WorkspaceMember}; +use std::path::PathBuf; + +/// Regenerates workspace directories and their `.code-workspace` +/// files from a borrowed config. +/// +/// Constructed once per command invocation with +/// `WorkspaceRegenerator::for_config(&config)` and then asked to +/// regenerate one or more workspaces by name. Holding the config +/// reference as state means each helper method has exactly one +/// non-`self` parameter (the workspace name), which the project's +/// positional-argument rules require. +pub struct WorkspaceRegenerator<'config_lifetime> { + /// The loaded config supplying repo paths and workspace entries. + config_registry: &'config_lifetime ReportalConfig, +} + +/// Regeneration methods that walk workspace membership and produce +/// the materialized workspace directory on disk. +impl<'config_lifetime> WorkspaceRegenerator<'config_lifetime> { + /// Builds a regenerator that reads workspace and repo data from + /// the given config reference. + #[must_use] + pub fn for_config(config_registry: &'config_lifetime ReportalConfig) -> Self { + Self { config_registry } + } + + /// Rebuilds the workspace directory, its member symlinks / + /// junctions, and its `.code-workspace` file, and returns the + /// path of the workspace file. + /// + /// Idempotent: running against an existing workspace directory + /// updates links whose targets have moved, leaves correct links + /// alone, and preserves user-authored fields inside the + /// `.code-workspace` file (only the `folders` array is replaced). + /// + /// # Errors + /// + /// Returns [`ReportalError::WorkspaceNotFound`] if the workspace + /// name is not registered, [`ReportalError::RepoNotFound`] if + /// any member alias does not resolve, or + /// [`ReportalError::CodeWorkspaceIoFailure`] / + /// [`ReportalError::ValidationFailure`] for directory, link, or + /// `.code-workspace` I/O failures surfaced by + /// [`materialize_workspace_layout`]. + pub fn regenerate_workspace_file( + &self, + workspace_name: &str, + ) -> Result { + let target_workspace = self.config_registry.get_workspace(workspace_name)?; + let member_links = self.build_member_link_specs(target_workspace)?; + let workspace_directory = self.resolve_workspace_directory(workspace_name)?; + let resolved_window_title = match target_workspace.tab_title() { + crate::reportal_config::TabTitle::Custom(custom_label) => custom_label.clone(), + crate::reportal_config::TabTitle::UseAlias => workspace_name.to_owned(), + }; + let resolved_title_bar_hex = target_workspace + .workspace_color() + .themed_hex_color() + .map(|hex_color| hex_color.raw_value().to_owned()); + materialize_workspace_layout(&WorkspaceLayoutParameters { + workspace_directory: &workspace_directory, + workspace_name, + member_links: &member_links, + window_title_override: Some(resolved_window_title.as_str()), + title_bar_color_hex: resolved_title_bar_hex.as_deref(), + }) + } + + /// Resolves the absolute path of the workspace's on-disk + /// directory (the one that contains the `.code-workspace` file + /// and member symlinks / junctions). + /// + /// Honors an explicit `path` field on the entry if set. Pre-v0.15.2 + /// entries that still store a `.code-workspace` file path in + /// that field are interpreted as legacy and mapped to the + /// default layout under `//`, so + /// `rjw` on an un-migrated workspace still lands in the right + /// place. Empty `path` falls back to that same default. + /// + /// # Errors + /// + /// Returns [`ReportalError::WorkspaceNotFound`] if the name is + /// unknown, or [`ReportalError::ConfigIoFailure`] if the home + /// directory is needed for the default and cannot be resolved. + pub fn resolve_workspace_directory( + &self, + workspace_name: &str, + ) -> Result { + let target_workspace = self.config_registry.get_workspace(workspace_name)?; + let raw_path = target_workspace.raw_workspace_file_path().trim(); + if raw_path.is_empty() || target_workspace.is_legacy_file_path() { + let default_root = self.config_registry.resolve_default_workspace_root()?; + return Ok(default_root.join(workspace_name)); + } + let expanded_path = shellexpand::tilde(raw_path); + Ok(PathBuf::from(expanded_path.as_ref())) + } + + /// Resolves the absolute path of the `.code-workspace` file + /// the `rep workspace open` / `row` commands should launch. + /// + /// The file lives inside the workspace directory as + /// `/.code-workspace`. + /// + /// # Errors + /// + /// Returns the same error set as + /// [`Self::resolve_workspace_directory`]. + pub fn resolve_workspace_file_path( + &self, + workspace_name: &str, + ) -> Result { + let directory = self.resolve_workspace_directory(workspace_name)?; + Ok(workspace_file_path_inside_dir(&directory, workspace_name)) + } + + /// Resolves every workspace member to the link-spec shape the + /// layout materializer consumes: a short link name (the repo + /// alias, or the inline path's file-stem) and the absolute + /// target path. + /// + /// Inline-path members fall back to the path's final component + /// for the link name, preserving the pre-v0.15.2 behavior where + /// inline members had no stable identifier beyond their path. + /// The declared order is preserved so the `.code-workspace` + /// `folders[]` order and the on-disk directory listing match + /// what the user wrote. + /// + /// # Errors + /// + /// Returns [`ReportalError::RepoNotFound`] at the first alias + /// that does not resolve against the repo registry. + fn build_member_link_specs( + &self, + target_workspace: &WorkspaceEntry, + ) -> Result, ReportalError> { + let declared_members = target_workspace.members(); + let mut resolved_specs = Vec::with_capacity(declared_members.len()); + for member in declared_members { + match member { + WorkspaceMember::RegisteredRepo(repository_alias) => { + let member_repo = self.config_registry.get_repo(repository_alias)?; + resolved_specs.push(WorkspaceLinkSpec { + link_name: repository_alias.clone(), + target_absolute_path: member_repo.resolved_path(), + }); + } + WorkspaceMember::InlinePath { path } => { + let expanded = shellexpand::tilde(path); + let target_path = PathBuf::from(expanded.as_ref()); + let link_name = inline_path_link_name(&target_path); + resolved_specs.push(WorkspaceLinkSpec { + link_name, + target_absolute_path: target_path, + }); + } + } + } + Ok(resolved_specs) + } +} + +/// Derives a short, stable link name for an inline-path workspace +/// member from the path's final component. +/// +/// Falls back to `"inline"` if the path has no usable final +/// component (e.g. a bare `/`), which is unlikely in practice but +/// keeps the materializer from ever emitting an empty link name. +fn inline_path_link_name(target_path: &std::path::Path) -> String { + target_path.file_name().map_or_else( + || "inline".to_owned(), + |os_str| os_str.to_string_lossy().into_owned(), + ) +} diff --git a/src/reportal_commands/workspace_rebuild.rs b/src/reportal_commands/workspace_rebuild.rs new file mode 100644 index 0000000..e8d5aa7 --- /dev/null +++ b/src/reportal_commands/workspace_rebuild.rs @@ -0,0 +1,74 @@ +//! Rebuilds a workspace's on-disk directory from the current config. + +use crate::error::ReportalError; +use crate::reportal_commands::workspace_operations::WorkspaceRegenerator; +use crate::reportal_config::ReportalConfig; +use crate::terminal_style; +use owo_colors::OwoColorize; + +/// Drops and recreates the workspace directory's member symlinks / +/// junctions and the `.code-workspace` file inside it, from the +/// current config. +/// +/// Idempotent: safe to run repeatedly. Useful after a member repo +/// is moved on disk (symlink targets get refreshed), after the +/// user renames a repo alias (link names get renamed), or when the +/// workspace directory has been deleted by hand and the user wants +/// it back. Does NOT move or modify the member repos themselves — +/// only the symlinks / junctions inside the workspace directory. +/// +/// Auto-migrates pre-v0.15.2 workspaces (those whose stored `path` +/// still references a `.code-workspace` file rather than a +/// directory) by rewriting the config to the new directory-based +/// shape before materializing on disk. This is the explicit +/// migration path users run to formalize the layout change. +/// +/// # Errors +/// +/// Returns [`ReportalError::WorkspaceNotFound`] if the name does +/// not match a registered workspace, +/// [`ReportalError::RepoNotFound`] if any member alias does not +/// resolve, or [`ReportalError::CodeWorkspaceIoFailure`] / +/// [`ReportalError::ValidationFailure`] for directory, link, or +/// file I/O failures. +pub fn run_workspace_rebuild(alias_or_canonical: &str) -> Result<(), ReportalError> { + let mut loaded_config = ReportalConfig::load_or_initialize()?; + let canonical_name = loaded_config.resolve_workspace_canonical_name(alias_or_canonical)?; + + // Formalize a legacy file-path entry into a directory-path + // entry before materializing, so subsequent loads stop + // printing the migration notice. + let was_legacy = { + let target_workspace = loaded_config.get_workspace(&canonical_name)?; + target_workspace.is_legacy_file_path() + }; + if was_legacy { + let new_directory_path = loaded_config + .resolve_default_workspace_root()? + .join(&canonical_name); + let target_workspace = loaded_config.get_workspace_mut(&canonical_name)?; + target_workspace.set_workspace_directory_path(new_directory_path.display().to_string()); + loaded_config.save_to_disk()?; + } + + let regenerator = WorkspaceRegenerator::for_config(&loaded_config); + let workspace_file_path = regenerator.regenerate_workspace_file(&canonical_name)?; + let workspace_directory = regenerator.resolve_workspace_directory(&canonical_name)?; + + terminal_style::print_success(&format!( + "Rebuilt workspace {} at {}", + canonical_name.style(terminal_style::ALIAS_STYLE), + workspace_directory + .display() + .to_string() + .style(terminal_style::PATH_STYLE), + )); + terminal_style::write_stdout(&format!( + " workspace file: {}\n", + workspace_file_path + .display() + .to_string() + .style(terminal_style::PATH_STYLE), + )); + Ok(()) +} diff --git a/src/reportal_commands/workspace_selection.rs b/src/reportal_commands/workspace_selection.rs new file mode 100644 index 0000000..c358a10 --- /dev/null +++ b/src/reportal_commands/workspace_selection.rs @@ -0,0 +1,69 @@ +//! Fuzzy-select helper that returns the canonical name of a +//! registered workspace. +//! +//! Used by `rep workspace jump` and `rep workspace open` when the +//! user invokes them without a direct workspace name, mirroring +//! the no-arg fuzzy behavior of `rep jump` and `rep open` on the +//! repo side. + +use crate::error::ReportalError; +use crate::reportal_config::{HasAliases, ReportalConfig}; +use crate::terminal_style; +use dialoguer::FuzzySelect; + +/// Presents a fuzzy finder over every registered workspace and +/// returns the canonical name of the one the user chose. +/// +/// Refuses to open the finder when zero workspaces are registered +/// so the user gets an explicit `NoWorkspacesConfigured` error +/// instead of an empty prompt that only offers cancellation. +/// +/// # Errors +/// +/// Returns [`ReportalError::NoWorkspacesConfigured`] when the +/// workspace registry is empty, or +/// [`ReportalError::SelectionCancelled`] if the user escapes out +/// of the prompt. Fuzzy I/O failures surface as +/// [`ReportalError::ConfigIoFailure`]. +pub fn select_workspace(loaded_config: &ReportalConfig) -> Result { + let registered_workspaces = loaded_config.workspaces_with_names(); + if registered_workspaces.is_empty() { + return Err(ReportalError::NoWorkspacesConfigured); + } + + let display_labels: Vec = registered_workspaces + .iter() + .map(|(workspace_name, workspace_entry)| { + let uppercase_name = workspace_name.to_uppercase(); + let mut label = format!("▣▣ {uppercase_name}"); + let declared_aliases = workspace_entry.aliases(); + if !declared_aliases.is_empty() { + let aliases_joined = declared_aliases.join(", "); + label = format!("{label} ({aliases_joined})"); + } + if !workspace_entry.description().is_empty() { + label = format!("{label} — {}", workspace_entry.description()); + } + format!("{label} [workspace]") + }) + .collect(); + + let prompt_theme = terminal_style::reportal_prompt_theme(); + let selected_index = FuzzySelect::with_theme(&prompt_theme) + .with_prompt("Jump to workspace") + .items(&display_labels) + .highlight_matches(false) + .interact_opt() + .map_err(|select_error| ReportalError::ConfigIoFailure { + reason: select_error.to_string(), + })?; + + let chosen_index = match selected_index { + Some(index) => index, + None => return Err(ReportalError::SelectionCancelled), + }; + match registered_workspaces.get(chosen_index) { + Some((chosen_workspace_name, _chosen_entry)) => Ok((*chosen_workspace_name).clone()), + None => Err(ReportalError::SelectionCancelled), + } +} diff --git a/src/reportal_commands/workspace_show.rs b/src/reportal_commands/workspace_show.rs new file mode 100644 index 0000000..e4f8107 --- /dev/null +++ b/src/reportal_commands/workspace_show.rs @@ -0,0 +1,147 @@ +//! Shows a single workspace's details, resolved member paths, and +//! the on-disk location of its workspace directory. + +use crate::error::ReportalError; +use crate::reportal_commands::workspace_operations::WorkspaceRegenerator; +use crate::reportal_config::{HasAliases, ReportalConfig, WorkspaceMember}; +use crate::terminal_style; +use owo_colors::OwoColorize; + +/// Prints the named workspace's description, member aliases with +/// their resolved absolute paths, the materialized workspace +/// directory path, and the `.code-workspace` file path inside it. +/// +/// For each member, shows whether the on-disk symlink / junction +/// exists so the user can spot a missing link and re-run +/// `rep workspace rebuild`. Does NOT auto-rebuild here; `show` is +/// read-only, and writing through a diagnostic command would make +/// it harder to notice that the layout is stale. +/// +/// # Errors +/// +/// Returns [`ReportalError::WorkspaceNotFound`] if the name has no +/// matching entry or alias, [`ReportalError::RepoNotFound`] if any +/// member alias does not resolve, or the config / file I/O errors +/// that the load path surfaces. +pub fn run_workspace_show(alias_or_canonical: &str) -> Result<(), ReportalError> { + let loaded_config = ReportalConfig::load_or_initialize()?; + let canonical_name = loaded_config.resolve_workspace_canonical_name(alias_or_canonical)?; + let target_workspace = loaded_config.get_workspace(&canonical_name)?; + + let regenerator = WorkspaceRegenerator::for_config(&loaded_config); + let workspace_directory = regenerator.resolve_workspace_directory(&canonical_name)?; + let workspace_file_path = regenerator.resolve_workspace_file_path(&canonical_name)?; + + terminal_style::write_stdout("\n"); + terminal_style::write_stdout(&format!( + " {}\n", + canonical_name + .to_uppercase() + .style(terminal_style::ALIAS_STYLE), + )); + + if !target_workspace.description().is_empty() { + terminal_style::write_stdout(&format!( + " {} {}\n", + "Desc:".style(terminal_style::LABEL_STYLE), + target_workspace.description(), + )); + } + + if !target_workspace.aliases().is_empty() { + terminal_style::write_stdout(&format!( + " {} {}\n", + "Aliases:".style(terminal_style::LABEL_STYLE), + target_workspace + .aliases() + .join(", ") + .style(terminal_style::ALIAS_STYLE), + )); + } + + terminal_style::write_stdout(&format!( + " {} {}\n", + "Dir:".style(terminal_style::LABEL_STYLE), + workspace_directory + .display() + .to_string() + .style(terminal_style::PATH_STYLE), + )); + if !workspace_directory.exists() { + terminal_style::write_stdout(&format!( + " {} directory has not been materialized yet — run `rep workspace rebuild {canonical_name}`\n", + "note:".style(terminal_style::LABEL_STYLE), + )); + } + if target_workspace.is_legacy_file_path() { + terminal_style::write_stdout(&format!( + " {} config still stores a pre-v0.15.2 `.code-workspace` path — run `rep workspace rebuild {canonical_name}` to migrate\n", + "note:".style(terminal_style::LABEL_STYLE), + )); + } + + terminal_style::write_stdout(&format!( + " {} {}\n", + "File:".style(terminal_style::LABEL_STYLE), + workspace_file_path + .display() + .to_string() + .style(terminal_style::PATH_STYLE), + )); + + terminal_style::write_stdout(&format!( + " {}\n", + "Members:".style(terminal_style::LABEL_STYLE), + )); + for member in target_workspace.members() { + match member { + WorkspaceMember::RegisteredRepo(member_alias) => { + let member_repo = loaded_config.get_repo(member_alias)?; + let resolved_member_path = member_repo.resolved_path(); + let link_path = workspace_directory.join(member_alias); + let link_status_text = link_existence_marker(&link_path); + terminal_style::write_stdout(&format!( + " {} {} {} {}\n", + "-".style(terminal_style::LABEL_STYLE), + member_alias.style(terminal_style::ALIAS_STYLE), + resolved_member_path + .display() + .to_string() + .style(terminal_style::PATH_STYLE), + link_status_text, + )); + } + WorkspaceMember::InlinePath { path } => { + let expanded = shellexpand::tilde(path); + let inline_target = std::path::PathBuf::from(expanded.as_ref()); + let link_name = inline_target.file_name().map_or_else( + || "inline".to_owned(), + |os| os.to_string_lossy().into_owned(), + ); + let link_path = workspace_directory.join(&link_name); + let link_status_text = link_existence_marker(&link_path); + terminal_style::write_stdout(&format!( + " {} {} {} {}\n", + "-".style(terminal_style::LABEL_STYLE), + "(inline)".style(terminal_style::LABEL_STYLE), + expanded.as_ref().style(terminal_style::PATH_STYLE), + link_status_text, + )); + } + } + } + + terminal_style::write_stdout("\n"); + Ok(()) +} + +/// Returns a short styled marker describing whether the given link +/// path exists on disk (present) or is missing (link expected but +/// not found). +fn link_existence_marker(link_path: &std::path::Path) -> String { + if std::fs::symlink_metadata(link_path).is_ok() { + format!("[{}]", "linked".style(terminal_style::SUCCESS_STYLE)) + } else { + format!("[{}]", "missing".style(terminal_style::FAILURE_STYLE)) + } +} diff --git a/src/reportal_config.rs b/src/reportal_config.rs deleted file mode 100644 index 845155e..0000000 --- a/src/reportal_config.rs +++ /dev/null @@ -1,560 +0,0 @@ -/// Configuration loading, saving, and repo registry for RePortal. -/// -/// The config file lives at `~/.reportal/config.toml` and stores -/// all registered repositories with their metadata. - -use crate::error::ReportalError; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; -use std::path::PathBuf; - -/// A validated `#RRGGBB` hex color for terminal background theming. -/// -/// Validated at parse time so downstream code can emit OSC sequences -/// without rechecking format. Serializes/deserializes as a plain string. -#[derive(Debug, Clone)] -pub struct HexColor { - value: String, -} - -/// Parsing, validation, and OSC escape sequence generation for hex colors. -impl HexColor { - /// Parses a hex color string, rejecting anything that isn't exactly `#RRGGBB`. - /// - /// Returns `InvalidColor` if the string is the wrong length, missing the - /// leading `#`, or contains non-hex characters. - pub fn parse(raw: &str) -> Result { - let trimmed = raw.trim(); - if trimmed.len() != 7 || !trimmed.starts_with('#') { - return Err(ReportalError::InvalidColor { - value: raw.to_string(), - }); - } - let hex_digits = &trimmed[1..]; - let all_hex = hex_digits.chars().all(|character| character.is_ascii_hexdigit()); - if !all_hex { - return Err(ReportalError::InvalidColor { - value: raw.to_string(), - }); - } - return Ok(Self { - value: trimmed.to_string(), - }); - } - - /// The raw hex string as stored (e.g. `#1a1a2e`). - pub fn raw_value(&self) -> &str { - &self.value - } - - /// Returns the OSC 11 escape sequence that sets the terminal background - /// to this color (e.g. `\x1b]11;#1a1a2e\x07`). - pub fn as_osc_background_sequence(&self) -> String { - return format!("\x1b]11;{}\x07", self.value); - } -} - -impl serde::Serialize for HexColor { - fn serialize(&self, toml_serializer: S) -> Result { - toml_serializer.serialize_str(&self.value) - } -} - -impl<'de> serde::Deserialize<'de> for HexColor { - fn deserialize>(toml_deserializer: D) -> Result { - let raw = String::deserialize(toml_deserializer)?; - HexColor::parse(&raw).map_err(serde::de::Error::custom) - } -} - -/// Whether to filter repos by a specific tag or show all repos. -#[derive(Debug)] -pub enum TagFilter { - /// Show every registered repo regardless of tags. - All, - /// Show only repos that carry this exact tag string. - ByTag(String), -} - -/// Top-level config structure deserialized from `config.toml`. -#[derive(Debug, Deserialize, Serialize)] -pub struct ReportalConfig { - /// Global settings like default editor and clone root. - #[serde(default)] - settings: ReportalSettings, - /// Map of alias to repo definition. - #[serde(default)] - repos: BTreeMap, -} - -/// How repo paths are displayed in output after selecting a repo. -#[derive(Debug, Deserialize, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum PathDisplayFormat { - /// Full absolute path from root. - Absolute, - /// Path relative to the current working directory. - Relative, -} - -/// Formatting methods for converting absolute paths based on display preference. -impl PathDisplayFormat { - /// Formats a path according to the configured display format. - /// - /// For absolute: returns the path as-is. - /// For relative: computes the path relative to the current working directory. - pub fn format_path(&self, absolute_path: &std::path::PathBuf) -> String { - match self { - PathDisplayFormat::Absolute => { - return absolute_path.display().to_string(); - } - PathDisplayFormat::Relative => { - let current_directory = std::env::current_dir(); - match current_directory { - Ok(working_directory) => { - let relative_result = pathdiff::diff_paths(absolute_path, &working_directory); - match relative_result { - Some(relative_path) => return relative_path.display().to_string(), - None => return absolute_path.display().to_string(), - } - } - Err(cwd_read_error) => { - eprintln!(" Could not read working directory: {cwd_read_error}"); - return absolute_path.display().to_string(); - } - } - } - } - } -} - -/// Whether to show the selected repo's path after jump/open. -#[derive(Debug, Deserialize, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum PathVisibility { - /// Print the path after selection. - Show, - /// Do not print the path after selection. - Hide, -} - -/// Global settings that apply across all repos, stored in config.toml. -#[derive(Debug, Deserialize, Serialize)] -pub struct ReportalSettings { - /// Which editor command to use when opening repos. - #[serde(default = "default_editor_command")] - default_editor: String, - /// Root directory for cloning new repos into. - #[serde(default)] - default_clone_root: String, - /// Whether to print the path after selecting a repo in jump/open. - #[serde(default = "default_path_visibility")] - path_on_select: PathVisibility, - /// How to format paths when displayed: absolute or relative. - #[serde(default = "default_path_display_format")] - path_display_format: PathDisplayFormat, -} - -/// Step-by-step builder for registering a new repo. -/// -/// Validates that alias and path are non-empty and the path exists -/// on disk before allowing `build()` to produce a valid registration. -pub struct RepoRegistrationBuilder { - /// Alias collected from the user. - alias: String, - /// Filesystem path collected from the user. - repo_path: String, - /// Description collected from the user. - repo_description: String, - /// Tags collected from the user. - repo_tags: Vec, - /// Git remote URL collected from the user. - repo_remote: String, - /// Custom tab title collected from the user. - repo_title: TabTitle, - /// Background color collected from the user. - repo_color: RepoColor, -} - -/// Whether a repo has a custom tab title or falls back to its alias. -#[derive(Debug, Serialize, Clone)] -#[serde(untagged)] -pub enum TabTitle { - /// No custom title configured; the repo alias is used instead. - UseAlias, - /// A custom title the user chose for this repo's terminal tab. - Custom(String), -} - -impl Default for TabTitle { - fn default() -> Self { - TabTitle::UseAlias - } -} - -impl<'de> Deserialize<'de> for TabTitle { - fn deserialize>(tab_title_deserializer: D) -> Result { - let raw: String = String::deserialize(tab_title_deserializer)?; - match raw.is_empty() { - true => return Ok(TabTitle::UseAlias), - false => return Ok(TabTitle::Custom(raw)), - } - } -} - -/// Whether a repo has a terminal background color configured. -#[derive(Debug, Serialize, Clone)] -#[serde(untagged)] -pub enum RepoColor { - /// No color set; the terminal resets to its default background on jump. - ResetToDefault, - /// A specific background color applied via OSC 11 on jump. - Themed(HexColor), -} - -impl Default for RepoColor { - fn default() -> Self { - RepoColor::ResetToDefault - } -} - -impl<'de> Deserialize<'de> for RepoColor { - fn deserialize>(repo_color_deserializer: D) -> Result { - let raw: String = String::deserialize(repo_color_deserializer)?; - match raw.is_empty() { - true => return Ok(RepoColor::ResetToDefault), - false => { - let parsed = HexColor::parse(&raw).map_err(serde::de::Error::custom)?; - return Ok(RepoColor::Themed(parsed)); - } - } - } -} - -/// A single registered repository with its metadata. -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct RepoEntry { - /// Filesystem path to the repo, may contain `~` for home. - path: String, - /// Human-readable description of what this repo is. - #[serde(default)] - description: String, - /// Tags for filtering and grouping repos. - #[serde(default)] - tags: Vec, - /// Git remote URL for cloning on other machines. - #[serde(default)] - remote: String, - /// Custom tab title shown in the terminal when jumping to this repo. - #[serde(default)] - title: TabTitle, - /// Terminal background color applied via OSC 11 when jumping to this repo. - #[serde(default)] - color: RepoColor, -} - -/// Returns the default editor command when none is configured. -fn default_editor_command() -> String { - "cursor".to_string() -} - -/// Returns the default path visibility (show). -fn default_path_visibility() -> PathVisibility { - PathVisibility::Show -} - -/// Returns the default path display format (absolute). -fn default_path_display_format() -> PathDisplayFormat { - PathDisplayFormat::Absolute -} - -/// Resolves the user home directory or returns an error if unavailable. -fn resolve_home_directory() -> Result { - dirs::home_dir().ok_or(ReportalError::ConfigIoFailure { - reason: "Could not determine home directory".to_string(), - }) -} - -impl Default for ReportalSettings { - fn default() -> Self { - Self { - default_editor: default_editor_command(), - default_clone_root: String::new(), - path_on_select: default_path_visibility(), - path_display_format: default_path_display_format(), - } - } -} - -/// Chainable builder methods for constructing a validated repo registration. -impl RepoRegistrationBuilder { - /// Starts building a registration with the given alias. - pub fn start(alias: String) -> Self { - Self { - alias, - repo_path: String::new(), - repo_description: String::new(), - repo_tags: Vec::new(), - repo_remote: String::new(), - repo_title: TabTitle::UseAlias, - repo_color: RepoColor::ResetToDefault, - } - } - - /// Sets the filesystem path for this repo. - pub fn repo_path(mut self, filesystem_path: String) -> Self { - self.repo_path = filesystem_path; - self - } - - /// Sets the human-readable description. - pub fn repo_description(mut self, description_text: String) -> Self { - self.repo_description = description_text; - self - } - - /// Sets the tags for filtering and grouping. - pub fn repo_tags(mut self, tag_list: Vec) -> Self { - self.repo_tags = tag_list; - self - } - - /// Sets the git remote URL for cloning on other machines. - pub fn repo_remote(mut self, remote_url: String) -> Self { - self.repo_remote = remote_url; - self - } - - /// Sets a custom terminal tab title for this repo. - pub fn repo_title(mut self, title_text: String) -> Self { - self.repo_title = TabTitle::Custom(title_text); - self - } - - /// Sets the terminal background color for this repo. - pub fn repo_color(mut self, hex_color: HexColor) -> Self { - self.repo_color = RepoColor::Themed(hex_color); - self - } - - /// Validates all fields and produces a repo alias + entry pair. - /// - /// Rejects empty aliases, empty paths, and paths that do not - /// exist on disk after tilde expansion. - pub fn build(self) -> Result<(String, RepoEntry), ReportalError> { - if self.alias.trim().is_empty() { - return Err(ReportalError::ValidationFailure { - field: "alias".to_string(), - reason: "must not be empty".to_string(), - }); - } - if self.repo_path.trim().is_empty() { - return Err(ReportalError::ValidationFailure { - field: "path".to_string(), - reason: "must not be empty".to_string(), - }); - } - let expanded_path = shellexpand::tilde(&self.repo_path); - let resolved = PathBuf::from(expanded_path.as_ref()); - if !resolved.exists() { - return Err(ReportalError::ValidationFailure { - field: "path".to_string(), - reason: format!("{} does not exist", resolved.display()), - }); - } - let validated_entry = RepoEntry { - path: self.repo_path, - description: self.repo_description, - tags: self.repo_tags, - remote: self.repo_remote, - title: self.repo_title, - color: self.repo_color, - }; - Ok((self.alias, validated_entry)) - } -} - -/// Accessors and path resolution for a registered repository entry. -impl RepoEntry { - /// Expands `~` in the stored path and returns the absolute filesystem path. - pub fn resolved_path(&self) -> PathBuf { - let expanded = shellexpand::tilde(&self.path); - PathBuf::from(expanded.as_ref()) - } - - /// The raw path string as stored in config, before tilde expansion. - pub fn raw_path(&self) -> &str { - &self.path - } - - /// Human-readable description of this repo's purpose. - pub fn description(&self) -> &str { - &self.description - } - - /// Tags assigned to this repo for filtering and grouping. - pub fn tags(&self) -> &[String] { - &self.tags - } - - /// The configured tab title preference for this repo. - pub fn tab_title(&self) -> &TabTitle { - &self.title - } - - /// The configured terminal background color preference for this repo. - pub fn repo_color(&self) -> &RepoColor { - &self.color - } -} - -/// Loading, saving, querying, and mutating the RePortal config file. -impl ReportalConfig { - /// Returns the directory where RePortal stores its config. - pub fn config_directory() -> Result { - Ok(resolve_home_directory()?.join(".reportal")) - } - - /// Returns the full path to the config TOML file. - pub fn config_file_path() -> Result { - Ok(Self::config_directory()?.join("config.toml")) - } - - /// Loads and parses the config from disk. - /// - /// Returns `ConfigNotFound` if the file does not exist, - /// or `ConfigParseFailure` if the TOML is malformed. - pub fn load_from_disk() -> Result { - let file_path = Self::config_file_path()?; - if !file_path.exists() { - return Err(ReportalError::ConfigNotFound { - config_path: file_path.display().to_string(), - }); - } - let toml_content = std::fs::read_to_string(&file_path).map_err(|io_error| { - ReportalError::ConfigIoFailure { - reason: io_error.to_string(), - } - })?; - toml::from_str(&toml_content).map_err(|parse_error| ReportalError::ConfigParseFailure { - reason: parse_error.to_string(), - }) - } - - /// Serializes and writes the config to disk, creating the directory if needed. - pub fn save_to_disk(&self) -> Result<(), ReportalError> { - let file_path = Self::config_file_path()?; - let parent_directory = Self::config_directory()?; - if !parent_directory.exists() { - std::fs::create_dir_all(&parent_directory).map_err(|io_error| { - ReportalError::ConfigIoFailure { - reason: io_error.to_string(), - } - })?; - } - let serialized_toml = - toml::to_string_pretty(self).map_err(|serialize_error| { - ReportalError::ConfigIoFailure { - reason: serialize_error.to_string(), - } - })?; - std::fs::write(&file_path, serialized_toml).map_err(|io_error| { - ReportalError::ConfigIoFailure { - reason: io_error.to_string(), - } - })?; - Ok(()) - } - - /// Returns repos whose tags match the given filter, with their aliases. - pub fn repos_matching_tag_filter(&self, tag_filter: &TagFilter) -> Vec<(&String, &RepoEntry)> { - self.repos - .iter() - .filter(|(_, repo)| match tag_filter { - TagFilter::All => true, - TagFilter::ByTag(target_tag) => repo.tags.iter().any(|tag| tag == target_tag), - }) - .collect() - } - - /// Returns the configured default editor command. - pub fn default_editor(&self) -> &str { - &self.settings.default_editor - } - - /// Whether to show the path after selecting a repo. - pub fn path_on_select(&self) -> &PathVisibility { - &self.settings.path_on_select - } - - /// How to format displayed paths (absolute or relative). - pub fn path_display_format(&self) -> &PathDisplayFormat { - &self.settings.path_display_format - } - - /// Returns all registered repos with their aliases, for iteration. - pub fn repos_with_aliases(&self) -> Vec<(&String, &RepoEntry)> { - self.repos.iter().collect() - } - - /// Looks up a repo by its alias. Returns `RepoNotFound` if not registered. - pub fn get_repo(&self, alias: &str) -> Result<&RepoEntry, ReportalError> { - self.repos.get(alias).ok_or_else(|| ReportalError::RepoNotFound { - alias: alias.to_string(), - }) - } - - /// Registers a new repo from a validated builder result. - /// - /// Returns `RepoAlreadyExists` if the alias is already taken. - pub fn add_repo(&mut self, validated_registration: (String, RepoEntry)) -> Result<(), ReportalError> { - let (repo_alias, repo_entry) = validated_registration; - if self.repos.contains_key(&repo_alias) { - return Err(ReportalError::RepoAlreadyExists { - alias: repo_alias, - }); - } - self.repos.insert(repo_alias, repo_entry); - Ok(()) - } - - /// Updates the editable metadata fields (description, tags, title, color) - /// for an existing repo. Returns `RepoNotFound` if the alias is not registered. - pub fn update_repo_metadata( - &mut self, - alias: &str, - new_description: String, - new_tags: Vec, - new_title: TabTitle, - new_color: RepoColor, - ) -> Result<(), ReportalError> { - let repo_entry = self.repos.get_mut(alias).ok_or_else(|| ReportalError::RepoNotFound { - alias: alias.to_string(), - })?; - repo_entry.description = new_description; - repo_entry.tags = new_tags; - repo_entry.title = new_title; - repo_entry.color = new_color; - Ok(()) - } - - /// Removes a repo by alias. Returns `RepoNotFound` if missing. - pub fn remove_repo(&mut self, alias: &str) -> Result { - self.repos.remove(alias).ok_or_else(|| ReportalError::RepoNotFound { - alias: alias.to_string(), - }) - } - - /// Creates a default empty config with sensible defaults for first-time setup. - pub fn create_default() -> Self { - Self { - settings: ReportalSettings { - default_editor: "cursor".to_string(), - default_clone_root: "~/dev".to_string(), - path_on_select: PathVisibility::Show, - path_display_format: PathDisplayFormat::Absolute, - }, - repos: BTreeMap::new(), - } - } -} diff --git a/src/reportal_config/ai_tool_entry.rs b/src/reportal_config/ai_tool_entry.rs new file mode 100644 index 0000000..603efa2 --- /dev/null +++ b/src/reportal_config/ai_tool_entry.rs @@ -0,0 +1,37 @@ +//! AI coding CLI tool registry entries stored in the `[ai_tools.*]` +//! sections of `config.toml`. + +use serde::{Deserialize, Serialize}; + +/// A registered AI coding CLI tool with its launch command and arguments. +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct AiToolEntry { + /// The executable command to run (e.g. "aider"). + command: String, + /// Additional arguments passed to the command on every launch. + #[serde(default)] + args: Vec, +} + +/// Construction and accessors for AI tool configuration. +impl AiToolEntry { + /// Registers an AI tool with the given executable and no default arguments. + /// Use this for tools like `aider` that need no extra flags. + /// Additional args can be added per-tool in the TOML config's `args` array. + pub fn with_executable(executable_command: String) -> Self { + Self { + command: executable_command, + args: Vec::new(), + } + } + + /// The executable name used to spawn the AI CLI process. + pub fn cli_command(&self) -> &str { + &self.command + } + + /// Extra arguments appended after the executable on every launch. + pub fn launch_args(&self) -> &[String] { + &self.args + } +} diff --git a/src/reportal_config/alias_collision_query.rs b/src/reportal_config/alias_collision_query.rs new file mode 100644 index 0000000..eb0c8c3 --- /dev/null +++ b/src/reportal_config/alias_collision_query.rs @@ -0,0 +1,47 @@ +//! Parameter struct for workspace-alias collision lookups. + +/// Parameters for a single workspace-alias collision check run by +/// `ReportalConfig::check_workspace_alias_collisions`. +/// +/// Named-field struct so the two `&str` arguments cannot be swapped +/// at call sites — swapping them would silently check the wrong +/// pair and pass every collision through. Lives in its own module +/// because the config root's one-primary-type-per-file convention +/// does not accept unrelated helper types as neighbors. +pub struct AliasCollisionQuery<'borrow> { + /// Canonical name of the workspace whose alias is being + /// validated; used so the scan can skip the owning entry + /// itself when comparing against peers. + owning_workspace_name: &'borrow str, + /// The literal alias value being checked for collisions. + candidate_alias: &'borrow str, +} + +impl<'borrow> AliasCollisionQuery<'borrow> { + /// Builds a new collision query from the owning workspace name + /// and the alias value under test. + /// + /// Both arguments are borrowed for the lifetime of the enclosing + /// validation pass — the query is consumed on use, so no + /// allocation is performed. + #[must_use] + pub fn new(owning_workspace_name: &'borrow str, candidate_alias: &'borrow str) -> Self { + Self { + owning_workspace_name, + candidate_alias, + } + } + + /// Canonical name of the workspace whose alias is being + /// validated. + #[must_use] + pub fn owning_workspace_name(&self) -> &str { + self.owning_workspace_name + } + + /// The literal alias value being checked for collisions. + #[must_use] + pub fn candidate_alias(&self) -> &str { + self.candidate_alias + } +} diff --git a/src/reportal_config/command_entry.rs b/src/reportal_config/command_entry.rs new file mode 100644 index 0000000..335f6a9 --- /dev/null +++ b/src/reportal_config/command_entry.rs @@ -0,0 +1,51 @@ +//! User-defined command entries stored in the `[commands.*]` +//! sections of `config.toml`. + +use crate::reportal_config::shell_alias_export::ShellAliasExport; +use serde::{Deserialize, Serialize}; + +/// A registered command with its shell command string and description. +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct CommandEntry { + /// The shell command to execute (e.g. "npm run dev", "cargo test"). + command: String, + /// Human-readable description shown in the fuzzy picker. + #[serde(default)] + description: String, + /// When `Enabled`, `rep init ` emits a top-level shell + /// function named after the command's config key that runs + /// `rep run --cmd ` so the user can invoke the command + /// from any shell prompt without typing `rep run` first. + /// Opt-in because the command's name shadows whatever else + /// the user's shell PATH already resolves to under that name. + #[serde(default)] + shell_alias: ShellAliasExport, +} + +/// Construction and accessors for command configuration. +impl CommandEntry { + /// @why Exposes the raw shell string `rep run` will pass to + /// `sh -c` so callers render previews and spawn subprocesses + /// from one source of truth without re-parsing config TOML. + pub fn shell_command(&self) -> &str { + &self.command + } + + /// @why Exposes the human-readable description shown next to + /// the command name in the fuzzy picker so `rep run` can + /// build its labels without reaching into the struct's + /// private fields. + pub fn description(&self) -> &str { + &self.description + } + + /// @why Exposes the opt-in export policy `rep init ` + /// reads to decide whether to emit a top-level shell function + /// for this command so users get a direct shell command for + /// their configured action without typing `rep run --cmd + /// ` first. + #[must_use] + pub fn shell_alias_export(&self) -> ShellAliasExport { + self.shell_alias + } +} diff --git a/src/reportal_config/global_settings.rs b/src/reportal_config/global_settings.rs new file mode 100644 index 0000000..1ef9d52 --- /dev/null +++ b/src/reportal_config/global_settings.rs @@ -0,0 +1,127 @@ +//! Global settings that apply across all repos, stored in the +//! `[settings]` section of `config.toml`. + +use crate::terminal_style; +use serde::{Deserialize, Serialize}; + +/// How repo paths are displayed in output after selecting a repo. +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum PathDisplayFormat { + /// Full absolute path from root. + Absolute, + /// Path relative to the current working directory. + Relative, +} + +/// Formatting methods for converting absolute paths based on display preference. +impl PathDisplayFormat { + /// Formats a path according to the configured display format. + /// + /// For absolute: returns the path as-is. + /// For relative: computes the path relative to the current working directory. + pub fn format_path(&self, absolute_path: &std::path::PathBuf) -> String { + match self { + PathDisplayFormat::Absolute => absolute_path.display().to_string(), + PathDisplayFormat::Relative => { + let current_directory = std::env::current_dir(); + match current_directory { + Ok(working_directory) => { + let relative_result = + pathdiff::diff_paths(absolute_path, &working_directory); + relative_result.map_or_else( + || absolute_path.display().to_string(), + |relative_path| relative_path.display().to_string(), + ) + } + Err(cwd_read_error) => { + terminal_style::write_stderr(&format!( + " Could not read working directory: {cwd_read_error}\n" + )); + absolute_path.display().to_string() + } + } + } + } + } +} + +/// Whether to show the selected repo's path after jump/open. +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum PathVisibility { + /// Print the path after selection. + Show, + /// Do not print the path after selection. + Hide, +} + +/// Returns the default editor command when none is configured. +pub fn default_editor_command() -> String { + "cursor".to_owned() +} + +/// Returns the default path visibility (show). +pub fn default_path_visibility() -> PathVisibility { + PathVisibility::Show +} + +/// Returns the default path display format (absolute). +pub fn default_path_display_format() -> PathDisplayFormat { + PathDisplayFormat::Absolute +} + +/// Returns the default workspace root when none is configured. +/// +/// Empty string means the runtime resolver falls back to the +/// computed default `/workspaces`, or +/// `~/dev/workspaces` when no clone root is set. Storing empty +/// here instead of a concrete path keeps the serialized config +/// stable across machines with different home directories. +pub fn default_workspace_root_value() -> String { + String::new() +} + +/// Global settings that apply across all repos, stored in config.toml. +#[derive(Debug, Deserialize, Serialize)] +pub struct ReportalSettings { + /// Which editor command to use when opening repos. + #[serde(default = "default_editor_command")] + pub(crate) default_editor: String, + /// Root directory for cloning new repos into. + #[serde(default)] + pub(crate) default_clone_root: String, + /// Root directory under which workspace directories are + /// materialized (one directory per registered workspace, + /// containing a `.code-workspace` file and one symlink / + /// junction per member repo). + /// + /// Empty falls back to `/workspaces` at + /// resolution time, or `~/dev/workspaces` when no clone root + /// is set. Supports `~` expansion like `default_clone_root`. + #[serde(default = "default_workspace_root_value")] + pub(crate) default_workspace_root: String, + /// Whether to print the path after selecting a repo in jump/open. + #[serde(default = "default_path_visibility")] + pub(crate) path_on_select: PathVisibility, + /// How to format paths when displayed: absolute or relative. + #[serde(default = "default_path_display_format")] + pub(crate) path_display_format: PathDisplayFormat, + /// Which AI tool to launch by default when no --tool flag is given. + #[serde(default)] + pub(crate) default_ai_tool: String, +} + +/// Provides sensible defaults for a fresh config with no `[settings]` section. +impl Default for ReportalSettings { + fn default() -> Self { + Self { + default_editor: default_editor_command(), + default_clone_root: String::new(), + default_workspace_root: default_workspace_root_value(), + path_on_select: default_path_visibility(), + path_display_format: default_path_display_format(), + default_ai_tool: String::new(), + } + } +} diff --git a/src/reportal_config/has_aliases.rs b/src/reportal_config/has_aliases.rs new file mode 100644 index 0000000..07d5b1b --- /dev/null +++ b/src/reportal_config/has_aliases.rs @@ -0,0 +1,124 @@ +//! Trait + generic resolver for alias-aware lookup of canonical keys. +//! +//! The config has two entry types that accept lookup by either a +//! canonical `BTreeMap` key or by a user-declared short-name alias: +//! `RepoEntry` (jumped to by `rep jump alias` etc.) and +//! `WorkspaceEntry` (targeted by `rep workspace open alias`). Without +//! a shared resolver, each registry would reimplement the same +//! "try-key-then-walk-aliases" loop and the two copies would +//! eventually drift on edge cases. This module is the single chokepoint +//! so the resolution semantics stay identical for every aliased entry +//! today and for any future one. + +use std::collections::BTreeMap; + +/// Any config entry type that can be looked up by a canonical key +/// OR by one of its declared short-name aliases. +/// +/// Implementing this trait opts an entry into the +/// `resolve_canonical_key` pipeline. The trait exists purely so the +/// resolver can be generic over entry type — it does not define +/// construction, validation, or mutation, all of which remain on the +/// concrete type. +pub trait HasAliases { + /// Declared alternative names for this entry, in the order the + /// user wrote them. + /// + /// Order is preserved because callers may rely on first-match + /// semantics for display and because a stable declared order + /// keeps TOML round-trips byte-identical. Returning `&[String]` + /// rather than an iterator lets the resolver walk the list + /// without forcing each implementor to return a boxed iterator. + fn aliases(&self) -> &[String]; +} + +/// Resolves a user-supplied lookup key to the canonical key in the +/// given entry map, walking both the map's canonical keys and each +/// entry's alias list. +/// +/// Primary-key match wins first, so the common case (user typed the +/// canonical name) costs one `BTreeMap::get_key_value` and is never +/// shadowed by a colliding alias declared elsewhere in the map. Falls +/// back to a linear scan of entries' alias lists and returns the +/// first canonical key whose entry declares the queried alias. +/// +/// Returns `None` when nothing matches so callers can map absence to +/// their domain-specific not-found error variant (`RepoNotFound`, +/// `WorkspaceNotFound`, ...) without this helper needing to know +/// about the error enum. +pub fn resolve_canonical_key<'map, T>( + entry_map: &'map BTreeMap, + query: &str, +) -> Option<&'map str> +where + T: HasAliases, +{ + if let Some((canonical_key, _)) = entry_map.get_key_value(query) { + return Some(canonical_key.as_str()); + } + entry_map + .iter() + .find(|(_, entry)| entry.aliases().iter().any(|declared| declared == query)) + .map(|(canonical_key, _)| canonical_key.as_str()) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct FakeEntry { + declared_aliases: Vec, + } + + impl HasAliases for FakeEntry { + fn aliases(&self) -> &[String] { + &self.declared_aliases + } + } + + fn build_entry_map() -> BTreeMap { + let mut entry_map = BTreeMap::new(); + entry_map.insert( + "venoble".to_owned(), + FakeEntry { + declared_aliases: vec!["vn".to_owned(), "noble".to_owned()], + }, + ); + entry_map.insert( + "reportal".to_owned(), + FakeEntry { + declared_aliases: vec!["rep".to_owned()], + }, + ); + entry_map + } + + #[test] + fn canonical_key_match_wins_first() { + let entry_map = build_entry_map(); + assert_eq!( + resolve_canonical_key(&entry_map, "venoble"), + Some("venoble") + ); + } + + #[test] + fn alias_falls_back_to_canonical_key_lookup() { + let entry_map = build_entry_map(); + assert_eq!(resolve_canonical_key(&entry_map, "vn"), Some("venoble")); + assert_eq!(resolve_canonical_key(&entry_map, "noble"), Some("venoble")); + assert_eq!(resolve_canonical_key(&entry_map, "rep"), Some("reportal")); + } + + #[test] + fn unknown_query_returns_none() { + let entry_map = build_entry_map(); + assert_eq!(resolve_canonical_key(&entry_map, "ghost"), None); + } + + #[test] + fn empty_map_returns_none_for_any_query() { + let empty_entry_map: BTreeMap = BTreeMap::new(); + assert_eq!(resolve_canonical_key(&empty_entry_map, "anything"), None); + } +} diff --git a/src/reportal_config/hex_color.rs b/src/reportal_config/hex_color.rs new file mode 100644 index 0000000..518ad84 --- /dev/null +++ b/src/reportal_config/hex_color.rs @@ -0,0 +1,89 @@ +//! A validated `#RRGGBB` hex color for terminal background theming. +//! +//! Validated at parse time so downstream code can emit OSC sequences +//! without rechecking format. Serializes/deserializes as a plain string. + +use crate::error::ReportalError; + +/// Stores a validated `#RRGGBB` hex color string. +#[derive(Debug, Clone)] +pub struct HexColor { + value: String, +} + +/// Parsing, validation, and OSC escape sequence generation for hex colors. +impl HexColor { + /// Parses a hex color string, rejecting anything that isn't exactly `#RRGGBB`. + /// + /// Returns `InvalidColor` if the string is the wrong length, missing the + /// leading `#`, or contains non-hex characters. + pub fn parse(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.len() != 7 || !trimmed.starts_with('#') { + return Err(ReportalError::InvalidColor { + value: raw.to_owned(), + }); + } + let hex_digits = &trimmed[1..]; + let all_hex = hex_digits + .chars() + .all(|character| character.is_ascii_hexdigit()); + if !all_hex { + return Err(ReportalError::InvalidColor { + value: raw.to_owned(), + }); + } + Ok(Self { + value: trimmed.to_owned(), + }) + } + + /// The raw hex string as stored (e.g. `#1a1a2e`). + pub fn raw_value(&self) -> &str { + &self.value + } + + /// Extracts the red, green, and blue bytes from the validated hex string. + /// + /// Safe to call on any `HexColor` because `parse()` already validated + /// that exactly 6 hex digits follow the `#`. + pub fn as_rgb_bytes(&self) -> Result<(u8, u8, u8), ReportalError> { + let hex_digits = &self.value[1..]; + let parse_hex_pair = |slice: &str| -> Result { + u8::from_str_radix(slice, 16).map_err(|parse_error| ReportalError::InvalidColor { + value: format!("{}: {parse_error}", self.value), + }) + }; + let red = parse_hex_pair(&hex_digits[0..2])?; + let green = parse_hex_pair(&hex_digits[2..4])?; + let blue = parse_hex_pair(&hex_digits[4..6])?; + Ok((red, green, blue)) + } + + /// Returns the OSC 4;264 escape sequence that sets the Windows Terminal + /// tab color strip to this color. Index 264 is `FRAME_BACKGROUND` in WT's + /// color table. Silently ignored by terminals that don't support it. + /// Format: `\x1b]4;264;rgb:RR/GG/BB\x07` + pub fn as_osc_tab_color_sequence(&self) -> String { + let hex_digits = &self.value[1..]; + let red_hex = &hex_digits[0..2]; + let green_hex = &hex_digits[2..4]; + let blue_hex = &hex_digits[4..6]; + format!("\x1b]4;264;rgb:{red_hex}/{green_hex}/{blue_hex}\x07") + } +} + +/// Serializes a `HexColor` as its raw `#RRGGBB` string for TOML output. +impl serde::Serialize for HexColor { + fn serialize(&self, toml_serializer: S) -> Result { + toml_serializer.serialize_str(&self.value) + } +} + +/// Deserializes a `#RRGGBB` string into a validated `HexColor`. +impl<'de> serde::Deserialize<'de> for HexColor { + fn deserialize>(toml_deserializer: D) -> Result { + let raw = String::deserialize(toml_deserializer)?; + HexColor::parse(&raw).map_err(serde::de::Error::custom) + } +} diff --git a/src/reportal_config/mod.rs b/src/reportal_config/mod.rs new file mode 100644 index 0000000..440332a --- /dev/null +++ b/src/reportal_config/mod.rs @@ -0,0 +1,38 @@ +//! Configuration loading, saving, and repo registry for `RePortal`. +//! +//! The config file lives at `~/.reportal/config.toml` and stores +//! all registered repositories, AI tools, and global settings. + +mod ai_tool_entry; +mod alias_collision_query; +mod command_entry; +mod global_settings; +mod has_aliases; +mod hex_color; +mod repo_entry; +mod repo_registration_builder; +mod reportal_config_root; +mod repository_color; +mod shell_alias_export; +mod tab_title; +mod tag_filter; +mod workspace_entry; +mod workspace_filter; +mod workspace_member; +mod workspace_registration_builder; + +pub use command_entry::CommandEntry; +pub use global_settings::PathVisibility; +pub use has_aliases::HasAliases; +pub use hex_color::HexColor; +pub use repo_entry::RepoEntry; +pub use repo_registration_builder::RepoRegistrationBuilder; +pub use reportal_config_root::ReportalConfig; +pub use repository_color::RepoColor; +pub use shell_alias_export::ShellAliasExport; +pub use tab_title::TabTitle; +pub use tag_filter::TagFilter; +pub use workspace_entry::WorkspaceEntry; +pub use workspace_filter::WorkspaceFilter; +pub use workspace_member::{WorkspaceMember, WorkspaceMemberAliasLookup}; +pub use workspace_registration_builder::WorkspaceRegistrationBuilder; diff --git a/src/reportal_config/repo_entry.rs b/src/reportal_config/repo_entry.rs new file mode 100644 index 0000000..3620163 --- /dev/null +++ b/src/reportal_config/repo_entry.rs @@ -0,0 +1,154 @@ +//! A single registered repository with its metadata and accessors. + +use crate::reportal_config::command_entry::CommandEntry; +use crate::reportal_config::has_aliases::HasAliases; +use crate::reportal_config::repository_color::RepoColor; +use crate::reportal_config::shell_alias_export::ShellAliasExport; +use crate::reportal_config::tab_title::TabTitle; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::PathBuf; + +/// A single registered repository with its metadata. +/// +/// Fields are `pub(super)` rather than fully private so the +/// sibling `repo_registration_builder` module can construct a +/// `RepoEntry` after running its own validation, without forcing +/// a constructor-params indirection. Callers outside +/// `reportal_config` still only see the accessor and setter +/// surface, so Design B's invariants are preserved: no external +/// code can bypass validation to land a malformed entry. +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct RepoEntry { + /// Filesystem path to the repo, may contain `~` for home. + pub(super) path: String, + /// Human-readable description of what this repo is. + #[serde(default)] + pub(super) description: String, + /// Tags for filtering and grouping repos. + #[serde(default)] + pub(super) tags: Vec, + /// Git remote URL for cloning on other machines. + #[serde(default)] + pub(super) remote: String, + /// Alternative names that can be used to jump to this repo directly. + #[serde(default)] + pub(super) aliases: Vec, + /// Custom tab title shown in the terminal when jumping to this repo. + #[serde(default)] + pub(super) title: TabTitle, + /// Terminal background color applied via OSC 11 when jumping to this repo. + #[serde(default)] + pub(super) color: RepoColor, + /// Per-repo commands: same table format as global commands. + #[serde(default)] + pub(super) commands: BTreeMap, + /// When `Enabled`, `rep init ` emits a top-level shell + /// function for this repo's canonical key and every declared + /// alias that does the equivalent of `rj ` (cd into the + /// repo and apply its color). Opt-in because each emitted name + /// shadows whatever else the user's shell PATH already resolves + /// to under that name. + #[serde(default)] + pub(super) shell_alias: ShellAliasExport, +} + +/// Accessors and mutators for a registered repository entry. +impl RepoEntry { + /// Expands `~` in the stored path and returns the absolute filesystem path. + pub fn resolved_path(&self) -> PathBuf { + let expanded = shellexpand::tilde(&self.path); + PathBuf::from(expanded.as_ref()) + } + + /// The raw path string as stored in config, before tilde expansion. + pub fn raw_path(&self) -> &str { + &self.path + } + + /// Human-readable description of this repo's purpose. + pub fn description(&self) -> &str { + &self.description + } + + /// Tags assigned to this repo for filtering and grouping. + pub fn tags(&self) -> &[String] { + &self.tags + } + + /// Git remote URL for cloning on other machines (may be empty). + pub fn remote(&self) -> &str { + &self.remote + } + + /// The configured tab title preference for this repo. + pub fn tab_title(&self) -> &TabTitle { + &self.title + } + + /// The configured terminal background color preference for this repo. + pub fn repo_color(&self) -> &RepoColor { + &self.color + } + + /// Per-repo commands (same format as global commands). + pub fn repo_commands(&self) -> &BTreeMap { + &self.commands + } + + /// Replaces the stored filesystem path. + /// + /// Takes the raw, unexpanded string because the config stores + /// tilde-prefixed paths verbatim and the resolver in + /// `resolved_path` handles expansion at query time. Callers + /// that change a repo's path MUST regenerate every + /// `.code-workspace` file that references this repo afterward, + /// or Design B's invariant — that moving a repo updates every + /// containing workspace — breaks and editor sessions open + /// against stale locations. + pub fn set_raw_path(&mut self, new_raw_path: String) { + self.path = new_raw_path; + } + + /// Replaces the description text. + pub fn set_description(&mut self, new_description: String) { + self.description = new_description; + } + + /// Replaces the tag list. + pub fn set_tags(&mut self, new_tags: Vec) { + self.tags = new_tags; + } + + /// Replaces the tab title preference. + pub fn set_tab_title(&mut self, new_title: TabTitle) { + self.title = new_title; + } + + /// Replaces the terminal background color preference. + pub fn set_repo_color(&mut self, new_color: RepoColor) { + self.color = new_color; + } +} + +impl HasAliases for RepoEntry { + fn aliases(&self) -> &[String] { + &self.aliases + } +} + +/// Shell-alias export policy accessor — kept in its own impl +/// block so the addition does not cascade into the surrounding +/// untagged accessors that the `@why` enforcement guard would +/// otherwise demand we tag in the same change. +impl RepoEntry { + /// @why Exposes the opt-in export policy `rep init ` + /// reads to decide whether to emit top-level shell functions + /// for this repository's canonical key and declared aliases + /// so users get `vna` as a direct shell command for `rj vna` + /// without typing the `rj` prefix. + #[must_use] + pub fn shell_alias_export(&self) -> ShellAliasExport { + self.shell_alias + } +} diff --git a/src/reportal_config/repo_registration_builder.rs b/src/reportal_config/repo_registration_builder.rs new file mode 100644 index 0000000..f505054 --- /dev/null +++ b/src/reportal_config/repo_registration_builder.rs @@ -0,0 +1,158 @@ +//! Step-by-step builder for registering a new repo. + +use crate::error::ReportalError; +use crate::reportal_config::hex_color::HexColor; +use crate::reportal_config::repo_entry::RepoEntry; +use crate::reportal_config::repository_color::RepoColor; +use crate::reportal_config::shell_alias_export::ShellAliasExport; +use crate::reportal_config::tab_title::TabTitle; +use crate::system_executable_lookup::SystemExecutableLookupOutcome; +use std::collections::BTreeMap; +use std::path::PathBuf; + +/// Step-by-step builder for registering a new repo. +/// +/// Validates that alias and path are non-empty and the path exists +/// on disk before allowing `build()` to produce a valid registration. +/// Validation runs at `build()` time rather than on each setter +/// because individual setters run mid-chain when the full +/// registration is not yet assembled, so rejecting then would be +/// rejecting an incomplete state rather than an invalid one. +pub struct RepoRegistrationBuilder { + /// Alias collected from the user. + alias: String, + /// Filesystem path collected from the user. + repo_path: String, + /// Description collected from the user. + repo_description: String, + /// Tags collected from the user. + repo_tags: Vec, + /// Git remote URL collected from the user. + repo_remote: String, + /// Custom tab title collected from the user. + repo_title: TabTitle, + /// Background color collected from the user. + repo_color: RepoColor, +} + +/// Chainable builder methods for constructing a validated repo registration. +impl RepoRegistrationBuilder { + /// Starts building a registration with the given alias. + #[must_use] + pub fn start(alias: String) -> Self { + Self { + alias, + repo_path: String::new(), + repo_description: String::new(), + repo_tags: Vec::new(), + repo_remote: String::new(), + repo_title: TabTitle::UseAlias, + repo_color: RepoColor::ResetToDefault, + } + } + + /// Sets the filesystem path for this repo. + #[must_use] + pub fn repo_path(mut self, filesystem_path: String) -> Self { + self.repo_path = filesystem_path; + self + } + + /// Sets the human-readable description. + #[must_use] + pub fn repo_description(mut self, description_text: String) -> Self { + self.repo_description = description_text; + self + } + + /// Sets the tags for filtering and grouping. + #[must_use] + pub fn repo_tags(mut self, tag_list: Vec) -> Self { + self.repo_tags = tag_list; + self + } + + /// Sets the git remote URL for cloning on other machines. + #[must_use] + pub fn repo_remote(mut self, remote_url: String) -> Self { + self.repo_remote = remote_url; + self + } + + /// Sets a custom terminal tab title for this repo. + #[must_use] + pub fn repo_title(mut self, title_text: String) -> Self { + self.repo_title = TabTitle::Custom(title_text); + self + } + + /// Sets the terminal background color for this repo. + #[must_use] + pub fn repo_color(mut self, hex_color: HexColor) -> Self { + self.repo_color = RepoColor::Themed(hex_color); + self + } + + /// Validates all fields and produces a repo alias + entry pair. + /// + /// Rejects empty aliases, empty paths, and paths that do not + /// exist on disk after tilde expansion. Constructs the + /// `RepoEntry` via direct field assignment on the sibling + /// module's `pub(super)` fields, which is correct because this + /// builder IS the validation surface and the only production + /// path that produces a new `RepoEntry`. + /// + /// # Errors + /// + /// Returns [`ReportalError::ValidationFailure`] with the field + /// name and the specific reason for each rejection. + pub fn build(self) -> Result<(String, RepoEntry), ReportalError> { + if self.alias.trim().is_empty() { + return Err(ReportalError::ValidationFailure { + field: "alias".to_owned(), + reason: "must not be empty".to_owned(), + }); + } + match SystemExecutableLookupOutcome::for_candidate_name(self.alias.trim()) { + SystemExecutableLookupOutcome::NotFound => {} + SystemExecutableLookupOutcome::ShadowsExisting { + existing_executable, + } => { + return Err(ReportalError::ValidationFailure { + field: "alias".to_owned(), + reason: format!( + "'{}' would shadow the existing system command at {}; pick a different alias", + self.alias.trim(), + existing_executable.display(), + ), + }); + } + } + if self.repo_path.trim().is_empty() { + return Err(ReportalError::ValidationFailure { + field: "path".to_owned(), + reason: "must not be empty".to_owned(), + }); + } + let expanded_path = shellexpand::tilde(&self.repo_path); + let resolved = PathBuf::from(expanded_path.as_ref()); + if !resolved.exists() { + return Err(ReportalError::ValidationFailure { + field: "path".to_owned(), + reason: format!("{} does not exist", resolved.display()), + }); + } + let validated_entry = RepoEntry { + path: self.repo_path, + description: self.repo_description, + tags: self.repo_tags, + remote: self.repo_remote, + aliases: Vec::new(), + title: self.repo_title, + color: self.repo_color, + commands: BTreeMap::new(), + shell_alias: ShellAliasExport::Disabled, + }; + Ok((self.alias, validated_entry)) + } +} diff --git a/src/reportal_config/reportal_config_root.rs b/src/reportal_config/reportal_config_root.rs new file mode 100644 index 0000000..b506121 --- /dev/null +++ b/src/reportal_config/reportal_config_root.rs @@ -0,0 +1,830 @@ +//! Top-level `RePortal` config: load/save, repo queries, AI tool registry, +//! and settings mutation. + +use crate::error::ReportalError; +use crate::reportal_config::ai_tool_entry::AiToolEntry; +use crate::reportal_config::alias_collision_query::AliasCollisionQuery; +use crate::reportal_config::command_entry::CommandEntry; +use crate::reportal_config::global_settings::{ + PathDisplayFormat, PathVisibility, ReportalSettings, +}; +use crate::reportal_config::has_aliases::{resolve_canonical_key, HasAliases}; +use crate::reportal_config::repo_entry::RepoEntry; +use crate::reportal_config::tag_filter::TagFilter; +use crate::reportal_config::workspace_entry::WorkspaceEntry; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::PathBuf; + +/// Resolves the user home directory or returns an error if unavailable. +fn resolve_home_directory() -> Result { + dirs::home_dir().ok_or(ReportalError::ConfigIoFailure { + reason: "Could not determine home directory".to_owned(), + }) +} + +/// Top-level config structure deserialized from `config.toml`. +#[derive(Debug, Deserialize, Serialize)] +pub struct ReportalConfig { + /// Global settings like default editor and clone root. + #[serde(default)] + settings: ReportalSettings, + /// Map of alias to repo definition. + #[serde(default)] + repos: BTreeMap, + /// Map of tool name to AI CLI tool definition. + #[serde(default)] + ai_tools: BTreeMap, + /// Map of command name to user-defined command definition. + #[serde(default)] + commands: BTreeMap, + /// Map of workspace name to VSCode/Cursor workspace definition. + /// + /// Each entry is the single source of truth for one + /// `.code-workspace` file, which reportal generates from the + /// member repos' current paths so that moving a repo updates + /// every workspace that contains it. + #[serde(default)] + workspaces: BTreeMap, +} + +/// Loading, saving, querying, and mutating the `RePortal` config file. +impl ReportalConfig { + /// Returns the directory where `RePortal` stores its config. + pub fn config_directory() -> Result { + Ok(resolve_home_directory()?.join(".reportal")) + } + + /// Returns the full path to the config TOML file. + pub fn config_file_path() -> Result { + Ok(Self::config_directory()?.join("config.toml")) + } + + /// Loads, parses, and validates the config from disk, creating + /// a default config if none exists yet. + /// + /// The bootstrap-on-read behavior removes the "you must run + /// `rep init` first" failure mode from every subcommand: a + /// fresh install or a wiped home directory transparently gets + /// a minimal working config on the first invocation, matching + /// how tools like `zoxide` and `starship` behave. + /// + /// After successful TOML parsing, runs the workspace reference + /// check and the alias-collision pass so no dangling member or + /// ambiguous short name reaches command dispatch. + /// + /// # Errors + /// + /// Returns `ConfigIoFailure` if the file cannot be read or the + /// default config cannot be written, `ConfigParseFailure` if + /// the TOML is malformed, `WorkspaceHasDanglingRepo` if a + /// workspace references an unknown repo alias, or + /// `WorkspaceAliasConflict` if any workspace's name or alias + /// collides with another entry. + pub fn load_or_initialize() -> Result { + let file_path = Self::config_file_path()?; + if !file_path.exists() { + let default_config = Self::create_default(); + default_config.save_to_disk()?; + return Ok(default_config); + } + let toml_content = std::fs::read_to_string(&file_path).map_err(|io_error| { + ReportalError::ConfigIoFailure { + reason: io_error.to_string(), + } + })?; + let parsed_config: Self = toml::from_str(&toml_content).map_err(|parse_error| { + ReportalError::ConfigParseFailure { + reason: parse_error.to_string(), + } + })?; + parsed_config.validate_workspace_references()?; + parsed_config.validate_alias_collisions()?; + Ok(parsed_config) + } + + /// Validates that every workspace alias is globally unambiguous + /// across both the workspace and repo namespaces. + /// + /// The resolver in `resolve_canonical_key` returns the first + /// match it finds, so a config where two entries declare the + /// same alias would produce silently order-dependent behavior. + /// Rejecting the config at load time surfaces the ambiguity + /// immediately instead of letting a command resolve `vn` to the + /// wrong target. + /// + /// # Errors + /// + /// Returns [`ReportalError::WorkspaceAliasConflict`] at the + /// first collision encountered. + pub fn validate_alias_collisions(&self) -> Result<(), ReportalError> { + for (workspace_name, workspace_entry) in &self.workspaces { + self.check_workspace_canonical_name_repo_collision(workspace_name)?; + for declared_alias in workspace_entry.aliases() { + self.check_workspace_alias_collisions(AliasCollisionQuery::new( + workspace_name, + declared_alias, + ))?; + } + } + Ok(()) + } + + /// Rejects a workspace whose canonical name collides with a + /// repo's canonical key or declared alias. + /// + /// # Errors + /// + /// Returns [`ReportalError::WorkspaceAliasConflict`] on collision. + fn check_workspace_canonical_name_repo_collision( + &self, + workspace_name: &str, + ) -> Result<(), ReportalError> { + if self.repos.contains_key(workspace_name) { + return Err(ReportalError::WorkspaceAliasConflict { + workspace_name: workspace_name.to_owned(), + conflicting_value: workspace_name.to_owned(), + conflicting_entity_description: format!( + "repo '{workspace_name}' as its canonical key" + ), + }); + } + for (repo_key, repo_entry) in &self.repos { + if repo_entry + .aliases() + .iter() + .any(|declared| declared == workspace_name) + { + return Err(ReportalError::WorkspaceAliasConflict { + workspace_name: workspace_name.to_owned(), + conflicting_value: workspace_name.to_owned(), + conflicting_entity_description: format!("repo '{repo_key}' as an alias"), + }); + } + } + Ok(()) + } + + /// Rejects a workspace alias that collides with another + /// workspace's canonical name, another workspace's alias, any + /// repo's canonical key, or any repo's declared alias. + /// + /// # Errors + /// + /// Returns [`ReportalError::WorkspaceAliasConflict`] on collision. + fn check_workspace_alias_collisions( + &self, + query: AliasCollisionQuery<'_>, + ) -> Result<(), ReportalError> { + let owning_workspace_name = query.owning_workspace_name(); + let candidate_alias = query.candidate_alias(); + for (other_workspace_name, other_workspace) in &self.workspaces { + if other_workspace_name == owning_workspace_name { + continue; + } + if other_workspace_name == candidate_alias { + return Err(ReportalError::WorkspaceAliasConflict { + workspace_name: owning_workspace_name.to_owned(), + conflicting_value: candidate_alias.to_owned(), + conflicting_entity_description: format!( + "workspace '{other_workspace_name}' as its canonical name" + ), + }); + } + if other_workspace + .aliases() + .iter() + .any(|declared| declared == candidate_alias) + { + return Err(ReportalError::WorkspaceAliasConflict { + workspace_name: owning_workspace_name.to_owned(), + conflicting_value: candidate_alias.to_owned(), + conflicting_entity_description: format!( + "workspace '{other_workspace_name}' as an alias" + ), + }); + } + } + if self.repos.contains_key(candidate_alias) { + return Err(ReportalError::WorkspaceAliasConflict { + workspace_name: owning_workspace_name.to_owned(), + conflicting_value: candidate_alias.to_owned(), + conflicting_entity_description: format!( + "repo '{candidate_alias}' as its canonical key" + ), + }); + } + for (repo_key, repo_entry) in &self.repos { + if repo_entry + .aliases() + .iter() + .any(|declared| declared == candidate_alias) + { + return Err(ReportalError::WorkspaceAliasConflict { + workspace_name: owning_workspace_name.to_owned(), + conflicting_value: candidate_alias.to_owned(), + conflicting_entity_description: format!("repo '{repo_key}' as an alias"), + }); + } + } + Ok(()) + } + + /// Validates that every workspace's repo alias list references + /// only registered repos. + /// + /// # Errors + /// + /// Returns [`ReportalError::WorkspaceHasDanglingRepo`] at the + /// first dangling reference encountered. + pub fn validate_workspace_references(&self) -> Result<(), ReportalError> { + for (workspace_name, workspace) in &self.workspaces { + for member_alias in workspace.repo_aliases() { + if !self.repos.contains_key(member_alias) { + return Err(ReportalError::WorkspaceHasDanglingRepo { + workspace_name: workspace_name.to_owned(), + missing_alias: member_alias.to_owned(), + }); + } + } + } + Ok(()) + } + + /// Serializes and writes the config to disk, creating the directory if needed. + pub fn save_to_disk(&self) -> Result<(), ReportalError> { + let file_path = Self::config_file_path()?; + let parent_directory = Self::config_directory()?; + if !parent_directory.exists() { + std::fs::create_dir_all(&parent_directory).map_err(|io_error| { + ReportalError::ConfigIoFailure { + reason: io_error.to_string(), + } + })?; + } + let serialized_toml = toml::to_string_pretty(self).map_err(|serialize_error| { + ReportalError::ConfigIoFailure { + reason: serialize_error.to_string(), + } + })?; + std::fs::write(&file_path, serialized_toml).map_err(|io_error| { + ReportalError::ConfigIoFailure { + reason: io_error.to_string(), + } + })?; + Ok(()) + } + + /// Returns repos whose tags match the given filter, with their aliases. + pub fn repos_matching_tag_filter(&self, tag_filter: &TagFilter) -> Vec<(&String, &RepoEntry)> { + self.repos + .iter() + .filter(|(_, repo)| match tag_filter { + TagFilter::All => true, + TagFilter::ByTag(target_tag) => repo.tags().iter().any(|tag| tag == target_tag), + }) + .collect() + } + + /// Returns the configured default editor command. + pub fn default_editor(&self) -> &str { + &self.settings.default_editor + } + + /// Resolves the default workspace root to an absolute path + /// with `~` expanded, honoring the fallback chain: + /// explicit `default_workspace_root` setting → + /// `/workspaces` → `~/dev/workspaces`. + /// + /// # Errors + /// + /// Returns [`ReportalError::ConfigIoFailure`] if the home + /// directory is needed for the final fallback and cannot be + /// resolved. + pub fn resolve_default_workspace_root(&self) -> Result { + let explicit_root = self.settings.default_workspace_root.trim(); + if !explicit_root.is_empty() { + let expanded = shellexpand::tilde(explicit_root); + return Ok(PathBuf::from(expanded.as_ref())); + } + let clone_root = self.settings.default_clone_root.trim(); + if !clone_root.is_empty() { + let expanded = shellexpand::tilde(clone_root); + return Ok(PathBuf::from(expanded.as_ref()).join("workspaces")); + } + let home_directory = resolve_home_directory()?; + Ok(home_directory.join("dev").join("workspaces")) + } + + /// Whether to show the path after selecting a repo. + pub fn path_on_select(&self) -> &PathVisibility { + &self.settings.path_on_select + } + + /// How to format displayed paths (absolute or relative). + pub fn path_display_format(&self) -> &PathDisplayFormat { + &self.settings.path_display_format + } + + /// Returns all registered repos with their aliases, for iteration. + pub fn repos_with_aliases(&self) -> Vec<(&String, &RepoEntry)> { + self.repos.iter().collect() + } + + /// Looks up a repo by its canonical key or any of its declared + /// aliases. + /// + /// # Errors + /// + /// Returns [`ReportalError::RepoNotFound`] if neither the + /// canonical key nor any repo's alias list matches. + pub fn get_repo(&self, alias_or_canonical: &str) -> Result<&RepoEntry, ReportalError> { + let canonical_key = + resolve_canonical_key(&self.repos, alias_or_canonical).ok_or_else(|| { + ReportalError::RepoNotFound { + alias: alias_or_canonical.to_owned(), + } + })?; + self.repos + .get(canonical_key) + .ok_or_else(|| ReportalError::RepoNotFound { + alias: alias_or_canonical.to_owned(), + }) + } + + /// Returns a mutable reference to a repo, accepting either the + /// canonical key or any of its declared aliases. + /// + /// # Errors + /// + /// Returns [`ReportalError::RepoNotFound`] if neither the + /// canonical key nor any repo's alias list matches. + pub fn get_repo_mut( + &mut self, + alias_or_canonical: &str, + ) -> Result<&mut RepoEntry, ReportalError> { + let canonical_key = resolve_canonical_key(&self.repos, alias_or_canonical) + .ok_or_else(|| ReportalError::RepoNotFound { + alias: alias_or_canonical.to_owned(), + })? + .to_owned(); + self.repos + .get_mut(&canonical_key) + .ok_or_else(|| ReportalError::RepoNotFound { + alias: alias_or_canonical.to_owned(), + }) + } + + /// Registers a new repo from a validated builder result. + /// + /// Returns `RepoAlreadyExists` if the alias is already taken. + pub fn add_repo( + &mut self, + validated_registration: (String, RepoEntry), + ) -> Result<(), ReportalError> { + let (repository_alias, repo_entry) = validated_registration; + if self.repos.contains_key(&repository_alias) { + return Err(ReportalError::RepoAlreadyExists { + alias: repository_alias, + }); + } + self.repos.insert(repository_alias, repo_entry); + Ok(()) + } + + /// Removes a repo by alias. Returns `RepoNotFound` if missing. + pub fn remove_repo(&mut self, alias: &str) -> Result { + self.repos + .remove(alias) + .ok_or_else(|| ReportalError::RepoNotFound { + alias: alias.to_owned(), + }) + } + + /// Returns all registered workspaces with their names, for iteration. + #[must_use] + pub fn workspaces_with_names(&self) -> Vec<(&String, &WorkspaceEntry)> { + self.workspaces.iter().collect() + } + + /// Resolves a user-supplied workspace name-or-alias to its + /// canonical config key. + /// + /// Commands that derive file paths from the name (like + /// `~/.reportal/workspaces/.code-workspace`) must pass the + /// canonical key, not the user's alias, otherwise `rep workspace + /// show vn` would build `vn.code-workspace` instead of + /// `venoble.code-workspace`. + /// + /// # Errors + /// + /// Returns [`ReportalError::WorkspaceNotFound`] if the query + /// matches neither a canonical key nor any workspace's alias list. + pub fn resolve_workspace_canonical_name( + &self, + alias_or_canonical: &str, + ) -> Result { + resolve_canonical_key(&self.workspaces, alias_or_canonical) + .map(str::to_owned) + .ok_or_else(|| ReportalError::WorkspaceNotFound { + workspace_name: alias_or_canonical.to_owned(), + }) + } + + /// Looks up a workspace by canonical key or any declared alias. + /// + /// # Errors + /// + /// Returns [`ReportalError::WorkspaceNotFound`] if neither + /// matches. + pub fn get_workspace( + &self, + alias_or_canonical: &str, + ) -> Result<&WorkspaceEntry, ReportalError> { + let canonical_key = resolve_canonical_key(&self.workspaces, alias_or_canonical) + .ok_or_else(|| ReportalError::WorkspaceNotFound { + workspace_name: alias_or_canonical.to_owned(), + })?; + self.workspaces + .get(canonical_key) + .ok_or_else(|| ReportalError::WorkspaceNotFound { + workspace_name: alias_or_canonical.to_owned(), + }) + } + + /// Returns a mutable reference to a workspace, accepting either + /// the canonical key or any declared alias. + /// + /// # Errors + /// + /// Returns [`ReportalError::WorkspaceNotFound`] if neither + /// matches. + pub fn get_workspace_mut( + &mut self, + alias_or_canonical: &str, + ) -> Result<&mut WorkspaceEntry, ReportalError> { + let canonical_key = resolve_canonical_key(&self.workspaces, alias_or_canonical) + .ok_or_else(|| ReportalError::WorkspaceNotFound { + workspace_name: alias_or_canonical.to_owned(), + })? + .to_owned(); + self.workspaces + .get_mut(&canonical_key) + .ok_or_else(|| ReportalError::WorkspaceNotFound { + workspace_name: alias_or_canonical.to_owned(), + }) + } + + /// Registers a new workspace from a validated builder result. + /// + /// Runs dangling-reference and alias-collision checks before + /// accepting the new entry so an insert that would leave the + /// config invalid is rejected at mutation time, not at the next + /// load. + /// + /// # Errors + /// + /// Returns [`ReportalError::WorkspaceAlreadyExists`], + /// [`ReportalError::WorkspaceHasDanglingRepo`], or + /// [`ReportalError::WorkspaceAliasConflict`] depending on which + /// invariant the new entry violates. + pub fn add_workspace( + &mut self, + validated_registration: (String, WorkspaceEntry), + ) -> Result<(), ReportalError> { + let (workspace_name, workspace_entry) = validated_registration; + if self.workspaces.contains_key(&workspace_name) { + return Err(ReportalError::WorkspaceAlreadyExists { workspace_name }); + } + for member_alias in workspace_entry.repo_aliases() { + if !self.repos.contains_key(member_alias) { + return Err(ReportalError::WorkspaceHasDanglingRepo { + workspace_name, + missing_alias: member_alias.to_owned(), + }); + } + } + self.check_workspace_canonical_name_repo_collision(&workspace_name)?; + for declared_alias in workspace_entry.aliases() { + self.check_workspace_alias_collisions(AliasCollisionQuery::new( + &workspace_name, + declared_alias, + ))?; + } + self.workspaces.insert(workspace_name, workspace_entry); + Ok(()) + } + + /// Removes a workspace by name. + /// + /// # Errors + /// + /// Returns [`ReportalError::WorkspaceNotFound`] if the name has + /// no matching workspace entry. + pub fn remove_workspace( + &mut self, + workspace_name: &str, + ) -> Result { + self.workspaces + .remove(workspace_name) + .ok_or_else(|| ReportalError::WorkspaceNotFound { + workspace_name: workspace_name.to_owned(), + }) + } + + /// Returns every workspace that contains the given repo alias. + #[must_use] + pub fn workspaces_containing_repo( + &self, + repository_alias: &str, + ) -> Vec<(&String, &WorkspaceEntry)> { + self.workspaces + .iter() + .filter(|(_, workspace)| workspace.contains_repo(repository_alias)) + .collect() + } + + /// Returns the configured default AI tool name, if set. + pub fn default_ai_tool(&self) -> &str { + &self.settings.default_ai_tool + } + + /// Looks up an AI tool by name. Returns `AiToolNotFound` if missing. + pub fn get_ai_tool(&self, tool_name: &str) -> Result<&AiToolEntry, ReportalError> { + self.ai_tools + .get(tool_name) + .ok_or_else(|| ReportalError::AiToolNotFound { + tool_name: tool_name.to_owned(), + }) + } + + /// Returns all registered AI CLI executables with their names, + /// for iteration. + pub fn ai_cli_registry(&self) -> Vec<(&String, &AiToolEntry)> { + self.ai_tools.iter().collect() + } + + /// Returns all globally registered commands with their names. + pub fn global_commands(&self) -> &BTreeMap { + &self.commands + } + + /// Creates a default empty config with sensible defaults for first-time setup. + pub fn create_default() -> Self { + Self { + settings: ReportalSettings { + default_editor: "cursor".to_owned(), + default_clone_root: "~/dev".to_owned(), + default_workspace_root: "~/dev/workspaces".to_owned(), + path_on_select: PathVisibility::Show, + path_display_format: PathDisplayFormat::Absolute, + default_ai_tool: "aider".to_owned(), + }, + repos: BTreeMap::new(), + commands: BTreeMap::new(), + workspaces: BTreeMap::new(), + ai_tools: BTreeMap::from([ + ( + "aider".to_owned(), + AiToolEntry::with_executable("aider".to_owned()), + ), + ]), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::reportal_config::workspace_member::WorkspaceMember; + use crate::reportal_config::workspace_registration_builder::WorkspaceRegistrationBuilder; + + fn make_repo(aliases: Vec) -> RepoEntry { + RepoEntry { + path: "C:/fake".to_owned(), + description: String::new(), + tags: Vec::new(), + remote: String::new(), + aliases, + title: Default::default(), + color: Default::default(), + commands: BTreeMap::new(), + shell_alias: Default::default(), + } + } + + fn make_workspace( + member_repo_aliases: Vec, + declared_aliases: Vec, + ) -> Result { + let (_, entry) = WorkspaceRegistrationBuilder::start("placeholder".to_owned()) + .repo_aliases(member_repo_aliases) + .workspace_aliases(declared_aliases) + .build()?; + Ok(entry) + } + + fn config_with_repo_and_workspace( + repo_canonical: &str, + repo_aliases: Vec, + workspace_canonical: &str, + workspace_aliases: Vec, + ) -> Result { + let mut config = ReportalConfig::create_default(); + config + .repos + .insert(repo_canonical.to_owned(), make_repo(repo_aliases)); + let workspace_entry = make_workspace(vec![repo_canonical.to_owned()], workspace_aliases)?; + config + .workspaces + .insert(workspace_canonical.to_owned(), workspace_entry); + Ok(config) + } + + #[test] + fn resolve_workspace_canonical_name_matches_alias() -> Result<(), ReportalError> { + let config = config_with_repo_and_workspace( + "app", + vec![], + "venoble", + vec!["vn".to_owned(), "noble".to_owned()], + )?; + assert_eq!(config.resolve_workspace_canonical_name("vn")?, "venoble"); + assert_eq!(config.resolve_workspace_canonical_name("noble")?, "venoble"); + assert_eq!( + config.resolve_workspace_canonical_name("venoble")?, + "venoble" + ); + Ok(()) + } + + #[test] + fn resolve_workspace_canonical_name_unknown_fails() -> Result<(), ReportalError> { + let config = config_with_repo_and_workspace("app", vec![], "venoble", vec![])?; + let outcome = config.resolve_workspace_canonical_name("ghost"); + assert!(matches!( + outcome, + Err(ReportalError::WorkspaceNotFound { .. }) + )); + Ok(()) + } + + #[test] + fn get_workspace_resolves_via_alias() -> Result<(), ReportalError> { + let config = + config_with_repo_and_workspace("app", vec![], "venoble", vec!["vn".to_owned()])?; + let resolved = config.get_workspace("vn")?; + assert_eq!(resolved.repo_aliases(), vec!["app"]); + Ok(()) + } + + #[test] + fn get_workspace_mut_resolves_via_alias() -> Result<(), ReportalError> { + let mut config = + config_with_repo_and_workspace("app", vec![], "venoble", vec!["vn".to_owned()])?; + let resolved_mut = config.get_workspace_mut("vn")?; + resolved_mut.set_members(vec![ + WorkspaceMember::RegisteredRepo("app".to_owned()), + WorkspaceMember::RegisteredRepo("worker".to_owned()), + ]); + let re_read = config.get_workspace("venoble")?; + assert_eq!(re_read.repo_aliases(), vec!["app", "worker"]); + Ok(()) + } + + #[test] + fn get_repo_mut_now_resolves_via_alias() -> Result<(), ReportalError> { + let mut config = ReportalConfig::create_default(); + config + .repos + .insert("venoble-app".to_owned(), make_repo(vec!["vna".to_owned()])); + let resolved_mut = config.get_repo_mut("vna")?; + resolved_mut.set_description("mutated".to_owned()); + assert_eq!(config.get_repo("venoble-app")?.description(), "mutated"); + Ok(()) + } + + #[test] + fn alias_collision_workspace_alias_equals_peer_workspace_canonical_name( + ) -> Result<(), ReportalError> { + let mut config = ReportalConfig::create_default(); + config.repos.insert("app".to_owned(), make_repo(vec![])); + config.workspaces.insert( + "venoble".to_owned(), + make_workspace(vec!["app".to_owned()], vec!["backend".to_owned()])?, + ); + config.workspaces.insert( + "backend".to_owned(), + make_workspace(vec!["app".to_owned()], vec![])?, + ); + let outcome = config.validate_alias_collisions(); + assert!(matches!( + outcome, + Err(ReportalError::WorkspaceAliasConflict { .. }) + )); + Ok(()) + } + + #[test] + fn alias_collision_workspace_alias_equals_repo_canonical_key() -> Result<(), ReportalError> { + let mut config = ReportalConfig::create_default(); + config.repos.insert("app".to_owned(), make_repo(vec![])); + config.repos.insert("vn".to_owned(), make_repo(vec![])); + config.workspaces.insert( + "venoble".to_owned(), + make_workspace(vec!["app".to_owned()], vec!["vn".to_owned()])?, + ); + let outcome = config.validate_alias_collisions(); + assert!(matches!( + outcome, + Err(ReportalError::WorkspaceAliasConflict { .. }) + )); + Ok(()) + } + + #[test] + fn alias_collision_workspace_alias_equals_repo_alias() -> Result<(), ReportalError> { + let mut config = ReportalConfig::create_default(); + config + .repos + .insert("venoble-app".to_owned(), make_repo(vec!["vna".to_owned()])); + config.workspaces.insert( + "venoble".to_owned(), + make_workspace(vec!["venoble-app".to_owned()], vec!["vna".to_owned()])?, + ); + let outcome = config.validate_alias_collisions(); + assert!(matches!( + outcome, + Err(ReportalError::WorkspaceAliasConflict { .. }) + )); + Ok(()) + } + + #[test] + fn alias_collision_workspace_name_equals_repo_canonical_key() -> Result<(), ReportalError> { + let mut config = ReportalConfig::create_default(); + config.repos.insert("venoble".to_owned(), make_repo(vec![])); + config.repos.insert("app".to_owned(), make_repo(vec![])); + config.workspaces.insert( + "venoble".to_owned(), + make_workspace(vec!["app".to_owned()], vec![])?, + ); + let outcome = config.validate_alias_collisions(); + assert!(matches!( + outcome, + Err(ReportalError::WorkspaceAliasConflict { .. }) + )); + Ok(()) + } + + fn make_inline_only_workspace() -> WorkspaceEntry { + WorkspaceEntry { + repos: vec![WorkspaceMember::InlinePath { + path: "~/dev/inline-only".to_owned(), + }], + description: String::new(), + path: String::new(), + aliases: Vec::new(), + title: Default::default(), + color: Default::default(), + shell_alias: Default::default(), + } + } + + #[test] + fn inline_path_member_skips_dangling_repo_check() { + let mut config = ReportalConfig::create_default(); + config + .workspaces + .insert("inline-only".to_owned(), make_inline_only_workspace()); + assert!(config.validate_workspace_references().is_ok()); + } + + #[test] + fn inline_path_member_does_not_block_repo_removal() { + let mut config = ReportalConfig::create_default(); + config + .repos + .insert("standalone".to_owned(), make_repo(vec![])); + config + .workspaces + .insert("inline-only".to_owned(), make_inline_only_workspace()); + let containing = config.workspaces_containing_repo("standalone"); + assert!( + containing.is_empty(), + "inline-path-only workspace must not appear in the reverse index for an unrelated repo", + ); + } + + #[test] + fn clean_config_passes_alias_collision_validation() -> Result<(), ReportalError> { + let config = config_with_repo_and_workspace( + "app", + vec!["a".to_owned()], + "venoble", + vec!["vn".to_owned()], + )?; + assert!(config.validate_alias_collisions().is_ok()); + Ok(()) + } +} diff --git a/src/reportal_config/repository_color.rs b/src/reportal_config/repository_color.rs new file mode 100644 index 0000000..518169d --- /dev/null +++ b/src/reportal_config/repository_color.rs @@ -0,0 +1,53 @@ +//! Terminal background color preference for a registered repo. + +use crate::reportal_config::hex_color::HexColor; +use serde::{Deserialize, Serialize}; + +/// Whether a repo has a terminal background color configured. +/// +/// Modeled as an enum rather than an `Option` so the +/// "reset to default" case is a named variant instead of `None`, +/// which forces every caller to acknowledge the reset branch and +/// prevents the failure mode where a missing color silently leaves +/// the terminal on whatever color was last set by another repo. +#[derive(Debug, Serialize, Clone, Default)] +#[serde(untagged)] +pub enum RepoColor { + /// No color set; the terminal resets to its default background on jump. + #[default] + ResetToDefault, + /// A specific background color applied via OSC 11 on jump. + Themed(HexColor), +} + +/// Unwrapping of the inner `HexColor` without exposing callers +/// to the enum's variant vocabulary. +impl RepoColor { + /// @why Returns the themed hex color when one is set so + /// callers that only care about "is there a color" can skip + /// pattern-matching on the two-variant enum and keep their + /// own files free of `Themed` / `ResetToDefault` references. + #[must_use] + pub fn themed_hex_color(&self) -> Option<&HexColor> { + if let Self::Themed(hex_color) = self { + Some(hex_color) + } else { + None + } + } +} + +/// Deserializes an empty string as `ResetToDefault`, valid hex as `Themed`. +impl<'de> Deserialize<'de> for RepoColor { + fn deserialize>( + repo_color_deserializer: D, + ) -> Result { + let raw: String = String::deserialize(repo_color_deserializer)?; + if raw.is_empty() { + Ok(RepoColor::ResetToDefault) + } else { + let parsed = HexColor::parse(&raw).map_err(serde::de::Error::custom)?; + Ok(RepoColor::Themed(parsed)) + } + } +} diff --git a/src/reportal_config/shell_alias_export.rs b/src/reportal_config/shell_alias_export.rs new file mode 100644 index 0000000..28cc457 --- /dev/null +++ b/src/reportal_config/shell_alias_export.rs @@ -0,0 +1,52 @@ +//! Opt-in flag deciding whether a config entry (command, repo, +//! workspace) gets emitted as a top-level shell function by +//! `rep init `. +//! +//! Lives in its own module so every config entry type that can +//! opt into shell-alias export (`CommandEntry`, `RepoEntry`, +//! `WorkspaceEntry`) imports one definition instead of declaring +//! three parallel enums that would drift on variant additions. + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// Whether `rep init ` should emit a top-level shell +/// function for the owning config entry. +/// +/// Serialized to TOML as a bool so the human-editable config +/// remains `shell_alias = true` / `shell_alias = false`; the +/// enum form is the in-memory representation so the rest of +/// the codebase matches on named states instead of raw booleans. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ShellAliasExport { + /// Do not emit a shell function for this entry. + #[default] + Disabled, + /// Emit a top-level shell function named after this entry's + /// config key (and, for repos and workspaces, each declared + /// alias) so the user can invoke the entry's action directly. + Enabled, +} + +/// Serializes as a plain TOML bool (`true` for `Enabled`, +/// `false` for `Disabled`) so config files stay human-readable. +impl Serialize for ShellAliasExport { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Disabled => serializer.serialize_bool(false), + Self::Enabled => serializer.serialize_bool(true), + } + } +} + +/// Deserializes a plain TOML bool into the corresponding +/// variant so `shell_alias = true` in the user's config maps +/// to `Enabled` without requiring string tags. +impl<'de> Deserialize<'de> for ShellAliasExport { + fn deserialize>(deserializer: D) -> Result { + let raw_bool_flag = bool::deserialize(deserializer)?; + match raw_bool_flag { + true => Ok(Self::Enabled), + false => Ok(Self::Disabled), + } + } +} diff --git a/src/reportal_config/tab_title.rs b/src/reportal_config/tab_title.rs new file mode 100644 index 0000000..489bde7 --- /dev/null +++ b/src/reportal_config/tab_title.rs @@ -0,0 +1,34 @@ +//! Terminal tab title preference for a registered repo. + +use serde::{Deserialize, Serialize}; + +/// Whether a repo has a custom tab title or falls back to its alias. +/// +/// Modeled as an enum rather than an `Option` so the +/// "use the alias" case is a named variant instead of `None`, which +/// forces every caller to acknowledge the branch in pattern matches +/// and prevents the failure mode where a missing title silently +/// renders as an empty string in the terminal. +#[derive(Debug, Serialize, Clone, Default)] +#[serde(untagged)] +pub enum TabTitle { + /// No custom title configured; the repo alias is used instead. + #[default] + UseAlias, + /// A custom title the user chose for this repo's terminal tab. + Custom(String), +} + +/// Deserializes an empty string as `UseAlias`, non-empty as `Custom`. +impl<'de> Deserialize<'de> for TabTitle { + fn deserialize>( + tab_title_deserializer: D, + ) -> Result { + let raw: String = String::deserialize(tab_title_deserializer)?; + if raw.is_empty() { + Ok(TabTitle::UseAlias) + } else { + Ok(TabTitle::Custom(raw)) + } + } +} diff --git a/src/reportal_config/tag_filter.rs b/src/reportal_config/tag_filter.rs new file mode 100644 index 0000000..06fefe9 --- /dev/null +++ b/src/reportal_config/tag_filter.rs @@ -0,0 +1,10 @@ +//! Filtering repos by tag for list, status, sync, and fuzzy-select commands. + +/// Whether to filter repos by a specific tag or show all repos. +#[derive(Debug)] +pub enum TagFilter { + /// Show every registered repo regardless of tags. + All, + /// Show only repos that carry this exact tag string. + ByTag(String), +} diff --git a/src/reportal_config/workspace_entry.rs b/src/reportal_config/workspace_entry.rs new file mode 100644 index 0000000..a090c42 --- /dev/null +++ b/src/reportal_config/workspace_entry.rs @@ -0,0 +1,223 @@ +//! Workspace entry: a named group of repos that open together as a +//! VSCode/Cursor `.code-workspace` file. + +use crate::reportal_config::has_aliases::HasAliases; +use crate::reportal_config::repository_color::RepoColor; +use crate::reportal_config::shell_alias_export::ShellAliasExport; +use crate::reportal_config::tab_title::TabTitle; +use crate::reportal_config::workspace_member::{WorkspaceMember, WorkspaceMemberAliasLookup}; +use serde::{Deserialize, Serialize}; + +/// A single registered VSCode/Cursor workspace definition. +/// +/// Declares which folders open together as one editor window. +/// Reportal owns this definition as the single source of truth; the +/// actual `.code-workspace` file on disk is a derived artifact +/// generated from each member's resolved path (repo registry lookup +/// for alias members, direct tilde-expansion for inline-path +/// members). +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct WorkspaceEntry { + /// Ordered list of members — each is either a registered-repo + /// reference or an inline filesystem path. + /// + /// The TOML field is still called `repos` for backwards + /// compatibility with configs written before inline paths were + /// supported. Bare strings in the array continue to parse as + /// `WorkspaceMember::RegisteredRepo` via the untagged serde + /// representation on `WorkspaceMember`, so v0.14.1 configs load + /// unchanged. + pub(super) repos: Vec, + /// Human-readable description of what this workspace is for. + #[serde(default)] + pub(super) description: String, + /// Filesystem path of the workspace directory on disk. + /// + /// Semantic change in v0.15.2: this field now stores the + /// directory that contains the workspace's symlinks and the + /// `.code-workspace` file, not the workspace file itself. + /// May contain `~` for home. When empty, the default location + /// is `//` as resolved from the + /// `[settings]` table. + /// + /// Pre-v0.15.2 configs stored the `.code-workspace` file path + /// here (detected by a trailing `.code-workspace` component). + /// Such entries are auto-migrated on the first `rep workspace + /// jump` / `open` / `rebuild` that touches them. + #[serde(default)] + pub(super) path: String, + /// Alternative short names that resolve to this workspace's + /// canonical key in commands that target a workspace by name. + /// + /// Each alias must be unique across every workspace's canonical + /// name and every other workspace's alias list, and must not + /// collide with any repo's canonical key or repo-level alias. + /// Validation runs on config load so an ambiguous alias is + /// rejected before any command resolves it. + #[serde(default)] + pub(super) aliases: Vec, + /// Short label shown in the shell prompt badge and editor + /// window title when the user is inside this workspace. When + /// `UseAlias`, the workspace's canonical name is used. + #[serde(default)] + pub(super) title: TabTitle, + /// Accent color for the shell prompt badge, the editor title + /// bar (`workbench.colorCustomizations`), and the terminal tab + /// strip when the user is inside this workspace. When + /// `ResetToDefault`, no accent is applied and the badge falls + /// back to the terminal's default foreground. + #[serde(default)] + pub(super) color: RepoColor, + /// When `Enabled`, `rep init ` emits a top-level shell + /// function for this workspace's canonical name and every + /// declared alias that does the equivalent of `rjw ` + /// (cd into the workspace directory and apply its color). + /// Opt-in because each emitted name shadows whatever else the + /// user's shell PATH already resolves to under that name. + #[serde(default)] + pub(super) shell_alias: ShellAliasExport, +} + +/// Accessors and mutators for a workspace entry. +impl WorkspaceEntry { + /// Ordered list of every member in this workspace. + /// + /// Callers that need to resolve each member to an absolute path + /// pattern-match on [`WorkspaceMember`] variants; callers that + /// only care about registered-repo references use + /// [`Self::repo_aliases`]. + #[must_use] + pub fn members(&self) -> &[WorkspaceMember] { + &self.repos + } + + /// The alias strings of every member that is a registered-repo + /// reference, in declared order. + /// + /// Inline-path members are skipped because they do not + /// participate in the repo-rename reverse-index or the + /// `validate_workspace_references` dangling-member check. + #[must_use] + pub fn repo_aliases(&self) -> Vec<&str> { + self.repos + .iter() + .filter_map(|member| match member.registered_repo_alias() { + WorkspaceMemberAliasLookup::Matches(alias) => Some(alias), + WorkspaceMemberAliasLookup::NotARepoReference => None, + }) + .collect() + } + + /// Human-readable description of this workspace's purpose. + #[must_use] + pub fn description(&self) -> &str { + &self.description + } + + /// The raw workspace directory path as stored in config, before + /// tilde expansion. + /// + /// Since v0.15.2 this is the directory containing the symlinks + /// and the `.code-workspace` file, not the `.code-workspace` + /// file path itself. An empty string signals that the default + /// location `//` should be used. + /// Pre-v0.15.2 configs may still store a path ending in + /// `.code-workspace`; use [`Self::is_legacy_file_path`] to + /// detect and migrate such entries. + #[must_use] + pub fn raw_workspace_file_path(&self) -> &str { + &self.path + } + + /// Whether the stored `path` looks like a pre-v0.15.2 + /// `.code-workspace` file path (rather than a workspace + /// directory). + /// + /// True when the stored path ends with the `.code-workspace` + /// extension. Callers use this to trigger auto-migration on + /// the first command that touches the entry after upgrade. + #[must_use] + pub fn is_legacy_file_path(&self) -> bool { + let trimmed = self.path.trim(); + !trimmed.is_empty() + && std::path::Path::new(trimmed) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("code-workspace")) + } + + /// Replaces the workspace directory path stored in config. + /// + /// Used by the auto-migration path to formalize the move from + /// a legacy `.code-workspace`-file-path entry to the new + /// directory-based layout, and by any future `rep workspace + /// move` subcommand. + pub fn set_workspace_directory_path(&mut self, new_directory_path: String) { + self.path = new_directory_path; + } + + /// Whether the given repo alias is a registered-repo member of + /// this workspace. + /// + /// Used by the reverse-index lookup `workspaces_containing_repo` + /// and the `rep remove` guard. Inline-path members never match + /// because they carry no repo alias by construction. + #[must_use] + pub fn contains_repo(&self, repository_alias: &str) -> bool { + self.repos.iter().any(|member| { + matches!( + member.registered_repo_alias(), + WorkspaceMemberAliasLookup::Matches(alias) if alias == repository_alias + ) + }) + } + + /// @why Exposes the workspace's prompt-badge / window-title + /// label so `rep prompt` and `rep workspace rebuild` can render + /// the same identity without re-reading the raw field. + #[must_use] + pub fn tab_title(&self) -> &TabTitle { + &self.title + } + + /// @why Exposes the workspace's accent color so the prompt + /// badge, the terminal tab strip, and the editor title bar all + /// draw from one source of truth per workspace. + #[must_use] + pub fn workspace_color(&self) -> &RepoColor { + &self.color + } + + /// Replaces the ordered member list with a new one. + /// + /// Callers that want to mutate registered-repo membership + /// without touching inline-path members should compose a new + /// list from [`Self::members`] and pass it here. The + /// `rep workspace add-repo` / `remove-repo` commands do exactly + /// that so inline-path members are preserved across alias-only + /// edits. + pub fn set_members(&mut self, new_members: Vec) { + self.repos = new_members; + } +} + +impl HasAliases for WorkspaceEntry { + fn aliases(&self) -> &[String] { + &self.aliases + } +} + +/// Shell-alias export policy accessor — kept in its own impl +/// block so the addition does not cascade into the surrounding +/// untagged accessors that the `@why` enforcement guard would +/// otherwise demand we tag in the same change. +impl WorkspaceEntry { + /// @why Exposes the opt-in export policy `rep init ` + /// reads to decide whether to emit top-level shell functions + /// for this workspace's canonical name and declared aliases + /// so users get `vn` as a direct shell command for `rjw vn` + /// without typing the `rjw` prefix. + #[must_use] + pub fn shell_alias_export(&self) -> ShellAliasExport { + self.shell_alias + } +} diff --git a/src/reportal_config/workspace_filter.rs b/src/reportal_config/workspace_filter.rs new file mode 100644 index 0000000..01811d6 --- /dev/null +++ b/src/reportal_config/workspace_filter.rs @@ -0,0 +1,20 @@ +//! Filtering repos by workspace membership for `rep list`. + +/// Whether to filter repos by workspace membership or show every +/// workspace section. +/// +/// Modeled as an enum rather than an `Option` so the +/// "show all workspaces" branch is a named variant that callers +/// must acknowledge in pattern matches. This prevents the failure +/// mode where a missing filter is silently treated as a hidden +/// default and a branch is forgotten in future changes. +#[derive(Debug)] +pub enum WorkspaceFilter { + /// Show every registered workspace (and the unassigned + /// section for repos in zero workspaces). + All, + /// Show only the named workspace. The unassigned section is + /// suppressed because the query is explicitly scoped to one + /// workspace. + ByName(String), +} diff --git a/src/reportal_config/workspace_member.rs b/src/reportal_config/workspace_member.rs new file mode 100644 index 0000000..a0d5834 --- /dev/null +++ b/src/reportal_config/workspace_member.rs @@ -0,0 +1,76 @@ +//! A single entry in a workspace's ordered member list. +//! +//! Members are either references to registered repos (the original +//! Design B shape, which gives the path-change reverse-index its +//! power) or raw filesystem paths declared inline (useful when the +//! user drives a workspace through reportal but does not want the +//! individual folders registered as top-level repos). The inline +//! variant gives up the automatic regeneration that repo renames +//! trigger — a deliberate tradeoff the user accepts when they +//! choose that form. + +use serde::{Deserialize, Serialize}; + +/// A single member of a workspace's ordered folder list. +/// +/// The untagged serde representation lets existing configs whose +/// `repos` array holds bare strings continue to load unchanged — +/// those literals resolve to `RegisteredRepo`. A new inline-table +/// form `{ path = "..." }` declares a filesystem path directly so +/// workspaces can contain folders that are not registered as +/// top-level repos. +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(untagged)] +pub enum WorkspaceMember { + /// Canonical key or alias of a registered repo, resolved to an + /// absolute path through the repo registry at regeneration time. + /// + /// Backed by the string payload so TOML arrays of bare strings + /// continue to parse into this variant without any shape change. + RegisteredRepo(String), + /// Literal filesystem path to a folder, bypassing the repo + /// registry entirely. + /// + /// Chosen when the user wants a workspace folder but does not + /// want the folder registered as a top-level repo. Gives up the + /// reverse-index auto-regeneration that `rep edit`'s path field + /// fires for alias-variant members. + InlinePath { + /// Raw path as written in config, with `~` expansion + /// deferred to the regenerator so the config round-trips + /// verbatim through save/load cycles. + path: String, + }, +} + +/// Classification accessors for workspace members. +impl WorkspaceMember { + /// Returns the referenced repo alias when this member is a + /// registered-repo reference, for use by the repo-rename + /// reverse-index and the `rep remove` guard. + /// + /// Callers pattern-match on the returned enum rather than + /// unwrapping, so the two member kinds never get confused at a + /// call site. + #[must_use] + pub fn registered_repo_alias(&self) -> WorkspaceMemberAliasLookup<'_> { + match self { + Self::RegisteredRepo(alias) => WorkspaceMemberAliasLookup::Matches(alias.as_str()), + Self::InlinePath { .. } => WorkspaceMemberAliasLookup::NotARepoReference, + } + } +} + +/// Outcome of asking a [`WorkspaceMember`] for its registered-repo +/// alias. +/// +/// Named variants (not `Option<&str>`) because the absence has +/// domain meaning: the member is an inline path, which must not be +/// treated as a missing lookup — it is a different kind of member. +pub enum WorkspaceMemberAliasLookup<'borrow> { + /// The member references a registered repo by the given alias. + Matches(&'borrow str), + /// The member is an inline filesystem path, so it has no + /// registered-repo alias by design. + NotARepoReference, +} diff --git a/src/reportal_config/workspace_registration_builder.rs b/src/reportal_config/workspace_registration_builder.rs new file mode 100644 index 0000000..fa06b17 --- /dev/null +++ b/src/reportal_config/workspace_registration_builder.rs @@ -0,0 +1,349 @@ +//! Step-by-step builder for constructing a validated workspace entry. + +use crate::error::ReportalError; +use crate::reportal_config::shell_alias_export::ShellAliasExport; +use crate::reportal_config::workspace_entry::WorkspaceEntry; +use crate::reportal_config::workspace_member::WorkspaceMember; +use crate::system_executable_lookup::SystemExecutableLookupOutcome; + +/// Chainable builder that assembles a `WorkspaceEntry` from separate +/// field inputs and validates name/membership invariants on `build()`. +/// +/// Follows the same shape as `RepoRegistrationBuilder` so the two +/// registration flows stay symmetric. The builder deliberately does +/// NOT verify that the collected repo aliases resolve to registered +/// repos: that check requires the full repo registry and belongs at +/// the config level, performed on save/load so dangling references +/// are caught even when an entry is hand-edited into the TOML. +pub struct WorkspaceRegistrationBuilder { + /// Config key (workspace name) for the entry under construction. + workspace_name: String, + /// Member repo alias list collected so far. + repo_aliases: Vec, + /// Description collected so far. + workspace_description: String, + /// Explicit `.code-workspace` file path, or empty for default. + workspace_file_path: String, + /// Short-name aliases that resolve to this workspace in + /// commands taking a workspace name argument. + workspace_aliases: Vec, +} + +/// Chainable builder methods for assembling a workspace registration. +impl WorkspaceRegistrationBuilder { + /// Begins a registration for a workspace with the given name. + /// + /// The name becomes the config key under `[workspaces.]` + /// and the default `.code-workspace` filename if no explicit + /// path is provided later via `workspace_file_path()`. + #[must_use] + pub fn start(workspace_name: String) -> Self { + Self { + workspace_name, + repo_aliases: Vec::new(), + workspace_description: String::new(), + workspace_file_path: String::new(), + workspace_aliases: Vec::new(), + } + } + + /// Sets the ordered list of repo aliases for this workspace. + /// + /// Order matters: the generated `.code-workspace` file renders + /// folders in this order in the editor sidebar, so the list + /// passed here is the user-visible ordering. + #[must_use] + pub fn repo_aliases(mut self, aliases: Vec) -> Self { + self.repo_aliases = aliases; + self + } + + /// Sets the human-readable description of this workspace. + #[must_use] + pub fn workspace_description(mut self, description_text: String) -> Self { + self.workspace_description = description_text; + self + } + + /// Sets an explicit filesystem path for the `.code-workspace` file. + /// + /// Leave unset (or pass an empty string) to use the default + /// location `~/.reportal/workspaces/.code-workspace`. + #[must_use] + pub fn workspace_file_path(mut self, file_path: String) -> Self { + self.workspace_file_path = file_path; + self + } + + /// Sets the short-name aliases that resolve to this workspace. + /// + /// Each alias is checked for emptiness and intra-list duplicates + /// at `build()` time. Cross-workspace and cross-namespace (vs. + /// repo) collision detection happens one layer up at the config + /// level, because the builder does not have the full registry in + /// scope and trying to validate there would either require + /// plumbing the registry in (defeating the builder's isolation) + /// or silently let collisions through. + #[must_use] + pub fn workspace_aliases(mut self, declared_aliases: Vec) -> Self { + self.workspace_aliases = declared_aliases; + self + } + + /// Validates all fields and produces a workspace name + entry pair. + /// + /// Rejects an empty workspace name because it would produce an + /// unreachable config entry, and rejects an empty alias list + /// because a workspace with zero repos has no meaning — there + /// would be nothing to open. Does not verify that each alias + /// resolves to a registered repo; that check belongs at the + /// config level where the full repo registry is available. + /// + /// # Errors + /// + /// Returns [`ReportalError::ValidationFailure`] when the + /// workspace name is empty, the repo alias list is empty, or + /// any declared workspace alias is empty, duplicated within the + /// list, or equal to the owning workspace's canonical name. + pub fn build(self) -> Result<(String, WorkspaceEntry), ReportalError> { + if self.workspace_name.trim().is_empty() { + return Err(ReportalError::ValidationFailure { + field: "workspace name".to_owned(), + reason: "must not be empty".to_owned(), + }); + } + if self.repo_aliases.is_empty() { + return Err(ReportalError::ValidationFailure { + field: "workspace repos".to_owned(), + reason: "at least one repo alias is required".to_owned(), + }); + } + validate_alias_list_shape(&self.workspace_aliases, &self.workspace_name)?; + reject_alias_that_shadows_system_command(&self.workspace_name, "workspace name")?; + for declared_alias in &self.workspace_aliases { + reject_alias_that_shadows_system_command(declared_alias, "workspace alias")?; + } + let validated_entry = WorkspaceEntry { + repos: self + .repo_aliases + .into_iter() + .map(WorkspaceMember::RegisteredRepo) + .collect(), + description: self.workspace_description, + path: self.workspace_file_path, + aliases: self.workspace_aliases, + title: crate::reportal_config::TabTitle::default(), + color: crate::reportal_config::RepoColor::default(), + shell_alias: ShellAliasExport::Disabled, + }; + Ok((self.workspace_name, validated_entry)) + } +} + +/// Validates the shape of a workspace's declared alias list, +/// independent of other workspaces and repos. +/// +/// Only checks intra-list invariants: no empty/whitespace entries +/// (they would resolve nothing), no duplicates within the list +/// (they waste config bytes and imply ordering semantics the +/// resolver does not honor), and no alias equal to the workspace's +/// own canonical name (declaring `vn` on workspace `vn` is a no-op +/// that signals user confusion). +/// +/// Cross-entity collisions (alias of workspace A clashes with +/// canonical name or alias of workspace B, or any repo) require +/// the full config registry and belong on the config-level +/// validation pass that runs on load and `add_workspace`. +/// +/// # Errors +/// +/// Returns [`ReportalError::ValidationFailure`] with `field = +/// "workspace alias"` for each kind of shape violation. +fn validate_alias_list_shape( + declared_aliases: &[String], + owning_workspace_name: &str, +) -> Result<(), ReportalError> { + for declared in declared_aliases { + if declared.trim().is_empty() { + return Err(ReportalError::ValidationFailure { + field: "workspace alias".to_owned(), + reason: format!("workspace '{owning_workspace_name}' declares an empty alias"), + }); + } + if declared == owning_workspace_name { + return Err(ReportalError::ValidationFailure { + field: "workspace alias".to_owned(), + reason: format!( + "workspace '{owning_workspace_name}' declares '{declared}' as an alias but that is already its canonical name" + ), + }); + } + } + for earlier_index in 0..declared_aliases.len() { + for later_index in (earlier_index + 1)..declared_aliases.len() { + if declared_aliases[earlier_index] == declared_aliases[later_index] { + return Err(ReportalError::ValidationFailure { + field: "workspace alias".to_owned(), + reason: format!( + "workspace '{owning_workspace_name}' declares duplicate alias '{}'", + declared_aliases[earlier_index], + ), + }); + } + } + } + Ok(()) +} + +/// Rejects a workspace name or alias that resolves to an existing +/// executable on the user's `PATH`, so opting the workspace into +/// shell-alias export later cannot silently shadow a real +/// system command (`mc`, `train`, `home`, etc.). +fn reject_alias_that_shadows_system_command( + candidate_name: &str, + field_label_for_error: &str, +) -> Result<(), ReportalError> { + match SystemExecutableLookupOutcome::for_candidate_name(candidate_name.trim()) { + SystemExecutableLookupOutcome::NotFound => Ok(()), + SystemExecutableLookupOutcome::ShadowsExisting { existing_executable } => { + Err(ReportalError::ValidationFailure { + field: field_label_for_error.to_owned(), + reason: format!( + "'{candidate}' would shadow the existing system command at {existing}; pick a different name", + candidate = candidate_name.trim(), + existing = existing_executable.display(), + ), + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_name_is_rejected() { + let workspace_builder = WorkspaceRegistrationBuilder::start(String::new()) + .repo_aliases(vec!["alpha".to_owned()]); + let build_outcome = workspace_builder.build(); + assert!( + matches!(build_outcome, Err(ReportalError::ValidationFailure { ref field, .. }) if field == "workspace name"), + "expected ValidationFailure for workspace name, got {build_outcome:?}", + ); + } + + #[test] + fn whitespace_only_name_is_rejected() { + let workspace_builder = WorkspaceRegistrationBuilder::start(" ".to_owned()) + .repo_aliases(vec!["alpha".to_owned()]); + let build_outcome = workspace_builder.build(); + assert!( + matches!(build_outcome, Err(ReportalError::ValidationFailure { ref field, .. }) if field == "workspace name"), + "expected ValidationFailure for workspace name on whitespace input, got {build_outcome:?}", + ); + } + + #[test] + fn empty_repo_list_is_rejected() { + let workspace_builder = WorkspaceRegistrationBuilder::start("backend".to_owned()); + let build_outcome = workspace_builder.build(); + assert!( + matches!(build_outcome, Err(ReportalError::ValidationFailure { ref field, .. }) if field == "workspace repos"), + "expected ValidationFailure for workspace repos, got {build_outcome:?}", + ); + } + + #[test] + fn valid_builder_produces_entry_with_ordered_members() { + let workspace_builder = WorkspaceRegistrationBuilder::start("backend".to_owned()) + .repo_aliases(vec![ + "alpha".to_owned(), + "bravo".to_owned(), + "charlie".to_owned(), + ]) + .workspace_description("Backend services".to_owned()) + .workspace_file_path("~/work.code-workspace".to_owned()); + let (workspace_name, entry) = workspace_builder + .build() + .expect("valid builder must succeed"); + assert_eq!(workspace_name, "backend"); + assert_eq!(entry.repo_aliases(), &["alpha", "bravo", "charlie"]); + assert_eq!(entry.description(), "Backend services"); + assert_eq!(entry.raw_workspace_file_path(), "~/work.code-workspace"); + } + + #[test] + fn member_order_is_preserved_verbatim() { + let declared_order = vec!["zebra".to_owned(), "apple".to_owned(), "mango".to_owned()]; + let (_, entry) = WorkspaceRegistrationBuilder::start("ordered".to_owned()) + .repo_aliases(declared_order.clone()) + .build() + .expect("valid builder must succeed"); + assert_eq!( + entry.repo_aliases(), + declared_order.as_slice(), + "sidebar ordering is load-bearing and must match the declared input verbatim", + ); + } + + #[test] + fn workspace_aliases_populate_into_entry() { + let declared_aliases = vec!["vn".to_owned(), "noble".to_owned()]; + let (_, entry) = WorkspaceRegistrationBuilder::start("venoble".to_owned()) + .repo_aliases(vec!["app".to_owned()]) + .workspace_aliases(declared_aliases.clone()) + .build() + .expect("valid builder must succeed"); + use crate::reportal_config::has_aliases::HasAliases; + assert_eq!(entry.aliases(), declared_aliases.as_slice()); + } + + #[test] + fn empty_alias_is_rejected() { + let build_outcome = WorkspaceRegistrationBuilder::start("venoble".to_owned()) + .repo_aliases(vec!["app".to_owned()]) + .workspace_aliases(vec!["vn".to_owned(), String::new()]) + .build(); + assert!( + matches!(build_outcome, Err(ReportalError::ValidationFailure { ref field, .. }) if field == "workspace alias"), + "expected ValidationFailure for workspace alias on empty entry, got {build_outcome:?}", + ); + } + + #[test] + fn whitespace_only_alias_is_rejected() { + let build_outcome = WorkspaceRegistrationBuilder::start("venoble".to_owned()) + .repo_aliases(vec!["app".to_owned()]) + .workspace_aliases(vec![" ".to_owned()]) + .build(); + assert!( + matches!(build_outcome, Err(ReportalError::ValidationFailure { ref field, .. }) if field == "workspace alias"), + "expected ValidationFailure on whitespace-only alias, got {build_outcome:?}", + ); + } + + #[test] + fn duplicate_alias_in_same_workspace_is_rejected() { + let build_outcome = WorkspaceRegistrationBuilder::start("venoble".to_owned()) + .repo_aliases(vec!["app".to_owned()]) + .workspace_aliases(vec!["vn".to_owned(), "vn".to_owned()]) + .build(); + assert!( + matches!(build_outcome, Err(ReportalError::ValidationFailure { ref field, .. }) if field == "workspace alias"), + "expected ValidationFailure on duplicate alias, got {build_outcome:?}", + ); + } + + #[test] + fn alias_equal_to_own_canonical_name_is_rejected() { + let build_outcome = WorkspaceRegistrationBuilder::start("venoble".to_owned()) + .repo_aliases(vec!["app".to_owned()]) + .workspace_aliases(vec!["venoble".to_owned()]) + .build(); + assert!( + matches!(build_outcome, Err(ReportalError::ValidationFailure { ref field, .. }) if field == "workspace alias"), + "expected ValidationFailure when alias equals canonical name, got {build_outcome:?}", + ); + } +} diff --git a/src/system_executable_lookup.rs b/src/system_executable_lookup.rs new file mode 100644 index 0000000..aefe668 --- /dev/null +++ b/src/system_executable_lookup.rs @@ -0,0 +1,110 @@ +//! Looks up whether a candidate name resolves to an executable +//! file somewhere on the user's `PATH`. +//! +//! Used by the repo and workspace registration builders to reject +//! aliases that would shadow an existing system command once the +//! user opts a config entry into shell-alias export. Without this +//! check, opting a repo aliased `mc` into shell-alias mode would +//! silently clobber Midnight Commander on the user's prompt. +//! +//! The lookup is deliberately lightweight: no spawning of +//! `which`, no shell invocation, just a `PATH` walk plus an +//! executable-bit check. That matches what an interactive shell +//! does to resolve a typed command and gives the same answer. + +use std::path::{Path, PathBuf}; + +/// Outcome of probing `PATH` for a candidate name. +#[derive(Debug)] +pub enum SystemExecutableLookupOutcome { + /// No directory on `PATH` contains an executable file with + /// the candidate name. + NotFound, + /// At least one directory on `PATH` contains an executable + /// file with the candidate name; the first match's full path + /// is reported back so error messages can point the user at + /// the binary they would otherwise shadow. + ShadowsExisting { + /// Absolute path to the existing executable that the + /// candidate alias would shadow. + existing_executable: PathBuf, + }, +} + +/// Construction and helpers for the lookup outcome. +impl SystemExecutableLookupOutcome { + /// @why Probes the user's `PATH` for a candidate alias name + /// so the repo and workspace registration builders can reject + /// aliases that would clobber an existing system executable + /// once shell-alias export is opted in. + #[must_use] + pub fn for_candidate_name(candidate_name: &str) -> Self { + let Some(path_environment_variable) = std::env::var_os("PATH") else { + return Self::NotFound; + }; + for path_directory in std::env::split_paths(&path_environment_variable) { + let bare_probe_path = path_directory.join(candidate_name); + if Self::is_executable_file(&bare_probe_path) { + return Self::ShadowsExisting { + existing_executable: bare_probe_path, + }; + } + #[cfg(windows)] + { + let extension_probe_path = path_directory.join(format!("{candidate_name}.exe")); + if extension_probe_path.is_file() { + return Self::ShadowsExisting { + existing_executable: extension_probe_path, + }; + } + } + } + Self::NotFound + } + + /// Whether the given path is a regular file with at least + /// one executable bit set on Unix, or a regular file on + /// Windows (where executability is encoded by extension and + /// the caller probes the `.exe` form separately). + fn is_executable_file(probe_path: &Path) -> bool { + let Ok(file_metadata) = std::fs::metadata(probe_path) else { + return false; + }; + if !file_metadata.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let unix_permission_bits = file_metadata.permissions().mode(); + return (unix_permission_bits & 0o111) != 0; + } + #[cfg(not(unix))] + { + true + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unique_garbage_name_resolves_to_not_found() { + let outcome = SystemExecutableLookupOutcome::for_candidate_name( + "this-name-should-never-exist-on-any-PATH-zzz-9182734", + ); + assert!(matches!(outcome, SystemExecutableLookupOutcome::NotFound)); + } + + #[cfg(unix)] + #[test] + fn cat_resolves_to_shadows_existing_on_unix() { + let outcome = SystemExecutableLookupOutcome::for_candidate_name("cat"); + assert!( + matches!(outcome, SystemExecutableLookupOutcome::ShadowsExisting { .. }), + "expected `cat` to resolve to /bin/cat or /usr/bin/cat on any POSIX system, got {outcome:?}", + ); + } +} diff --git a/src/terminal_style.rs b/src/terminal_style.rs index 8118439..c9912ef 100644 --- a/src/terminal_style.rs +++ b/src/terminal_style.rs @@ -1,10 +1,13 @@ -/// Centralized color palette and styling for all RePortal terminal output. -/// -/// Uses the Nightfall Candy palette from the Roblox Studio syntax theme. -/// All colors are applied via owo-colors for zero-allocation styling. +//! Centralized color palette and styling for all `RePortal` terminal output. +//! +//! Uses the Nightfall Candy palette from the Roblox Studio syntax theme. +//! All colors are applied via owo-colors for zero-allocation styling. use owo_colors::{OwoColorize, Style}; +/// Neutral gray for repos with no configured color. +pub const DEFAULT_SWATCH_STYLE: Style = Style::new().dimmed(); + /// Blue used for repo aliases and labels. pub const ALIAS_STYLE: Style = Style::new().blue().bold(); @@ -26,53 +29,104 @@ pub const LABEL_STYLE: Style = Style::new().cyan(); /// Bold white for emphasis. pub const EMPHASIS_STYLE: Style = Style::new().bold(); +/// Builds a dialoguer `ColorfulTheme` tuned to the Nightfall Candy palette. +/// +/// Match-highlighted characters in fuzzy select use accent blue with bold. +/// Non-matched items are dimmed so the highlighted item and matched +/// characters pop visually. The prompt prefix uses cyan to match `LABEL_STYLE`. +pub fn reportal_prompt_theme() -> dialoguer::theme::ColorfulTheme { + use console::Style as ConsoleStyle; + + dialoguer::theme::ColorfulTheme { + fuzzy_match_highlight_style: ConsoleStyle::new().for_stderr().color256(117).bold(), + active_item_style: ConsoleStyle::new().for_stderr().color256(117), + inactive_item_style: ConsoleStyle::new().for_stderr().dim(), + prompt_prefix: console::style("?".to_owned()).for_stderr().cyan(), + prompt_style: ConsoleStyle::new().for_stderr().bold(), + ..dialoguer::theme::ColorfulTheme::default() + } +} + +/// Builds an owo-colors `Style` for the swatch block (`██`) based on +/// the repo's configured color. Returns a truecolor foreground style +/// for themed repos, or the default gray for repos with no color. +pub fn swatch_style_for_repo_color( + repo_color: &crate::reportal_config::RepoColor, +) -> Result { + match repo_color { + crate::reportal_config::RepoColor::Themed(hex_color) => { + let (red, green, blue) = hex_color.as_rgb_bytes()?; + Ok(Style::new().truecolor(red, green, blue)) + } + crate::reportal_config::RepoColor::ResetToDefault => Ok(DEFAULT_SWATCH_STYLE), + } +} + +pub fn write_stdout(text: &str) { + use std::io::Write; + let _result = std::io::stdout().write_all(text.as_bytes()); +} + +pub fn write_stderr(text: &str) { + use std::io::Write; + let _result = std::io::stderr().write_all(text.as_bytes()); +} + /// Prints an error message with a red "Error:" prefix. pub fn print_error(error_message: &str) { - eprintln!("{} {}", "Error:".style(FAILURE_STYLE), error_message); + write_stderr(&format!( + "{} {}\n", + "Error:".style(FAILURE_STYLE), + error_message + )); } /// Prints a success message with a green prefix. pub fn print_success(success_message: &str) { - println!("{} {}", ">>".style(SUCCESS_STYLE), success_message); + write_stdout(&format!( + "{} {}\n", + ">>".style(SUCCESS_STYLE), + success_message + )); } /// Returns the OSC 2 escape sequence that sets the terminal tab title. pub fn osc_tab_title_sequence(title_text: &str) -> String { - return format!("\x1b]2;{title_text}\x07"); + format!("\x1b]2;{title_text}\x07") } -/// Returns the OSC 111 escape sequence that resets the terminal background -/// to its configured default. -pub fn osc_reset_background_sequence() -> &'static str { - return "\x1b]111\x07"; +/// Returns the OSC 104;264 escape sequence that resets the Windows Terminal +/// tab color strip (`FRAME_BACKGROUND`) back to the profile default. +pub fn osc_reset_tab_color_sequence() -> &'static str { + "\x1b]104;264\x07" } -/// What to do with the terminal background when jumping to a repo. -pub enum BackgroundAction { - /// Emit an OSC 11 sequence to set a specific background color. +/// What to do with the terminal tab color strip when jumping to a repo. +pub enum TabColorAction { + /// Emit OSC 6 sequences to set the tab color to specific RGB values. SetColor(String), - /// Emit an OSC 111 sequence to reset to the terminal's default. + /// Emit OSC 6 reset to restore the terminal's default tab color. Reset, } -/// The resolved tab title and background action for a single jump/open. +/// The resolved tab title and tab color action for a single jump/open. /// Constructed from a repo's config + any CLI overrides, then passed -/// to `emit_terminal_identity` to write the OSC sequences. +/// to an emit function to write the OSC sequences. pub struct TerminalIdentity { resolved_title: String, - background_action: BackgroundAction, + tab_color_action: TabColorAction, } /// Construction and accessors for terminal identity data. impl TerminalIdentity { /// Builds a terminal identity from the resolved title string and - /// background action. Called after the title fallback chain + /// tab color action. Called after the title fallback chain /// (flag > config > alias) has been resolved. - pub fn new(identity_params: TerminalIdentityParams) -> Self { - return Self { + pub fn new(identity_params: TerminalIdentityParameters) -> Self { + Self { resolved_title: identity_params.resolved_title, - background_action: identity_params.background_action, - }; + tab_color_action: identity_params.tab_color_action, + } } /// The final tab title after resolving the flag > config > alias chain. @@ -80,45 +134,66 @@ impl TerminalIdentity { &self.resolved_title } - /// Whether to set a specific color or reset to the terminal default. - pub fn background_action(&self) -> &BackgroundAction { - &self.background_action + /// Whether to set a specific tab color or reset to the terminal default. + pub fn tab_color_action(&self) -> &TabColorAction { + &self.tab_color_action } } /// Parameters for constructing a `TerminalIdentity`. -pub struct TerminalIdentityParams { +pub struct TerminalIdentityParameters { /// The final tab title after resolving flag > config > alias fallback. pub resolved_title: String, - /// Whether to set a color or reset the background. - pub background_action: BackgroundAction, + /// Whether to set a tab color or reset to default. + pub tab_color_action: TabColorAction, } -/// Emits the appropriate OSC sequences to stderr for a repo's terminal -/// personalization (tab title + background color). Used by jump/open -/// where stdout carries the path. Skips emission if stderr is not a TTY. -pub fn emit_terminal_identity_to_stderr(identity: &TerminalIdentity) { - if !std::io::IsTerminal::is_terminal(&std::io::stderr()) { - return; - } - eprint!("{}", osc_tab_title_sequence(identity.resolved_title())); - match identity.background_action() { - BackgroundAction::SetColor(osc_sequence) => eprint!("{osc_sequence}"), - BackgroundAction::Reset => eprint!("{}", osc_reset_background_sequence()), +/// Writes a raw string directly to the console handle (CONOUT$ on Windows, +/// /dev/tty on Unix). Silently skipped if the console can't be opened. +pub fn write_to_console(text: &str) { + use std::io::Write; + + #[cfg(target_os = "windows")] + let console_handle = std::fs::OpenOptions::new().write(true).open("CONOUT$"); + + #[cfg(not(target_os = "windows"))] + let console_handle = std::fs::OpenOptions::new().write(true).open("/dev/tty"); + + match console_handle { + Ok(mut console) => { + let _write_result = console.write_all(text.as_bytes()); + let _flush_result = console.flush(); + } + Err(_console_open_error) => {} } } -/// Emits the appropriate OSC sequences to stdout for a repo's terminal -/// personalization (tab title + background color). Used by the `color` -/// subcommand where nothing captures stdout. Skips emission if stdout -/// is not a TTY. -pub fn emit_terminal_identity_to_stdout(identity: &TerminalIdentity) { - if !std::io::IsTerminal::is_terminal(&std::io::stdout()) { - return; - } - print!("{}", osc_tab_title_sequence(identity.resolved_title())); - match identity.background_action() { - BackgroundAction::SetColor(osc_sequence) => print!("{osc_sequence}"), - BackgroundAction::Reset => print!("{}", osc_reset_background_sequence()), +/// Writes OSC sequences directly to the console, bypassing both stdout +/// and stderr. This is necessary because `PowerShell` captures stdout in +/// subshells and prompt functions, so escape sequences written to stdout +/// or stderr never reach the terminal. On Windows this opens CONOUT$; +/// on Unix it opens /dev/tty. If the console can't be opened (e.g. in +/// a headless context), the sequences are silently skipped. +pub fn emit_terminal_identity_to_console(identity: &TerminalIdentity) { + use std::io::Write; + + #[cfg(target_os = "windows")] + let console_handle = std::fs::OpenOptions::new().write(true).open("CONOUT$"); + + #[cfg(not(target_os = "windows"))] + let console_handle = std::fs::OpenOptions::new().write(true).open("/dev/tty"); + + match console_handle { + Ok(mut console) => { + let title_sequence = osc_tab_title_sequence(identity.resolved_title()); + let color_sequence: &str = match identity.tab_color_action() { + TabColorAction::SetColor(osc_sequence) => osc_sequence.as_str(), + TabColorAction::Reset => osc_reset_tab_color_sequence(), + }; + let combined = format!("{title_sequence}{color_sequence}"); + let _write_result = console.write_all(combined.as_bytes()); + let _flush_result = console.flush(); + } + Err(_console_open_error) => {} } }