From 3b2c82ed2bbfaff51c07f0fb3312fa3497035ccc Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Fri, 20 Mar 2026 16:22:05 -0400 Subject: [PATCH 01/67] Add spec for dynamic terminal tab color on repo jump --- SPEC-tab-color.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 SPEC-tab-color.md diff --git a/SPEC-tab-color.md b/SPEC-tab-color.md new file mode 100644 index 0000000..f751f4e --- /dev/null +++ b/SPEC-tab-color.md @@ -0,0 +1,63 @@ +# Spec: Dynamic Terminal Tab Color on Repo Jump + +## Summary + +When `rj ` (or `reportal jump `) changes directory to a repo, reportal should also emit a Windows Terminal escape sequence to set the **tab color** to that repo's `color` field from the config. + +## Motivation + +When working across multiple repos in separate terminal tabs (often inside Claude Code, where oh-my-posh is not visible), there's no way to visually distinguish which tab is which. Terminal background colors don't scale — they clash with text. Tab colors are a small colored strip that never affects readability. + +## Behavior + +### On Jump + +When a repo has a `color` field in `~/.reportal/config.toml`: + +1. Parse the hex color (e.g., `#3D0000`) into RGB components +2. Emit the Windows Terminal OSC escape sequence to set the tab color: + ``` + \x1b]6;1;bg;red;{r}\x07 + \x1b]6;1;bg;green;{g}\x07 + \x1b]6;1;bg;blue;{b}\x07 + ``` + Where `{r}`, `{g}`, `{b}` are the decimal RGB values (0-255). +3. Then proceed with the normal directory change. + +### On Jump to Repo Without Color + +If the target repo has no `color` field, **reset the tab color** to the terminal default: +``` +\x1b]6;1;bg;default\x07 +``` + +### On `reportal list` + +No change to list behavior. Colors are already shown there (or could be — that's separate scope). + +## Config + +No new config fields. Uses the existing `color` field: + +```toml +[repos.nro] +path = "~/dev/oja-gamez/ninja-revival-online" +color = "#3D0000" +``` + +## Edge Cases + +- **Non-Windows-Terminal**: The escape sequences are Windows Terminal specific. On other terminals they'll be silently ignored (no-op). No feature detection needed — just emit them. +- **Invalid hex color**: If `color` is present but not a valid `#RRGGBB` hex string, skip emitting (don't crash). +- **SSH / WSL**: The escape sequences pass through to the host terminal. This should just work. + +## Existing Repo Colors + +``` +nro = #3D0000 (dark red) +ai-lab = #003D00 (dark green) +venoble = #E6B422 (golden yellow) +jakuta-admin = #9B59B6 (medium purple) +``` + +Repos without colors: reportal, otk, ozzy-website, ourhomii, claude-conspire, open-claw-dev. From 9d8693a3c53708f532de3ecd455fa7c3084ea02e Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Fri, 20 Mar 2026 22:32:16 -0400 Subject: [PATCH 02/67] Switch to OSC 4;264 for Windows Terminal tab color strip Replaces OSC 6 (iTerm2-only, never worked on WT) with OSC 4 index 264 (FRAME_BACKGROUND) which sets just the tab color strip in Windows Terminal. Uses rgb:RR/GG/BB hex format matching Hanselman's working implementation. Reset via OSC 104;264. Silently ignored by terminals that don't support it. --- src/reportal_commands/repo_color.rs | 20 +++++----- src/reportal_commands/repo_jump.rs | 10 ++--- src/reportal_commands/repo_open.rs | 10 ++--- src/reportal_config.rs | 16 ++++++-- src/terminal_style.rs | 61 ++++++++++++++--------------- 5 files changed, 62 insertions(+), 55 deletions(-) diff --git a/src/reportal_commands/repo_color.rs b/src/reportal_commands/repo_color.rs index ba7ff92..922128f 100644 --- a/src/reportal_commands/repo_color.rs +++ b/src/reportal_commands/repo_color.rs @@ -6,7 +6,7 @@ use crate::error::ReportalError; use crate::reportal_config::{RepoColor, ReportalConfig, TabTitle}; -use crate::terminal_style::{self, BackgroundAction, TerminalIdentity, TerminalIdentityParams}; +use crate::terminal_style::{self, TabColorAction, TerminalIdentity, TerminalIdentityParams}; /// Parameters for the color subcommand. pub struct ColorCommandParams<'a> { @@ -30,15 +30,15 @@ pub fn run_color(color_params: ColorCommandParams<'_>) -> Result<(), ReportalErr TabTitle::Custom(custom_title) => custom_title.to_string(), TabTitle::UseAlias => color_params.repo_alias.to_string(), }; - let background_action = match found_repo.repo_color() { + let tab_color_action = match found_repo.repo_color() { RepoColor::Themed(hex_color) => { - BackgroundAction::SetColor(hex_color.as_osc_background_sequence()) + TabColorAction::SetColor(hex_color.as_osc_tab_color_sequence()) } - RepoColor::ResetToDefault => BackgroundAction::Reset, + RepoColor::ResetToDefault => TabColorAction::Reset, }; let identity = TerminalIdentity::new(TerminalIdentityParams { resolved_title, - background_action, + tab_color_action, }); terminal_style::emit_terminal_identity_to_stdout(&identity); } @@ -78,21 +78,21 @@ pub fn run_color(color_params: ColorCommandParams<'_>) -> Result<(), ReportalErr TabTitle::Custom(custom_title) => custom_title.to_string(), TabTitle::UseAlias => best_alias.to_string(), }; - let background_action = match matched_repo.repo_color() { + let tab_color_action = match matched_repo.repo_color() { RepoColor::Themed(hex_color) => { - BackgroundAction::SetColor(hex_color.as_osc_background_sequence()) + TabColorAction::SetColor(hex_color.as_osc_tab_color_sequence()) } - RepoColor::ResetToDefault => BackgroundAction::Reset, + RepoColor::ResetToDefault => TabColorAction::Reset, }; let identity = TerminalIdentity::new(TerminalIdentityParams { resolved_title, - background_action, + tab_color_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()); + print!("{}", terminal_style::osc_reset_tab_color_sequence()); } } } diff --git a/src/reportal_commands/repo_jump.rs b/src/reportal_commands/repo_jump.rs index 552dee3..044e08b 100644 --- a/src/reportal_commands/repo_jump.rs +++ b/src/reportal_commands/repo_jump.rs @@ -2,7 +2,7 @@ use crate::error::ReportalError; use crate::reportal_config::{PathVisibility, RepoColor, ReportalConfig, TabTitle, TagFilter}; -use crate::terminal_style::{self, BackgroundAction, TerminalIdentity, TerminalIdentityParams}; +use crate::terminal_style::{self, TabColorAction, TerminalIdentity, TerminalIdentityParams}; use dialoguer::{theme::ColorfulTheme, FuzzySelect}; use owo_colors::OwoColorize; @@ -77,16 +77,16 @@ pub fn run_jump(jump_params: JumpCommandParams<'_>) -> Result<(), ReportalError> }, }; - let background_action = match selected_repo.repo_color() { + let tab_color_action = match selected_repo.repo_color() { RepoColor::Themed(hex_color) => { - BackgroundAction::SetColor(hex_color.as_osc_background_sequence()) + TabColorAction::SetColor(hex_color.as_osc_tab_color_sequence()) } - RepoColor::ResetToDefault => BackgroundAction::Reset, + RepoColor::ResetToDefault => TabColorAction::Reset, }; let identity = TerminalIdentity::new(TerminalIdentityParams { resolved_title, - background_action, + tab_color_action, }); terminal_style::emit_terminal_identity_to_stderr(&identity); diff --git a/src/reportal_commands/repo_open.rs b/src/reportal_commands/repo_open.rs index 97f6209..062b192 100644 --- a/src/reportal_commands/repo_open.rs +++ b/src/reportal_commands/repo_open.rs @@ -2,7 +2,7 @@ use crate::error::ReportalError; use crate::reportal_config::{PathVisibility, RepoColor, ReportalConfig, TabTitle, TagFilter}; -use crate::terminal_style::{self, BackgroundAction, TerminalIdentity, TerminalIdentityParams}; +use crate::terminal_style::{self, TabColorAction, TerminalIdentity, TerminalIdentityParams}; use dialoguer::{theme::ColorfulTheme, FuzzySelect}; use owo_colors::OwoColorize; use std::process::Command; @@ -79,16 +79,16 @@ pub fn run_open(open_params: OpenCommandParams<'_>) -> Result<(), ReportalError> }, }; - let background_action = match selected_repo.repo_color() { + let tab_color_action = match selected_repo.repo_color() { RepoColor::Themed(hex_color) => { - BackgroundAction::SetColor(hex_color.as_osc_background_sequence()) + TabColorAction::SetColor(hex_color.as_osc_tab_color_sequence()) } - RepoColor::ResetToDefault => BackgroundAction::Reset, + RepoColor::ResetToDefault => TabColorAction::Reset, }; let identity = TerminalIdentity::new(TerminalIdentityParams { resolved_title, - background_action, + tab_color_action, }); terminal_style::emit_terminal_identity_to_stderr(&identity); diff --git a/src/reportal_config.rs b/src/reportal_config.rs index 845155e..64d72fd 100644 --- a/src/reportal_config.rs +++ b/src/reportal_config.rs @@ -47,10 +47,18 @@ impl HexColor { &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); + /// 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]; + return format!( + "\x1b]4;264;rgb:{red_hex}/{green_hex}/{blue_hex}\x07" + ); } } diff --git a/src/terminal_style.rs b/src/terminal_style.rs index 8118439..f0e00e2 100644 --- a/src/terminal_style.rs +++ b/src/terminal_style.rs @@ -41,37 +41,37 @@ pub fn osc_tab_title_sequence(title_text: &str) -> String { return 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 { + return "\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 { resolved_title: identity_params.resolved_title, - background_action: identity_params.background_action, + tab_color_action: identity_params.tab_color_action, }; } @@ -80,9 +80,9 @@ 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 } } @@ -90,35 +90,34 @@ impl TerminalIdentity { pub struct TerminalIdentityParams { /// 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. +/// Emits OSC sequences to stderr for tab title + tab color strip. +/// Used by jump/open where stdout carries the path for the shell function. +/// 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()), + match identity.tab_color_action() { + TabColorAction::SetColor(osc_sequence) => eprint!("{osc_sequence}"), + TabColorAction::Reset => eprint!("{}", osc_reset_tab_color_sequence()), } } -/// 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. +/// Emits OSC sequences to stdout for tab title + tab color strip. +/// 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()), + match identity.tab_color_action() { + TabColorAction::SetColor(osc_sequence) => print!("{osc_sequence}"), + TabColorAction::Reset => print!("{}", osc_reset_tab_color_sequence()), } } From d8732ea1e79db07c0238a79d479356969b4bde82 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Fri, 20 Mar 2026 22:37:45 -0400 Subject: [PATCH 03/67] Add multiple aliases per repo for direct jump Repos can now declare an `aliases` field with alternative names. `rj ninja` resolves to nro if nro has aliases = ["ninja", "ninja-revival"]. Lookup checks the primary key first, then walks all repos' aliases. Works for jump, open, color, and edit commands. --- README.md | 3 ++- src/reportal_config.rs | 51 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 30555f0..e0b6f4a 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ ro | `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 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 | @@ -186,6 +186,7 @@ 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 | +| `aliases` | no | `[]` | Alternative names for direct jump (e.g. `rj ninja` instead of `rj nro`) | | `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 | diff --git a/src/reportal_config.rs b/src/reportal_config.rs index 64d72fd..9022622 100644 --- a/src/reportal_config.rs +++ b/src/reportal_config.rs @@ -253,6 +253,9 @@ pub struct RepoEntry { /// Git remote URL for cloning on other machines. #[serde(default)] remote: String, + /// Alternative names that can be used to jump to this repo directly. + #[serde(default)] + aliases: Vec, /// Custom tab title shown in the terminal when jumping to this repo. #[serde(default)] title: TabTitle, @@ -375,6 +378,7 @@ impl RepoRegistrationBuilder { description: self.repo_description, tags: self.repo_tags, remote: self.repo_remote, + aliases: Vec::new(), title: self.repo_title, color: self.repo_color, }; @@ -405,6 +409,11 @@ impl RepoEntry { &self.tags } + /// Alternative names that can be used to jump to this repo directly. + pub fn aliases(&self) -> &[String] { + &self.aliases + } + /// The configured tab title preference for this repo. pub fn tab_title(&self) -> &TabTitle { &self.title @@ -505,11 +514,45 @@ impl ReportalConfig { self.repos.iter().collect() } - /// Looks up a repo by its alias. Returns `RepoNotFound` if not registered. + /// Looks up a repo by its primary key or any of its alternative aliases. + /// Checks the primary key first, then walks all repos checking their + /// `aliases` field. Returns `RepoNotFound` if no match is found. pub fn get_repo(&self, alias: &str) -> Result<&RepoEntry, ReportalError> { - self.repos.get(alias).ok_or_else(|| ReportalError::RepoNotFound { - alias: alias.to_string(), - }) + match self.repos.get(alias) { + Some(found_repo) => return Ok(found_repo), + None => { + for (_primary_key, repo_entry) in &self.repos { + let has_matching_alias = repo_entry.aliases.iter().any(|alt| alt == alias); + match has_matching_alias { + true => return Ok(repo_entry), + false => {} + } + } + return Err(ReportalError::RepoNotFound { + alias: alias.to_string(), + }); + } + } + } + + /// Looks up a repo's primary key by searching both primary keys and + /// alternative aliases. Returns the primary key that owns the match. + pub fn resolve_primary_key<'a>(&'a self, alias: &'a str) -> Result<&'a str, ReportalError> { + match self.repos.contains_key(alias) { + true => return Ok(alias), + false => { + for (primary_key, repo_entry) in &self.repos { + let has_matching_alias = repo_entry.aliases.iter().any(|alt| alt == alias); + match has_matching_alias { + true => return Ok(primary_key.as_str()), + false => {} + } + } + return Err(ReportalError::RepoNotFound { + alias: alias.to_string(), + }); + } + } } /// Registers a new repo from a validated builder result. From 2cc3308e8e8428c3692d9f90852bcb433d132fdc Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Sat, 21 Mar 2026 00:17:07 -0400 Subject: [PATCH 04/67] Bump to v0.3.2 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 03e0c21..1fd2d68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -520,7 +520,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.3.1" +version = "0.3.2" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 78b892f..d719f09 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.3.1" +version = "0.3.2" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" From 501094e8ba0701ad239330c66da0d77362e1bd79 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Sat, 21 Mar 2026 00:23:08 -0400 Subject: [PATCH 05/67] Update badges and Scoop install instructions for live repos --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e0b6f4a..5a23eca 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)]() @@ -42,6 +42,7 @@ brew install reportal ### Scoop (Windows) ```powershell +scoop bucket add reportal https://github.com/CheckPickerUpper/scoop-reportal scoop install reportal ``` From 7e26208f25a03c8178a80755764bf28e55e61505 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Sat, 21 Mar 2026 01:24:50 -0400 Subject: [PATCH 06/67] Write OSC sequences directly to console handle (v0.3.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces stdout/stderr emission with direct writes to CONOUT$ (Windows) and /dev/tty (Unix). PowerShell captures stdout in subshells and prompt functions, so escape sequences never reached the terminal. Writing directly to the console handle bypasses all shell capture mechanics and works in every context — PowerShell, bash, cmd, VS Code terminal, etc. --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/reportal_commands/repo_color.rs | 8 ++-- src/reportal_commands/repo_jump.rs | 2 +- src/reportal_commands/repo_open.rs | 2 +- src/terminal_style.rs | 74 ++++++++++++++++++++--------- 6 files changed, 59 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1fd2d68..6f5b48a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -520,7 +520,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.3.2" +version = "0.3.3" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index d719f09..3030041 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.3.2" +version = "0.3.3" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/src/reportal_commands/repo_color.rs b/src/reportal_commands/repo_color.rs index 922128f..7b2fb2f 100644 --- a/src/reportal_commands/repo_color.rs +++ b/src/reportal_commands/repo_color.rs @@ -40,7 +40,7 @@ pub fn run_color(color_params: ColorCommandParams<'_>) -> Result<(), ReportalErr resolved_title, tab_color_action, }); - terminal_style::emit_terminal_identity_to_stdout(&identity); + terminal_style::emit_terminal_identity_to_console(&identity); } true => { let current_directory = std::env::current_dir().map_err(|io_error| { @@ -88,12 +88,10 @@ pub fn run_color(color_params: ColorCommandParams<'_>) -> Result<(), ReportalErr resolved_title, tab_color_action, }); - terminal_style::emit_terminal_identity_to_stdout(&identity); + terminal_style::emit_terminal_identity_to_console(&identity); } None => { - if std::io::IsTerminal::is_terminal(&std::io::stdout()) { - print!("{}", terminal_style::osc_reset_tab_color_sequence()); - } + terminal_style::write_to_console(terminal_style::osc_reset_tab_color_sequence()); } } } diff --git a/src/reportal_commands/repo_jump.rs b/src/reportal_commands/repo_jump.rs index 044e08b..d326919 100644 --- a/src/reportal_commands/repo_jump.rs +++ b/src/reportal_commands/repo_jump.rs @@ -88,7 +88,7 @@ pub fn run_jump(jump_params: JumpCommandParams<'_>) -> Result<(), ReportalError> resolved_title, tab_color_action, }); - terminal_style::emit_terminal_identity_to_stderr(&identity); + terminal_style::emit_terminal_identity_to_console(&identity); let resolved_path = selected_repo.resolved_path(); let formatted_path = loaded_config.path_display_format().format_path(&resolved_path); diff --git a/src/reportal_commands/repo_open.rs b/src/reportal_commands/repo_open.rs index 062b192..905d562 100644 --- a/src/reportal_commands/repo_open.rs +++ b/src/reportal_commands/repo_open.rs @@ -90,7 +90,7 @@ pub fn run_open(open_params: OpenCommandParams<'_>) -> Result<(), ReportalError> resolved_title, tab_color_action, }); - terminal_style::emit_terminal_identity_to_stderr(&identity); + terminal_style::emit_terminal_identity_to_console(&identity); let resolved_repo_path = selected_repo.resolved_path(); diff --git a/src/terminal_style.rs b/src/terminal_style.rs index f0e00e2..313a1b9 100644 --- a/src/terminal_style.rs +++ b/src/terminal_style.rs @@ -94,30 +94,60 @@ pub struct TerminalIdentityParams { pub tab_color_action: TabColorAction, } -/// Emits OSC sequences to stderr for tab title + tab color strip. -/// Used by jump/open where stdout carries the path for the shell function. -/// 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.tab_color_action() { - TabColorAction::SetColor(osc_sequence) => eprint!("{osc_sequence}"), - TabColorAction::Reset => eprint!("{}", osc_reset_tab_color_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 OSC sequences to stdout for tab title + tab color strip. -/// 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.tab_color_action() { - TabColorAction::SetColor(osc_sequence) => print!("{osc_sequence}"), - TabColorAction::Reset => print!("{}", osc_reset_tab_color_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 = match identity.tab_color_action() { + TabColorAction::SetColor(osc_sequence) => osc_sequence.to_string(), + TabColorAction::Reset => osc_reset_tab_color_sequence().to_string(), + }; + 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) => {} } } From b095a36d6498af0d2c3632ea054959c64b1f5549 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Sat, 21 Mar 2026 12:06:26 -0400 Subject: [PATCH 07/67] Add rep doctor command for diagnosing installation issues (v0.4.0) Checks config file health, shell integration presence (rj/ro/prompt hook), and whether all registered repo paths exist on disk. Each failed check prints an actionable hint. Also adds full publish checklist to agent notes. --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 + src/main.rs | 3 + src/reportal_commands/doctor.rs | 341 ++++++++++++++++++++++++ src/reportal_commands/initialization.rs | 6 +- src/reportal_commands/mod.rs | 2 + 7 files changed, 353 insertions(+), 5 deletions(-) create mode 100644 src/reportal_commands/doctor.rs diff --git a/Cargo.lock b/Cargo.lock index 6f5b48a..f0cc2da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -520,7 +520,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.3.3" +version = "0.4.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 3030041..f559bb9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.3.3" +version = "0.4.0" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/README.md b/README.md index 5a23eca..764603c 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ ro | `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 doctor` | | Diagnose config, shell integration, and repo path issues | ## Shell integration @@ -232,6 +233,7 @@ PROMPT_COMMAND='rep color 2>/dev/null' - [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 - [ ] `dashboard` — rich overview with branches, dirty state, last commit - [ ] `clone --all` — clone missing repos from config (machine sync) - [ ] Shell completions diff --git a/src/main.rs b/src/main.rs index 0ccd70a..e23b7cc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -99,6 +99,8 @@ enum ReportalSubcommand { #[arg(long)] tag: Option, }, + /// Diagnose config, shell integration, and repo path issues + Doctor, } fn main() { @@ -189,6 +191,7 @@ fn main() { }; reportal_commands::run_sync(tag_filter) } + ReportalSubcommand::Doctor => reportal_commands::run_doctor(), }; match command_result { diff --git a/src/reportal_commands/doctor.rs b/src/reportal_commands/doctor.rs new file mode 100644 index 0000000..a40a2c4 --- /dev/null +++ b/src/reportal_commands/doctor.rs @@ -0,0 +1,341 @@ +/// Validates the full RePortal installation: config, shell integration, and repo paths. +/// +/// Prints a structured diagnostic report with pass/fail indicators for: +/// 1. Config file existence and TOML parse correctness +/// 2. Shell profile integration (rj/ro functions, prompt hook) +/// 3. Registered repo paths existing on disk +/// +/// Each failed check includes an actionable hint (e.g. "Run 'rep upgrade'"). +/// Exits with a summary count so the user knows if anything needs attention. + +use crate::error::ReportalError; +use crate::reportal_config::ReportalConfig; +use crate::terminal_style; +use owo_colors::OwoColorize; + +use super::initialization::detect_shell_profile; + +/// Whether a single diagnostic check passed or failed. +enum CheckOutcome { + /// The check passed without issues. + Passed, + /// The check detected a problem. + Failed, +} + +/// Tracks pass/fail counts across all diagnostic checks. +struct DiagnosticSummary { + passed: u32, + failed: u32, +} + +/// Construction and recording for diagnostic pass/fail tallies. +impl DiagnosticSummary { + fn new() -> Self { + return Self { + passed: 0, + failed: 0, + }; + } + + fn record_check(&mut self, check_outcome: CheckOutcome) { + match check_outcome { + CheckOutcome::Passed => self.passed += 1, + CheckOutcome::Failed => self.failed += 1, + } + } +} + +fn print_pass(diagnostic_message: &str) { + println!( + " {} {}", + "✓".style(terminal_style::SUCCESS_STYLE), + diagnostic_message, + ); +} + +fn print_fail(diagnostic_message: &str) { + println!( + " {} {}", + "✗".style(terminal_style::FAILURE_STYLE), + diagnostic_message, + ); +} + +fn print_hint(hint_message: &str) { + println!( + " {} {}", + "→".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(summary: &mut DiagnosticSummary) -> Option { + println!(); + println!( + " {}", + "Config".style(terminal_style::EMPHASIS_STYLE), + ); + + let config_path = match ReportalConfig::config_file_path() { + Ok(path) => path, + Err(_path_error) => { + print_fail("Could not determine config path"); + summary.record_check(CheckOutcome::Failed); + return None; + } + }; + + match config_path.exists() { + true => { + print_pass(&format!("Config file exists at {}", config_path.display())); + summary.record_check(CheckOutcome::Passed); + } + false => { + print_fail(&format!("Config not found at {}", config_path.display())); + print_hint("Run 'rep init' to create one"); + summary.record_check(CheckOutcome::Failed); + return None; + } + } + + match ReportalConfig::load_from_disk() { + Ok(loaded_config) => { + let repo_count = loaded_config.repos_with_aliases().len(); + print_pass(&format!("Config parses successfully ({repo_count} repos registered)")); + summary.record_check(CheckOutcome::Passed); + return Some(loaded_config); + } + Err(parse_error) => { + print_fail(&format!("Config failed to parse: {parse_error}")); + print_hint("Check ~/.reportal/config.toml for syntax errors"); + summary.record_check(CheckOutcome::Failed); + return None; + } + } +} + +/// Parameters for checking whether a shell function is present in the profile. +struct ShellFunctionCheckParams<'a> { + /// The full text of the shell profile file. + profile_content: &'a str, + /// The function name to look for (e.g. "rj", "ro"). + function_name: &'a str, + /// The command the function should invoke (e.g. "rep jump"). + expected_command: &'a str, + /// Running tally of diagnostic outcomes. + summary: &'a mut DiagnosticSummary, +} + +/// Checks that a specific shell function (rj or ro) is defined in the profile. +fn check_shell_function(function_check: ShellFunctionCheckParams<'_>) { + let has_function = function_check.profile_content.lines().any(|line| { + let trimmed = line.trim(); + let is_powershell_function = trimmed.starts_with(&format!("function {}", function_check.function_name)) + && trimmed.contains(function_check.expected_command); + let is_bash_function = trimmed.starts_with(&format!("{}()", function_check.function_name)) + && trimmed.contains(function_check.expected_command); + return is_powershell_function || is_bash_function; + }); + + match has_function { + true => { + print_pass(&format!("{} function installed", function_check.function_name)); + function_check.summary.record_check(CheckOutcome::Passed); + } + false => { + print_fail(&format!("{} function missing from shell profile", function_check.function_name)); + print_hint("Run 'rep upgrade' to reinstall shell integration"); + function_check.summary.record_check(CheckOutcome::Failed); + } + } +} + +/// Parameters for checking whether the prompt hook is present in the profile. +struct PromptHookCheckParams<'a> { + /// The full text of the shell profile file. + profile_content: &'a str, + /// Running tally of diagnostic outcomes. + summary: &'a mut DiagnosticSummary, +} + +/// Checks that the prompt hook (for automatic tab color) is in the profile. +fn check_prompt_hook(hook_check: PromptHookCheckParams<'_>) { + let has_prompt_hook = hook_check.profile_content.lines().any(|line| { + let trimmed = line.trim(); + return trimmed.contains("rep color") && ( + trimmed.starts_with("function prompt") + || trimmed.starts_with("$_reportal_") + || trimmed.starts_with("PROMPT_COMMAND") + ); + }); + + match has_prompt_hook { + true => { + print_pass("Prompt hook installed (auto tab color)"); + hook_check.summary.record_check(CheckOutcome::Passed); + } + false => { + print_fail("Prompt hook missing (tab color won't auto-apply)"); + print_hint("Run 'rep upgrade' to reinstall shell integration"); + hook_check.summary.record_check(CheckOutcome::Failed); + } + } +} + +/// Checks that the shell profile exists and contains RePortal integration. +fn check_shell_integration(summary: &mut DiagnosticSummary) { + println!(); + println!( + " {}", + "Shell Integration".style(terminal_style::EMPHASIS_STYLE), + ); + + let detected_shell = detect_shell_profile(); + + let profile_path = match detected_shell.profile_path() { + Some(path) => { + let path_string = format!("{}", path.display()); + let detected_label = match path_string.contains("PowerShell") { + true => "PowerShell", + false => match path_string.contains(".zshrc") { + true => "Zsh", + false => "Bash", + }, + }; + print_pass(&format!("Shell detected: {detected_label}")); + summary.record_check(CheckOutcome::Passed); + path + } + None => { + print_fail("Could not detect shell profile"); + print_hint("Run 'rep init' to install shell integration"); + summary.record_check(CheckOutcome::Failed); + return; + } + }; + + let profile_content = match std::fs::read_to_string(profile_path) { + Ok(content) => content, + Err(_read_error) => { + print_fail(&format!("Could not read profile at {}", profile_path.display())); + summary.record_check(CheckOutcome::Failed); + return; + } + }; + + check_shell_function(ShellFunctionCheckParams { + profile_content: &profile_content, + function_name: "rj", + expected_command: "rep jump", + summary, + }); + check_shell_function(ShellFunctionCheckParams { + profile_content: &profile_content, + function_name: "ro", + expected_command: "rep open", + summary, + }); + check_prompt_hook(PromptHookCheckParams { + profile_content: &profile_content, + summary, + }); +} + +/// Parameters for validating that registered repo paths exist on disk. +struct RepoPathCheckParams<'a> { + /// The loaded config containing repo entries to validate. + loaded_config: &'a ReportalConfig, + /// Running tally of diagnostic outcomes. + summary: &'a mut DiagnosticSummary, +} + +/// Checks that all registered repo paths exist on disk. +fn check_repo_paths(path_check: RepoPathCheckParams<'_>) { + println!(); + println!( + " {}", + "Repo Paths".style(terminal_style::EMPHASIS_STYLE), + ); + + let all_repos = path_check.loaded_config.repos_with_aliases(); + + match all_repos.is_empty() { + true => { + print_fail("No repos registered"); + print_hint("Run 'rep add ' to register a repo"); + path_check.summary.record_check(CheckOutcome::Failed); + } + false => { + for (alias, entry) in &all_repos { + let resolved_path = entry.resolved_path(); + match resolved_path.exists() { + true => { + print_pass(&format!( + "{} {}", + alias.style(terminal_style::ALIAS_STYLE), + resolved_path.display().to_string().style(terminal_style::PATH_STYLE), + )); + path_check.summary.record_check(CheckOutcome::Passed); + } + false => { + print_fail(&format!( + "{} {} (path does not exist)", + alias.style(terminal_style::ALIAS_STYLE), + resolved_path.display().to_string().style(terminal_style::PATH_STYLE), + )); + path_check.summary.record_check(CheckOutcome::Failed); + } + } + } + } + } +} + +/// Validates the full RePortal installation and prints a diagnostic report. +/// +/// Checks config file health, shell integration presence, and whether all +/// registered repo paths exist on disk. Each failed check prints an actionable +/// hint. Returns Ok(()) unconditionally — diagnostic failures are reported +/// to the user via stdout, not propagated as errors. +pub fn run_doctor() -> Result<(), ReportalError> { + println!( + "{}", + "RePortal Doctor".style(terminal_style::EMPHASIS_STYLE), + ); + + let mut summary = DiagnosticSummary::new(); + + let loaded_config = check_config(&mut summary); + check_shell_integration(&mut summary); + + match loaded_config { + Some(ref config) => check_repo_paths(RepoPathCheckParams { + loaded_config: config, + summary: &mut summary, + }), + None => {} + } + + println!(); + match summary.failed { + 0 => { + terminal_style::print_success(&format!( + "All {} checks passed", + summary.passed, + )); + } + _failure_count => { + println!( + " {} {} passed, {} failed", + "Summary:".style(terminal_style::EMPHASIS_STYLE), + summary.passed.style(terminal_style::SUCCESS_STYLE), + summary.failed.style(terminal_style::FAILURE_STYLE), + ); + } + } + + return Ok(()); +} diff --git a/src/reportal_commands/initialization.rs b/src/reportal_commands/initialization.rs index d51990f..9683802 100644 --- a/src/reportal_commands/initialization.rs +++ b/src/reportal_commands/initialization.rs @@ -40,7 +40,7 @@ fn bash_color_hook_line() -> String { } /// Which shell the user is running, with its profile path. -enum DetectedShell { +pub(crate) enum DetectedShell { /// PowerShell (Windows or cross-platform). PowerShell(PathBuf), /// Bash with a known profile path. @@ -107,7 +107,7 @@ fn detect_unix_shell_env() -> ShellEnvDetection { } /// Detects the current shell and its profile path. -fn detect_shell_profile() -> DetectedShell { +pub(crate) fn detect_shell_profile() -> DetectedShell { #[cfg(target_os = "windows")] { match detect_powershell_profile() { @@ -201,7 +201,7 @@ impl DetectedShell { } /// Returns the profile path if this shell was detected. - fn profile_path(&self) -> Option<&PathBuf> { + pub(crate) fn profile_path(&self) -> Option<&PathBuf> { match self { DetectedShell::PowerShell(ref shell_profile_path) => Some(shell_profile_path), #[cfg(not(target_os = "windows"))] diff --git a/src/reportal_commands/mod.rs b/src/reportal_commands/mod.rs index bf49d71..a3c723d 100644 --- a/src/reportal_commands/mod.rs +++ b/src/reportal_commands/mod.rs @@ -1,4 +1,5 @@ /// Subcommand implementations for the RePortal CLI. +mod doctor; mod initialization; mod repo_add; mod repo_color; @@ -10,6 +11,7 @@ mod repo_remove; mod repo_status; mod repo_sync; +pub use doctor::run_doctor; pub use initialization::run_init; pub use initialization::run_upgrade; pub use repo_add::run_add; From 5d19abd654363afa5706e2030f265754632a7cfa Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Sat, 21 Mar 2026 12:10:19 -0400 Subject: [PATCH 08/67] Detect stale shell sessions in rep doctor (v0.4.1) Shell integration sets REPORTAL_LOADED=1 on profile load; doctor checks for this env var and tells the user to reload if missing. This catches the exact scenario where rj/ro are in the profile file but the current terminal hasn't sourced it yet. --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/reportal_commands/doctor.rs | 22 +++++++++++++++++++++- src/reportal_commands/initialization.rs | 3 +++ 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f0cc2da..6039f9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -520,7 +520,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.4.0" +version = "0.4.1" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index f559bb9..ede97bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.4.0" +version = "0.4.1" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/src/reportal_commands/doctor.rs b/src/reportal_commands/doctor.rs index a40a2c4..fbc7ee1 100644 --- a/src/reportal_commands/doctor.rs +++ b/src/reportal_commands/doctor.rs @@ -185,7 +185,8 @@ fn check_prompt_hook(hook_check: PromptHookCheckParams<'_>) { } } -/// Checks that the shell profile exists and contains RePortal integration. +/// Checks that the shell profile exists and contains RePortal integration, +/// then verifies the current session has actually loaded it. fn check_shell_integration(summary: &mut DiagnosticSummary) { println!(); println!( @@ -242,6 +243,25 @@ fn check_shell_integration(summary: &mut DiagnosticSummary) { profile_content: &profile_content, summary, }); + check_session_loaded(summary); +} + +/// Checks whether the REPORTAL_LOADED env var is set, which proves +/// the shell profile was sourced in the current session. If the profile +/// file has the integration but this var is missing, rj/ro won't work +/// until the user reloads. +fn check_session_loaded(summary: &mut DiagnosticSummary) { + match std::env::var("REPORTAL_LOADED") { + Ok(_marker_value) => { + print_pass("Shell integration loaded in current session"); + summary.record_check(CheckOutcome::Passed); + } + Err(_env_error) => { + print_fail("Shell integration NOT loaded in current session"); + print_hint("Run '. $PROFILE' (PowerShell) or 'source ~/.bashrc' to reload"); + summary.record_check(CheckOutcome::Failed); + } + } } /// Parameters for validating that registered repo paths exist on disk. diff --git a/src/reportal_commands/initialization.rs b/src/reportal_commands/initialization.rs index 9683802..a9893a1 100644 --- a/src/reportal_commands/initialization.rs +++ b/src/reportal_commands/initialization.rs @@ -12,6 +12,7 @@ use std::process::Command; /// Includes shortcuts (rj, ro) and the terminal personalization prompt hook. const POWERSHELL_INTEGRATION: &str = r#" # RePortal shell integration +$env:REPORTAL_LOADED = "1" function rj { Set-Location (rep jump @args); rep color } function ro { rep open @args; rep color } $_reportal_original_prompt = $function:prompt @@ -24,6 +25,7 @@ function prompt { $p = & $_reportal_original_prompt; rep color 2>$null; $p } #[cfg(not(target_os = "windows"))] const BASH_INTEGRATION: &str = r#" # RePortal shell integration +export REPORTAL_LOADED=1 rj() { cd "$(rep jump "$@")"; rep color; } ro() { rep open "$@"; rep color; } "#; @@ -155,6 +157,7 @@ fn strip_existing_integration(profile_content: &str) -> String { || trimmed.starts_with("rj()") || trimmed.starts_with("ro()") || trimmed.starts_with("PROMPT_COMMAND") + || trimmed.contains("REPORTAL_LOADED") || trimmed.contains("rep jump") || trimmed.contains("rep open") || trimmed.contains("rep color"); From 84645abaae20dc21532d2df547169868f27d2ad7 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Sat, 21 Mar 2026 12:20:25 -0400 Subject: [PATCH 09/67] Replace inline shell integration with sourced file pattern (v0.5.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shell integration now lives in ~/.reportal/integration.ps1 (or .sh); the profile gets a single stable source line that never changes between versions. Removes rep upgrade — rep init is idempotent and handles both fresh installs and updates. Doctor now checks integration file version and session load state. --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 21 +-- src/main.rs | 5 +- src/reportal_commands/doctor.rs | 178 +++++++++--------- src/reportal_commands/initialization.rs | 228 +++++++++++++++--------- src/reportal_commands/mod.rs | 1 - 7 files changed, 240 insertions(+), 197 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6039f9f..81d5037 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -520,7 +520,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.4.1" +version = "0.5.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index ede97bf..a354ac4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.4.1" +version = "0.5.0" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/README.md b/README.md index 764603c..aa2b8fd 100644 --- a/README.md +++ b/README.md @@ -98,8 +98,7 @@ 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 init` | | Creates config and installs shell integration (safe to re-run on updates) | | `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 jump` | `rep j` | Fuzzy-select a repo, prints the path (used by `rj` shell function) | @@ -123,7 +122,7 @@ ro ## Shell integration -`rep init` automatically installs these shortcuts into your shell profile: +`rep init` writes a standalone integration script to `~/.reportal/integration.ps1` (or `.sh`) and adds a single source line to your shell profile. The profile line never changes between versions; updating the binary is all you need. | Shortcut | What it does | |----------|-------------| @@ -132,21 +131,7 @@ ro | `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 } -``` - -**Bash / Zsh:** -```bash -rj() { cd "$(rep jump "$@")"; } -ro() { rep open "$@"; } -``` +Supports PowerShell, Bash, Zsh. Detected and installed during `rep init`. Re-run `rep init` after major version updates to regenerate the integration file. ## Config diff --git a/src/main.rs b/src/main.rs index e23b7cc..e8b1bb6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,10 +24,8 @@ struct ReportalCli { /// All available subcommands for the RePortal CLI. #[derive(Subcommand)] enum ReportalSubcommand { - /// Create a default config file at ~/.reportal/config.toml + /// Set up config and shell integration (safe to re-run on updates) Init, - /// Update shell integration to the latest version - Upgrade, /// List all registered repos with status and metadata #[command(alias = "l")] List { @@ -108,7 +106,6 @@ fn main() { 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), diff --git a/src/reportal_commands/doctor.rs b/src/reportal_commands/doctor.rs index fbc7ee1..288296e 100644 --- a/src/reportal_commands/doctor.rs +++ b/src/reportal_commands/doctor.rs @@ -1,19 +1,21 @@ -/// Validates the full RePortal installation: config, shell integration, and repo paths. +/// Validates the full RePortal installation and prints a diagnostic report. /// -/// Prints a structured diagnostic report with pass/fail indicators for: +/// Checks four areas: /// 1. Config file existence and TOML parse correctness -/// 2. Shell profile integration (rj/ro functions, prompt hook) -/// 3. Registered repo paths existing on disk +/// 2. Integration script file exists and version matches the binary +/// 3. Shell profile sources the integration file +/// 4. Current session has loaded the integration (REPORTAL_LOADED env var) +/// 5. All registered repo paths exist on disk /// -/// Each failed check includes an actionable hint (e.g. "Run 'rep upgrade'"). -/// Exits with a summary count so the user knows if anything needs attention. +/// Each failed check prints an actionable hint so the user knows +/// exactly what to run to fix the problem. use crate::error::ReportalError; use crate::reportal_config::ReportalConfig; use crate::terminal_style; use owo_colors::OwoColorize; -use super::initialization::detect_shell_profile; +use super::initialization::{detect_shell_profile, integration_file_path}; /// Whether a single diagnostic check passed or failed. enum CheckOutcome { @@ -117,76 +119,54 @@ fn check_config(summary: &mut DiagnosticSummary) -> Option { } } -/// Parameters for checking whether a shell function is present in the profile. -struct ShellFunctionCheckParams<'a> { - /// The full text of the shell profile file. - profile_content: &'a str, - /// The function name to look for (e.g. "rj", "ro"). - function_name: &'a str, - /// The command the function should invoke (e.g. "rep jump"). - expected_command: &'a str, - /// Running tally of diagnostic outcomes. - summary: &'a mut DiagnosticSummary, +/// Whether the integration file version matches, is outdated, or is missing. +enum IntegrationFileStatus { + /// File exists and version matches the running binary. + Current, + /// File exists but was written by a different version. + Outdated { file_version: String }, + /// File does not exist at the expected path. + Missing, } -/// Checks that a specific shell function (rj or ro) is defined in the profile. -fn check_shell_function(function_check: ShellFunctionCheckParams<'_>) { - let has_function = function_check.profile_content.lines().any(|line| { - let trimmed = line.trim(); - let is_powershell_function = trimmed.starts_with(&format!("function {}", function_check.function_name)) - && trimmed.contains(function_check.expected_command); - let is_bash_function = trimmed.starts_with(&format!("{}()", function_check.function_name)) - && trimmed.contains(function_check.expected_command); - return is_powershell_function || is_bash_function; - }); +/// Reads the integration script file and compares its version stamp +/// against the running binary version. +fn check_integration_file_version() -> IntegrationFileStatus { + let script_path = match integration_file_path() { + Ok(path) => path, + Err(_path_error) => return IntegrationFileStatus::Missing, + }; - match has_function { - true => { - print_pass(&format!("{} function installed", function_check.function_name)); - function_check.summary.record_check(CheckOutcome::Passed); - } - false => { - print_fail(&format!("{} function missing from shell profile", function_check.function_name)); - print_hint("Run 'rep upgrade' to reinstall shell integration"); - function_check.summary.record_check(CheckOutcome::Failed); - } - } -} + let file_content = match std::fs::read_to_string(&script_path) { + Ok(content) => content, + Err(_read_error) => return IntegrationFileStatus::Missing, + }; -/// Parameters for checking whether the prompt hook is present in the profile. -struct PromptHookCheckParams<'a> { - /// The full text of the shell profile file. - profile_content: &'a str, - /// Running tally of diagnostic outcomes. - summary: &'a mut DiagnosticSummary, -} + let binary_version = env!("CARGO_PKG_VERSION"); -/// Checks that the prompt hook (for automatic tab color) is in the profile. -fn check_prompt_hook(hook_check: PromptHookCheckParams<'_>) { - let has_prompt_hook = hook_check.profile_content.lines().any(|line| { - let trimmed = line.trim(); - return trimmed.contains("rep color") && ( - trimmed.starts_with("function prompt") - || trimmed.starts_with("$_reportal_") - || trimmed.starts_with("PROMPT_COMMAND") - ); - }); + let first_line = match file_content.lines().next() { + Some(line) => line, + None => return IntegrationFileStatus::Outdated { + file_version: String::from("empty"), + }, + }; - match has_prompt_hook { - true => { - print_pass("Prompt hook installed (auto tab color)"); - hook_check.summary.record_check(CheckOutcome::Passed); - } + match first_line.contains(binary_version) { + true => IntegrationFileStatus::Current, false => { - print_fail("Prompt hook missing (tab color won't auto-apply)"); - print_hint("Run 'rep upgrade' to reinstall shell integration"); - hook_check.summary.record_check(CheckOutcome::Failed); + let extracted_version = first_line + .rsplit("— v") + .next() + .map(String::from) + .unwrap_or_else(|| String::from("unknown")); + IntegrationFileStatus::Outdated { + file_version: extracted_version, + } } } } -/// Checks that the shell profile exists and contains RePortal integration, -/// then verifies the current session has actually loaded it. +/// Checks the integration file, shell profile source line, and session state. fn check_shell_integration(summary: &mut DiagnosticSummary) { println!(); println!( @@ -218,6 +198,26 @@ fn check_shell_integration(summary: &mut DiagnosticSummary) { } }; + match check_integration_file_version() { + IntegrationFileStatus::Current => { + print_pass(&format!("Integration file up to date (v{})", env!("CARGO_PKG_VERSION"))); + summary.record_check(CheckOutcome::Passed); + } + IntegrationFileStatus::Outdated { ref file_version } => { + print_fail(&format!( + "Integration file outdated (file: {file_version}, binary: v{})", + env!("CARGO_PKG_VERSION"), + )); + print_hint("Run 'rep init' to update"); + summary.record_check(CheckOutcome::Failed); + } + IntegrationFileStatus::Missing => { + print_fail("Integration file missing"); + print_hint("Run 'rep init' to create it"); + summary.record_check(CheckOutcome::Failed); + } + } + let profile_content = match std::fs::read_to_string(profile_path) { Ok(content) => content, Err(_read_error) => { @@ -227,29 +227,30 @@ fn check_shell_integration(summary: &mut DiagnosticSummary) { } }; - check_shell_function(ShellFunctionCheckParams { - profile_content: &profile_content, - function_name: "rj", - expected_command: "rep jump", - summary, - }); - check_shell_function(ShellFunctionCheckParams { - profile_content: &profile_content, - function_name: "ro", - expected_command: "rep open", - summary, - }); - check_prompt_hook(PromptHookCheckParams { - profile_content: &profile_content, - summary, + let integration_marker = format!(".reportal{}integration", std::path::MAIN_SEPARATOR); + let profile_sources_integration = profile_content.lines().any(|line| { + return line.contains(&integration_marker); }); + + match profile_sources_integration { + true => { + print_pass("Profile sources integration file"); + summary.record_check(CheckOutcome::Passed); + } + false => { + print_fail("Profile does not source integration file"); + print_hint("Run 'rep init' to set it up"); + summary.record_check(CheckOutcome::Failed); + } + } + check_session_loaded(summary); } /// Checks whether the REPORTAL_LOADED env var is set, which proves -/// the shell profile was sourced in the current session. If the profile -/// file has the integration but this var is missing, rj/ro won't work -/// until the user reloads. +/// the shell profile was sourced in the current session. If the +/// integration file exists but this var is missing, rj and ro +/// will not work until the user opens a new terminal. fn check_session_loaded(summary: &mut DiagnosticSummary) { match std::env::var("REPORTAL_LOADED") { Ok(_marker_value) => { @@ -258,7 +259,7 @@ fn check_session_loaded(summary: &mut DiagnosticSummary) { } Err(_env_error) => { print_fail("Shell integration NOT loaded in current session"); - print_hint("Run '. $PROFILE' (PowerShell) or 'source ~/.bashrc' to reload"); + print_hint("Open a new terminal tab to pick up changes"); summary.record_check(CheckOutcome::Failed); } } @@ -316,9 +317,10 @@ fn check_repo_paths(path_check: RepoPathCheckParams<'_>) { /// Validates the full RePortal installation and prints a diagnostic report. /// -/// Checks config file health, shell integration presence, and whether all -/// registered repo paths exist on disk. Each failed check prints an actionable -/// hint. Returns Ok(()) unconditionally — diagnostic failures are reported +/// Checks config file health, integration file version, shell profile +/// source line, session load state, and whether all registered repo +/// paths exist on disk. Each failed check prints an actionable hint. +/// Returns Ok(()) unconditionally — diagnostic failures are reported /// to the user via stdout, not propagated as errors. pub fn run_doctor() -> Result<(), ReportalError> { println!( diff --git a/src/reportal_commands/initialization.rs b/src/reportal_commands/initialization.rs index a9893a1..ad8d348 100644 --- a/src/reportal_commands/initialization.rs +++ b/src/reportal_commands/initialization.rs @@ -1,4 +1,11 @@ /// Creates the default RePortal config file and installs shell integration. +/// +/// Shell integration works by writing a standalone script file into the +/// `~/.reportal` directory and adding a single stable `source` line to +/// the user's shell profile. The profile line never changes between +/// versions; only the integration file does. This means binary updates +/// via cargo, scoop, or brew take effect immediately — no manual +/// "upgrade" step required. use crate::error::ReportalError; use crate::reportal_config::ReportalConfig; @@ -8,39 +15,59 @@ 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 +/// Marker prefix used in the integration file and matched during strip. +/// Built at runtime to avoid static analysis false positives on the +/// path separator appearing between "reportal" and "integration". +fn reportal_integration_marker() -> String { + return format!(".reportal{}integration", std::path::MAIN_SEPARATOR); +} + +/// Returns the PowerShell integration script content, stamped with the +/// current binary version so `rep doctor` can detect stale files. +fn powershell_integration_content() -> String { + return format!( + r#"# RePortal shell integration — v{} +# Do not edit; regenerated by 'rep init'. $env:REPORTAL_LOADED = "1" -function rj { Set-Location (rep jump @args); rep color } -function ro { rep open @args; rep color } +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 -export REPORTAL_LOADED=1 -rj() { cd "$(rep jump "$@")"; rep color; } -ro() { rep open "$@"; rep color; } -"#; +function prompt {{ $p = & $_reportal_original_prompt; rep color 2>$null; $p }} +"#, + env!("CARGO_PKG_VERSION"), + ); +} -/// 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. +/// Returns the Bash and Zsh integration script content, stamped with the +/// current binary version so `rep doctor` can detect stale files. #[cfg(not(target_os = "windows"))] -fn bash_color_hook_line() -> String { +fn bash_integration_content() -> 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() + r#"# RePortal shell integration — v{version} +# Do not edit; regenerated by 'rep init'. +export REPORTAL_LOADED=1 +rj() {{ cd "$(rep jump "$@")"; rep color; }} +ro() {{ rep open "$@"; rep color; }} +PROMPT_COMMAND="${{PROMPT_COMMAND:+$PROMPT_COMMAND;}}rep color 2>{null_device}" +"#, + version = env!("CARGO_PKG_VERSION"), + null_device = null_device.display(), ); } +/// The one-liner added to the PowerShell profile; sources the integration +/// file so the profile itself never needs updating between versions. +fn powershell_source_line(integration_script_path: &std::path::Path) -> String { + return format!(". \"{}\"", integration_script_path.display()); +} + +/// The one-liner added to .bashrc or .zshrc; sources the integration file. +#[cfg(not(target_os = "windows"))] +fn bash_source_line(integration_script_path: &std::path::Path) -> String { + return format!("source \"{}\"", integration_script_path.display()); +} + /// Which shell the user is running, with its profile path. pub(crate) enum DetectedShell { /// PowerShell (Windows or cross-platform). @@ -138,13 +165,13 @@ pub(crate) fn detect_shell_profile() -> DetectedShell { } } -/// 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). +/// Strips any existing RePortal integration from a profile string. +/// Handles both legacy inline blocks (pre-v0.5) and the current +/// single source line that points to the integration file. fn strip_existing_integration(profile_content: &str) -> String { let mut result_lines: Vec<&str> = Vec::new(); let mut skipping_reportal_block = false; + let integration_path_marker = reportal_integration_marker(); for line in profile_content.lines() { match skipping_reportal_block { @@ -152,14 +179,17 @@ fn strip_existing_integration(profile_content: &str) -> String { let trimmed = line.trim(); let is_reportal_line = trimmed.starts_with("function rj") || trimmed.starts_with("function ro") + || trimmed.starts_with("function repup") || trimmed.starts_with("function prompt") || trimmed.starts_with("$_reportal_") || trimmed.starts_with("rj()") || trimmed.starts_with("ro()") + || trimmed.starts_with("repup()") || trimmed.starts_with("PROMPT_COMMAND") || trimmed.contains("REPORTAL_LOADED") || trimmed.contains("rep jump") || trimmed.contains("rep open") + || trimmed.contains("rep upgrade") || trimmed.contains("rep color"); match is_reportal_line { true => {} @@ -172,37 +202,41 @@ fn strip_existing_integration(profile_content: &str) -> String { } } } - false => match line.contains("RePortal shell integration") { - true => { - skipping_reportal_block = true; - } - false => { - result_lines.push(line); + false => { + let is_reportal_marker = line.contains("RePortal shell integration") + || line.contains(&integration_path_marker); + match is_reportal_marker { + 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 path to the integration script file for the current platform. +pub(crate) fn integration_file_path() -> Result { + let config_directory = ReportalConfig::config_directory()?; + + #[cfg(target_os = "windows")] + { + return Ok(config_directory.join("integration.ps1")); } + #[cfg(not(target_os = "windows"))] + { + return Ok(config_directory.join("integration.sh")); + } +} + +/// Shell profile and integration file installation methods. +impl DetectedShell { /// Returns the profile path if this shell was detected. pub(crate) fn profile_path(&self) -> Option<&PathBuf> { match self { @@ -214,15 +248,37 @@ impl DetectedShell { } } - /// 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. + /// Writes the integration script file and ensures a source line + /// exists in the shell profile. Strips any legacy inline blocks + /// or stale source lines first, making this idempotent. fn install_integration(&self) -> Result<(), ReportalError> { let target_path = match self.profile_path() { Some(resolved_path) => resolved_path, None => return Ok(()), }; + let script_path = integration_file_path()?; + + let integration_content = match self { + DetectedShell::PowerShell(_) => powershell_integration_content(), + #[cfg(not(target_os = "windows"))] + DetectedShell::Bash(_) | DetectedShell::Zsh(_) => bash_integration_content(), + DetectedShell::Unknown => return Ok(()), + }; + + std::fs::write(&script_path, &integration_content).map_err(|io_error| { + ReportalError::ConfigIoFailure { + reason: io_error.to_string(), + } + })?; + + let source_line = match self { + DetectedShell::PowerShell(_) => powershell_source_line(&script_path), + #[cfg(not(target_os = "windows"))] + DetectedShell::Bash(_) | DetectedShell::Zsh(_) => bash_source_line(&script_path), + DetectedShell::Unknown => return Ok(()), + }; + let existing_content = match target_path.exists() { true => std::fs::read_to_string(target_path).map_err(|io_error| { ReportalError::ConfigIoFailure { @@ -232,21 +288,22 @@ impl DetectedShell { false => String::new(), }; - let mut cleaned_content = strip_existing_integration(&existing_content); - cleaned_content.push_str(&self.integration_code()); + let cleaned_content = strip_existing_integration(&existing_content); - std::fs::write(target_path, cleaned_content).map_err(|io_error| { + let updated_content = format!("{cleaned_content}\n{source_line}\n"); + + std::fs::write(target_path, updated_content).map_err(|io_error| { ReportalError::ConfigIoFailure { reason: io_error.to_string(), } })?; - Ok(()) + return Ok(()); } } -/// Detects the shell, strips any old RePortal block, and appends the -/// latest integration code. Used by both `init` and `upgrade`. +/// Writes the integration file and installs the source line in the +/// shell profile. Displays what will be installed and asks for confirmation. fn install_or_update_shell_integration() -> Result<(), ReportalError> { println!(); println!(" {} Shell integration:", ">>".style(terminal_style::LABEL_STYLE)); @@ -275,6 +332,11 @@ fn install_or_update_shell_integration() -> Result<(), ReportalError> { match user_wants_install { true => { detected_shell.install_integration()?; + let script_path = integration_file_path()?; + terminal_style::print_success(&format!( + "Wrote {}", + script_path.display(), + )); terminal_style::print_success(&format!( "Updated {}. Restart your shell to activate.", profile_path.display(), @@ -287,41 +349,39 @@ fn install_or_update_shell_integration() -> Result<(), ReportalError> { } 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!(" Add this to your profile manually:"); println!(); - println!(" Bash/Zsh:"); - println!(" rj() {{ cd \"$(rep jump)\"; }}"); - println!(" ro() {{ rep open; }}"); + match integration_file_path() { + Ok(script_path) => { + println!(" PowerShell: . \"{}\"", script_path.display()); + println!(" Bash/Zsh: source \"{}\"", script_path.display()); + } + Err(_path_error) => { + println!(" Run 'rep init' again after fixing the config directory."); + } + } } } - Ok(()) + return Ok(()); } /// Creates a default config at `~/.reportal/config.toml` if none exists, -/// then installs shell integration (rj, ro, color prompt hook). +/// then writes the shell integration file and source line. +/// Safe to re-run at any time; idempotent on both config and profile. 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())); + match config_path.exists() { + true => { + println!("Config already exists at {}", config_path.display()); + println!("Use 'reportal add' to register repos."); + } + false => { + 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 a3c723d..577b58f 100644 --- a/src/reportal_commands/mod.rs +++ b/src/reportal_commands/mod.rs @@ -13,7 +13,6 @@ mod repo_sync; pub use doctor::run_doctor; pub use initialization::run_init; -pub use initialization::run_upgrade; pub use repo_add::run_add; pub use repo_edit::run_edit; pub use repo_color::{run_color, ColorCommandParams}; From 2399b992abe173223b2453645feb171ca279bec3 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Sat, 21 Mar 2026 19:24:23 -0400 Subject: [PATCH 10/67] Auto-regenerate integration file on binary update (v0.5.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every rep command now checks if the integration script version matches the binary version; if not, it silently rewrites the file. This means cargo install, scoop update, or brew upgrade just work — no rep init or rep upgrade needed after the initial setup. --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/main.rs | 2 + src/reportal_commands/initialization.rs | 66 +++++++++++++++++++++++++ src/reportal_commands/mod.rs | 1 + 5 files changed, 71 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 81d5037..dc4b88e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -520,7 +520,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index a354ac4..9f8452e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.5.0" +version = "0.5.1" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/src/main.rs b/src/main.rs index e8b1bb6..b8822b6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -102,6 +102,8 @@ enum ReportalSubcommand { } fn main() { + reportal_commands::ensure_integration_file_current(); + let parsed_cli = ReportalCli::parse(); let command_result = match parsed_cli.subcommand { diff --git a/src/reportal_commands/initialization.rs b/src/reportal_commands/initialization.rs index ad8d348..9b22276 100644 --- a/src/reportal_commands/initialization.rs +++ b/src/reportal_commands/initialization.rs @@ -366,6 +366,72 @@ fn install_or_update_shell_integration() -> Result<(), ReportalError> { return Ok(()); } +/// Whether the integration file needs to be rewritten. +enum IntegrationFileState { + /// File exists and its version stamp matches the running binary. + Current, + /// File is missing or its version stamp does not match the binary. + NeedsUpdate, +} + +/// Checks whether the integration file version matches the binary. +fn check_integration_file_state() -> IntegrationFileState { + let script_path = match integration_file_path() { + Ok(path) => path, + Err(_path_error) => return IntegrationFileState::NeedsUpdate, + }; + + let file_content = match std::fs::read_to_string(&script_path) { + Ok(script_content) => script_content, + Err(_read_error) => return IntegrationFileState::NeedsUpdate, + }; + + let binary_version = env!("CARGO_PKG_VERSION"); + + match file_content.lines().next() { + Some(first_line) => match first_line.contains(binary_version) { + true => IntegrationFileState::Current, + false => IntegrationFileState::NeedsUpdate, + }, + None => IntegrationFileState::NeedsUpdate, + } +} + +/// Rewrites the integration script file if it is missing or was written +/// by a different binary version. Called at the top of main() before +/// subcommand dispatch so that binary updates via cargo, scoop, or brew +/// take effect on the next shell session without any manual steps. +/// Silently skipped if the config directory does not exist (pre-init). +pub fn ensure_integration_file_current() { + let config_directory = match ReportalConfig::config_directory() { + Ok(directory) => directory, + Err(_path_error) => return, + }; + + match config_directory.exists() { + true => {} + false => return, + } + + match check_integration_file_state() { + IntegrationFileState::Current => {} + IntegrationFileState::NeedsUpdate => { + let script_path = match integration_file_path() { + Ok(path) => path, + Err(_path_error) => return, + }; + + #[cfg(target_os = "windows")] + let integration_script = powershell_integration_content(); + + #[cfg(not(target_os = "windows"))] + let integration_script = bash_integration_content(); + + let _write_result = std::fs::write(&script_path, &integration_script); + } + } +} + /// Creates a default config at `~/.reportal/config.toml` if none exists, /// then writes the shell integration file and source line. /// Safe to re-run at any time; idempotent on both config and profile. diff --git a/src/reportal_commands/mod.rs b/src/reportal_commands/mod.rs index 577b58f..0b7bf90 100644 --- a/src/reportal_commands/mod.rs +++ b/src/reportal_commands/mod.rs @@ -12,6 +12,7 @@ mod repo_status; mod repo_sync; pub use doctor::run_doctor; +pub use initialization::ensure_integration_file_current; pub use initialization::run_init; pub use repo_add::run_add; pub use repo_edit::run_edit; From 0c79fb06e268231828b75b856dd876772c17dea1 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Mon, 23 Mar 2026 16:28:53 -0400 Subject: [PATCH 11/67] Strip competing tab title sequences from prompt tools (v0.5.2) --- Cargo.toml | 2 +- README.md | 2 +- src/reportal_commands/initialization.rs | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9f8452e..d392671 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.5.1" +version = "0.5.2" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/README.md b/README.md index aa2b8fd..5231fb8 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ When you jump to or open a repo, RePortal automatically sets: 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 -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. +Unsupported terminals silently ignore these sequences. RePortal's tab title takes priority over other prompt tools. The `--title` flag on `jump`/`open` lets you override the tab title for a single session without changing config. diff --git a/src/reportal_commands/initialization.rs b/src/reportal_commands/initialization.rs index 9b22276..71926aa 100644 --- a/src/reportal_commands/initialization.rs +++ b/src/reportal_commands/initialization.rs @@ -32,7 +32,7 @@ $env:REPORTAL_LOADED = "1" 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 }} +function prompt {{ $p = & $_reportal_original_prompt; rep color 2>$null; $p -replace "`e\]0;[^`a]*`a","" -replace "`e\]2;[^`a]*`a","" }} "#, env!("CARGO_PKG_VERSION"), ); @@ -49,7 +49,8 @@ fn bash_integration_content() -> String { export REPORTAL_LOADED=1 rj() {{ cd "$(rep jump "$@")"; rep color; }} ro() {{ rep open "$@"; rep color; }} -PROMPT_COMMAND="${{PROMPT_COMMAND:+$PROMPT_COMMAND;}}rep color 2>{null_device}" +_reportal_hook() {{ rep color 2>{null_device}; PS1=$(printf '%s' "$PS1" | sed 's/\x1b\][02];[^\x07]*\x07//g'); }} +PROMPT_COMMAND="${{PROMPT_COMMAND:+$PROMPT_COMMAND;}}_reportal_hook" "#, version = env!("CARGO_PKG_VERSION"), null_device = null_device.display(), From ff5d1c6448931653133e729ac9fd8914f03c8ceb Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Mon, 23 Mar 2026 16:29:51 -0400 Subject: [PATCH 12/67] Remove unnecessary terminal compatibility caveat from README --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 5231fb8..ca7992c 100644 --- a/README.md +++ b/README.md @@ -184,8 +184,6 @@ When you jump to or open a repo, RePortal automatically sets: 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 -Unsupported terminals silently ignore these sequences. RePortal's tab title takes priority over other prompt tools. - The `--title` flag on `jump`/`open` lets you override the tab title for a single session without changing config. ### Shell hook for new terminals From 88c44fe0c03f695cd7ba604902963ca9f119ec24 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Mon, 23 Mar 2026 16:30:05 -0400 Subject: [PATCH 13/67] Update Cargo.lock for v0.5.2 --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index dc4b88e..e2f110f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -520,7 +520,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.5.1" +version = "0.5.2" dependencies = [ "anyhow", "clap", From 639be520c56875bc646c9757e6e8d09fbc839d76 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Mon, 23 Mar 2026 16:31:24 -0400 Subject: [PATCH 14/67] Remove OSC implementation details from README --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ca7992c..2dbff24 100644 --- a/README.md +++ b/README.md @@ -174,15 +174,15 @@ tags = ["personal", "frontend"] | `tags` | no | `[]` | Filter repos with `--tag` | | `remote` | no | `""` | Git remote URL for cloning on other machines | | `aliases` | no | `[]` | Alternative names for direct jump (e.g. `rj ninja` instead of `rj nro`) | -| `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 | +| `title` | no | repo alias | Terminal tab title on jump/open | +| `color` | no | reset to default | Terminal tab color (`#RRGGBB`) on jump/open | ## Terminal personalization When you jump to or open a repo, RePortal automatically sets: -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 +1. **Tab title** — uses the `title` config field, falling back to the repo alias +2. **Tab color** — uses the `color` config field; repos 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. From f4a33f2828d8293e9c190852de77919925d70f41 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Mon, 23 Mar 2026 16:52:33 -0400 Subject: [PATCH 15/67] Set tab title via native API instead of OSC 2 in prompt hook (v0.5.3) --- Cargo.toml | 2 +- src/main.rs | 12 ++- src/reportal_commands/initialization.rs | 4 +- src/reportal_commands/mod.rs | 2 +- src/reportal_commands/repo_color.rs | 107 ++++++++++++++++-------- 5 files changed, 85 insertions(+), 42 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d392671..5601b72 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.5.2" +version = "0.5.3" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/src/main.rs b/src/main.rs index b8822b6..437de69 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,7 @@ mod reportal_config; mod terminal_style; use clap::{Parser, Subcommand}; -use reportal_commands::{ColorCommandParams, JumpCommandParams, OpenCommandParams}; +use reportal_commands::{ColorCommandParams, JumpCommandParams, OpenCommandParams, TitleOutput}; use reportal_config::TagFilter; /// A fast CLI tool for jumping between and managing your dev repos. @@ -83,6 +83,9 @@ enum ReportalSubcommand { /// Look up this repo by alias instead of matching the current directory #[arg(long)] repo: Option, + /// Print the resolved tab title to stdout (for shell integration) + #[arg(long)] + print_title: bool, }, /// Show git status across all registered repos #[command(alias = "s")] @@ -167,13 +170,18 @@ fn main() { ReportalSubcommand::Remove { alias } => { reportal_commands::run_remove(&alias) } - ReportalSubcommand::Color { repo } => { + ReportalSubcommand::Color { repo, print_title } => { let repo_alias = match repo { Some(ref provided_repo) => provided_repo.as_str(), None => "", }; + let title_output = match print_title { + true => TitleOutput::PrintToStdout, + false => TitleOutput::Silent, + }; reportal_commands::run_color(ColorCommandParams { repo_alias, + title_output, }) } ReportalSubcommand::Status { tag } => { diff --git a/src/reportal_commands/initialization.rs b/src/reportal_commands/initialization.rs index 71926aa..40f6baf 100644 --- a/src/reportal_commands/initialization.rs +++ b/src/reportal_commands/initialization.rs @@ -32,7 +32,7 @@ $env:REPORTAL_LOADED = "1" 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 -replace "`e\]0;[^`a]*`a","" -replace "`e\]2;[^`a]*`a","" }} +function prompt {{ $p = & $_reportal_original_prompt; $t = rep color --print-title 2>$null; if ($t) {{ $Host.UI.RawUI.WindowTitle = $t }}; $p }} "#, env!("CARGO_PKG_VERSION"), ); @@ -49,7 +49,7 @@ fn bash_integration_content() -> String { export REPORTAL_LOADED=1 rj() {{ cd "$(rep jump "$@")"; rep color; }} ro() {{ rep open "$@"; rep color; }} -_reportal_hook() {{ rep color 2>{null_device}; PS1=$(printf '%s' "$PS1" | sed 's/\x1b\][02];[^\x07]*\x07//g'); }} +_reportal_hook() {{ local _t; _t=$(rep color --print-title 2>{null_device}); [ -n "$_t" ] && printf '\033]2;%s\007' "$_t"; }} PROMPT_COMMAND="${{PROMPT_COMMAND:+$PROMPT_COMMAND;}}_reportal_hook" "#, version = env!("CARGO_PKG_VERSION"), diff --git a/src/reportal_commands/mod.rs b/src/reportal_commands/mod.rs index 0b7bf90..d747f1a 100644 --- a/src/reportal_commands/mod.rs +++ b/src/reportal_commands/mod.rs @@ -16,7 +16,7 @@ pub use initialization::ensure_integration_file_current; pub use initialization::run_init; pub use repo_add::run_add; pub use repo_edit::run_edit; -pub use repo_color::{run_color, ColorCommandParams}; +pub use repo_color::{run_color, ColorCommandParams, TitleOutput}; pub use repo_jump::{run_jump, JumpCommandParams}; pub use repo_listing::run_list; pub use repo_open::{run_open, OpenCommandParams}; diff --git a/src/reportal_commands/repo_color.rs b/src/reportal_commands/repo_color.rs index 7b2fb2f..7a48c30 100644 --- a/src/reportal_commands/repo_color.rs +++ b/src/reportal_commands/repo_color.rs @@ -8,10 +8,49 @@ use crate::error::ReportalError; use crate::reportal_config::{RepoColor, ReportalConfig, TabTitle}; use crate::terminal_style::{self, TabColorAction, TerminalIdentity, TerminalIdentityParams}; +/// Whether to print the resolved title to stdout for shell integration. +pub enum TitleOutput { + /// Print the resolved title text to stdout. + PrintToStdout, + /// Do not print the title to stdout (OSC-only). + Silent, +} + /// 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, + /// Whether to print the resolved tab title to stdout. + pub title_output: TitleOutput, +} + +/// Resolved repo identity for a single color invocation. +struct ResolvedIdentity { + title: String, + tab_color_action: TabColorAction, +} + +/// Parameters for resolving a repo's terminal identity. +struct ResolveIdentityParams<'a> { + /// The repo entry to resolve identity for. + repo: &'a crate::reportal_config::RepoEntry, + /// The alias to use as fallback when no custom title is configured. + fallback_alias: &'a str, +} + +/// Resolves the tab title and color action for a repo entry. +fn resolve_identity(identity_params: ResolveIdentityParams<'_>) -> ResolvedIdentity { + let title = match identity_params.repo.tab_title() { + TabTitle::Custom(custom_title) => custom_title.to_string(), + TabTitle::UseAlias => identity_params.fallback_alias.to_string(), + }; + let tab_color_action = match identity_params.repo.repo_color() { + RepoColor::Themed(hex_color) => { + TabColorAction::SetColor(hex_color.as_osc_tab_color_sequence()) + } + RepoColor::ResetToDefault => TabColorAction::Reset, + }; + return ResolvedIdentity { title, tab_color_action }; } /// Emits OSC sequences for a repo's terminal identity. @@ -20,27 +59,20 @@ pub struct ColorCommandParams<'a> { /// 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. +/// +/// When `title_output` is `PrintToStdout`, also writes the resolved +/// title text to stdout so shell integrations can set it via the +/// native API (e.g. `$Host.UI.RawUI.WindowTitle` in PowerShell). pub fn run_color(color_params: ColorCommandParams<'_>) -> Result<(), ReportalError> { let loaded_config = ReportalConfig::load_from_disk()?; - match color_params.repo_alias.is_empty() { + let resolved = 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 tab_color_action = match found_repo.repo_color() { - RepoColor::Themed(hex_color) => { - TabColorAction::SetColor(hex_color.as_osc_tab_color_sequence()) - } - RepoColor::ResetToDefault => TabColorAction::Reset, - }; - let identity = TerminalIdentity::new(TerminalIdentityParams { - resolved_title, - tab_color_action, - }); - terminal_style::emit_terminal_identity_to_console(&identity); + Some(resolve_identity(ResolveIdentityParams { + repo: found_repo, + fallback_alias: color_params.repo_alias, + })) } true => { let current_directory = std::env::current_dir().map_err(|io_error| { @@ -73,28 +105,31 @@ pub fn run_color(color_params: ColorCommandParams<'_>) -> Result<(), ReportalErr } 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 tab_color_action = match matched_repo.repo_color() { - RepoColor::Themed(hex_color) => { - TabColorAction::SetColor(hex_color.as_osc_tab_color_sequence()) - } - RepoColor::ResetToDefault => TabColorAction::Reset, - }; - let identity = TerminalIdentity::new(TerminalIdentityParams { - resolved_title, - tab_color_action, - }); - terminal_style::emit_terminal_identity_to_console(&identity); - } - None => { - terminal_style::write_to_console(terminal_style::osc_reset_tab_color_sequence()); - } + Some(matched_repo) => Some(resolve_identity(ResolveIdentityParams { + repo: matched_repo, + fallback_alias: best_alias, + })), + None => None, + } + } + }; + + match resolved { + Some(identity) => { + let terminal_identity = TerminalIdentity::new(TerminalIdentityParams { + resolved_title: identity.title.as_str().to_string(), + tab_color_action: identity.tab_color_action, + }); + terminal_style::emit_terminal_identity_to_console(&terminal_identity); + + match color_params.title_output { + TitleOutput::PrintToStdout => print!("{}", identity.title), + TitleOutput::Silent => {} } } + None => { + terminal_style::write_to_console(terminal_style::osc_reset_tab_color_sequence()); + } } return Ok(()); From 094c9f69b4e6c424dd45ad729870a5e60793e75c Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Mon, 23 Mar 2026 16:52:41 -0400 Subject: [PATCH 16/67] Update Cargo.lock for v0.5.3 --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index e2f110f..47620c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -520,7 +520,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.5.2" +version = "0.5.3" dependencies = [ "anyhow", "clap", From f19e07eb154910886986570575f2820442d403bd Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Tue, 24 Mar 2026 16:47:44 -0400 Subject: [PATCH 17/67] Color swatches use repo hex color in rep list, show aliases in fuzzy finder (v0.5.4) rep list now renders the swatch block in each repo's configured color (gray default for repos with no color). Fuzzy finder labels in jump and open now show aliases and use em-dash separator. Added as_rgb_bytes() to HexColor and swatch_style_for_repo_color() to terminal_style. Updated README roadmap with planned edit overhaul and AI CLI launcher. --- Cargo.lock | 14 +++++++------- Cargo.toml | 2 +- README.md | 4 +++- src/reportal_commands/repo_jump.rs | 23 ++++++++++++++++++----- src/reportal_commands/repo_listing.rs | 12 +++++++++++- src/reportal_commands/repo_open.rs | 23 ++++++++++++++++++----- src/reportal_config.rs | 18 ++++++++++++++++++ src/terminal_style.rs | 18 ++++++++++++++++++ 8 files changed, 94 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 47620c0..3d6bf4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -342,9 +342,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "leb128fmt" @@ -360,9 +360,9 @@ checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[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", ] @@ -520,7 +520,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.5.3" +version = "0.5.4" dependencies = [ "anyhow", "clap", @@ -762,9 +762,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "a559e63b5d8004e12f9bce88af5c6d939c58de839b7532cfe9653846cedd2a9e" [[package]] name = "unicode-width" diff --git a/Cargo.toml b/Cargo.toml index 5601b72..c7fc52d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.5.3" +version = "0.5.4" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/README.md b/README.md index 2dbff24..c947e00 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,7 @@ 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`, aliases in 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) @@ -217,6 +217,8 @@ PROMPT_COMMAND='rep color 2>/dev/null' - [x] `status` — git status across all repos - [x] `sync` — pull latest across repos - [x] `doctor` — diagnose config, shell integration, and repo path issues +- [ ] `edit` UX overhaul — fuzzy-select what to edit (repos, AI tools, global settings), field menu +- [ ] `ai` — launch AI coding CLIs (aider) in any repo with configurable defaults - [ ] `dashboard` — rich overview with branches, dirty state, last commit - [ ] `clone --all` — clone missing repos from config (machine sync) - [ ] Shell completions diff --git a/src/reportal_commands/repo_jump.rs b/src/reportal_commands/repo_jump.rs index d326919..91fb10d 100644 --- a/src/reportal_commands/repo_jump.rs +++ b/src/reportal_commands/repo_jump.rs @@ -43,11 +43,24 @@ pub fn run_jump(jump_params: JumpCommandParams<'_>) -> Result<(), ReportalError> 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); + let mut label = alias.to_string(); + + match repo.aliases().is_empty() { + true => {} + false => { + let aliases_joined = repo.aliases().join(", "); + label.push_str(&format!(" ({aliases_joined})")); + } + } + + match repo.description().is_empty() { + true => {} + false => { + label.push_str(&format!(" — {}", repo.description())); + } + } + + return label; }) .collect(); diff --git a/src/reportal_commands/repo_listing.rs b/src/reportal_commands/repo_listing.rs index e6f39d1..7e5742d 100644 --- a/src/reportal_commands/repo_listing.rs +++ b/src/reportal_commands/repo_listing.rs @@ -31,10 +31,11 @@ pub fn run_list(tag_filter: TagFilter) -> Result<(), ReportalError> { for (alias, repo) in &matching_repos { let directory_exists = repo.resolved_path().exists(); + let swatch_style = terminal_style::swatch_style_for_repo_color(repo.repo_color())?; let uppercase_alias = alias.to_uppercase(); println!( " {} {}", - "██".style(terminal_style::ALIAS_STYLE), + "██".style(swatch_style), uppercase_alias.style(terminal_style::ALIAS_STYLE), ); @@ -52,6 +53,15 @@ pub fn run_list(tag_filter: TagFilter) -> Result<(), ReportalError> { ); } + if !repo.aliases().is_empty() { + let formatted_aliases = repo.aliases().join(", "); + println!( + " {} {}", + "Aliases:".style(terminal_style::LABEL_STYLE), + formatted_aliases.style(terminal_style::PATH_STYLE), + ); + } + if !repo.tags().is_empty() { let formatted_tags = repo.tags().join(", "); println!( diff --git a/src/reportal_commands/repo_open.rs b/src/reportal_commands/repo_open.rs index 905d562..33d4c70 100644 --- a/src/reportal_commands/repo_open.rs +++ b/src/reportal_commands/repo_open.rs @@ -45,11 +45,24 @@ pub fn run_open(open_params: OpenCommandParams<'_>) -> Result<(), ReportalError> 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) + let mut label = alias.to_string(); + + match repo.aliases().is_empty() { + true => {} + false => { + let aliases_joined = repo.aliases().join(", "); + label.push_str(&format!(" ({aliases_joined})")); + } + } + + match repo.description().is_empty() { + true => {} + false => { + label.push_str(&format!(" — {}", repo.description())); + } + } + + return label; }) .collect(); diff --git a/src/reportal_config.rs b/src/reportal_config.rs index 9022622..948be4e 100644 --- a/src/reportal_config.rs +++ b/src/reportal_config.rs @@ -47,6 +47,24 @@ impl HexColor { &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])?; + return 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. diff --git a/src/terminal_style.rs b/src/terminal_style.rs index 313a1b9..1b95cc9 100644 --- a/src/terminal_style.rs +++ b/src/terminal_style.rs @@ -5,6 +5,9 @@ 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,6 +29,21 @@ pub const LABEL_STYLE: Style = Style::new().cyan(); /// Bold white for emphasis. pub const EMPHASIS_STYLE: Style = Style::new().bold(); +/// 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()?; + return Ok(Style::new().truecolor(red, green, blue)); + } + crate::reportal_config::RepoColor::ResetToDefault => { + return Ok(DEFAULT_SWATCH_STYLE); + } + } +} + /// Prints an error message with a red "Error:" prefix. pub fn print_error(error_message: &str) { eprintln!("{} {}", "Error:".style(FAILURE_STYLE), error_message); From 819c21d883b29c8e15b9800d20105e1ca80acdc3 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Tue, 24 Mar 2026 17:12:34 -0400 Subject: [PATCH 18/67] Add rep ai command for launching AI coding CLIs in repos (v0.6.0) New rep ai command fuzzy-selects a repo and launches an AI CLI (AI tool, AI, aider) with interactive passthrough. Config supports [ai_tools.*] registry and default_ai_tool setting; new configs ship with ai, ai, aider pre-registered. Also extracted repeated clap fields into shared Args structs. --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 21 ++- src/error.rs | 22 +++ src/main.rs | 253 +++++++++++++++++++------------ src/reportal_commands/mod.rs | 2 + src/reportal_commands/repo_ai.rs | 160 +++++++++++++++++++ src/reportal_config.rs | 62 ++++++++ 8 files changed, 423 insertions(+), 101 deletions(-) create mode 100644 src/reportal_commands/repo_ai.rs diff --git a/Cargo.lock b/Cargo.lock index 3d6bf4e..ec65173 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -520,7 +520,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.5.4" +version = "0.6.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index c7fc52d..e2b6092 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.5.4" +version = "0.6.0" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/README.md b/README.md index c947e00..d62892b 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,8 @@ ro | `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 ai my-api` | | Launch default AI tool directly in a repo by alias | | `rep doctor` | | Diagnose config, shell integration, and repo path issues | ## Shell integration @@ -164,6 +166,7 @@ tags = ["personal", "frontend"] | `default_clone_root` | Any path | `~/dev` | Where `rep add ` clones to | | `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 @@ -177,6 +180,22 @@ tags = ["personal", "frontend"] | `title` | no | repo alias | Terminal tab title on jump/open | | `color` | no | reset to default | Terminal tab color (`#RRGGBB`) on jump/open | +### AI tools + +Configure which AI coding CLIs are available for `rep ai`: + +```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 | + + ## Terminal personalization When you jump to or open a repo, RePortal automatically sets: @@ -217,8 +236,8 @@ PROMPT_COMMAND='rep color 2>/dev/null' - [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 - [ ] `edit` UX overhaul — fuzzy-select what to edit (repos, AI tools, global settings), field menu -- [ ] `ai` — launch AI coding CLIs (aider) in any repo with configurable defaults - [ ] `dashboard` — rich overview with branches, dirty state, last commit - [ ] `clone --all` — clone missing repos from config (machine sync) - [ ] Shell completions diff --git a/src/error.rs b/src/error.rs index b40f2c1..abddb42 100644 --- a/src/error.rs +++ b/src/error.rs @@ -66,4 +66,26 @@ 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, } diff --git a/src/main.rs b/src/main.rs index 437de69..815144b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,10 +8,116 @@ mod reportal_commands; mod reportal_config; mod terminal_style; -use clap::{Parser, Subcommand}; -use reportal_commands::{ColorCommandParams, JumpCommandParams, OpenCommandParams, TitleOutput}; +use clap::{Args, Parser, Subcommand}; +use reportal_commands::{AiCommandParams, ColorCommandParams, JumpCommandParams, OpenCommandParams, TitleOutput}; use reportal_config::TagFilter; +/// Shared --tag flag used by commands that filter repos. +#[derive(Args)] +struct TagFilterArgs { + /// Filter repos by this tag + #[arg(long)] + tag: Option, +} + +/// Converts the optional tag CLI arg into the domain's `TagFilter` enum. +impl TagFilterArgs { + fn into_tag_filter(self) -> TagFilter { + match self.tag { + Some(tag_value) => TagFilter::ByTag(tag_value), + None => TagFilter::All, + } + } +} + +/// Shared optional alias positional arg + --tag flag for repo selection. +#[derive(Args)] +struct RepoSelectionArgs { + /// Jump directly to this alias (skip fuzzy finder) + alias: Option, + #[command(flatten)] + tag_filter: TagFilterArgs, +} + +/// CLI args for `rep list`. +#[derive(Args)] +struct ListArgs { + #[command(flatten)] + filter: TagFilterArgs, +} + +/// CLI args for `rep jump`. +#[derive(Args)] +struct JumpArgs { + #[command(flatten)] + selection: RepoSelectionArgs, + /// Override the tab title for this session + #[arg(long)] + title: Option, +} + +/// CLI args for `rep open`. +#[derive(Args)] +struct OpenArgs { + #[command(flatten)] + selection: RepoSelectionArgs, + /// Override the default editor command + #[arg(long)] + editor: Option, + /// Override the tab title for this session + #[arg(long)] + title: Option, +} + +/// CLI args for `rep edit`. +#[derive(Args)] +struct EditArgs { + /// Alias of the repo to edit + alias: String, +} + +/// CLI args for `rep remove`. +#[derive(Args)] +struct RemoveArgs { + /// Alias of the repo to remove + alias: String, +} + +/// CLI args for `rep color`. +#[derive(Args)] +struct ColorArgs { + /// Look up this repo by alias instead of matching the current directory + #[arg(long)] + repo: Option, + /// Print the resolved tab title to stdout (for shell integration) + #[arg(long)] + print_title: bool, +} + +/// CLI args for `rep status`. +#[derive(Args)] +struct StatusArgs { + #[command(flatten)] + filter: TagFilterArgs, +} + +/// CLI args for `rep sync`. +#[derive(Args)] +struct SyncArgs { + #[command(flatten)] + filter: TagFilterArgs, +} + +/// CLI args for `rep ai`. +#[derive(Args)] +struct AiArgs { + #[command(flatten)] + selection: RepoSelectionArgs, + /// Which AI tool to launch (overrides default_ai_tool setting) + #[arg(long)] + tool: Option, +} + /// A fast CLI tool for jumping between and managing your dev repos. #[derive(Parser)] #[command(name = "reportal", version, about)] @@ -28,38 +134,13 @@ enum ReportalSubcommand { Init, /// List all registered repos with status and metadata #[command(alias = "l")] - List { - /// Filter repos by this tag - #[arg(long)] - tag: Option, - }, + List(ListArgs), /// 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, - }, + Jump(JumpArgs), /// 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, - }, + Open(OpenArgs), /// Register a local repo in the config #[command(alias = "a")] Add { @@ -68,40 +149,21 @@ enum ReportalSubcommand { }, /// Edit a repo's description, tags, title, and color #[command(alias = "e")] - Edit { - /// Alias of the repo to edit - alias: String, - }, + Edit(EditArgs), /// Unregister a repo from the config (does not delete files) #[command(alias = "rm")] - Remove { - /// Alias of the repo to remove - alias: String, - }, + Remove(RemoveArgs), /// 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, - /// Print the resolved tab title to stdout (for shell integration) - #[arg(long)] - print_title: bool, - }, + Color(ColorArgs), /// Show git status across all registered repos #[command(alias = "s")] - Status { - /// Filter repos by this tag - #[arg(long)] - tag: Option, - }, + Status(StatusArgs), /// Pull latest changes across all registered repos - Sync { - /// Filter repos by this tag - #[arg(long)] - tag: Option, - }, + Sync(SyncArgs), /// Diagnose config, shell integration, and repo path issues Doctor, + /// Launch an AI coding CLI in a repo + Ai(AiArgs), } fn main() { @@ -111,51 +173,39 @@ fn main() { let command_result = match parsed_cli.subcommand { ReportalSubcommand::Init => reportal_commands::run_init(), - 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::List(list_args) => { + reportal_commands::run_list(list_args.filter.into_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 { + ReportalSubcommand::Jump(jump_args) => { + let direct_alias = match jump_args.selection.alias { Some(ref provided_alias) => provided_alias.as_str(), None => "", }; - let title_override = match title { + let title_override = match jump_args.title { Some(ref provided_title) => provided_title.as_str(), None => "", }; reportal_commands::run_jump(JumpCommandParams { - tag_filter, + tag_filter: jump_args.selection.tag_filter.into_tag_filter(), direct_alias, 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 { + ReportalSubcommand::Open(open_args) => { + let direct_alias = match open_args.selection.alias { Some(ref provided_alias) => provided_alias.as_str(), None => "", }; - let editor_override = match editor { + let editor_override = match open_args.editor { Some(ref provided_editor) => provided_editor.as_str(), None => "", }; - let title_override = match title { + let title_override = match open_args.title { Some(ref provided_title) => provided_title.as_str(), None => "", }; reportal_commands::run_open(OpenCommandParams { - tag_filter, + tag_filter: open_args.selection.tag_filter.into_tag_filter(), direct_alias, editor_override, title_override, @@ -164,18 +214,18 @@ fn main() { ReportalSubcommand::Add { repo_path } => { reportal_commands::run_add(&repo_path) } - ReportalSubcommand::Edit { alias } => { - reportal_commands::run_edit(&alias) + ReportalSubcommand::Edit(edit_args) => { + reportal_commands::run_edit(&edit_args.alias) } - ReportalSubcommand::Remove { alias } => { - reportal_commands::run_remove(&alias) + ReportalSubcommand::Remove(remove_args) => { + reportal_commands::run_remove(&remove_args.alias) } - ReportalSubcommand::Color { repo, print_title } => { - let repo_alias = match repo { + ReportalSubcommand::Color(color_args) => { + let repo_alias = match color_args.repo { Some(ref provided_repo) => provided_repo.as_str(), None => "", }; - let title_output = match print_title { + let title_output = match color_args.print_title { true => TitleOutput::PrintToStdout, false => TitleOutput::Silent, }; @@ -184,21 +234,28 @@ fn main() { title_output, }) } - 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::Status(status_args) => { + reportal_commands::run_status(status_args.filter.into_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) + ReportalSubcommand::Sync(sync_args) => { + reportal_commands::run_sync(sync_args.filter.into_tag_filter()) } ReportalSubcommand::Doctor => reportal_commands::run_doctor(), + ReportalSubcommand::Ai(ai_args) => { + let direct_alias = match ai_args.selection.alias { + Some(ref provided_alias) => provided_alias.as_str(), + None => "", + }; + let tool_override = match ai_args.tool { + Some(ref provided_tool) => provided_tool.as_str(), + None => "", + }; + reportal_commands::run_ai(AiCommandParams { + tag_filter: ai_args.selection.tag_filter.into_tag_filter(), + direct_alias, + tool_override, + }) + } }; match command_result { diff --git a/src/reportal_commands/mod.rs b/src/reportal_commands/mod.rs index d747f1a..1759f84 100644 --- a/src/reportal_commands/mod.rs +++ b/src/reportal_commands/mod.rs @@ -2,6 +2,7 @@ mod doctor; mod initialization; mod repo_add; +mod repo_ai; mod repo_color; mod repo_jump; mod repo_listing; @@ -15,6 +16,7 @@ pub use doctor::run_doctor; pub use initialization::ensure_integration_file_current; pub use initialization::run_init; pub use repo_add::run_add; +pub use repo_ai::{run_ai, AiCommandParams}; pub use repo_edit::run_edit; pub use repo_color::{run_color, ColorCommandParams, TitleOutput}; pub use repo_jump::{run_jump, JumpCommandParams}; diff --git a/src/reportal_commands/repo_ai.rs b/src/reportal_commands/repo_ai.rs new file mode 100644 index 0000000..4d664e7 --- /dev/null +++ b/src/reportal_commands/repo_ai.rs @@ -0,0 +1,160 @@ +/// Fuzzy-selects a repo and launches an AI coding CLI in it. + +use crate::error::ReportalError; +use crate::reportal_config::{RepoColor, ReportalConfig, TabTitle, TagFilter}; +use crate::terminal_style::{self, TabColorAction, TerminalIdentity, TerminalIdentityParams}; +use dialoguer::{theme::ColorfulTheme, FuzzySelect}; +use owo_colors::OwoColorize; +use std::process::Command; + +/// All parameters needed to run the ai command. +pub struct AiCommandParams<'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: AiCommandParams<'_>) -> Result<(), ReportalError> { + let loaded_config = ReportalConfig::load_from_disk()?; + + let ai_tools = loaded_config.ai_tools_list(); + match ai_tools.is_empty() { + true => return Err(ReportalError::NoAiToolsConfigured), + false => {} + } + + let tool_name = match ai_params.tool_override.is_empty() { + false => ai_params.tool_override, + true => { + let default_tool = loaded_config.default_ai_tool(); + match default_tool.is_empty() { + true => return Err(ReportalError::NoDefaultAiTool), + false => default_tool, + } + } + }; + let ai_tool = loaded_config.get_ai_tool(tool_name)?; + + let (selected_alias, selected_repo): (&str, &crate::reportal_config::RepoEntry) = + match ai_params.direct_alias.is_empty() { + false => { + let found_repo = loaded_config.get_repo(ai_params.direct_alias)?; + (ai_params.direct_alias, found_repo) + } + true => { + let matching_repos = + loaded_config.repos_matching_tag_filter(&ai_params.tag_filter); + + match matching_repos.is_empty() { + true => return Err(ReportalError::NoReposMatchFilter), + false => {} + } + + let display_labels: Vec = matching_repos + .iter() + .map(|(alias, repo)| { + let mut label = alias.to_string(); + + match repo.aliases().is_empty() { + true => {} + false => { + let aliases_joined = repo.aliases().join(", "); + label.push_str(&format!(" ({aliases_joined})")); + } + } + + match repo.description().is_empty() { + true => {} + false => { + label.push_str(&format!(" — {}", repo.description())); + } + } + + return label; + }) + .collect(); + + let selected_index = FuzzySelect::with_theme(&ColorfulTheme::default()) + .with_prompt(format!("Launch {tool_name} in")) + .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), + } + } + }; + + let resolved_title = match selected_repo.tab_title() { + TabTitle::Custom(custom_title) => custom_title.to_string(), + TabTitle::UseAlias => selected_alias.to_string(), + }; + + let tab_color_action = match 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(TerminalIdentityParams { + resolved_title, + tab_color_action, + }); + terminal_style::emit_terminal_identity_to_console(&identity); + + let resolved_repo_path = selected_repo.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}"), + })?; + + return Ok(()); +} diff --git a/src/reportal_config.rs b/src/reportal_config.rs index 948be4e..0a4ea1c 100644 --- a/src/reportal_config.rs +++ b/src/reportal_config.rs @@ -111,6 +111,9 @@ pub struct ReportalConfig { /// Map of alias to repo definition. #[serde(default)] repos: BTreeMap, + /// Map of tool name to AI CLI tool definition. + #[serde(default)] + ai_tools: BTreeMap, } /// How repo paths are displayed in output after selecting a repo. @@ -179,6 +182,32 @@ pub struct ReportalSettings { /// How to format paths when displayed: absolute or relative. #[serde(default = "default_path_display_format")] path_display_format: PathDisplayFormat, + /// Which AI tool to launch by default when no --tool flag is given. + #[serde(default)] + default_ai_tool: String, +} + +/// 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, +} + +/// Accessors for AI tool configuration. +impl AiToolEntry { + /// 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 + } } /// Step-by-step builder for registering a new repo. @@ -311,6 +340,7 @@ impl Default for ReportalSettings { default_clone_root: String::new(), path_on_select: default_path_visibility(), path_display_format: default_path_display_format(), + default_ai_tool: String::new(), } } } @@ -614,6 +644,23 @@ impl ReportalConfig { }) } + /// 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_string(), + }) + } + + /// Returns all registered AI tools with their names. + pub fn ai_tools_list(&self) -> Vec<(&String, &AiToolEntry)> { + self.ai_tools.iter().collect() + } + /// Creates a default empty config with sensible defaults for first-time setup. pub fn create_default() -> Self { Self { @@ -622,8 +669,23 @@ impl ReportalConfig { default_clone_root: "~/dev".to_string(), path_on_select: PathVisibility::Show, path_display_format: PathDisplayFormat::Absolute, + default_ai_tool: "aider".to_string(), }, repos: BTreeMap::new(), + ai_tools: BTreeMap::from([ + ("aider".to_string(), AiToolEntry { + command: "aider".to_string(), + args: Vec::new(), + }), + ("aider".to_string(), AiToolEntry { + command: "aider".to_string(), + args: Vec::new(), + }), + ("aider".to_string(), AiToolEntry { + command: "aider".to_string(), + args: Vec::new(), + }), + ]), } } } From 51e95407540fe651ea46bb44af5d69bfc5fe0d4a Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Tue, 24 Mar 2026 23:18:09 -0400 Subject: [PATCH 19/67] Split reportal_config.rs monolith into module directory (v0.6.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 720-line monolith split into 7 focused files under src/reportal_config/: hex_color, tag_filter, global_settings, ai_tool_entry, repo_entry, reportal_config_root, and a pure re-export mod.rs. Added per-field setters on RepoEntry; removed old update_repo_metadata 5-param method. Made edit alias optional in preparation for the edit UX overhaul. Zero behavior changes — purely structural. --- Cargo.lock | 6 +- Cargo.toml | 2 +- src/main.rs | 10 +- src/reportal_commands/repo_edit.rs | 6 +- src/reportal_config.rs | 691 -------------------- src/reportal_config/ai_tool_entry.rs | 38 ++ src/reportal_config/global_settings.rs | 103 +++ src/reportal_config/hex_color.rs | 90 +++ src/reportal_config/mod.rs | 18 + src/reportal_config/repo_entry.rs | 260 ++++++++ src/reportal_config/reportal_config_root.rs | 242 +++++++ src/reportal_config/tag_filter.rs | 10 + 12 files changed, 777 insertions(+), 699 deletions(-) delete mode 100644 src/reportal_config.rs create mode 100644 src/reportal_config/ai_tool_entry.rs create mode 100644 src/reportal_config/global_settings.rs create mode 100644 src/reportal_config/hex_color.rs create mode 100644 src/reportal_config/mod.rs create mode 100644 src/reportal_config/repo_entry.rs create mode 100644 src/reportal_config/reportal_config_root.rs create mode 100644 src/reportal_config/tag_filter.rs diff --git a/Cargo.lock b/Cargo.lock index ec65173..b08f19a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -520,7 +520,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.6.0" +version = "0.6.1" dependencies = [ "anyhow", "clap", @@ -762,9 +762,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a559e63b5d8004e12f9bce88af5c6d939c58de839b7532cfe9653846cedd2a9e" +checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b" [[package]] name = "unicode-width" diff --git a/Cargo.toml b/Cargo.toml index e2b6092..3de2e14 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.6.0" +version = "0.6.1" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/src/main.rs b/src/main.rs index 815144b..b8d30dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -72,8 +72,8 @@ struct OpenArgs { /// CLI args for `rep edit`. #[derive(Args)] struct EditArgs { - /// Alias of the repo to edit - alias: String, + /// Alias of the repo to edit (skip top-level menu) + alias: Option, } /// CLI args for `rep remove`. @@ -215,7 +215,11 @@ fn main() { reportal_commands::run_add(&repo_path) } ReportalSubcommand::Edit(edit_args) => { - reportal_commands::run_edit(&edit_args.alias) + let direct_alias = match edit_args.alias { + Some(ref provided_alias) => provided_alias.as_str(), + None => "", + }; + reportal_commands::run_edit(direct_alias) } ReportalSubcommand::Remove(remove_args) => { reportal_commands::run_remove(&remove_args.alias) diff --git a/src/reportal_commands/repo_edit.rs b/src/reportal_commands/repo_edit.rs index d5d716e..7c08510 100644 --- a/src/reportal_commands/repo_edit.rs +++ b/src/reportal_commands/repo_edit.rs @@ -122,7 +122,11 @@ pub fn run_edit(alias: &str) -> Result<(), ReportalError> { ColorEditResult::Cleared => RepoColor::ResetToDefault, }; - loaded_config.update_repo_metadata(alias, new_description, new_tags, resolved_title, resolved_color)?; + let repo_to_update = loaded_config.get_repo_mut(alias)?; + repo_to_update.set_description(new_description); + repo_to_update.set_tags(new_tags); + repo_to_update.set_tab_title(resolved_title); + repo_to_update.set_repo_color(resolved_color); loaded_config.save_to_disk()?; terminal_style::print_success(&format!("Updated '{alias}'")); diff --git a/src/reportal_config.rs b/src/reportal_config.rs deleted file mode 100644 index 0a4ea1c..0000000 --- a/src/reportal_config.rs +++ /dev/null @@ -1,691 +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 - } - - /// 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])?; - return 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]; - return format!( - "\x1b]4;264;rgb:{red_hex}/{green_hex}/{blue_hex}\x07" - ); - } -} - -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, - /// Map of tool name to AI CLI tool definition. - #[serde(default)] - ai_tools: 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, - /// Which AI tool to launch by default when no --tool flag is given. - #[serde(default)] - default_ai_tool: String, -} - -/// 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, -} - -/// Accessors for AI tool configuration. -impl AiToolEntry { - /// 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 - } -} - -/// 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, - /// Alternative names that can be used to jump to this repo directly. - #[serde(default)] - aliases: Vec, - /// 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(), - default_ai_tool: String::new(), - } - } -} - -/// 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, - aliases: Vec::new(), - 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 - } - - /// Alternative names that can be used to jump to this repo directly. - pub fn aliases(&self) -> &[String] { - &self.aliases - } - - /// 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 primary key or any of its alternative aliases. - /// Checks the primary key first, then walks all repos checking their - /// `aliases` field. Returns `RepoNotFound` if no match is found. - pub fn get_repo(&self, alias: &str) -> Result<&RepoEntry, ReportalError> { - match self.repos.get(alias) { - Some(found_repo) => return Ok(found_repo), - None => { - for (_primary_key, repo_entry) in &self.repos { - let has_matching_alias = repo_entry.aliases.iter().any(|alt| alt == alias); - match has_matching_alias { - true => return Ok(repo_entry), - false => {} - } - } - return Err(ReportalError::RepoNotFound { - alias: alias.to_string(), - }); - } - } - } - - /// Looks up a repo's primary key by searching both primary keys and - /// alternative aliases. Returns the primary key that owns the match. - pub fn resolve_primary_key<'a>(&'a self, alias: &'a str) -> Result<&'a str, ReportalError> { - match self.repos.contains_key(alias) { - true => return Ok(alias), - false => { - for (primary_key, repo_entry) in &self.repos { - let has_matching_alias = repo_entry.aliases.iter().any(|alt| alt == alias); - match has_matching_alias { - true => return Ok(primary_key.as_str()), - false => {} - } - } - return Err(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(), - }) - } - - /// 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_string(), - }) - } - - /// Returns all registered AI tools with their names. - pub fn ai_tools_list(&self) -> Vec<(&String, &AiToolEntry)> { - self.ai_tools.iter().collect() - } - - /// 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, - default_ai_tool: "aider".to_string(), - }, - repos: BTreeMap::new(), - ai_tools: BTreeMap::from([ - ("aider".to_string(), AiToolEntry { - command: "aider".to_string(), - args: Vec::new(), - }), - ("aider".to_string(), AiToolEntry { - command: "aider".to_string(), - args: Vec::new(), - }), - ("aider".to_string(), AiToolEntry { - command: "aider".to_string(), - args: Vec::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..6e4b6da --- /dev/null +++ b/src/reportal_config/ai_tool_entry.rs @@ -0,0 +1,38 @@ +/// 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/global_settings.rs b/src/reportal_config/global_settings.rs new file mode 100644 index 0000000..b2db255 --- /dev/null +++ b/src/reportal_config/global_settings.rs @@ -0,0 +1,103 @@ +/// Global settings that apply across all repos, stored in the +/// `[settings]` section of `config.toml`. + +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 => { + 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, +} + +/// Returns the default editor command when none is configured. +pub fn default_editor_command() -> String { + "cursor".to_string() +} + +/// 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 +} + +/// 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, + /// 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(), + path_on_select: default_path_visibility(), + path_display_format: default_path_display_format(), + default_ai_tool: String::new(), + } + } +} diff --git a/src/reportal_config/hex_color.rs b/src/reportal_config/hex_color.rs new file mode 100644 index 0000000..d09eaff --- /dev/null +++ b/src/reportal_config/hex_color.rs @@ -0,0 +1,90 @@ +/// 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_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 + } + + /// 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])?; + return 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]; + return 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..40d5b3b --- /dev/null +++ b/src/reportal_config/mod.rs @@ -0,0 +1,18 @@ +/// 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 global_settings; +mod hex_color; +mod reportal_config_root; +mod repo_entry; +mod tag_filter; + +pub use ai_tool_entry::AiToolEntry; +pub use global_settings::{PathDisplayFormat, PathVisibility, ReportalSettings}; +pub use hex_color::HexColor; +pub use repo_entry::{RepoColor, RepoEntry, RepoRegistrationBuilder, TabTitle}; +pub use reportal_config_root::ReportalConfig; +pub use tag_filter::TagFilter; diff --git a/src/reportal_config/repo_entry.rs b/src/reportal_config/repo_entry.rs new file mode 100644 index 0000000..5ccf876 --- /dev/null +++ b/src/reportal_config/repo_entry.rs @@ -0,0 +1,260 @@ +/// Repository entry types: `RepoEntry`, `RepoRegistrationBuilder`, +/// `TabTitle`, and `RepoColor`. + +use crate::error::ReportalError; +use crate::reportal_config::hex_color::HexColor; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// 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), +} + +/// Defaults to using the repo alias as the tab title. +impl Default for TabTitle { + fn default() -> Self { + TabTitle::UseAlias + } +} + +/// 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)?; + 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), +} + +/// Defaults to resetting the terminal background color. +impl Default for RepoColor { + fn default() -> Self { + RepoColor::ResetToDefault + } +} + +/// 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)?; + 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, + /// Alternative names that can be used to jump to this repo directly. + #[serde(default)] + aliases: Vec, + /// 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, +} + +/// 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 + } + + /// Alternative names that can be used to jump to this repo directly. + pub fn aliases(&self) -> &[String] { + &self.aliases + } + + /// 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 + } + + /// 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; + } +} + +/// 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, +} + +/// 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, + aliases: Vec::new(), + title: self.repo_title, + color: self.repo_color, + }; + 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..e498fb9 --- /dev/null +++ b/src/reportal_config/reportal_config_root.rs @@ -0,0 +1,242 @@ +/// 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::global_settings::{PathDisplayFormat, PathVisibility, ReportalSettings}; +use crate::reportal_config::repo_entry::RepoEntry; +use crate::reportal_config::tag_filter::TagFilter; +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_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, + /// Map of tool name to AI CLI tool definition. + #[serde(default)] + ai_tools: 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 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 primary key or any of its alternative aliases. + /// Checks the primary key first, then walks all repos checking their + /// `aliases` field. Returns `RepoNotFound` if no match is found. + pub fn get_repo(&self, alias: &str) -> Result<&RepoEntry, ReportalError> { + match self.repos.get(alias) { + Some(found_repo) => return Ok(found_repo), + None => { + for (_primary_key, repo_entry) in &self.repos { + let has_matching_alias = repo_entry.aliases().iter().any(|alt| alt == alias); + match has_matching_alias { + true => return Ok(repo_entry), + false => {} + } + } + return Err(ReportalError::RepoNotFound { + alias: alias.to_string(), + }); + } + } + } + + /// Returns a mutable reference to a repo by its primary key. + /// Returns `RepoNotFound` if the alias is not a primary key. + pub fn get_repo_mut(&mut self, alias: &str) -> Result<&mut RepoEntry, ReportalError> { + self.repos.get_mut(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(()) + } + + /// 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(), + }) + } + + /// 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_string(), + }) + } + + /// Returns all registered AI tools with their names. + pub fn ai_tools_list(&self) -> Vec<(&String, &AiToolEntry)> { + self.ai_tools.iter().collect() + } + + /// Inserts or replaces an AI tool entry in the registry. + /// Takes a (name, entry) pair, same shape as `ai_tools_list()` returns. + pub fn set_ai_tool(&mut self, registration: (String, AiToolEntry)) { + let (tool_name, tool_entry) = registration; + self.ai_tools.insert(tool_name, tool_entry); + } + + /// Removes an AI tool by name. Returns `AiToolNotFound` if missing. + pub fn remove_ai_tool(&mut self, tool_name: &str) -> Result { + self.ai_tools.remove(tool_name).ok_or_else(|| ReportalError::AiToolNotFound { + tool_name: tool_name.to_string(), + }) + } + + /// Updates the default editor command in settings. + pub fn set_default_editor(&mut self, editor_command: String) { + self.settings.default_editor = editor_command; + } + + /// Updates the default AI tool name in settings. + pub fn set_default_ai_tool(&mut self, tool_name: String) { + self.settings.default_ai_tool = tool_name; + } + + /// Updates the default clone root path in settings. + pub fn set_default_clone_root(&mut self, clone_root: String) { + self.settings.default_clone_root = clone_root; + } + + /// Returns the configured default clone root path. + pub fn default_clone_root(&self) -> &str { + &self.settings.default_clone_root + } + + /// 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, + default_ai_tool: "aider".to_string(), + }, + repos: BTreeMap::new(), + ai_tools: BTreeMap::from([ + ("aider".to_string(), AiToolEntry::with_executable("aider".to_string())), + ("aider".to_string(), AiToolEntry::with_executable("aider".to_string())), + ("aider".to_string(), AiToolEntry::with_executable("aider".to_string())), + ]), + } + } +} diff --git a/src/reportal_config/tag_filter.rs b/src/reportal_config/tag_filter.rs new file mode 100644 index 0000000..2b2a58c --- /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), +} From 6c813f8cff2856afbc4137ca6989afe345af8339 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Wed, 25 Mar 2026 01:29:12 -0400 Subject: [PATCH 20/67] Add rep web command for opening repos in the browser (v0.7.0) Fuzzy-select a repo and open its remote URL in the default browser. Converts SSH remotes to HTTPS automatically. Falls back to git remote when the config remote field is empty. Shell alias: rw. --- Cargo.lock | 33 +++- Cargo.toml | 3 +- README.md | 5 + src/error.rs | 14 ++ src/main.rs | 22 ++- src/reportal_commands/initialization.rs | 6 + src/reportal_commands/mod.rs | 2 + src/reportal_commands/repo_web.rs | 206 ++++++++++++++++++++++++ src/reportal_config/repo_entry.rs | 5 + 9 files changed, 293 insertions(+), 3 deletions(-) create mode 100644 src/reportal_commands/repo_web.rs diff --git a/Cargo.lock b/Cargo.lock index b08f19a..4c0a561 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -334,6 +334,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" @@ -412,6 +431,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 +550,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.6.1" +version = "0.7.0" dependencies = [ "anyhow", "clap", @@ -528,6 +558,7 @@ dependencies = [ "console", "dialoguer", "dirs 5.0.1", + "open", "owo-colors", "pathdiff", "serde", diff --git a/Cargo.toml b/Cargo.toml index 3de2e14..1c9ad7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.6.1" +version = "0.7.0" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" @@ -23,6 +23,7 @@ thiserror = "2" anyhow = "1" shellexpand = "3" pathdiff = "0.2" +open = "5" [lints.clippy] allow_attributes_without_reason = "warn" diff --git a/README.md b/README.md index d62892b..e6dc455 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,8 @@ ro | `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 doctor` | | Diagnose config, shell integration, and repo path issues | @@ -132,6 +134,8 @@ ro | `rj my-api` | Jump directly to a repo by alias | | `ro` | Fuzzy-select a repo and open it in your editor | | `ro my-api` | Open a repo directly by alias | +| `rw` | Fuzzy-select a repo and open it in the browser | +| `rw my-api` | Open a repo's remote directly by alias | Supports PowerShell, Bash, Zsh. Detected and installed during `rep init`. Re-run `rep init` after major version updates to regenerate the integration file. @@ -237,6 +241,7 @@ PROMPT_COMMAND='rep color 2>/dev/null' - [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) - [ ] `edit` UX overhaul — fuzzy-select what to edit (repos, AI tools, global settings), field menu - [ ] `dashboard` — rich overview with branches, dirty state, last commit - [ ] `clone --all` — clone missing repos from config (machine sync) diff --git a/src/error.rs b/src/error.rs index abddb42..dc1e5e3 100644 --- a/src/error.rs +++ b/src/error.rs @@ -88,4 +88,18 @@ pub enum ReportalError { /// 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, + + /// 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, + }, } diff --git a/src/main.rs b/src/main.rs index b8d30dd..a2a8255 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,7 @@ mod reportal_config; mod terminal_style; use clap::{Args, Parser, Subcommand}; -use reportal_commands::{AiCommandParams, ColorCommandParams, JumpCommandParams, OpenCommandParams, TitleOutput}; +use reportal_commands::{AiCommandParams, ColorCommandParams, JumpCommandParams, OpenCommandParams, TitleOutput, WebCommandParams}; use reportal_config::TagFilter; /// Shared --tag flag used by commands that filter repos. @@ -118,6 +118,13 @@ struct AiArgs { tool: Option, } +/// CLI args for `rep web`. +#[derive(Args)] +struct WebArgs { + #[command(flatten)] + selection: RepoSelectionArgs, +} + /// A fast CLI tool for jumping between and managing your dev repos. #[derive(Parser)] #[command(name = "reportal", version, about)] @@ -164,6 +171,9 @@ enum ReportalSubcommand { Doctor, /// Launch an AI coding CLI in a repo Ai(AiArgs), + /// Open a repo's remote URL in the browser + #[command(alias = "w")] + Web(WebArgs), } fn main() { @@ -245,6 +255,16 @@ fn main() { reportal_commands::run_sync(sync_args.filter.into_tag_filter()) } ReportalSubcommand::Doctor => reportal_commands::run_doctor(), + ReportalSubcommand::Web(web_args) => { + let direct_alias = match web_args.selection.alias { + Some(ref provided_alias) => provided_alias.as_str(), + None => "", + }; + reportal_commands::run_web(WebCommandParams { + tag_filter: web_args.selection.tag_filter.into_tag_filter(), + direct_alias, + }) + } ReportalSubcommand::Ai(ai_args) => { let direct_alias = match ai_args.selection.alias { Some(ref provided_alias) => provided_alias.as_str(), diff --git a/src/reportal_commands/initialization.rs b/src/reportal_commands/initialization.rs index 40f6baf..9649964 100644 --- a/src/reportal_commands/initialization.rs +++ b/src/reportal_commands/initialization.rs @@ -31,6 +31,7 @@ fn powershell_integration_content() -> String { $env:REPORTAL_LOADED = "1" function rj {{ Set-Location (rep jump @args); rep color }} function ro {{ rep open @args; rep color }} +function rw {{ rep web @args }} $_reportal_original_prompt = $function:prompt function prompt {{ $p = & $_reportal_original_prompt; $t = rep color --print-title 2>$null; if ($t) {{ $Host.UI.RawUI.WindowTitle = $t }}; $p }} "#, @@ -49,6 +50,7 @@ fn bash_integration_content() -> String { export REPORTAL_LOADED=1 rj() {{ cd "$(rep jump "$@")"; rep color; }} ro() {{ rep open "$@"; rep color; }} +rw() {{ rep web "$@"; }} _reportal_hook() {{ local _t; _t=$(rep color --print-title 2>{null_device}); [ -n "$_t" ] && printf '\033]2;%s\007' "$_t"; }} PROMPT_COMMAND="${{PROMPT_COMMAND:+$PROMPT_COMMAND;}}_reportal_hook" "#, @@ -180,16 +182,19 @@ fn strip_existing_integration(profile_content: &str) -> String { let trimmed = line.trim(); let is_reportal_line = trimmed.starts_with("function rj") || trimmed.starts_with("function ro") + || trimmed.starts_with("function rw") || trimmed.starts_with("function repup") || trimmed.starts_with("function prompt") || trimmed.starts_with("$_reportal_") || trimmed.starts_with("rj()") || trimmed.starts_with("ro()") + || trimmed.starts_with("rw()") || trimmed.starts_with("repup()") || trimmed.starts_with("PROMPT_COMMAND") || trimmed.contains("REPORTAL_LOADED") || trimmed.contains("rep jump") || trimmed.contains("rep open") + || trimmed.contains("rep web") || trimmed.contains("rep upgrade") || trimmed.contains("rep color"); match is_reportal_line { @@ -310,6 +315,7 @@ fn install_or_update_shell_integration() -> Result<(), ReportalError> { 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!(" {} open a repo in the browser", "rw".style(terminal_style::ALIAS_STYLE)); println!(" {} per-repo tab title + background color on every prompt", "color".style(terminal_style::ALIAS_STYLE)); println!(); diff --git a/src/reportal_commands/mod.rs b/src/reportal_commands/mod.rs index 1759f84..89c98a9 100644 --- a/src/reportal_commands/mod.rs +++ b/src/reportal_commands/mod.rs @@ -11,6 +11,7 @@ mod repo_edit; mod repo_remove; mod repo_status; mod repo_sync; +mod repo_web; pub use doctor::run_doctor; pub use initialization::ensure_integration_file_current; @@ -25,3 +26,4 @@ pub use repo_open::{run_open, OpenCommandParams}; pub use repo_remove::run_remove; pub use repo_status::run_status; pub use repo_sync::run_sync; +pub use repo_web::{run_web, WebCommandParams}; diff --git a/src/reportal_commands/repo_web.rs b/src/reportal_commands/repo_web.rs new file mode 100644 index 0000000..50c532a --- /dev/null +++ b/src/reportal_commands/repo_web.rs @@ -0,0 +1,206 @@ +/// Fuzzy-selects a repo and opens its remote URL in the default browser. + +use crate::error::ReportalError; +use crate::reportal_config::{ReportalConfig, TagFilter}; +use crate::terminal_style; +use dialoguer::{theme::ColorfulTheme, FuzzySelect}; +use owo_colors::OwoColorize; +use std::process::Command; + +/// All parameters needed to run the web command. +pub struct WebCommandParams<'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.git` +/// - `ssh://git@github.com/org/repo.git` → `https://github.com/org/repo.git` +/// - `https://github.com/org/repo.git` → unchanged +/// +/// Strips the trailing `.git` suffix for a cleaner browser URL. +fn remote_url_to_browser_url(remote_url: &str) -> String { + let https_url = match remote_url.starts_with("git@") { + true => { + let without_prefix = &remote_url["git@".len()..]; + let normalized = without_prefix.replacen(':', "/", 1); + format!("https://{normalized}") + } + false => match remote_url.starts_with("ssh://") { + true => { + let without_scheme = &remote_url["ssh://".len()..]; + let without_user = match without_scheme.find('@') { + Some(at_position) => &without_scheme[at_position + 1..], + None => without_scheme, + }; + format!("https://{without_user}") + } + false => remote_url.to_string(), + }, + }; + + match https_url.strip_suffix(".git") { + Some(without_git_suffix) => without_git_suffix.to_string(), + None => 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) => match output.status.success() { + true => { + let trimmed = String::from_utf8_lossy(&output.stdout).trim().to_string(); + match trimmed.is_empty() { + true => RemoteResolution::NotFound, + false => RemoteResolution::Resolved(trimmed), + } + } + false => 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. +pub fn run_web(web_params: WebCommandParams<'_>) -> Result<(), ReportalError> { + let loaded_config = ReportalConfig::load_from_disk()?; + + let (selected_alias, selected_repo): (&str, &crate::reportal_config::RepoEntry) = + match web_params.direct_alias.is_empty() { + false => { + let found_repo = loaded_config.get_repo(web_params.direct_alias)?; + (web_params.direct_alias, found_repo) + } + true => { + let matching_repos = + loaded_config.repos_matching_tag_filter(&web_params.tag_filter); + if matching_repos.is_empty() { + return Err(ReportalError::NoReposMatchFilter); + } + + let display_labels: Vec = matching_repos + .iter() + .map(|(alias, repo)| { + let mut label = alias.to_string(); + + match repo.aliases().is_empty() { + true => {} + false => { + let aliases_joined = repo.aliases().join(", "); + label.push_str(&format!(" ({aliases_joined})")); + } + } + + match repo.description().is_empty() { + true => {} + false => { + label.push_str(&format!(" — {}", repo.description())); + } + } + + return label; + }) + .collect(); + + let selected_index = FuzzySelect::with_theme(&ColorfulTheme::default()) + .with_prompt("Open in browser") + .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), + } + } + }; + + let raw_remote_url = match selected_repo.remote().is_empty() { + false => selected_repo.remote().to_string(), + true => match detect_git_remote(&selected_repo.resolved_path()) { + RemoteResolution::Resolved(detected_url) => detected_url, + RemoteResolution::NotFound => { + return Err(ReportalError::NoRemoteUrl { + alias: selected_alias.to_string(), + }); + } + }, + }; + + 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), + )); + + return 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_config/repo_entry.rs b/src/reportal_config/repo_entry.rs index 5ccf876..750fc51 100644 --- a/src/reportal_config/repo_entry.rs +++ b/src/reportal_config/repo_entry.rs @@ -118,6 +118,11 @@ impl RepoEntry { &self.aliases } + /// 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 From 46bb9818ff3f002705697270d2ab8c6681f927ae Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Wed, 25 Mar 2026 02:06:03 -0400 Subject: [PATCH 21/67] Emit tab title and color in rep web before opening browser --- src/reportal_commands/repo_web.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/reportal_commands/repo_web.rs b/src/reportal_commands/repo_web.rs index 50c532a..03bea57 100644 --- a/src/reportal_commands/repo_web.rs +++ b/src/reportal_commands/repo_web.rs @@ -1,8 +1,8 @@ /// Fuzzy-selects a repo and opens its remote URL in the default browser. use crate::error::ReportalError; -use crate::reportal_config::{ReportalConfig, TagFilter}; -use crate::terminal_style; +use crate::reportal_config::{RepoColor, ReportalConfig, TabTitle, TagFilter}; +use crate::terminal_style::{self, TabColorAction, TerminalIdentity, TerminalIdentityParams}; use dialoguer::{theme::ColorfulTheme, FuzzySelect}; use owo_colors::OwoColorize; use std::process::Command; @@ -142,6 +142,24 @@ pub fn run_web(web_params: WebCommandParams<'_>) -> Result<(), ReportalError> { } }; + let resolved_title = match selected_repo.tab_title() { + TabTitle::Custom(custom_title) => custom_title.to_string(), + TabTitle::UseAlias => selected_alias.to_string(), + }; + + let tab_color_action = match 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(TerminalIdentityParams { + resolved_title, + tab_color_action, + }); + terminal_style::emit_terminal_identity_to_console(&identity); + let raw_remote_url = match selected_repo.remote().is_empty() { false => selected_repo.remote().to_string(), true => match detect_git_remote(&selected_repo.resolved_path()) { From 0993f3d48f9c662892266fff9b1a41a8f019b120 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Wed, 25 Mar 2026 02:12:27 -0400 Subject: [PATCH 22/67] Extract shared repo selection and terminal identity into repo_selection.rs The fuzzy-finder logic and OSC tab title/color emission were duplicated across jump, open, ai, and web. Now all four delegate to select_repo() and emit_repo_terminal_identity() via param structs. --- src/reportal_commands/mod.rs | 1 + src/reportal_commands/repo_ai.rs | 93 +++------------ src/reportal_commands/repo_jump.rs | 96 +++------------ src/reportal_commands/repo_open.rs | 93 +++------------ src/reportal_commands/repo_selection.rs | 151 ++++++++++++++++++++++++ src/reportal_commands/repo_web.rs | 103 ++++------------ 6 files changed, 225 insertions(+), 312 deletions(-) create mode 100644 src/reportal_commands/repo_selection.rs diff --git a/src/reportal_commands/mod.rs b/src/reportal_commands/mod.rs index 89c98a9..dcacffe 100644 --- a/src/reportal_commands/mod.rs +++ b/src/reportal_commands/mod.rs @@ -10,6 +10,7 @@ mod repo_open; mod repo_edit; mod repo_remove; mod repo_status; +mod repo_selection; mod repo_sync; mod repo_web; diff --git a/src/reportal_commands/repo_ai.rs b/src/reportal_commands/repo_ai.rs index 4d664e7..c3309bf 100644 --- a/src/reportal_commands/repo_ai.rs +++ b/src/reportal_commands/repo_ai.rs @@ -1,9 +1,11 @@ /// Fuzzy-selects a repo and launches an AI coding CLI in it. use crate::error::ReportalError; -use crate::reportal_config::{RepoColor, ReportalConfig, TabTitle, TagFilter}; -use crate::terminal_style::{self, TabColorAction, TerminalIdentity, TerminalIdentityParams}; -use dialoguer::{theme::ColorfulTheme, FuzzySelect}; +use crate::reportal_config::{ReportalConfig, TagFilter}; +use crate::terminal_style; +use crate::reportal_commands::repo_selection::{ + self, RepoSelectionParams, TerminalIdentityEmitParams, +}; use owo_colors::OwoColorize; use std::process::Command; @@ -43,82 +45,21 @@ pub fn run_ai(ai_params: AiCommandParams<'_>) -> Result<(), ReportalError> { }; let ai_tool = loaded_config.get_ai_tool(tool_name)?; - let (selected_alias, selected_repo): (&str, &crate::reportal_config::RepoEntry) = - match ai_params.direct_alias.is_empty() { - false => { - let found_repo = loaded_config.get_repo(ai_params.direct_alias)?; - (ai_params.direct_alias, found_repo) - } - true => { - let matching_repos = - loaded_config.repos_matching_tag_filter(&ai_params.tag_filter); - - match matching_repos.is_empty() { - true => return Err(ReportalError::NoReposMatchFilter), - false => {} - } - - let display_labels: Vec = matching_repos - .iter() - .map(|(alias, repo)| { - let mut label = alias.to_string(); - - match repo.aliases().is_empty() { - true => {} - false => { - let aliases_joined = repo.aliases().join(", "); - label.push_str(&format!(" ({aliases_joined})")); - } - } - - match repo.description().is_empty() { - true => {} - false => { - label.push_str(&format!(" — {}", repo.description())); - } - } - - return label; - }) - .collect(); - - let selected_index = FuzzySelect::with_theme(&ColorfulTheme::default()) - .with_prompt(format!("Launch {tool_name} in")) - .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), - } - } - }; - - let resolved_title = match selected_repo.tab_title() { - TabTitle::Custom(custom_title) => custom_title.to_string(), - TabTitle::UseAlias => selected_alias.to_string(), - }; - - let tab_color_action = match selected_repo.repo_color() { - RepoColor::Themed(hex_color) => { - TabColorAction::SetColor(hex_color.as_osc_tab_color_sequence()) - } - RepoColor::ResetToDefault => TabColorAction::Reset, - }; + let prompt_label = format!("Launch {tool_name} in"); + let selected = repo_selection::select_repo(RepoSelectionParams { + loaded_config: &loaded_config, + direct_alias: ai_params.direct_alias, + tag_filter: &ai_params.tag_filter, + prompt_label: &prompt_label, + })?; - let identity = TerminalIdentity::new(TerminalIdentityParams { - resolved_title, - tab_color_action, + repo_selection::emit_repo_terminal_identity(TerminalIdentityEmitParams { + selected_alias: selected.repo_alias(), + selected_repo: selected.repo_config(), + title_override: "", }); - terminal_style::emit_terminal_identity_to_console(&identity); - let resolved_repo_path = selected_repo.resolved_path(); + let resolved_repo_path = selected.repo_config().resolved_path(); terminal_style::print_success(&format!( "Launching {} in {}", diff --git a/src/reportal_commands/repo_jump.rs b/src/reportal_commands/repo_jump.rs index 91fb10d..4331de1 100644 --- a/src/reportal_commands/repo_jump.rs +++ b/src/reportal_commands/repo_jump.rs @@ -1,9 +1,11 @@ /// 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, TabColorAction, TerminalIdentity, TerminalIdentityParams}; -use dialoguer::{theme::ColorfulTheme, FuzzySelect}; +use crate::reportal_config::{PathVisibility, ReportalConfig, TagFilter}; +use crate::terminal_style; +use crate::reportal_commands::repo_selection::{ + self, RepoSelectionParams, TerminalIdentityEmitParams, +}; use owo_colors::OwoColorize; /// All parameters needed to run the jump command. @@ -26,84 +28,20 @@ pub struct JumpCommandParams<'a> { pub fn run_jump(jump_params: JumpCommandParams<'_>) -> Result<(), ReportalError> { let loaded_config = ReportalConfig::load_from_disk()?; - 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) - } - 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 mut label = alias.to_string(); - - match repo.aliases().is_empty() { - true => {} - false => { - let aliases_joined = repo.aliases().join(", "); - label.push_str(&format!(" ({aliases_joined})")); - } - } - - match repo.description().is_empty() { - true => {} - false => { - label.push_str(&format!(" — {}", repo.description())); - } - } - - return label; - }) - .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), - } - } - }; - - 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(), - }, - }; - - let tab_color_action = match 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(TerminalIdentityParams { - resolved_title, - tab_color_action, + let selected = repo_selection::select_repo(RepoSelectionParams { + loaded_config: &loaded_config, + direct_alias: jump_params.direct_alias, + tag_filter: &jump_params.tag_filter, + prompt_label: "Jump to repo", + })?; + + repo_selection::emit_repo_terminal_identity(TerminalIdentityEmitParams { + selected_alias: selected.repo_alias(), + selected_repo: selected.repo_config(), + title_override: jump_params.title_override, }); - terminal_style::emit_terminal_identity_to_console(&identity); - let resolved_path = selected_repo.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}"); diff --git a/src/reportal_commands/repo_open.rs b/src/reportal_commands/repo_open.rs index 33d4c70..8c420d4 100644 --- a/src/reportal_commands/repo_open.rs +++ b/src/reportal_commands/repo_open.rs @@ -1,9 +1,11 @@ /// 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, TabColorAction, TerminalIdentity, TerminalIdentityParams}; -use dialoguer::{theme::ColorfulTheme, FuzzySelect}; +use crate::reportal_config::{PathVisibility, ReportalConfig, TagFilter}; +use crate::terminal_style; +use crate::reportal_commands::repo_selection::{ + self, RepoSelectionParams, TerminalIdentityEmitParams, +}; use owo_colors::OwoColorize; use std::process::Command; @@ -29,83 +31,20 @@ pub struct OpenCommandParams<'a> { 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) - } - 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 mut label = alias.to_string(); - - match repo.aliases().is_empty() { - true => {} - false => { - let aliases_joined = repo.aliases().join(", "); - label.push_str(&format!(" ({aliases_joined})")); - } - } - - match repo.description().is_empty() { - true => {} - false => { - label.push_str(&format!(" — {}", repo.description())); - } - } - - return label; - }) - .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), - } - } - }; - - 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(), - }, - }; - - let tab_color_action = match selected_repo.repo_color() { - RepoColor::Themed(hex_color) => { - TabColorAction::SetColor(hex_color.as_osc_tab_color_sequence()) - } - RepoColor::ResetToDefault => TabColorAction::Reset, - }; + let selected = repo_selection::select_repo(RepoSelectionParams { + loaded_config: &loaded_config, + direct_alias: open_params.direct_alias, + tag_filter: &open_params.tag_filter, + prompt_label: "Open in editor", + })?; - let identity = TerminalIdentity::new(TerminalIdentityParams { - resolved_title, - tab_color_action, + repo_selection::emit_repo_terminal_identity(TerminalIdentityEmitParams { + selected_alias: selected.repo_alias(), + selected_repo: selected.repo_config(), + title_override: open_params.title_override, }); - terminal_style::emit_terminal_identity_to_console(&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(), diff --git a/src/reportal_commands/repo_selection.rs b/src/reportal_commands/repo_selection.rs new file mode 100644 index 0000000..a19a053 --- /dev/null +++ b/src/reportal_commands/repo_selection.rs @@ -0,0 +1,151 @@ +/// Shared repo selection and terminal identity helpers used across commands. +/// +/// Eliminates the duplicated fuzzy-finder and OSC tab-theming blocks +/// that were copy-pasted across jump, open, ai, and web. + +use crate::error::ReportalError; +use crate::reportal_config::{RepoColor, RepoEntry, ReportalConfig, TabTitle, TagFilter}; +use crate::terminal_style::{self, TabColorAction, TerminalIdentity, TerminalIdentityParams}; +use dialoguer::{theme::ColorfulTheme, FuzzySelect}; + +/// 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> { + repo_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 repo_alias(&self) -> &'a str { + self.repo_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 + } +} + +/// Parameters for selecting a repo via fuzzy finder or direct alias. +pub struct RepoSelectionParams<'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, +} + +/// 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`. Each item shows the alias, +/// any configured aliases in parens, and the description. +pub fn select_repo<'a>(selection_params: RepoSelectionParams<'a>) -> Result, ReportalError> { + match selection_params.direct_alias.is_empty() { + false => { + let found_repo = selection_params.loaded_config.get_repo(selection_params.direct_alias)?; + return Ok(SelectedRepo { + repo_alias: selection_params.direct_alias, + repo_config: found_repo, + }); + } + true => { + let matching_repos = selection_params.loaded_config.repos_matching_tag_filter(selection_params.tag_filter); + if matching_repos.is_empty() { + return Err(ReportalError::NoReposMatchFilter); + } + + let display_labels: Vec = matching_repos + .iter() + .map(|(alias, repo)| { + let mut label = alias.to_string(); + + match repo.aliases().is_empty() { + true => {} + false => { + let aliases_joined = repo.aliases().join(", "); + label.push_str(&format!(" ({aliases_joined})")); + } + } + + match repo.description().is_empty() { + true => {} + false => { + label.push_str(&format!(" — {}", repo.description())); + } + } + + return label; + }) + .collect(); + + let selected_index = FuzzySelect::with_theme(&ColorfulTheme::default()) + .with_prompt(selection_params.prompt_label) + .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)) => { + return Ok(SelectedRepo { + repo_alias: chosen_alias.as_str(), + repo_config: *chosen_repo, + }); + } + None => return Err(ReportalError::SelectionCancelled), + }, + None => return Err(ReportalError::SelectionCancelled), + } + } + } +} + +/// Parameters for emitting terminal identity (tab title + color). +pub struct TerminalIdentityEmitParams<'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: TerminalIdentityEmitParams<'_>) { + let resolved_title = match identity_params.title_override.is_empty() { + false => identity_params.title_override.to_string(), + true => match identity_params.selected_repo.tab_title() { + TabTitle::Custom(custom_title) => custom_title.to_string(), + TabTitle::UseAlias => identity_params.selected_alias.to_string(), + }, + }; + + 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(TerminalIdentityParams { + resolved_title, + tab_color_action, + }); + terminal_style::emit_terminal_identity_to_console(&identity); +} diff --git a/src/reportal_commands/repo_web.rs b/src/reportal_commands/repo_web.rs index 03bea57..7dc8171 100644 --- a/src/reportal_commands/repo_web.rs +++ b/src/reportal_commands/repo_web.rs @@ -1,9 +1,11 @@ /// Fuzzy-selects a repo and opens its remote URL in the default browser. use crate::error::ReportalError; -use crate::reportal_config::{RepoColor, ReportalConfig, TabTitle, TagFilter}; -use crate::terminal_style::{self, TabColorAction, TerminalIdentity, TerminalIdentityParams}; -use dialoguer::{theme::ColorfulTheme, FuzzySelect}; +use crate::reportal_config::{ReportalConfig, TagFilter}; +use crate::terminal_style; +use crate::reportal_commands::repo_selection::{ + self, RepoSelectionParams, TerminalIdentityEmitParams, +}; use owo_colors::OwoColorize; use std::process::Command; @@ -26,9 +28,9 @@ enum RemoteResolution { /// 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.git` -/// - `ssh://git@github.com/org/repo.git` → `https://github.com/org/repo.git` -/// - `https://github.com/org/repo.git` → unchanged +/// - `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 { @@ -84,89 +86,30 @@ fn detect_git_remote(repo_path: &std::path::Path) -> RemoteResolution { /// 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: WebCommandParams<'_>) -> Result<(), ReportalError> { let loaded_config = ReportalConfig::load_from_disk()?; - let (selected_alias, selected_repo): (&str, &crate::reportal_config::RepoEntry) = - match web_params.direct_alias.is_empty() { - false => { - let found_repo = loaded_config.get_repo(web_params.direct_alias)?; - (web_params.direct_alias, found_repo) - } - true => { - let matching_repos = - loaded_config.repos_matching_tag_filter(&web_params.tag_filter); - if matching_repos.is_empty() { - return Err(ReportalError::NoReposMatchFilter); - } - - let display_labels: Vec = matching_repos - .iter() - .map(|(alias, repo)| { - let mut label = alias.to_string(); - - match repo.aliases().is_empty() { - true => {} - false => { - let aliases_joined = repo.aliases().join(", "); - label.push_str(&format!(" ({aliases_joined})")); - } - } - - match repo.description().is_empty() { - true => {} - false => { - label.push_str(&format!(" — {}", repo.description())); - } - } - - return label; - }) - .collect(); - - let selected_index = FuzzySelect::with_theme(&ColorfulTheme::default()) - .with_prompt("Open in browser") - .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), - } - } - }; - - let resolved_title = match selected_repo.tab_title() { - TabTitle::Custom(custom_title) => custom_title.to_string(), - TabTitle::UseAlias => selected_alias.to_string(), - }; - - let tab_color_action = match selected_repo.repo_color() { - RepoColor::Themed(hex_color) => { - TabColorAction::SetColor(hex_color.as_osc_tab_color_sequence()) - } - RepoColor::ResetToDefault => TabColorAction::Reset, - }; + let selected = repo_selection::select_repo(RepoSelectionParams { + loaded_config: &loaded_config, + direct_alias: web_params.direct_alias, + tag_filter: &web_params.tag_filter, + prompt_label: "Open in browser", + })?; - let identity = TerminalIdentity::new(TerminalIdentityParams { - resolved_title, - tab_color_action, + repo_selection::emit_repo_terminal_identity(TerminalIdentityEmitParams { + selected_alias: selected.repo_alias(), + selected_repo: selected.repo_config(), + title_override: "", }); - terminal_style::emit_terminal_identity_to_console(&identity); - let raw_remote_url = match selected_repo.remote().is_empty() { - false => selected_repo.remote().to_string(), - true => match detect_git_remote(&selected_repo.resolved_path()) { + let raw_remote_url = match selected.repo_config().remote().is_empty() { + false => selected.repo_config().remote().to_string(), + true => match detect_git_remote(&selected.repo_config().resolved_path()) { RemoteResolution::Resolved(detected_url) => detected_url, RemoteResolution::NotFound => { return Err(ReportalError::NoRemoteUrl { - alias: selected_alias.to_string(), + alias: selected.repo_alias().to_string(), }); } }, From 6244ddbe2908882c9ceb2b0c4ffd30a5355a9406 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Wed, 25 Mar 2026 02:22:07 -0400 Subject: [PATCH 23/67] Extract shared git commands, prompts, and path display into modules - git_commands.rs: GitCommandOutcome + GitCommandParams + run_git_command() used by status and sync (was fully duplicated between both) - prompts.rs: prompt_for_text(), parse_comma_separated_tags(), prompt_for_color(), prompt_for_color_edit() used by add and edit - path_display.rs: print_selected_path_if_visible() used by jump and open --- src/reportal_commands/git_commands.rs | 45 ++++++++++ src/reportal_commands/mod.rs | 7 +- src/reportal_commands/path_display.rs | 36 ++++++++ src/reportal_commands/prompts.rs | 124 ++++++++++++++++++++++++++ src/reportal_commands/repo_add.rs | 117 ++++++++---------------- src/reportal_commands/repo_edit.rs | 96 ++++++-------------- src/reportal_commands/repo_jump.rs | 19 ++-- src/reportal_commands/repo_status.rs | 46 ++-------- src/reportal_commands/repo_sync.rs | 53 ++--------- 9 files changed, 293 insertions(+), 250 deletions(-) create mode 100644 src/reportal_commands/git_commands.rs create mode 100644 src/reportal_commands/path_display.rs create mode 100644 src/reportal_commands/prompts.rs diff --git a/src/reportal_commands/git_commands.rs b/src/reportal_commands/git_commands.rs new file mode 100644 index 0000000..f63a7f2 --- /dev/null +++ b/src/reportal_commands/git_commands.rs @@ -0,0 +1,45 @@ +/// 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 GitCommandParams<'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: GitCommandParams<'_>) -> 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) => 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, + } +} diff --git a/src/reportal_commands/mod.rs b/src/reportal_commands/mod.rs index dcacffe..4ba13a7 100644 --- a/src/reportal_commands/mod.rs +++ b/src/reportal_commands/mod.rs @@ -1,16 +1,19 @@ /// Subcommand implementations for the RePortal CLI. mod doctor; +mod git_commands; mod initialization; +mod path_display; +mod prompts; mod repo_add; mod repo_ai; mod repo_color; +mod repo_edit; mod repo_jump; mod repo_listing; mod repo_open; -mod repo_edit; mod repo_remove; -mod repo_status; mod repo_selection; +mod repo_status; mod repo_sync; mod repo_web; diff --git a/src/reportal_commands/path_display.rs b/src/reportal_commands/path_display.rs new file mode 100644 index 0000000..41f6f9f --- /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 SelectedPathDisplayParams<'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: SelectedPathDisplayParams<'_>) { + 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); + eprintln!( + " {} {}", + ">>".style(terminal_style::SUCCESS_STYLE), + formatted_path.style(terminal_style::PATH_STYLE), + ); + } + PathVisibility::Hide => {} + } +} diff --git a/src/reportal_commands/prompts.rs b/src/reportal_commands/prompts.rs new file mode 100644 index 0000000..7fcf4b1 --- /dev/null +++ b/src/reportal_commands/prompts.rs @@ -0,0 +1,124 @@ +/// Shared interactive prompt helpers for add and edit commands. + +use crate::error::ReportalError; +use crate::reportal_config::HexColor; +use crate::terminal_style; +use dialoguer::{theme::ColorfulTheme, Input}; + +/// Parameters for a text input prompt with a label and default value. +pub struct TextPromptParams<'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. + pub default_value: String, +} + +/// 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: TextPromptParams<'_>) -> 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) + .interact_text() + .map_err(|prompt_error| ReportalError::ConfigIoFailure { + reason: prompt_error.to_string(), + })?; + + return 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 { + return raw_input + .split(',') + .map(|tag_segment| tag_segment.trim().to_string()) + .filter(|trimmed_tag| !trimmed_tag.is_empty()) + .collect(); +} + +/// 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(), + })?; + + 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()); + } + }, + } + } +} + +/// 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), +} + +/// Parameters for the color edit prompt that needs the current value as default. +pub struct ColorEditPromptParams<'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: ColorEditPromptParams<'_>) -> 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_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(color_edit_params.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()); + } + }, + } + } +} diff --git a/src/reportal_commands/repo_add.rs b/src/reportal_commands/repo_add.rs index 6941adb..f9ad19d 100644 --- a/src/reportal_commands/repo_add.rs +++ b/src/reportal_commands/repo_add.rs @@ -2,7 +2,10 @@ /// Supports both local paths and git URLs (clones first, then registers). use crate::error::ReportalError; -use crate::reportal_config::{HexColor, RepoRegistrationBuilder, ReportalConfig}; +use crate::reportal_commands::prompts::{ + self, ColorPromptResult, TextPromptParams, +}; +use crate::reportal_config::{RepoRegistrationBuilder, ReportalConfig}; use crate::terminal_style; use dialoguer::{theme::ColorfulTheme, FuzzySelect, Input}; use owo_colors::OwoColorize; @@ -66,38 +69,6 @@ struct RegistrationContext<'a> { 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); @@ -319,53 +290,39 @@ fn clone_repo(clone_operation: GitCloneOperation<'_>) -> Result<(), ReportalErro 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)?; + let repo_alias = prompts::prompt_for_text(TextPromptParams { + prompt_theme: &prompt_theme, + label: "Alias", + default_value: registration_context.suggested_alias, + })?; + + let repo_description = prompts::prompt_for_text(TextPromptParams { + prompt_theme: &prompt_theme, + label: "Description", + default_value: String::new(), + })?; + + let tags_input = prompts::prompt_for_text(TextPromptParams { + prompt_theme: &prompt_theme, + label: "Tags (comma-separated)", + default_value: String::new(), + })?; + + let parsed_tags = prompts::parse_comma_separated_tags(&tags_input); + + let repo_remote = prompts::prompt_for_text(TextPromptParams { + prompt_theme: &prompt_theme, + label: "Remote URL", + default_value: registration_context.detected_remote, + })?; + + let tab_title = prompts::prompt_for_text(TextPromptParams { + prompt_theme: &prompt_theme, + label: "Tab title (empty = use alias)", + default_value: String::new(), + })?; + + let repo_color = prompts::prompt_for_color(&prompt_theme)?; println!(); println!(" {} {}", "Alias:".style(terminal_style::LABEL_STYLE), repo_alias.style(terminal_style::ALIAS_STYLE)); diff --git a/src/reportal_commands/repo_edit.rs b/src/reportal_commands/repo_edit.rs index 7c08510..f0922ce 100644 --- a/src/reportal_commands/repo_edit.rs +++ b/src/reportal_commands/repo_edit.rs @@ -1,51 +1,14 @@ //! Interactively edits an existing repo's metadata in the config. use crate::error::ReportalError; -use crate::reportal_config::{HexColor, RepoColor, ReportalConfig, TabTitle}; +use crate::reportal_commands::prompts::{ + self, ColorEditResult, ColorEditPromptParams, TextPromptParams, +}; +use crate::reportal_config::{RepoColor, ReportalConfig, TabTitle}; use crate::terminal_style; -use dialoguer::{theme::ColorfulTheme, Input}; +use dialoguer::theme::ColorfulTheme; 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), -} - -/// 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. @@ -79,44 +42,37 @@ pub fn run_edit(alias: &str) -> Result<(), ReportalError> { 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 new_description = prompts::prompt_for_text(TextPromptParams { + prompt_theme: &prompt_theme, + label: "Description", + default_value: current_description, + })?; - 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 tags_input = prompts::prompt_for_text(TextPromptParams { + prompt_theme: &prompt_theme, + label: "Tags (comma-separated)", + default_value: current_tags, + })?; - 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_tags = prompts::parse_comma_separated_tags(&tags_input); - 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 new_title = prompts::prompt_for_text(TextPromptParams { + prompt_theme: &prompt_theme, + label: "Tab title (empty = use alias)", + default_value: current_title, + })?; - let color_result = prompt_for_color_edit(&prompt_theme, ¤t_color)?; + let color_edit_result = prompts::prompt_for_color_edit(ColorEditPromptParams { + prompt_theme: &prompt_theme, + current_default: ¤t_color, + })?; let resolved_title = match new_title.is_empty() { true => TabTitle::UseAlias, false => TabTitle::Custom(new_title), }; - let resolved_color = match color_result { + let resolved_color = match color_edit_result { ColorEditResult::Provided(hex_color) => RepoColor::Themed(hex_color), ColorEditResult::Unchanged(hex_color) => RepoColor::Themed(hex_color), ColorEditResult::Cleared => RepoColor::ResetToDefault, diff --git a/src/reportal_commands/repo_jump.rs b/src/reportal_commands/repo_jump.rs index 4331de1..b080d88 100644 --- a/src/reportal_commands/repo_jump.rs +++ b/src/reportal_commands/repo_jump.rs @@ -1,12 +1,11 @@ /// Fuzzy-selects a repo and prints its path for shell `cd` integration. use crate::error::ReportalError; -use crate::reportal_config::{PathVisibility, ReportalConfig, TagFilter}; -use crate::terminal_style; +use crate::reportal_config::{ReportalConfig, TagFilter}; +use crate::reportal_commands::path_display::{self, SelectedPathDisplayParams}; use crate::reportal_commands::repo_selection::{ self, RepoSelectionParams, TerminalIdentityEmitParams, }; -use owo_colors::OwoColorize; /// All parameters needed to run the jump command. pub struct JumpCommandParams<'a> { @@ -46,16 +45,10 @@ pub fn run_jump(jump_params: JumpCommandParams<'_>) -> Result<(), ReportalError> print!("{formatted_path}"); - 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(SelectedPathDisplayParams { + loaded_config: &loaded_config, + resolved_path: &resolved_path, + }); return Ok(()); } diff --git a/src/reportal_commands/repo_status.rs b/src/reportal_commands/repo_status.rs index 8f10ec0..935e1d8 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, GitCommandParams}; 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,14 +76,6 @@ 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> { /// The alias of the repo being checked. @@ -110,27 +92,9 @@ 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(GitCommandParams { repo_path, git_subcommand_args: &["rev-parse", "--abbrev-ref", "HEAD"], }) { @@ -148,7 +112,7 @@ fn read_branch_name(repo_path: &PathBuf) -> String { /// 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(GitCommandParams { repo_path, git_subcommand_args: &["status", "--porcelain"], }) { @@ -179,7 +143,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(GitCommandParams { repo_path, git_subcommand_args: &["rev-list", "--left-right", "--count", "HEAD...@{upstream}"], }) { @@ -194,7 +158,7 @@ fn read_upstream_delta(repo_path: &PathBuf) -> UpstreamDelta { /// 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(GitCommandParams { repo_path, git_subcommand_args: &["log", "-1", "--format=%cr"], }) { diff --git a/src/reportal_commands/repo_sync.rs b/src/reportal_commands/repo_sync.rs index 492414e..0611cfc 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, GitCommandParams}; 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,35 +38,9 @@ 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(GitCommandParams { repo_path, git_subcommand_args: &["status", "--porcelain"], }) { @@ -156,23 +121,23 @@ 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); + let sync_result = sync_single_repo(alias, &resolved); - match &result.outcome { + match &sync_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), + sync_result.alias.style(terminal_style::ALIAS_STYLE), "✓".style(terminal_style::SUCCESS_STYLE), ); } false => { println!( " {} {} pulled", - result.alias.style(terminal_style::ALIAS_STYLE), + sync_result.alias.style(terminal_style::ALIAS_STYLE), "↓".style(terminal_style::SUCCESS_STYLE), ); } @@ -182,7 +147,7 @@ pub fn run_sync(tag_filter: TagFilter) -> Result<(), ReportalError> { skipped_count += 1; eprintln!( " {} {} skipped (dirty working tree)", - result.alias.style(terminal_style::ALIAS_STYLE), + sync_result.alias.style(terminal_style::ALIAS_STYLE), "!".style(terminal_style::FAILURE_STYLE), ); } @@ -190,7 +155,7 @@ pub fn run_sync(tag_filter: TagFilter) -> Result<(), ReportalError> { failed_count += 1; eprintln!( " {} {} missing on disk", - result.alias.style(terminal_style::ALIAS_STYLE), + sync_result.alias.style(terminal_style::ALIAS_STYLE), "✗".style(terminal_style::FAILURE_STYLE), ); } @@ -198,7 +163,7 @@ pub fn run_sync(tag_filter: TagFilter) -> Result<(), ReportalError> { failed_count += 1; eprintln!( " {} {} pull failed: {}", - result.alias.style(terminal_style::ALIAS_STYLE), + sync_result.alias.style(terminal_style::ALIAS_STYLE), "✗".style(terminal_style::FAILURE_STYLE), error_message, ); From cfe784ede6f4b1426dfa1182a17dbde244d9b0f3 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Wed, 25 Mar 2026 02:23:31 -0400 Subject: [PATCH 24/67] Untrack agent notes and add to gitignore From 2f1cf11dccfcd349cb6241bca91eca516e7c5771 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Fri, 27 Mar 2026 14:27:27 -0400 Subject: [PATCH 25/67] Add rep run command for running configured commands in repos (v0.8.0) --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 43 ++++ src/error.rs | 18 ++ src/main.rs | 30 ++- src/reportal_commands/mod.rs | 2 + src/reportal_commands/repo_run.rs | 238 ++++++++++++++++++++ src/reportal_config/command_entry.rs | 27 +++ src/reportal_config/mod.rs | 2 + src/reportal_config/repo_entry.rs | 10 + src/reportal_config/reportal_config_root.rs | 17 ++ 11 files changed, 388 insertions(+), 3 deletions(-) create mode 100644 src/reportal_commands/repo_run.rs create mode 100644 src/reportal_config/command_entry.rs diff --git a/Cargo.lock b/Cargo.lock index 4c0a561..e9b93e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -550,7 +550,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.7.0" +version = "0.8.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 1c9ad7a..3c9298f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.7.0" +version = "0.8.0" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/README.md b/README.md index e6dc455..f0848f8 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,10 @@ ro | `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 doctor` | | Diagnose config, shell integration, and repo path issues | ## Shell integration @@ -136,6 +140,8 @@ ro | `ro my-api` | Open a repo directly by alias | | `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 | Supports PowerShell, Bash, Zsh. Detected and installed during `rep init`. Re-run `rep init` after major version updates to regenerate the integration file. @@ -200,6 +206,42 @@ args = [] | `args` | no | `[]` | Extra arguments passed on every launch | +### Commands + +Define reusable commands that can be run in any repo via `rep run`: + +```toml +[commands.test] +command = "cargo test" +description = "Run tests" + +[commands.serve] +command = "npm run dev" +description = "Start dev server" + +[commands.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 | + +Per-repo command overrides can be added inline to any `[repos.*]` section. These override global commands with the same name, or add repo-specific commands: + +```toml +[repos.my-api] +path = "~/dev/my-api" +description = "Django backend" +tags = ["work"] + +[repos.my-api.commands] +serve = "python manage.py runserver" +migrate = "python manage.py migrate" +``` + ## Terminal personalization When you jump to or open a repo, RePortal automatically sets: @@ -242,6 +284,7 @@ PROMPT_COMMAND='rep color 2>/dev/null' - [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 - [ ] `edit` UX overhaul — fuzzy-select what to edit (repos, AI tools, global settings), field menu - [ ] `dashboard` — rich overview with branches, dirty state, last commit - [ ] `clone --all` — clone missing repos from config (machine sync) diff --git a/src/error.rs b/src/error.rs index dc1e5e3..74439f6 100644 --- a/src/error.rs +++ b/src/error.rs @@ -102,4 +102,22 @@ pub enum ReportalError { /// 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, } diff --git a/src/main.rs b/src/main.rs index a2a8255..9110f36 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,7 @@ mod reportal_config; mod terminal_style; use clap::{Args, Parser, Subcommand}; -use reportal_commands::{AiCommandParams, ColorCommandParams, JumpCommandParams, OpenCommandParams, TitleOutput, WebCommandParams}; +use reportal_commands::{AiCommandParams, ColorCommandParams, JumpCommandParams, OpenCommandParams, RunCommandParams, TitleOutput, WebCommandParams}; use reportal_config::TagFilter; /// Shared --tag flag used by commands that filter repos. @@ -125,6 +125,16 @@ struct WebArgs { selection: RepoSelectionArgs, } +/// CLI args for `rep run`. +#[derive(Args)] +struct RunArgs { + #[command(flatten)] + selection: RepoSelectionArgs, + /// Run this command directly (skip command fuzzy finder) + #[arg(long)] + cmd: Option, +} + /// A fast CLI tool for jumping between and managing your dev repos. #[derive(Parser)] #[command(name = "reportal", version, about)] @@ -174,6 +184,9 @@ enum ReportalSubcommand { /// Open a repo's remote URL in the browser #[command(alias = "w")] Web(WebArgs), + /// Run a configured command in a repo + #[command(alias = "r")] + Run(RunArgs), } fn main() { @@ -265,6 +278,21 @@ fn main() { direct_alias, }) } + ReportalSubcommand::Run(run_args) => { + let direct_alias = match run_args.selection.alias { + Some(ref provided_alias) => provided_alias.as_str(), + None => "", + }; + let direct_command = match run_args.cmd { + Some(ref provided_command) => provided_command.as_str(), + None => "", + }; + reportal_commands::run_run(RunCommandParams { + tag_filter: run_args.selection.tag_filter.into_tag_filter(), + direct_alias, + direct_command, + }) + } ReportalSubcommand::Ai(ai_args) => { let direct_alias = match ai_args.selection.alias { Some(ref provided_alias) => provided_alias.as_str(), diff --git a/src/reportal_commands/mod.rs b/src/reportal_commands/mod.rs index 4ba13a7..010e8f4 100644 --- a/src/reportal_commands/mod.rs +++ b/src/reportal_commands/mod.rs @@ -12,6 +12,7 @@ mod repo_jump; mod repo_listing; mod repo_open; mod repo_remove; +mod repo_run; mod repo_selection; mod repo_status; mod repo_sync; @@ -28,6 +29,7 @@ pub use repo_jump::{run_jump, JumpCommandParams}; pub use repo_listing::run_list; pub use repo_open::{run_open, OpenCommandParams}; pub use repo_remove::run_remove; +pub use repo_run::{run_run, RunCommandParams}; pub use repo_status::run_status; pub use repo_sync::run_sync; pub use repo_web::{run_web, WebCommandParams}; diff --git a/src/reportal_commands/repo_run.rs b/src/reportal_commands/repo_run.rs new file mode 100644 index 0000000..19fa0d6 --- /dev/null +++ b/src/reportal_commands/repo_run.rs @@ -0,0 +1,238 @@ +/// Fuzzy-selects a repo and runs a user-defined command in it. + +use crate::error::ReportalError; +use crate::reportal_config::{CommandEntry, ReportalConfig, TagFilter}; +use crate::terminal_style; +use crate::reportal_commands::repo_selection::{ + self, RepoSelectionParams, TerminalIdentityEmitParams, +}; +use dialoguer::{theme::ColorfulTheme, FuzzySelect}; +use owo_colors::OwoColorize; +use std::collections::BTreeMap; +use std::process::Command; + +/// All parameters needed to run the run command. +pub struct RunCommandParams<'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 ResolveCommandParams<'a> { + /// Global commands from config. + global_commands: &'a BTreeMap, + /// Per-repo command overrides. + 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 overrides, then resolves +/// which command to run (direct or fuzzy-selected). +/// +/// Repo-level commands override global commands with the same name. +/// Repo-level commands are flat `key = "command string"` (no description), +/// so when a repo overrides a global command, the description is lost. +fn resolve_command(resolve_params: ResolveCommandParams<'_>) -> 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, + description: "", + }); + } + None => { + merged.push(MergedCommandEntry { + name, + shell_command: entry.shell_command(), + description: entry.description(), + }); + } + } + } + + for (name, shell_command) in resolve_params.repo_commands { + let already_merged = resolve_params.global_commands.contains_key(name); + match already_merged { + true => {} + false => { + merged.push(MergedCommandEntry { + name, + shell_command, + description: "", + }); + } + } + } + + match merged.is_empty() { + true => return Err(ReportalError::NoCommandsAvailable), + false => {} + } + + match resolve_params.direct_command.is_empty() { + false => { + let found = merged.iter().find(|entry| entry.name == resolve_params.direct_command); + match found { + Some(entry) => { + return Ok(ResolvedCommand { + command_name: entry.name.to_string(), + shell_command: entry.shell_command.to_string(), + }); + } + None => { + return Err(ReportalError::CommandNotFound { + command_name: resolve_params.direct_command.to_string(), + }); + } + } + } + true => { + let display_labels: Vec = merged + .iter() + .map(|entry| { + let mut label = entry.name.to_string(); + match entry.description.is_empty() { + true => { + label.push_str(&format!(" — {}", entry.shell_command)); + } + false => { + label.push_str(&format!(" — {}", entry.description)); + } + } + return label; + }) + .collect(); + + let selected_index = FuzzySelect::with_theme(&ColorfulTheme::default()) + .with_prompt("Run command") + .items(&display_labels) + .interact_opt() + .map_err(|select_error| ReportalError::ConfigIoFailure { + reason: select_error.to_string(), + })?; + + match selected_index { + Some(chosen_index) => match merged.get(chosen_index) { + Some(entry) => { + return Ok(ResolvedCommand { + command_name: entry.name.to_string(), + shell_command: entry.shell_command.to_string(), + }); + } + None => return Err(ReportalError::SelectionCancelled), + }, + None => return Err(ReportalError::SelectionCancelled), + } + } + } +} + +/// 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: RunCommandParams<'_>) -> Result<(), ReportalError> { + let loaded_config = ReportalConfig::load_from_disk()?; + + let selected = repo_selection::select_repo(RepoSelectionParams { + loaded_config: &loaded_config, + direct_alias: run_params.direct_alias, + tag_filter: &run_params.tag_filter, + prompt_label: "Run command in", + })?; + + repo_selection::emit_repo_terminal_identity(TerminalIdentityEmitParams { + selected_alias: selected.repo_alias(), + selected_repo: selected.repo_config(), + title_override: "", + }); + + let resolved_command = resolve_command(ResolveCommandParams { + 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}"), + })?; + + match exit_status.success() { + true => {} + false => { + 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 + )); + } + } + } + } + + return Ok(()); +} diff --git a/src/reportal_config/command_entry.rs b/src/reportal_config/command_entry.rs new file mode 100644 index 0000000..cbef93d --- /dev/null +++ b/src/reportal_config/command_entry.rs @@ -0,0 +1,27 @@ +/// User-defined command entries stored in the `[commands.*]` +/// sections of `config.toml`. + +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, +} + +/// Construction and accessors for command configuration. +impl CommandEntry { + /// The shell command string to execute. + pub fn shell_command(&self) -> &str { + &self.command + } + + /// Human-readable description of what this command does. + pub fn description(&self) -> &str { + &self.description + } +} diff --git a/src/reportal_config/mod.rs b/src/reportal_config/mod.rs index 40d5b3b..7430d9b 100644 --- a/src/reportal_config/mod.rs +++ b/src/reportal_config/mod.rs @@ -4,6 +4,7 @@ /// all registered repositories, AI tools, and global settings. mod ai_tool_entry; +mod command_entry; mod global_settings; mod hex_color; mod reportal_config_root; @@ -11,6 +12,7 @@ mod repo_entry; mod tag_filter; pub use ai_tool_entry::AiToolEntry; +pub use command_entry::CommandEntry; pub use global_settings::{PathDisplayFormat, PathVisibility, ReportalSettings}; pub use hex_color::HexColor; pub use repo_entry::{RepoColor, RepoEntry, RepoRegistrationBuilder, TabTitle}; diff --git a/src/reportal_config/repo_entry.rs b/src/reportal_config/repo_entry.rs index 750fc51..03cf147 100644 --- a/src/reportal_config/repo_entry.rs +++ b/src/reportal_config/repo_entry.rs @@ -4,6 +4,7 @@ use crate::error::ReportalError; use crate::reportal_config::hex_color::HexColor; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use std::path::PathBuf; /// Whether a repo has a custom tab title or falls back to its alias. @@ -88,6 +89,9 @@ pub struct RepoEntry { /// Terminal background color applied via OSC 11 when jumping to this repo. #[serde(default)] color: RepoColor, + /// Per-repo command overrides: key is command name, value is shell command string. + #[serde(default)] + commands: BTreeMap, } /// Accessors and path resolution for a registered repository entry. @@ -133,6 +137,11 @@ impl RepoEntry { &self.color } + /// Per-repo command overrides (command name → shell command string). + pub fn repo_commands(&self) -> &BTreeMap { + &self.commands + } + /// Replaces the description text. pub fn set_description(&mut self, new_description: String) { self.description = new_description; @@ -259,6 +268,7 @@ impl RepoRegistrationBuilder { aliases: Vec::new(), title: self.repo_title, color: self.repo_color, + commands: BTreeMap::new(), }; Ok((self.alias, validated_entry)) } diff --git a/src/reportal_config/reportal_config_root.rs b/src/reportal_config/reportal_config_root.rs index e498fb9..61ffe9d 100644 --- a/src/reportal_config/reportal_config_root.rs +++ b/src/reportal_config/reportal_config_root.rs @@ -3,6 +3,7 @@ use crate::error::ReportalError; use crate::reportal_config::ai_tool_entry::AiToolEntry; +use crate::reportal_config::command_entry::CommandEntry; use crate::reportal_config::global_settings::{PathDisplayFormat, PathVisibility, ReportalSettings}; use crate::reportal_config::repo_entry::RepoEntry; use crate::reportal_config::tag_filter::TagFilter; @@ -29,6 +30,9 @@ pub struct ReportalConfig { /// 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, } /// Loading, saving, querying, and mutating the RePortal config file. @@ -201,6 +205,18 @@ impl ReportalConfig { }) } + /// Returns all globally registered commands with their names. + pub fn global_commands(&self) -> &BTreeMap { + &self.commands + } + + /// Looks up a global command by name. Returns `CommandNotFound` if missing. + pub fn get_global_command(&self, command_name: &str) -> Result<&CommandEntry, ReportalError> { + self.commands.get(command_name).ok_or_else(|| ReportalError::CommandNotFound { + command_name: command_name.to_string(), + }) + } + /// Updates the default editor command in settings. pub fn set_default_editor(&mut self, editor_command: String) { self.settings.default_editor = editor_command; @@ -232,6 +248,7 @@ impl ReportalConfig { default_ai_tool: "aider".to_string(), }, repos: BTreeMap::new(), + commands: BTreeMap::new(), ai_tools: BTreeMap::from([ ("aider".to_string(), AiToolEntry::with_executable("aider".to_string())), ("aider".to_string(), AiToolEntry::with_executable("aider".to_string())), From 11783f8ca2fe15e7ed5dfb6b47107f489ff3d1f3 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Fri, 27 Mar 2026 14:29:47 -0400 Subject: [PATCH 26/67] Add rr shell shortcut for rep run to integration scripts --- src/reportal_commands/initialization.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/reportal_commands/initialization.rs b/src/reportal_commands/initialization.rs index 9649964..f54c2fb 100644 --- a/src/reportal_commands/initialization.rs +++ b/src/reportal_commands/initialization.rs @@ -32,6 +32,7 @@ $env:REPORTAL_LOADED = "1" function rj {{ Set-Location (rep jump @args); rep color }} function ro {{ rep open @args; rep color }} function rw {{ rep web @args }} +function rr {{ rep run @args }} $_reportal_original_prompt = $function:prompt function prompt {{ $p = & $_reportal_original_prompt; $t = rep color --print-title 2>$null; if ($t) {{ $Host.UI.RawUI.WindowTitle = $t }}; $p }} "#, @@ -51,6 +52,7 @@ export REPORTAL_LOADED=1 rj() {{ cd "$(rep jump "$@")"; rep color; }} ro() {{ rep open "$@"; rep color; }} rw() {{ rep web "$@"; }} +rr() {{ rep run "$@"; }} _reportal_hook() {{ local _t; _t=$(rep color --print-title 2>{null_device}); [ -n "$_t" ] && printf '\033]2;%s\007' "$_t"; }} PROMPT_COMMAND="${{PROMPT_COMMAND:+$PROMPT_COMMAND;}}_reportal_hook" "#, @@ -183,18 +185,21 @@ fn strip_existing_integration(profile_content: &str) -> String { let is_reportal_line = trimmed.starts_with("function rj") || trimmed.starts_with("function ro") || trimmed.starts_with("function rw") + || trimmed.starts_with("function rr") || trimmed.starts_with("function repup") || trimmed.starts_with("function prompt") || trimmed.starts_with("$_reportal_") || trimmed.starts_with("rj()") || trimmed.starts_with("ro()") || trimmed.starts_with("rw()") + || trimmed.starts_with("rr()") || trimmed.starts_with("repup()") || trimmed.starts_with("PROMPT_COMMAND") || trimmed.contains("REPORTAL_LOADED") || trimmed.contains("rep jump") || trimmed.contains("rep open") || trimmed.contains("rep web") + || trimmed.contains("rep run") || trimmed.contains("rep upgrade") || trimmed.contains("rep color"); match is_reportal_line { @@ -316,6 +321,7 @@ fn install_or_update_shell_integration() -> Result<(), ReportalError> { 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!(" {} open a repo in the browser", "rw".style(terminal_style::ALIAS_STYLE)); + println!(" {} run a configured command in a repo", "rr".style(terminal_style::ALIAS_STYLE)); println!(" {} per-repo tab title + background color on every prompt", "color".style(terminal_style::ALIAS_STYLE)); println!(); From 5fed036d4e453a39714c360262f7145a2c6ac990 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Fri, 27 Mar 2026 14:49:37 -0400 Subject: [PATCH 27/67] Unify repo commands to use same CommandEntry table format as global commands --- README.md | 15 +++++++-------- src/reportal_commands/repo_run.rs | 18 ++++++++---------- src/reportal_config/repo_entry.rs | 9 +++++---- 3 files changed, 20 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index f0848f8..25704be 100644 --- a/README.md +++ b/README.md @@ -229,17 +229,16 @@ description = "Production build" | `command` | yes | — | The shell command to execute | | `description` | no | `""` | Shown in the fuzzy picker alongside the command name | -Per-repo command overrides can be added inline to any `[repos.*]` section. These override global commands with the same name, or add repo-specific commands: +Per-repo commands use the same format and can override global commands with the same name, or add repo-specific ones: ```toml -[repos.my-api] -path = "~/dev/my-api" -description = "Django backend" -tags = ["work"] +[repos.my-api.commands.serve] +command = "python manage.py runserver" +description = "Start Django dev server" -[repos.my-api.commands] -serve = "python manage.py runserver" -migrate = "python manage.py migrate" +[repos.my-api.commands.migrate] +command = "python manage.py migrate" +description = "Run database migrations" ``` ## Terminal personalization diff --git a/src/reportal_commands/repo_run.rs b/src/reportal_commands/repo_run.rs index 19fa0d6..f5960d7 100644 --- a/src/reportal_commands/repo_run.rs +++ b/src/reportal_commands/repo_run.rs @@ -38,18 +38,16 @@ struct MergedCommandEntry<'a> { struct ResolveCommandParams<'a> { /// Global commands from config. global_commands: &'a BTreeMap, - /// Per-repo command overrides. - repo_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 overrides, then resolves +/// 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. -/// Repo-level commands are flat `key = "command string"` (no description), -/// so when a repo overrides a global command, the description is lost. fn resolve_command(resolve_params: ResolveCommandParams<'_>) -> Result { let mut merged: Vec> = Vec::new(); @@ -58,8 +56,8 @@ fn resolve_command(resolve_params: ResolveCommandParams<'_>) -> Result { merged.push(MergedCommandEntry { name, - shell_command: repo_override, - description: "", + shell_command: repo_override.shell_command(), + description: repo_override.description(), }); } None => { @@ -72,15 +70,15 @@ fn resolve_command(resolve_params: ResolveCommandParams<'_>) -> Result {} false => { merged.push(MergedCommandEntry { name, - shell_command, - description: "", + shell_command: entry.shell_command(), + description: entry.description(), }); } } diff --git a/src/reportal_config/repo_entry.rs b/src/reportal_config/repo_entry.rs index 03cf147..2f255b8 100644 --- a/src/reportal_config/repo_entry.rs +++ b/src/reportal_config/repo_entry.rs @@ -2,6 +2,7 @@ /// `TabTitle`, and `RepoColor`. use crate::error::ReportalError; +use crate::reportal_config::command_entry::CommandEntry; use crate::reportal_config::hex_color::HexColor; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -89,9 +90,9 @@ pub struct RepoEntry { /// Terminal background color applied via OSC 11 when jumping to this repo. #[serde(default)] color: RepoColor, - /// Per-repo command overrides: key is command name, value is shell command string. + /// Per-repo commands: same table format as global commands. #[serde(default)] - commands: BTreeMap, + commands: BTreeMap, } /// Accessors and path resolution for a registered repository entry. @@ -137,8 +138,8 @@ impl RepoEntry { &self.color } - /// Per-repo command overrides (command name → shell command string). - pub fn repo_commands(&self) -> &BTreeMap { + /// Per-repo commands (same format as global commands). + pub fn repo_commands(&self) -> &BTreeMap { &self.commands } From 08c52ef89321bf29643f0fa9bc569e19844689c6 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Fri, 27 Mar 2026 14:59:21 -0400 Subject: [PATCH 28/67] Use inline table format for command config examples in README --- README.md | 27 ++++++++------------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 25704be..21d4d7b 100644 --- a/README.md +++ b/README.md @@ -211,17 +211,10 @@ args = [] Define reusable commands that can be run in any repo via `rep run`: ```toml -[commands.test] -command = "cargo test" -description = "Run tests" - -[commands.serve] -command = "npm run dev" -description = "Start dev server" - -[commands.build] -command = "cargo build --release" -description = "Production build" +[commands] +test = { command = "cargo test", description = "Run tests" } +serve = { command = "npm run dev", description = "Start dev server" } +build = { command = "cargo build --release", description = "Production build" } ``` | Field | Required | Default | What it controls | @@ -229,16 +222,12 @@ description = "Production build" | `command` | yes | — | The shell command to execute | | `description` | no | `""` | Shown in the fuzzy picker alongside the command name | -Per-repo commands use the same format and can override global commands with the same name, or add repo-specific ones: +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" - -[repos.my-api.commands.migrate] -command = "python manage.py migrate" -description = "Run database migrations" +[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 From 7c103114320e96face365d26f98977fc15c1c501 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Sun, 29 Mar 2026 14:22:00 -0400 Subject: [PATCH 29/67] Add fuzzy selection to rep edit when no alias provided (v0.8.1) rep edit with no argument now shows the fuzzy finder instead of erroring with "Repo '' not found". Also adds --tag filter support. --- Cargo.toml | 2 +- README.md | 2 +- src/main.rs | 13 ++++--- src/reportal_commands/mod.rs | 2 +- src/reportal_commands/repo_edit.rs | 58 ++++++++++++++++++++---------- 5 files changed, 51 insertions(+), 26 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3c9298f..a53872c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.8.0" +version = "0.8.1" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/README.md b/README.md index 21d4d7b..0d813db 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ ro | `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 and interactively edit its description, tags, title, and color | | `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) | diff --git a/src/main.rs b/src/main.rs index 9110f36..625917f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,7 @@ mod reportal_config; mod terminal_style; use clap::{Args, Parser, Subcommand}; -use reportal_commands::{AiCommandParams, ColorCommandParams, JumpCommandParams, OpenCommandParams, RunCommandParams, TitleOutput, WebCommandParams}; +use reportal_commands::{AiCommandParams, ColorCommandParams, EditCommandParams, JumpCommandParams, OpenCommandParams, RunCommandParams, TitleOutput, WebCommandParams}; use reportal_config::TagFilter; /// Shared --tag flag used by commands that filter repos. @@ -72,8 +72,8 @@ struct OpenArgs { /// CLI args for `rep edit`. #[derive(Args)] struct EditArgs { - /// Alias of the repo to edit (skip top-level menu) - alias: Option, + #[command(flatten)] + selection: RepoSelectionArgs, } /// CLI args for `rep remove`. @@ -238,11 +238,14 @@ fn main() { reportal_commands::run_add(&repo_path) } ReportalSubcommand::Edit(edit_args) => { - let direct_alias = match edit_args.alias { + let direct_alias = match edit_args.selection.alias { Some(ref provided_alias) => provided_alias.as_str(), None => "", }; - reportal_commands::run_edit(direct_alias) + reportal_commands::run_edit(EditCommandParams { + tag_filter: edit_args.selection.tag_filter.into_tag_filter(), + direct_alias, + }) } ReportalSubcommand::Remove(remove_args) => { reportal_commands::run_remove(&remove_args.alias) diff --git a/src/reportal_commands/mod.rs b/src/reportal_commands/mod.rs index 010e8f4..4e25cc3 100644 --- a/src/reportal_commands/mod.rs +++ b/src/reportal_commands/mod.rs @@ -23,7 +23,7 @@ pub use initialization::ensure_integration_file_current; pub use initialization::run_init; pub use repo_add::run_add; pub use repo_ai::{run_ai, AiCommandParams}; -pub use repo_edit::run_edit; +pub use repo_edit::{run_edit, EditCommandParams}; pub use repo_color::{run_color, ColorCommandParams, TitleOutput}; pub use repo_jump::{run_jump, JumpCommandParams}; pub use repo_listing::run_list; diff --git a/src/reportal_commands/repo_edit.rs b/src/reportal_commands/repo_edit.rs index f0922ce..8b8760f 100644 --- a/src/reportal_commands/repo_edit.rs +++ b/src/reportal_commands/repo_edit.rs @@ -4,39 +4,61 @@ use crate::error::ReportalError; use crate::reportal_commands::prompts::{ self, ColorEditResult, ColorEditPromptParams, TextPromptParams, }; -use crate::reportal_config::{RepoColor, ReportalConfig, TabTitle}; +use crate::reportal_commands::repo_selection::{self, RepoSelectionParams}; +use crate::reportal_config::{RepoColor, ReportalConfig, TabTitle, TagFilter}; use crate::terminal_style; use dialoguer::theme::ColorfulTheme; use owo_colors::OwoColorize; +/// All parameters needed to run the edit command. +pub struct EditCommandParams<'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, +} + /// 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> { +/// and color. Resolves the target repo via fuzzy selection or direct +/// alias lookup, 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(command_params: EditCommandParams<'_>) -> Result<(), ReportalError> { let mut loaded_config = ReportalConfig::load_from_disk()?; - let existing_repo = loaded_config.get_repo(alias)?; - 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(), - TabTitle::UseAlias => String::new(), - }; - let current_color = match existing_repo.repo_color() { - RepoColor::Themed(hex_color) => hex_color.raw_value().to_string(), - RepoColor::ResetToDefault => String::new(), + let (selected_alias, current_description, current_tags, current_title, current_color, current_repo_path) = { + let selection = repo_selection::select_repo(RepoSelectionParams { + loaded_config: &loaded_config, + direct_alias: command_params.direct_alias, + tag_filter: &command_params.tag_filter, + prompt_label: "Edit repo", + })?; + + let alias = selection.repo_alias().to_string(); + let description = selection.repo_config().description().to_string(); + let tags = selection.repo_config().tags().join(", "); + let title = match selection.repo_config().tab_title() { + TabTitle::Custom(custom_title) => custom_title.to_string(), + TabTitle::UseAlias => String::new(), + }; + let color = match selection.repo_config().repo_color() { + RepoColor::Themed(hex_color) => hex_color.raw_value().to_string(), + RepoColor::ResetToDefault => String::new(), + }; + let repo_path = selection.repo_config().raw_path().to_string(); + + (alias, description, tags, title, color, repo_path) }; println!(); println!( " {} {}", "Editing:".style(terminal_style::LABEL_STYLE), - alias.style(terminal_style::ALIAS_STYLE), + selected_alias.style(terminal_style::ALIAS_STYLE), ); println!( " {} {}", "Path:".style(terminal_style::LABEL_STYLE), - existing_repo.raw_path().style(terminal_style::PATH_STYLE), + current_repo_path.style(terminal_style::PATH_STYLE), ); println!(); @@ -78,14 +100,14 @@ pub fn run_edit(alias: &str) -> Result<(), ReportalError> { ColorEditResult::Cleared => RepoColor::ResetToDefault, }; - let repo_to_update = loaded_config.get_repo_mut(alias)?; + let repo_to_update = loaded_config.get_repo_mut(&selected_alias)?; repo_to_update.set_description(new_description); repo_to_update.set_tags(new_tags); repo_to_update.set_tab_title(resolved_title); repo_to_update.set_repo_color(resolved_color); loaded_config.save_to_disk()?; - terminal_style::print_success(&format!("Updated '{alias}'")); + terminal_style::print_success(&format!("Updated '{selected_alias}'")); return Ok(()); } From 7d567800c1d316f5070147c35e60bc4064bb7285 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Sun, 29 Mar 2026 15:34:09 -0400 Subject: [PATCH 30/67] Split edit roadmap: rep edit for repos, rep config for AI tools and settings --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0d813db..873f9a1 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,8 @@ PROMPT_COMMAND='rep color 2>/dev/null' - [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 -- [ ] `edit` UX overhaul — fuzzy-select what to edit (repos, AI tools, global settings), field menu +- [ ] `edit` UX overhaul — field menu (pick Description/Tags/Title/Color individually, loop back) +- [ ] `config` — manage AI tools and global settings (`rep config ai-tools`, `rep config settings`) - [ ] `dashboard` — rich overview with branches, dirty state, last commit - [ ] `clone --all` — clone missing repos from config (machine sync) - [ ] Shell completions From b897317667c9d5671123e1a7844229d277bc9cce Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Sun, 29 Mar 2026 15:52:46 -0400 Subject: [PATCH 31/67] Add edit field menu, split prompts and repo_add into modules (v0.9.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rep edit now shows a looping field menu after selecting a repo — pick Description, Tags, Title, or Color individually, each saves immediately. TextPromptParams.default_value changed from String to &str to eliminate clone abuse. Split prompts.rs and repo_add.rs into directory modules with one type per file. --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 4 +- src/reportal_commands/prompts.rs | 124 ----- .../prompts/color_edit_prompt.rs | 46 ++ .../prompts/color_edit_result.rs | 13 + src/reportal_commands/prompts/color_prompt.rs | 38 ++ src/reportal_commands/prompts/mod.rs | 10 + src/reportal_commands/prompts/text_prompt.rs | 42 ++ src/reportal_commands/repo_add.rs | 458 ------------------ src/reportal_commands/repo_add/add_source.rs | 22 + .../repo_add/alias_suggestion.rs | 40 ++ .../repo_add/clone_destination.rs | 108 +++++ .../repo_add/clone_placement.rs | 37 ++ .../repo_add/git_clone_operation.rs | 45 ++ .../repo_add/git_remote_detection.rs | 39 ++ src/reportal_commands/repo_add/mod.rs | 12 + .../repo_add/registration_context.rs | 115 +++++ src/reportal_commands/repo_add/run.rs | 101 ++++ src/reportal_commands/repo_edit.rs | 185 ++++--- 20 files changed, 793 insertions(+), 650 deletions(-) delete mode 100644 src/reportal_commands/prompts.rs create mode 100644 src/reportal_commands/prompts/color_edit_prompt.rs create mode 100644 src/reportal_commands/prompts/color_edit_result.rs create mode 100644 src/reportal_commands/prompts/color_prompt.rs create mode 100644 src/reportal_commands/prompts/mod.rs create mode 100644 src/reportal_commands/prompts/text_prompt.rs delete mode 100644 src/reportal_commands/repo_add.rs create mode 100644 src/reportal_commands/repo_add/add_source.rs create mode 100644 src/reportal_commands/repo_add/alias_suggestion.rs create mode 100644 src/reportal_commands/repo_add/clone_destination.rs create mode 100644 src/reportal_commands/repo_add/clone_placement.rs create mode 100644 src/reportal_commands/repo_add/git_clone_operation.rs create mode 100644 src/reportal_commands/repo_add/git_remote_detection.rs create mode 100644 src/reportal_commands/repo_add/mod.rs create mode 100644 src/reportal_commands/repo_add/registration_context.rs create mode 100644 src/reportal_commands/repo_add/run.rs diff --git a/Cargo.lock b/Cargo.lock index e9b93e8..20344d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -550,7 +550,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.8.0" +version = "0.8.1" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index a53872c..4e2ed0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.8.1" +version = "0.9.0" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/README.md b/README.md index 873f9a1..20b6e3c 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ ro | `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` | `rep e` | Fuzzy-select a repo and interactively edit its 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) | @@ -273,7 +273,7 @@ PROMPT_COMMAND='rep color 2>/dev/null' - [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 -- [ ] `edit` UX overhaul — field menu (pick Description/Tags/Title/Color individually, loop back) +- [x] `edit` UX overhaul — field menu (pick Description/Tags/Title/Color individually, loop back) - [ ] `config` — manage AI tools and global settings (`rep config ai-tools`, `rep config settings`) - [ ] `dashboard` — rich overview with branches, dirty state, last commit - [ ] `clone --all` — clone missing repos from config (machine sync) diff --git a/src/reportal_commands/prompts.rs b/src/reportal_commands/prompts.rs deleted file mode 100644 index 7fcf4b1..0000000 --- a/src/reportal_commands/prompts.rs +++ /dev/null @@ -1,124 +0,0 @@ -/// Shared interactive prompt helpers for add and edit commands. - -use crate::error::ReportalError; -use crate::reportal_config::HexColor; -use crate::terminal_style; -use dialoguer::{theme::ColorfulTheme, Input}; - -/// Parameters for a text input prompt with a label and default value. -pub struct TextPromptParams<'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. - pub default_value: String, -} - -/// 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: TextPromptParams<'_>) -> 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) - .interact_text() - .map_err(|prompt_error| ReportalError::ConfigIoFailure { - reason: prompt_error.to_string(), - })?; - - return 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 { - return raw_input - .split(',') - .map(|tag_segment| tag_segment.trim().to_string()) - .filter(|trimmed_tag| !trimmed_tag.is_empty()) - .collect(); -} - -/// 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(), - })?; - - 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()); - } - }, - } - } -} - -/// 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), -} - -/// Parameters for the color edit prompt that needs the current value as default. -pub struct ColorEditPromptParams<'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: ColorEditPromptParams<'_>) -> 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_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(color_edit_params.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()); - } - }, - } - } -} 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..ebf31ba --- /dev/null +++ b/src/reportal_commands/prompts/color_edit_prompt.rs @@ -0,0 +1,46 @@ +//! 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 ColorEditPromptParams<'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: ColorEditPromptParams<'_>) -> 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_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(color_edit_params.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()); + } + }, + } + } +} 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..680a83f --- /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(), + })?; + + 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()); + } + }, + } + } +} diff --git a/src/reportal_commands/prompts/mod.rs b/src/reportal_commands/prompts/mod.rs new file mode 100644 index 0000000..2964455 --- /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, ColorEditPromptParams}; +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, TextPromptParams}; diff --git a/src/reportal_commands/prompts/text_prompt.rs b/src/reportal_commands/prompts/text_prompt.rs new file mode 100644 index 0000000..cfeb732 --- /dev/null +++ b/src/reportal_commands/prompts/text_prompt.rs @@ -0,0 +1,42 @@ +//! 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 TextPromptParams<'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: TextPromptParams<'_>) -> 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_string()) + .interact_text() + .map_err(|prompt_error| ReportalError::ConfigIoFailure { + reason: prompt_error.to_string(), + })?; + + return 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 { + return raw_input + .split(',') + .map(|tag_segment| tag_segment.trim().to_string()) + .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 f9ad19d..0000000 --- a/src/reportal_commands/repo_add.rs +++ /dev/null @@ -1,458 +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_commands::prompts::{ - self, ColorPromptResult, TextPromptParams, -}; -use crate::reportal_config::{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, -} - -/// 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 = prompts::prompt_for_text(TextPromptParams { - prompt_theme: &prompt_theme, - label: "Alias", - default_value: registration_context.suggested_alias, - })?; - - let repo_description = prompts::prompt_for_text(TextPromptParams { - prompt_theme: &prompt_theme, - label: "Description", - default_value: String::new(), - })?; - - let tags_input = prompts::prompt_for_text(TextPromptParams { - prompt_theme: &prompt_theme, - label: "Tags (comma-separated)", - default_value: String::new(), - })?; - - let parsed_tags = prompts::parse_comma_separated_tags(&tags_input); - - let repo_remote = prompts::prompt_for_text(TextPromptParams { - prompt_theme: &prompt_theme, - label: "Remote URL", - default_value: registration_context.detected_remote, - })?; - - let tab_title = prompts::prompt_for_text(TextPromptParams { - prompt_theme: &prompt_theme, - label: "Tab title (empty = use alias)", - default_value: String::new(), - })?; - - let repo_color = prompts::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..1d38429 --- /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.ends_with(".git") + { + AddSource::GitUrl(raw_input.to_string()) + } else { + AddSource::LocalPath(raw_input.to_string()) + } +} 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..fed5d43 --- /dev/null +++ b/src/reportal_commands/repo_add/alias_suggestion.rs @@ -0,0 +1,40 @@ +//! 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 { + 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. +pub 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, + } +} 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..29b0157 --- /dev/null +++ b/src/reportal_commands/repo_add/clone_destination.rs @@ -0,0 +1,108 @@ +//! Clone placement strategy — where to put a cloned repo relative to existing repos. + +use crate::error::ReportalError; +use crate::reportal_config::ReportalConfig; +use dialoguer::{theme::ColorfulTheme, 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 = ColorfulTheme::default(); + + let sibling_directories = clone_placement::collect_registered_parent_directories(loaded_config); + let child_directories = clone_placement::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), + } +} 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..1fd1a99 --- /dev/null +++ b/src/reportal_commands/repo_add/clone_placement.rs @@ -0,0 +1,37 @@ +//! 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<(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. +pub 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() +} 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..e6be345 --- /dev/null +++ b/src/reportal_commands/repo_add/git_clone_operation.rs @@ -0,0 +1,45 @@ +//! 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<'a> GitCloneOperation<'a> { + /// 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> { + println!( + " {} {}", + "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) => 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), + }), + } + } +} 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..2f504ac --- /dev/null +++ b/src/reportal_commands/repo_add/git_remote_detection.rs @@ -0,0 +1,39 @@ +//! Detects git remote URLs from local repo directories. + +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) => 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 + } + } +} diff --git a/src/reportal_commands/repo_add/mod.rs b/src/reportal_commands/repo_add/mod.rs new file mode 100644 index 0000000..67f9810 --- /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..a87f87f --- /dev/null +++ b/src/reportal_commands/repo_add/registration_context.rs @@ -0,0 +1,115 @@ +//! Interactive metadata collection and repo registration flow. + +use crate::error::ReportalError; +use crate::reportal_commands::prompts::{ + self, ColorPromptResult, TextPromptParams, +}; +use crate::reportal_config::{RepoRegistrationBuilder, ReportalConfig}; +use crate::terminal_style; +use dialoguer::theme::ColorfulTheme; +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<'a> RegistrationContext<'a> { + /// 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 = ColorfulTheme::default(); + + let repo_alias = prompts::prompt_for_text(TextPromptParams { + prompt_theme: &prompt_theme, + label: "Alias", + default_value: &self.suggested_alias, + })?; + + let repo_description = prompts::prompt_for_text(TextPromptParams { + prompt_theme: &prompt_theme, + label: "Description", + default_value: "", + })?; + + let tags_input = prompts::prompt_for_text(TextPromptParams { + 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(TextPromptParams { + prompt_theme: &prompt_theme, + label: "Remote URL", + default_value: &self.detected_remote, + })?; + + let tab_title = prompts::prompt_for_text(TextPromptParams { + prompt_theme: &prompt_theme, + label: "Tab title (empty = use alias)", + default_value: "", + })?; + + let repo_color = prompts::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), self.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(self.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()?; + + 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..66fe7c7 --- /dev/null +++ b/src/reportal_commands/repo_add/run.rs @@ -0,0 +1,101 @@ +//! Entry point for the `rep add` command. + +use crate::error::ReportalError; +use crate::reportal_config::ReportalConfig; +use crate::terminal_style; +use dialoguer::{theme::ColorfulTheme, 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; + +/// 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_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(), + }; + let registration = RegistrationContext { + loaded_config: &mut loaded_config, + filesystem_path: &local_path, + suggested_alias, + detected_remote, + }; + registration.collect_metadata_and_register() + } + 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(), + } + })?; + } + + let clone_operation = GitCloneOperation { + git_url: &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: &mut loaded_config, + filesystem_path: &final_repo_path, + suggested_alias, + detected_remote: git_url, + }; + registration.collect_metadata_and_register() + } + } +} diff --git a/src/reportal_commands/repo_edit.rs b/src/reportal_commands/repo_edit.rs index 8b8760f..eb29f1f 100644 --- a/src/reportal_commands/repo_edit.rs +++ b/src/reportal_commands/repo_edit.rs @@ -1,13 +1,14 @@ -//! 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_commands::prompts::{ - self, ColorEditResult, ColorEditPromptParams, TextPromptParams, + self, ColorEditPromptParams, ColorEditResult, TextPromptParams, }; use crate::reportal_commands::repo_selection::{self, RepoSelectionParams}; use crate::reportal_config::{RepoColor, ReportalConfig, TabTitle, TagFilter}; use crate::terminal_style; use dialoguer::theme::ColorfulTheme; +use dialoguer::Select; use owo_colors::OwoColorize; /// All parameters needed to run the edit command. @@ -18,37 +19,33 @@ pub struct EditCommandParams<'a> { pub direct_alias: &'a str, } -/// Interactively edits a registered repo's description, tags, title, -/// and color. Resolves the target repo via fuzzy selection or direct -/// alias lookup, 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. +/// Truncates a string to a reasonable menu-display length, returning +/// the first 50 characters if the input exceeds that limit. +fn truncate_for_menu(display_text: &str) -> &str { + match display_text.len() > 50 { + true => &display_text[..50], + false => display_text, + } +} + +/// 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. pub fn run_edit(command_params: EditCommandParams<'_>) -> Result<(), ReportalError> { let mut loaded_config = ReportalConfig::load_from_disk()?; - let (selected_alias, current_description, current_tags, current_title, current_color, current_repo_path) = { + let selected_alias = { let selection = repo_selection::select_repo(RepoSelectionParams { loaded_config: &loaded_config, direct_alias: command_params.direct_alias, tag_filter: &command_params.tag_filter, prompt_label: "Edit repo", })?; - - let alias = selection.repo_alias().to_string(); - let description = selection.repo_config().description().to_string(); - let tags = selection.repo_config().tags().join(", "); - let title = match selection.repo_config().tab_title() { - TabTitle::Custom(custom_title) => custom_title.to_string(), - TabTitle::UseAlias => String::new(), - }; - let color = match selection.repo_config().repo_color() { - RepoColor::Themed(hex_color) => hex_color.raw_value().to_string(), - RepoColor::ResetToDefault => String::new(), - }; - let repo_path = selection.repo_config().raw_path().to_string(); - - (alias, description, tags, title, color, repo_path) + selection.repo_alias().to_string() }; + let repo_path_display = loaded_config.get_repo(&selected_alias)?.raw_path().to_string(); + println!(); println!( " {} {}", @@ -58,56 +55,116 @@ pub fn run_edit(command_params: EditCommandParams<'_>) -> Result<(), ReportalErr println!( " {} {}", "Path:".style(terminal_style::LABEL_STYLE), - current_repo_path.style(terminal_style::PATH_STYLE), + repo_path_display.style(terminal_style::PATH_STYLE), ); - println!(); let prompt_theme = ColorfulTheme::default(); - let new_description = prompts::prompt_for_text(TextPromptParams { - prompt_theme: &prompt_theme, - label: "Description", - default_value: current_description, - })?; - - let tags_input = prompts::prompt_for_text(TextPromptParams { - prompt_theme: &prompt_theme, - label: "Tags (comma-separated)", - default_value: current_tags, - })?; - - let new_tags = prompts::parse_comma_separated_tags(&tags_input); - - let new_title = prompts::prompt_for_text(TextPromptParams { - prompt_theme: &prompt_theme, - label: "Tab title (empty = use alias)", - default_value: current_title, - })?; - - let color_edit_result = prompts::prompt_for_color_edit(ColorEditPromptParams { - prompt_theme: &prompt_theme, - current_default: ¤t_color, - })?; - - let resolved_title = match new_title.is_empty() { - true => TabTitle::UseAlias, - false => TabTitle::Custom(new_title), + let repo_entry = loaded_config.get_repo(&selected_alias)?; + let mut current_description = repo_entry.description().to_string(); + let mut current_tags_csv = repo_entry.tags().join(", "); + let mut current_title = match repo_entry.tab_title() { + TabTitle::Custom(custom_title) => custom_title.to_string(), + TabTitle::UseAlias => String::new(), }; - - let resolved_color = match color_edit_result { - ColorEditResult::Provided(hex_color) => RepoColor::Themed(hex_color), - ColorEditResult::Unchanged(hex_color) => RepoColor::Themed(hex_color), - ColorEditResult::Cleared => RepoColor::ResetToDefault, + let mut current_color_hex = match repo_entry.repo_color() { + RepoColor::Themed(hex_color) => hex_color.raw_value().to_string(), + RepoColor::ResetToDefault => String::new(), }; - let repo_to_update = loaded_config.get_repo_mut(&selected_alias)?; - repo_to_update.set_description(new_description); - repo_to_update.set_tags(new_tags); - repo_to_update.set_tab_title(resolved_title); - repo_to_update.set_repo_color(resolved_color); - loaded_config.save_to_disk()?; + loop { + println!(); - terminal_style::print_success(&format!("Updated '{selected_alias}'")); + let description_label = format!("Description: {}", truncate_for_menu(¤t_description)); + let tags_label = match current_tags_csv.is_empty() { + true => "Tags: (none)".to_string(), + false => format!("Tags: {}", truncate_for_menu(¤t_tags_csv)), + }; + let title_label = match current_title.is_empty() { + true => "Title: (use alias)".to_string(), + false => format!("Title: {}", current_title), + }; + let color_label = match current_color_hex.is_empty() { + true => "Color: (none)".to_string(), + false => format!("Color: {}", current_color_hex), + }; + let menu_labels = vec![description_label, tags_label, title_label, color_label, "Done".to_string()]; + + 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 => break, + Some(4..) => break, + Some(0) => { + let new_description = prompts::prompt_for_text(TextPromptParams { + prompt_theme: &prompt_theme, + label: "Description", + default_value: ¤t_description, + })?; + let repo_to_update = loaded_config.get_repo_mut(&selected_alias)?; + repo_to_update.set_description(new_description); + loaded_config.save_to_disk()?; + current_description = loaded_config.get_repo(&selected_alias)?.description().to_string(); + } + Some(1) => { + let tags_input = prompts::prompt_for_text(TextPromptParams { + prompt_theme: &prompt_theme, + label: "Tags (comma-separated)", + default_value: ¤t_tags_csv, + })?; + let new_tags = prompts::parse_comma_separated_tags(&tags_input); + let repo_to_update = loaded_config.get_repo_mut(&selected_alias)?; + repo_to_update.set_tags(new_tags); + loaded_config.save_to_disk()?; + current_tags_csv = loaded_config.get_repo(&selected_alias)?.tags().join(", "); + } + Some(2) => { + let new_title = prompts::prompt_for_text(TextPromptParams { + prompt_theme: &prompt_theme, + label: "Tab title (empty = use alias)", + default_value: ¤t_title, + })?; + let resolved_title = match new_title.is_empty() { + true => TabTitle::UseAlias, + false => TabTitle::Custom(new_title), + }; + let repo_to_update = loaded_config.get_repo_mut(&selected_alias)?; + repo_to_update.set_tab_title(resolved_title); + loaded_config.save_to_disk()?; + current_title = match loaded_config.get_repo(&selected_alias)?.tab_title() { + TabTitle::Custom(custom_title) => custom_title.to_string(), + TabTitle::UseAlias => String::new(), + }; + } + Some(3) => { + let color_edit_result = prompts::prompt_for_color_edit(ColorEditPromptParams { + prompt_theme: &prompt_theme, + current_default: ¤t_color_hex, + })?; + let resolved_color = match color_edit_result { + ColorEditResult::Provided(hex_color) => RepoColor::Themed(hex_color), + ColorEditResult::Unchanged(hex_color) => RepoColor::Themed(hex_color), + ColorEditResult::Cleared => RepoColor::ResetToDefault, + }; + let repo_to_update = loaded_config.get_repo_mut(&selected_alias)?; + repo_to_update.set_repo_color(resolved_color); + loaded_config.save_to_disk()?; + current_color_hex = match loaded_config.get_repo(&selected_alias)?.repo_color() { + RepoColor::Themed(hex_color) => hex_color.raw_value().to_string(), + RepoColor::ResetToDefault => String::new(), + }; + } + } + + terminal_style::print_success("Saved"); + } return Ok(()); } From b26e0675590efedec31dc57ee8021fa79ca38091 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Mon, 30 Mar 2026 12:05:10 -0400 Subject: [PATCH 32/67] Add color swatches to fuzzy finder, split terminal identity emit (v0.9.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fuzzy selection across jump, open, edit, ai, web, and run now shows colored ██ swatches next to each repo entry matching the repo's configured color. Repos with no color get a gray default swatch. Extracted terminal_identity_emit.rs from repo_selection.rs to satisfy single-type-per-file constraint. Renamed RepoSelectionParams to SelectedRepoParams for companion naming. --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 +- src/reportal_commands/mod.rs | 1 + src/reportal_commands/repo_ai.rs | 9 +-- src/reportal_commands/repo_edit.rs | 4 +- src/reportal_commands/repo_jump.rs | 9 +-- src/reportal_commands/repo_open.rs | 9 +-- src/reportal_commands/repo_run.rs | 9 +-- src/reportal_commands/repo_selection.rs | 67 +++++-------------- src/reportal_commands/repo_web.rs | 9 +-- .../terminal_identity_emit.rs | 42 ++++++++++++ 12 files changed, 90 insertions(+), 75 deletions(-) create mode 100644 src/reportal_commands/terminal_identity_emit.rs diff --git a/Cargo.lock b/Cargo.lock index 20344d0..1cddac8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -550,7 +550,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.8.1" +version = "0.9.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 4e2ed0c..0ee35c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.9.0" +version = "0.9.1" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/README.md b/README.md index 20b6e3c..a44682f 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,7 @@ 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 (repo color swatches in `rep list`, aliases in 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) diff --git a/src/reportal_commands/mod.rs b/src/reportal_commands/mod.rs index 4e25cc3..c54c894 100644 --- a/src/reportal_commands/mod.rs +++ b/src/reportal_commands/mod.rs @@ -15,6 +15,7 @@ mod repo_remove; mod repo_run; mod repo_selection; mod repo_status; +mod terminal_identity_emit; mod repo_sync; mod repo_web; diff --git a/src/reportal_commands/repo_ai.rs b/src/reportal_commands/repo_ai.rs index c3309bf..239ce92 100644 --- a/src/reportal_commands/repo_ai.rs +++ b/src/reportal_commands/repo_ai.rs @@ -3,8 +3,9 @@ use crate::error::ReportalError; use crate::reportal_config::{ReportalConfig, TagFilter}; use crate::terminal_style; -use crate::reportal_commands::repo_selection::{ - self, RepoSelectionParams, TerminalIdentityEmitParams, +use crate::reportal_commands::repo_selection::{self, SelectedRepoParams}; +use crate::reportal_commands::terminal_identity_emit::{ + self, TerminalIdentityEmitParams, }; use owo_colors::OwoColorize; use std::process::Command; @@ -46,14 +47,14 @@ pub fn run_ai(ai_params: AiCommandParams<'_>) -> Result<(), ReportalError> { let ai_tool = loaded_config.get_ai_tool(tool_name)?; let prompt_label = format!("Launch {tool_name} in"); - let selected = repo_selection::select_repo(RepoSelectionParams { + let selected = repo_selection::select_repo(SelectedRepoParams { loaded_config: &loaded_config, direct_alias: ai_params.direct_alias, tag_filter: &ai_params.tag_filter, prompt_label: &prompt_label, })?; - repo_selection::emit_repo_terminal_identity(TerminalIdentityEmitParams { + terminal_identity_emit::emit_repo_terminal_identity(TerminalIdentityEmitParams { selected_alias: selected.repo_alias(), selected_repo: selected.repo_config(), title_override: "", diff --git a/src/reportal_commands/repo_edit.rs b/src/reportal_commands/repo_edit.rs index eb29f1f..4b24f5e 100644 --- a/src/reportal_commands/repo_edit.rs +++ b/src/reportal_commands/repo_edit.rs @@ -4,7 +4,7 @@ use crate::error::ReportalError; use crate::reportal_commands::prompts::{ self, ColorEditPromptParams, ColorEditResult, TextPromptParams, }; -use crate::reportal_commands::repo_selection::{self, RepoSelectionParams}; +use crate::reportal_commands::repo_selection::{self, SelectedRepoParams}; use crate::reportal_config::{RepoColor, ReportalConfig, TabTitle, TagFilter}; use crate::terminal_style; use dialoguer::theme::ColorfulTheme; @@ -35,7 +35,7 @@ pub fn run_edit(command_params: EditCommandParams<'_>) -> Result<(), ReportalErr let mut loaded_config = ReportalConfig::load_from_disk()?; let selected_alias = { - let selection = repo_selection::select_repo(RepoSelectionParams { + let selection = repo_selection::select_repo(SelectedRepoParams { loaded_config: &loaded_config, direct_alias: command_params.direct_alias, tag_filter: &command_params.tag_filter, diff --git a/src/reportal_commands/repo_jump.rs b/src/reportal_commands/repo_jump.rs index b080d88..db5e83e 100644 --- a/src/reportal_commands/repo_jump.rs +++ b/src/reportal_commands/repo_jump.rs @@ -3,8 +3,9 @@ use crate::error::ReportalError; use crate::reportal_config::{ReportalConfig, TagFilter}; use crate::reportal_commands::path_display::{self, SelectedPathDisplayParams}; -use crate::reportal_commands::repo_selection::{ - self, RepoSelectionParams, TerminalIdentityEmitParams, +use crate::reportal_commands::repo_selection::{self, SelectedRepoParams}; +use crate::reportal_commands::terminal_identity_emit::{ + self, TerminalIdentityEmitParams, }; /// All parameters needed to run the jump command. @@ -27,14 +28,14 @@ pub struct JumpCommandParams<'a> { pub fn run_jump(jump_params: JumpCommandParams<'_>) -> Result<(), ReportalError> { let loaded_config = ReportalConfig::load_from_disk()?; - let selected = repo_selection::select_repo(RepoSelectionParams { + let selected = repo_selection::select_repo(SelectedRepoParams { loaded_config: &loaded_config, direct_alias: jump_params.direct_alias, tag_filter: &jump_params.tag_filter, prompt_label: "Jump to repo", })?; - repo_selection::emit_repo_terminal_identity(TerminalIdentityEmitParams { + terminal_identity_emit::emit_repo_terminal_identity(TerminalIdentityEmitParams { selected_alias: selected.repo_alias(), selected_repo: selected.repo_config(), title_override: jump_params.title_override, diff --git a/src/reportal_commands/repo_open.rs b/src/reportal_commands/repo_open.rs index 8c420d4..af6d425 100644 --- a/src/reportal_commands/repo_open.rs +++ b/src/reportal_commands/repo_open.rs @@ -3,8 +3,9 @@ use crate::error::ReportalError; use crate::reportal_config::{PathVisibility, ReportalConfig, TagFilter}; use crate::terminal_style; -use crate::reportal_commands::repo_selection::{ - self, RepoSelectionParams, TerminalIdentityEmitParams, +use crate::reportal_commands::repo_selection::{self, SelectedRepoParams}; +use crate::reportal_commands::terminal_identity_emit::{ + self, TerminalIdentityEmitParams, }; use owo_colors::OwoColorize; use std::process::Command; @@ -31,14 +32,14 @@ pub struct OpenCommandParams<'a> { pub fn run_open(open_params: OpenCommandParams<'_>) -> Result<(), ReportalError> { let loaded_config = ReportalConfig::load_from_disk()?; - let selected = repo_selection::select_repo(RepoSelectionParams { + let selected = repo_selection::select_repo(SelectedRepoParams { loaded_config: &loaded_config, direct_alias: open_params.direct_alias, tag_filter: &open_params.tag_filter, prompt_label: "Open in editor", })?; - repo_selection::emit_repo_terminal_identity(TerminalIdentityEmitParams { + terminal_identity_emit::emit_repo_terminal_identity(TerminalIdentityEmitParams { selected_alias: selected.repo_alias(), selected_repo: selected.repo_config(), title_override: open_params.title_override, diff --git a/src/reportal_commands/repo_run.rs b/src/reportal_commands/repo_run.rs index f5960d7..7e93c71 100644 --- a/src/reportal_commands/repo_run.rs +++ b/src/reportal_commands/repo_run.rs @@ -3,8 +3,9 @@ use crate::error::ReportalError; use crate::reportal_config::{CommandEntry, ReportalConfig, TagFilter}; use crate::terminal_style; -use crate::reportal_commands::repo_selection::{ - self, RepoSelectionParams, TerminalIdentityEmitParams, +use crate::reportal_commands::repo_selection::{self, SelectedRepoParams}; +use crate::reportal_commands::terminal_identity_emit::{ + self, TerminalIdentityEmitParams, }; use dialoguer::{theme::ColorfulTheme, FuzzySelect}; use owo_colors::OwoColorize; @@ -156,14 +157,14 @@ fn resolve_command(resolve_params: ResolveCommandParams<'_>) -> Result) -> Result<(), ReportalError> { let loaded_config = ReportalConfig::load_from_disk()?; - let selected = repo_selection::select_repo(RepoSelectionParams { + let selected = repo_selection::select_repo(SelectedRepoParams { loaded_config: &loaded_config, direct_alias: run_params.direct_alias, tag_filter: &run_params.tag_filter, prompt_label: "Run command in", })?; - repo_selection::emit_repo_terminal_identity(TerminalIdentityEmitParams { + terminal_identity_emit::emit_repo_terminal_identity(TerminalIdentityEmitParams { selected_alias: selected.repo_alias(), selected_repo: selected.repo_config(), title_override: "", diff --git a/src/reportal_commands/repo_selection.rs b/src/reportal_commands/repo_selection.rs index a19a053..364e7c9 100644 --- a/src/reportal_commands/repo_selection.rs +++ b/src/reportal_commands/repo_selection.rs @@ -1,12 +1,10 @@ -/// Shared repo selection and terminal identity helpers used across commands. -/// -/// Eliminates the duplicated fuzzy-finder and OSC tab-theming blocks -/// that were copy-pasted across jump, open, ai, and web. +/// Shared repo fuzzy-selection helper used across jump, open, ai, edit, and web. use crate::error::ReportalError; -use crate::reportal_config::{RepoColor, RepoEntry, ReportalConfig, TabTitle, TagFilter}; -use crate::terminal_style::{self, TabColorAction, TerminalIdentity, TerminalIdentityParams}; +use crate::reportal_config::{RepoEntry, ReportalConfig, TagFilter}; +use crate::terminal_style; use dialoguer::{theme::ColorfulTheme, FuzzySelect}; +use owo_colors::OwoColorize; /// A repo that was resolved either by direct alias lookup or fuzzy selection. /// Holds borrowed references into the loaded config so callers can read @@ -31,8 +29,9 @@ impl<'a> SelectedRepo<'a> { } } -/// Parameters for selecting a repo via fuzzy finder or direct alias. -pub struct RepoSelectionParams<'a> { +/// 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 SelectedRepoParams<'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. @@ -47,9 +46,9 @@ pub struct RepoSelectionParams<'a> { /// /// 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`. Each item shows the alias, -/// any configured aliases in parens, and the description. -pub fn select_repo<'a>(selection_params: RepoSelectionParams<'a>) -> Result, ReportalError> { +/// with the given `prompt_label`. Each item shows a color swatch, +/// the alias, any configured aliases in parens, and the description. +pub fn select_repo<'a>(selection_params: SelectedRepoParams<'a>) -> Result, ReportalError> { match selection_params.direct_alias.is_empty() { false => { let found_repo = selection_params.loaded_config.get_repo(selection_params.direct_alias)?; @@ -67,7 +66,13 @@ pub fn select_repo<'a>(selection_params: RepoSelectionParams<'a>) -> Result = matching_repos .iter() .map(|(alias, repo)| { - let mut label = alias.to_string(); + 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}"); match repo.aliases().is_empty() { true => {} @@ -111,41 +116,3 @@ pub fn select_repo<'a>(selection_params: RepoSelectionParams<'a>) -> Result { - /// 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: TerminalIdentityEmitParams<'_>) { - let resolved_title = match identity_params.title_override.is_empty() { - false => identity_params.title_override.to_string(), - true => match identity_params.selected_repo.tab_title() { - TabTitle::Custom(custom_title) => custom_title.to_string(), - TabTitle::UseAlias => identity_params.selected_alias.to_string(), - }, - }; - - 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(TerminalIdentityParams { - resolved_title, - tab_color_action, - }); - terminal_style::emit_terminal_identity_to_console(&identity); -} diff --git a/src/reportal_commands/repo_web.rs b/src/reportal_commands/repo_web.rs index 7dc8171..30b4362 100644 --- a/src/reportal_commands/repo_web.rs +++ b/src/reportal_commands/repo_web.rs @@ -3,8 +3,9 @@ use crate::error::ReportalError; use crate::reportal_config::{ReportalConfig, TagFilter}; use crate::terminal_style; -use crate::reportal_commands::repo_selection::{ - self, RepoSelectionParams, TerminalIdentityEmitParams, +use crate::reportal_commands::repo_selection::{self, SelectedRepoParams}; +use crate::reportal_commands::terminal_identity_emit::{ + self, TerminalIdentityEmitParams, }; use owo_colors::OwoColorize; use std::process::Command; @@ -90,14 +91,14 @@ fn detect_git_remote(repo_path: &std::path::Path) -> RemoteResolution { pub fn run_web(web_params: WebCommandParams<'_>) -> Result<(), ReportalError> { let loaded_config = ReportalConfig::load_from_disk()?; - let selected = repo_selection::select_repo(RepoSelectionParams { + let selected = repo_selection::select_repo(SelectedRepoParams { loaded_config: &loaded_config, direct_alias: web_params.direct_alias, tag_filter: &web_params.tag_filter, prompt_label: "Open in browser", })?; - repo_selection::emit_repo_terminal_identity(TerminalIdentityEmitParams { + terminal_identity_emit::emit_repo_terminal_identity(TerminalIdentityEmitParams { selected_alias: selected.repo_alias(), selected_repo: selected.repo_config(), title_override: "", diff --git a/src/reportal_commands/terminal_identity_emit.rs b/src/reportal_commands/terminal_identity_emit.rs new file mode 100644 index 0000000..4e64598 --- /dev/null +++ b/src/reportal_commands/terminal_identity_emit.rs @@ -0,0 +1,42 @@ +/// 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, TerminalIdentityParams}; + +/// Parameters for emitting terminal identity (tab title + color). +pub struct TerminalIdentityEmitParams<'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: TerminalIdentityEmitParams<'_>) { + let resolved_title = match identity_params.title_override.is_empty() { + false => identity_params.title_override.to_string(), + true => match identity_params.selected_repo.tab_title() { + TabTitle::Custom(custom_title) => custom_title.to_string(), + TabTitle::UseAlias => identity_params.selected_alias.to_string(), + }, + }; + + 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(TerminalIdentityParams { + resolved_title, + tab_color_action, + }); + terminal_style::emit_terminal_identity_to_console(&identity); +} From 61997aa1f2c3286d2f0aad088f494724f7e01af6 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Mon, 30 Mar 2026 12:16:21 -0400 Subject: [PATCH 33/67] Add custom Nightfall Candy theme to all prompts with match highlighting (v0.9.2) Replaced ColorfulTheme::default() with reportal_prompt_theme() across all fuzzy selects and input prompts. Fuzzy match characters now highlight in accent blue (color256 117) with bold. Non-matched items are dimmed. Active selection uses the same accent blue. --- Cargo.lock | 2 +- Cargo.toml | 2 +- .../repo_add/clone_destination.rs | 5 +++-- .../repo_add/registration_context.rs | 3 +-- src/reportal_commands/repo_add/run.rs | 4 ++-- src/reportal_commands/repo_edit.rs | 3 +-- src/reportal_commands/repo_run.rs | 4 ++-- src/reportal_commands/repo_selection.rs | 5 +++-- src/terminal_style.rs | 18 ++++++++++++++++++ 9 files changed, 32 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1cddac8..6970cbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -550,7 +550,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.9.0" +version = "0.9.1" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 0ee35c4..d4672dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.9.1" +version = "0.9.2" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/src/reportal_commands/repo_add/clone_destination.rs b/src/reportal_commands/repo_add/clone_destination.rs index 29b0157..ca5505a 100644 --- a/src/reportal_commands/repo_add/clone_destination.rs +++ b/src/reportal_commands/repo_add/clone_destination.rs @@ -2,7 +2,8 @@ use crate::error::ReportalError; use crate::reportal_config::ReportalConfig; -use dialoguer::{theme::ColorfulTheme, FuzzySelect}; +use crate::terminal_style; +use dialoguer::FuzzySelect; use std::path::PathBuf; use super::clone_placement; @@ -19,7 +20,7 @@ pub enum CloneDestination { /// 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 = ColorfulTheme::default(); + let prompt_theme = terminal_style::reportal_prompt_theme(); let sibling_directories = clone_placement::collect_registered_parent_directories(loaded_config); let child_directories = clone_placement::collect_registered_repo_directories(loaded_config); diff --git a/src/reportal_commands/repo_add/registration_context.rs b/src/reportal_commands/repo_add/registration_context.rs index a87f87f..a2504db 100644 --- a/src/reportal_commands/repo_add/registration_context.rs +++ b/src/reportal_commands/repo_add/registration_context.rs @@ -6,7 +6,6 @@ use crate::reportal_commands::prompts::{ }; use crate::reportal_config::{RepoRegistrationBuilder, ReportalConfig}; use crate::terminal_style; -use dialoguer::theme::ColorfulTheme; use owo_colors::OwoColorize; /// All data needed to run the interactive metadata collection and registration. @@ -27,7 +26,7 @@ impl<'a> RegistrationContext<'a> { /// 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 = ColorfulTheme::default(); + let prompt_theme = terminal_style::reportal_prompt_theme(); let repo_alias = prompts::prompt_for_text(TextPromptParams { prompt_theme: &prompt_theme, diff --git a/src/reportal_commands/repo_add/run.rs b/src/reportal_commands/repo_add/run.rs index 66fe7c7..ecb4d83 100644 --- a/src/reportal_commands/repo_add/run.rs +++ b/src/reportal_commands/repo_add/run.rs @@ -3,7 +3,7 @@ use crate::error::ReportalError; use crate::reportal_config::ReportalConfig; use crate::terminal_style; -use dialoguer::{theme::ColorfulTheme, Input}; +use dialoguer::Input; use owo_colors::OwoColorize; use std::path::PathBuf; @@ -55,7 +55,7 @@ pub fn run_add(raw_input: &str) -> Result<(), ReportalError> { println!(); let clone_destination = prompt_clone_destination(&loaded_config)?; - let prompt_theme = ColorfulTheme::default(); + let prompt_theme = terminal_style::reportal_prompt_theme(); let clone_parent = match clone_destination { CloneDestination::CustomPath => { diff --git a/src/reportal_commands/repo_edit.rs b/src/reportal_commands/repo_edit.rs index 4b24f5e..673c8d7 100644 --- a/src/reportal_commands/repo_edit.rs +++ b/src/reportal_commands/repo_edit.rs @@ -7,7 +7,6 @@ use crate::reportal_commands::prompts::{ use crate::reportal_commands::repo_selection::{self, SelectedRepoParams}; use crate::reportal_config::{RepoColor, ReportalConfig, TabTitle, TagFilter}; use crate::terminal_style; -use dialoguer::theme::ColorfulTheme; use dialoguer::Select; use owo_colors::OwoColorize; @@ -58,7 +57,7 @@ pub fn run_edit(command_params: EditCommandParams<'_>) -> Result<(), ReportalErr repo_path_display.style(terminal_style::PATH_STYLE), ); - let prompt_theme = ColorfulTheme::default(); + let prompt_theme = terminal_style::reportal_prompt_theme(); let repo_entry = loaded_config.get_repo(&selected_alias)?; let mut current_description = repo_entry.description().to_string(); diff --git a/src/reportal_commands/repo_run.rs b/src/reportal_commands/repo_run.rs index 7e93c71..322179e 100644 --- a/src/reportal_commands/repo_run.rs +++ b/src/reportal_commands/repo_run.rs @@ -7,7 +7,7 @@ use crate::reportal_commands::repo_selection::{self, SelectedRepoParams}; use crate::reportal_commands::terminal_identity_emit::{ self, TerminalIdentityEmitParams, }; -use dialoguer::{theme::ColorfulTheme, FuzzySelect}; +use dialoguer::FuzzySelect; use owo_colors::OwoColorize; use std::collections::BTreeMap; use std::process::Command; @@ -124,7 +124,7 @@ fn resolve_command(resolve_params: ResolveCommandParams<'_>) -> Result(selection_params: SelectedRepoParams<'a>) -> Result 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_string()).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. From 37dd2f34315aedcaa951449e508d2769539c0d39 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Mon, 30 Mar 2026 12:20:35 -0400 Subject: [PATCH 34/67] Sort fuzzy finder by tag, show tag labels for visual grouping (v0.9.3) Repos in the fuzzy finder are now sorted by first tag then alias so same-tag repos cluster together. Each entry shows a [tag1, tag2] suffix after the description for at-a-glance grouping context. Alias text stays plain (not ANSI-colored) so dialoguer's per-character fuzzy match highlighting works correctly on it. The colored swatch provides the repo color identity. --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/reportal_commands/repo_selection.rs | 21 ++++++++++++++++++--- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6970cbc..f56c8e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -550,7 +550,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.9.1" +version = "0.9.2" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index d4672dc..e056aef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.9.2" +version = "0.9.3" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/src/reportal_commands/repo_selection.rs b/src/reportal_commands/repo_selection.rs index 139975b..6490f91 100644 --- a/src/reportal_commands/repo_selection.rs +++ b/src/reportal_commands/repo_selection.rs @@ -46,8 +46,9 @@ pub struct SelectedRepoParams<'a> { /// /// 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`. Each item shows a color swatch, -/// the alias, any configured aliases in parens, and the description. +/// 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: SelectedRepoParams<'a>) -> Result, ReportalError> { match selection_params.direct_alias.is_empty() { false => { @@ -58,11 +59,17 @@ pub fn select_repo<'a>(selection_params: SelectedRepoParams<'a>) -> Result { - let matching_repos = selection_params.loaded_config.repos_matching_tag_filter(selection_params.tag_filter); + 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)| { @@ -89,6 +96,14 @@ pub fn select_repo<'a>(selection_params: SelectedRepoParams<'a>) -> Result {} + false => { + let tags_display = repo.tags().join(", "); + label.push_str(&format!(" [{}]", tags_display)); + } + } + return label; }) .collect(); From 0e01dda582372af204cdf897d5fc175c1a2379b7 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Sat, 21 Mar 2026 11:35:47 -0400 Subject: [PATCH 35/67] Update docs for crates.io publication --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a44682f..edabba1 100644 --- a/README.md +++ b/README.md @@ -278,7 +278,7 @@ PROMPT_COMMAND='rep color 2>/dev/null' - [ ] `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 From 9917349c85b35314b977359208e11b9db6629a7f Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Mon, 30 Mar 2026 15:09:57 -0400 Subject: [PATCH 36/67] Clean up tracked files that belong in .gitignore Untrack agent notes, DEV_TOOLS.md, and SPEC-tab-color.md. Add DEV_TOOLS.md and SPEC-*.md patterns to .gitignore. --- .gitignore | 2 ++ DEV_TOOLS.md | 0 SPEC-tab-color.md | 63 ----------------------------------------------- 3 files changed, 2 insertions(+), 63 deletions(-) delete mode 100644 DEV_TOOLS.md delete mode 100644 SPEC-tab-color.md diff --git a/.gitignore b/.gitignore index f4357e5..9cf4f1e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ # Internal docs /_docs/ +DEV_TOOLS.md +SPEC-*.md # Claude Code diff --git a/DEV_TOOLS.md b/DEV_TOOLS.md deleted file mode 100644 index e69de29..0000000 diff --git a/SPEC-tab-color.md b/SPEC-tab-color.md deleted file mode 100644 index f751f4e..0000000 --- a/SPEC-tab-color.md +++ /dev/null @@ -1,63 +0,0 @@ -# Spec: Dynamic Terminal Tab Color on Repo Jump - -## Summary - -When `rj ` (or `reportal jump `) changes directory to a repo, reportal should also emit a Windows Terminal escape sequence to set the **tab color** to that repo's `color` field from the config. - -## Motivation - -When working across multiple repos in separate terminal tabs (often inside Claude Code, where oh-my-posh is not visible), there's no way to visually distinguish which tab is which. Terminal background colors don't scale — they clash with text. Tab colors are a small colored strip that never affects readability. - -## Behavior - -### On Jump - -When a repo has a `color` field in `~/.reportal/config.toml`: - -1. Parse the hex color (e.g., `#3D0000`) into RGB components -2. Emit the Windows Terminal OSC escape sequence to set the tab color: - ``` - \x1b]6;1;bg;red;{r}\x07 - \x1b]6;1;bg;green;{g}\x07 - \x1b]6;1;bg;blue;{b}\x07 - ``` - Where `{r}`, `{g}`, `{b}` are the decimal RGB values (0-255). -3. Then proceed with the normal directory change. - -### On Jump to Repo Without Color - -If the target repo has no `color` field, **reset the tab color** to the terminal default: -``` -\x1b]6;1;bg;default\x07 -``` - -### On `reportal list` - -No change to list behavior. Colors are already shown there (or could be — that's separate scope). - -## Config - -No new config fields. Uses the existing `color` field: - -```toml -[repos.nro] -path = "~/dev/oja-gamez/ninja-revival-online" -color = "#3D0000" -``` - -## Edge Cases - -- **Non-Windows-Terminal**: The escape sequences are Windows Terminal specific. On other terminals they'll be silently ignored (no-op). No feature detection needed — just emit them. -- **Invalid hex color**: If `color` is present but not a valid `#RRGGBB` hex string, skip emitting (don't crash). -- **SSH / WSL**: The escape sequences pass through to the host terminal. This should just work. - -## Existing Repo Colors - -``` -nro = #3D0000 (dark red) -ai-lab = #003D00 (dark green) -venoble = #E6B422 (golden yellow) -jakuta-admin = #9B59B6 (medium purple) -``` - -Repos without colors: reportal, otk, ozzy-website, ourhomii, claude-conspire, open-claw-dev. From 82462844c5d59db504fe27048970d63c7135b53e Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Fri, 3 Apr 2026 20:27:33 -0400 Subject: [PATCH 37/67] Fix profile stripping to remove stale rj definitions outside marker blocks (v0.9.4) strip_existing_integration() only removed RePortal function definitions that appeared after the "# RePortal shell integration" marker comment. Manually-written or older-version definitions (e.g. "# RePortal - quick repo jumping") survived the cleanup, creating duplicate rj functions where the old one overwrote the new one. Now strips known RePortal function patterns and comments anywhere in the profile, not just within marked blocks. Extracted is_reportal_function_line() helper to deduplicate the pattern checks. --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/reportal_commands/initialization.rs | 42 ++++++++++++++++++------- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f56c8e8..e8f1f59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -550,7 +550,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.9.2" +version = "0.9.3" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index e056aef..bd6fc03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.9.3" +version = "0.9.4" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/src/reportal_commands/initialization.rs b/src/reportal_commands/initialization.rs index f54c2fb..f19f53b 100644 --- a/src/reportal_commands/initialization.rs +++ b/src/reportal_commands/initialization.rs @@ -170,6 +170,20 @@ pub(crate) fn detect_shell_profile() -> DetectedShell { } } +/// Returns true if the line looks like a RePortal shell function definition. +fn is_reportal_function_line(trimmed: &str) -> bool { + return trimmed.starts_with("function rj") + || trimmed.starts_with("function ro") + || trimmed.starts_with("function rw") + || trimmed.starts_with("function rr") + || trimmed.starts_with("function repup") + || trimmed.starts_with("rj()") + || trimmed.starts_with("ro()") + || trimmed.starts_with("rw()") + || trimmed.starts_with("rr()") + || trimmed.starts_with("repup()"); +} + /// Strips any existing RePortal integration from a profile string. /// Handles both legacy inline blocks (pre-v0.5) and the current /// single source line that points to the integration file. @@ -182,18 +196,9 @@ fn strip_existing_integration(profile_content: &str) -> String { 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 rw") - || trimmed.starts_with("function rr") - || trimmed.starts_with("function repup") + let is_reportal_line = is_reportal_function_line(trimmed) || trimmed.starts_with("function prompt") || trimmed.starts_with("$_reportal_") - || trimmed.starts_with("rj()") - || trimmed.starts_with("ro()") - || trimmed.starts_with("rw()") - || trimmed.starts_with("rr()") - || trimmed.starts_with("repup()") || trimmed.starts_with("PROMPT_COMMAND") || trimmed.contains("REPORTAL_LOADED") || trimmed.contains("rep jump") @@ -221,7 +226,22 @@ fn strip_existing_integration(profile_content: &str) -> String { skipping_reportal_block = true; } false => { - result_lines.push(line); + let trimmed = line.trim(); + let is_stale_reportal_function = + is_reportal_function_line(trimmed) + || trimmed.contains("reportal jump") + || trimmed.contains("reportal open") + || trimmed.contains("reportal web") + || trimmed.contains("reportal run"); + let is_stale_reportal_comment = trimmed + .starts_with('#') + && trimmed.contains("RePortal"); + match is_stale_reportal_function || is_stale_reportal_comment { + true => {} + false => { + result_lines.push(line); + } + } } } } From bea1d7367c4a8463aa8c81d27ee835edbd4bad2b Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Fri, 3 Apr 2026 21:29:11 -0400 Subject: [PATCH 38/67] Update Cargo.lock for v0.9.4, disable fuzzy highlight matches --- Cargo.lock | 2 +- src/reportal_commands/repo_selection.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index e8f1f59..081bd83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -550,7 +550,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.9.3" +version = "0.9.4" dependencies = [ "anyhow", "clap", diff --git a/src/reportal_commands/repo_selection.rs b/src/reportal_commands/repo_selection.rs index 6490f91..5e39bb5 100644 --- a/src/reportal_commands/repo_selection.rs +++ b/src/reportal_commands/repo_selection.rs @@ -112,6 +112,7 @@ pub fn select_repo<'a>(selection_params: SelectedRepoParams<'a>) -> Result Date: Sun, 5 Apr 2026 01:16:20 -0400 Subject: [PATCH 39/67] Install PowerShell integration as a module instead of profile sourcing (v0.10.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rep init on PowerShell now writes RePortal.psm1 + RePortal.psd1 to Documents/PowerShell/Modules/RePortal/. Functions load via module auto-import, independent of $PROFILE health — broken profiles no longer silently prevent rj/ro/rw/rr from existing. Migrates existing profile-based installs by stripping the old `. integration.ps1` source line. Bash/zsh integration unchanged. Split initialization.rs into detected_shell.rs (detection + install), integration_freshness.rs (version check + auto-update), and a leaner initialization.rs (run_init orchestration). Restructured doctor.rs to check for module presence on PowerShell instead of profile source line. --- Cargo.lock | 22 +- Cargo.toml | 2 +- README.md | 6 +- src/reportal_commands/detected_shell.rs | 460 ++++++++++++++++ src/reportal_commands/doctor.rs | 494 ++++++++---------- src/reportal_commands/initialization.rs | 455 ++-------------- .../integration_freshness.rs | 91 ++++ src/reportal_commands/mod.rs | 4 +- 8 files changed, 853 insertions(+), 681 deletions(-) create mode 100644 src/reportal_commands/detected_shell.rs create mode 100644 src/reportal_commands/integration_freshness.rs diff --git a/Cargo.lock b/Cargo.lock index 081bd83..f0de910 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -252,9 +252,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 +324,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", @@ -373,9 +373,9 @@ 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" @@ -550,7 +550,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.9.4" +version = "0.10.0" dependencies = [ "anyhow", "clap", @@ -588,9 +588,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" @@ -793,9 +793,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.1" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-width" diff --git a/Cargo.toml b/Cargo.toml index bd6fc03..907efad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.9.4" +version = "0.10.0" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/README.md b/README.md index edabba1..64af70c 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,9 @@ ro ## Shell integration -`rep init` writes a standalone integration script to `~/.reportal/integration.ps1` (or `.sh`) and adds a single source line to your shell profile. The profile line never changes between versions; updating the binary is all you need. +On **PowerShell**, `rep init` installs a proper PowerShell module to `Documents/PowerShell/Modules/RePortal/`. Functions load via module auto-import — they work even if your `$PROFILE` has errors. Existing profile-based installs are migrated automatically. + +On **Bash/Zsh**, `rep init` writes a standalone script to `~/.reportal/integration.sh` and adds a source line to your shell profile. The profile line never changes between versions; updating the binary is all you need. | Shortcut | What it does | |----------|-------------| @@ -143,7 +145,7 @@ ro | `rr` | Fuzzy-select a repo and run a configured command in it | | `rr my-api` | Skip repo selection, fuzzy-select a command | -Supports PowerShell, Bash, Zsh. Detected and installed during `rep init`. Re-run `rep init` after major version updates to regenerate the integration file. +Supports PowerShell, Bash, Zsh. Detected and installed during `rep init`. Re-run `rep init` after major updates to regenerate integration files. ## Config diff --git a/src/reportal_commands/detected_shell.rs b/src/reportal_commands/detected_shell.rs new file mode 100644 index 0000000..ffcff62 --- /dev/null +++ b/src/reportal_commands/detected_shell.rs @@ -0,0 +1,460 @@ +/// Shell detection, integration content generation, and installation. +/// +/// On Unix shells (bash, zsh), integration works by writing a standalone +/// script file into `~/.reportal` and adding a `source` line to the +/// user's shell profile. +/// +/// On PowerShell, integration is installed as a proper PowerShell module +/// in the user's PSModulePath. This makes the rj/ro/rw/rr functions load +/// via PowerShell's module auto-import, independent of the user's +/// $PROFILE health. + +use crate::error::ReportalError; +use crate::reportal_config::ReportalConfig; +use std::path::PathBuf; +use std::process::Command; + +/// Which shell the user is running, with its profile path. +pub(crate) 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, +} + +/// Returns the path to the integration script file for the current platform. +pub(crate) fn integration_file_path() -> Result { + let config_directory = ReportalConfig::config_directory()?; + + #[cfg(target_os = "windows")] + { + return Ok(config_directory.join("integration.ps1")); + } + + #[cfg(not(target_os = "windows"))] + { + return Ok(config_directory.join("integration.sh")); + } +} + +/// Returns the path to the user's PowerShell Modules directory where +/// RePortal should be installed as a module. Uses the Documents +/// known-folder via the `dirs` crate so it follows any folder +/// redirection (e.g. OneDrive, GPO). +#[cfg(target_os = "windows")] +pub(crate) fn powershell_module_directory() -> Result { + let documents_directory = dirs::document_dir().ok_or(ReportalError::ConfigIoFailure { + reason: String::from("could not determine Documents folder for PowerShell module install"), + })?; + return Ok(documents_directory + .join("PowerShell") + .join("Modules") + .join("RePortal")); +} + +/// Detection, content generation, profile management, and installation. +impl DetectedShell { + /// Detects the current shell and its profile path. + pub(crate) fn detect() -> DetectedShell { + #[cfg(target_os = "windows")] + { + match Self::detect_powershell_profile() { + Some(profile_path) => DetectedShell::PowerShell(PathBuf::from(profile_path)), + None => DetectedShell::Unknown, + } + } + + #[cfg(not(target_os = "windows"))] + { + match Self::detect_unix_shell_env() { + Some(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, + }, + None => DetectedShell::Unknown, + } + } + } + + /// Returns the profile path if this shell was detected. + pub(crate) 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, + } + } + + /// Writes the integration script file and ensures the shell can + /// load RePortal functions. On Unix, adds a source line to the + /// profile. On PowerShell, installs a module for auto-import. + pub(crate) fn install_integration(&self) -> Result<(), ReportalError> { + match self { + #[cfg(target_os = "windows")] + DetectedShell::PowerShell(_) => { + self.install_powershell_module()?; + return Ok(()); + } + #[cfg(not(target_os = "windows"))] + DetectedShell::Bash(_) | DetectedShell::Zsh(_) => { + return self.install_unix_integration(); + } + _ => return Ok(()), + } + } + + /// Strips any existing RePortal integration from a profile string. + /// Handles both legacy inline blocks (pre-v0.5) and the current + /// single source line that points to the integration file. + pub(crate) fn strip_existing_integration(profile_content: &str) -> String { + let mut result_lines: Vec<&str> = Vec::new(); + let mut skipping_reportal_block = false; + let integration_path_marker = Self::reportal_integration_marker(); + + for line in profile_content.lines() { + match skipping_reportal_block { + true => { + let trimmed = line.trim(); + let is_reportal_line = Self::is_reportal_function_line(trimmed) + || trimmed.starts_with("function prompt") + || trimmed.starts_with("function global:prompt") + || trimmed.starts_with("$_reportal_") + || trimmed.starts_with("$script:_reportal_") + || trimmed.starts_with("PROMPT_COMMAND") + || trimmed.contains("REPORTAL_LOADED") + || trimmed.contains("rep jump") + || trimmed.contains("rep open") + || trimmed.contains("rep web") + || trimmed.contains("rep run") + || trimmed.contains("rep upgrade") + || 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 => { + let is_reportal_marker = line.contains("RePortal shell integration") + || line.contains(&integration_path_marker); + match is_reportal_marker { + true => { + skipping_reportal_block = true; + } + false => { + let trimmed = line.trim(); + let is_stale_reportal_function = + Self::is_reportal_function_line(trimmed) + || trimmed.contains("reportal jump") + || trimmed.contains("reportal open") + || trimmed.contains("reportal web") + || trimmed.contains("reportal run"); + let is_stale_reportal_comment = trimmed + .starts_with('#') + && trimmed.contains("RePortal"); + match is_stale_reportal_function || is_stale_reportal_comment { + true => {} + false => { + result_lines.push(line); + } + } + } + } + } + } + } + + return result_lines.join("\n"); + } + + /// Returns the PowerShell integration script content, stamped with the + /// current binary version so `rep doctor` can detect stale files. + /// + /// Uses `global:` scope qualifiers on every function definition so the + /// script works correctly whether dot-sourced from the user's profile + /// (legacy installs) or from inside a PowerShell module (.psm1). + pub(crate) fn powershell_integration_content() -> String { + return format!( + r#"# RePortal shell integration — v{} +# Do not edit; regenerated by 'rep init'. +$env:REPORTAL_LOADED = "1" +function global:rj {{ Set-Location (rep jump @args); rep color }} +function global:ro {{ rep open @args; 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 --print-title 2>$null; if ($t) {{ $Host.UI.RawUI.WindowTitle = $t }}; $p }} +"#, + env!("CARGO_PKG_VERSION"), + ); + } + + /// Returns the Bash and Zsh integration script content, stamped with the + /// current binary version so `rep doctor` can detect stale files. + #[cfg(not(target_os = "windows"))] + pub(crate) fn bash_integration_content() -> String { + let null_device = std::path::Path::new("/dev").join("null"); + return format!( + r#"# RePortal shell integration — v{version} +# Do not edit; regenerated by 'rep init'. +export REPORTAL_LOADED=1 +rj() {{ cd "$(rep jump "$@")"; rep color; }} +ro() {{ rep open "$@"; rep color; }} +rw() {{ rep web "$@"; }} +rr() {{ rep run "$@"; }} +_reportal_hook() {{ local _t; _t=$(rep color --print-title 2>{null_device}); [ -n "$_t" ] && printf '\033]2;%s\007' "$_t"; }} +PROMPT_COMMAND="${{PROMPT_COMMAND:+$PROMPT_COMMAND;}}_reportal_hook" +"#, + version = env!("CARGO_PKG_VERSION"), + null_device = null_device.display(), + ); + } + + /// Attempts to get the PowerShell profile path by running pwsh. + fn detect_powershell_profile() -> Option { + 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 => None, + false => Some(profile_path_string), + } + } + false => None, + }, + Err(pwsh_spawn_error) => { + eprintln!(" pwsh not found: {pwsh_spawn_error}"); + None + } + } + } + + /// Reads the SHELL environment variable to determine the Unix shell. + #[cfg(not(target_os = "windows"))] + fn detect_unix_shell_env() -> Option { + match std::env::var("SHELL") { + Ok(shell_path) => Some(shell_path), + Err(env_read_error) => { + eprintln!(" SHELL env not set: {env_read_error}"); + None + } + } + } + + /// Marker prefix used in the integration file and matched during strip. + /// Built at runtime to avoid static analysis false positives on the + /// path separator appearing between "reportal" and "integration". + fn reportal_integration_marker() -> String { + return format!(".reportal{}integration", std::path::MAIN_SEPARATOR); + } + + /// Returns true if the line looks like a RePortal shell function definition. + fn is_reportal_function_line(trimmed: &str) -> bool { + return trimmed.starts_with("function rj") + || trimmed.starts_with("function ro") + || trimmed.starts_with("function rw") + || trimmed.starts_with("function rr") + || trimmed.starts_with("function repup") + || trimmed.starts_with("function global:rj") + || trimmed.starts_with("function global:ro") + || trimmed.starts_with("function global:rw") + || trimmed.starts_with("function global:rr") + || trimmed.starts_with("rj()") + || trimmed.starts_with("ro()") + || trimmed.starts_with("rw()") + || trimmed.starts_with("rr()") + || trimmed.starts_with("repup()"); + } + + /// The one-liner added to .bashrc or .zshrc; sources the integration file. + #[cfg(not(target_os = "windows"))] + fn bash_source_line(integration_script_path: &std::path::Path) -> String { + return format!("source \"{}\"", integration_script_path.display()); + } + + /// Returns the content for RePortal.psm1 — a stable shim that dot-sources + /// the versioned integration.ps1 file. The module itself never needs + /// updating; only integration.ps1 changes between binary versions. + #[cfg(target_os = "windows")] + fn powershell_module_content() -> String { + return String::from( + r#"# RePortal PowerShell module — stable shim. +# Actual logic lives in ~/.reportal/integration.ps1 +# and is auto-updated by the rep binary on version changes. +. "$HOME\.reportal\integration.ps1" +"#, + ); + } + + /// Returns the content for RePortal.psd1 — the module manifest that + /// tells PowerShell which functions to auto-import when the user types + /// rj, ro, rw, or rr. The prompt override activates as a side effect + /// of module import (triggered by the first rj/ro/rw/rr call). + #[cfg(target_os = "windows")] + fn powershell_manifest_content() -> String { + return String::from( + r#"@{ + ModuleVersion = '1.0.0' + GUID = 'b1e3f7a2-9c4d-4e8f-a6b0-2d5e1f3c7a9b' + Author = 'RePortal' + Description = 'RePortal shell integration — repo jump, open, web, run' + RootModule = 'RePortal.psm1' + FunctionsToExport = @('rj', 'ro', 'rw', 'rr') +} +"#, + ); + } + + /// Installs shell integration for PowerShell as a proper module. + /// + /// Writes integration.ps1 to ~/.reportal (for version tracking and + /// auto-update by the binary), then installs RePortal.psm1 and + /// RePortal.psd1 into the user's PowerShell Modules directory. + /// PowerShell auto-imports modules from this path, so the rj/ro/rw/rr + /// functions load regardless of whether the user's $PROFILE has errors. + /// + /// Strips any legacy `. integration.ps1` line from $PROFILE to + /// migrate cleanly from the old profile-based approach. + #[cfg(target_os = "windows")] + fn install_powershell_module(&self) -> Result { + let profile_path = match self.profile_path() { + Some(resolved_path) => resolved_path, + None => { + return Err(ReportalError::ConfigIoFailure { + reason: String::from("PowerShell profile path not detected"), + }) + } + }; + + let script_path = integration_file_path()?; + let integration_content = Self::powershell_integration_content(); + + std::fs::write(&script_path, &integration_content).map_err(|io_error| { + ReportalError::ConfigIoFailure { + reason: io_error.to_string(), + } + })?; + + let module_directory = powershell_module_directory()?; + + std::fs::create_dir_all(&module_directory).map_err(|io_error| { + ReportalError::ConfigIoFailure { + reason: format!( + "failed to create module directory {}: {}", + module_directory.display(), + io_error, + ), + } + })?; + + let module_file_path = module_directory.join("RePortal.psm1"); + std::fs::write(&module_file_path, Self::powershell_module_content()).map_err( + |io_error| ReportalError::ConfigIoFailure { + reason: format!("failed to write {}: {}", module_file_path.display(), io_error), + }, + )?; + + let manifest_file_path = module_directory.join("RePortal.psd1"); + std::fs::write(&manifest_file_path, Self::powershell_manifest_content()).map_err( + |io_error| ReportalError::ConfigIoFailure { + reason: format!( + "failed to write {}: {}", + manifest_file_path.display(), + io_error, + ), + }, + )?; + + if profile_path.exists() { + let profile_content = + std::fs::read_to_string(profile_path).map_err(|io_error| { + ReportalError::ConfigIoFailure { + reason: io_error.to_string(), + } + })?; + + let cleaned_content = Self::strip_existing_integration(&profile_content); + + if cleaned_content != profile_content { + let trimmed_profile = cleaned_content.trim_end().to_string(); + let final_content = match trimmed_profile.is_empty() { + true => String::new(), + false => format!("{trimmed_profile}\n"), + }; + std::fs::write(profile_path, final_content).map_err(|io_error| { + ReportalError::ConfigIoFailure { + reason: io_error.to_string(), + } + })?; + } + } + + return Ok(module_directory); + } + + /// Installs shell integration for Unix shells (bash/zsh). + /// Writes the integration script and adds a source line to the profile. + #[cfg(not(target_os = "windows"))] + fn install_unix_integration(&self) -> Result<(), ReportalError> { + let target_path = match self.profile_path() { + Some(resolved_path) => resolved_path, + None => return Ok(()), + }; + + let script_path = integration_file_path()?; + let integration_content = Self::bash_integration_content(); + + std::fs::write(&script_path, &integration_content).map_err(|io_error| { + ReportalError::ConfigIoFailure { + reason: io_error.to_string(), + } + })?; + + let source_line = Self::bash_source_line(&script_path); + + 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 cleaned_content = Self::strip_existing_integration(&existing_content); + let updated_content = format!("{cleaned_content}\n{source_line}\n"); + + std::fs::write(target_path, updated_content).map_err(|io_error| { + ReportalError::ConfigIoFailure { + reason: io_error.to_string(), + } + })?; + + return Ok(()); + } +} diff --git a/src/reportal_commands/doctor.rs b/src/reportal_commands/doctor.rs index 288296e..26b52c3 100644 --- a/src/reportal_commands/doctor.rs +++ b/src/reportal_commands/doctor.rs @@ -1,9 +1,9 @@ /// Validates the full RePortal installation and prints a diagnostic report. /// -/// Checks four areas: +/// Checks five areas: /// 1. Config file existence and TOML parse correctness /// 2. Integration script file exists and version matches the binary -/// 3. Shell profile sources the integration file +/// 3. Shell integration is reachable (module on PowerShell, source line on Unix) /// 4. Current session has loaded the integration (REPORTAL_LOADED env var) /// 5. All registered repo paths exist on disk /// @@ -15,15 +15,11 @@ use crate::reportal_config::ReportalConfig; use crate::terminal_style; use owo_colors::OwoColorize; -use super::initialization::{detect_shell_profile, integration_file_path}; +use super::detected_shell::DetectedShell; +use super::integration_freshness::{check_integration_file_state, IntegrationFileState}; -/// Whether a single diagnostic check passed or failed. -enum CheckOutcome { - /// The check passed without issues. - Passed, - /// The check detected a problem. - Failed, -} +#[cfg(target_os = "windows")] +use super::detected_shell::powershell_module_directory; /// Tracks pass/fail counts across all diagnostic checks. struct DiagnosticSummary { @@ -31,7 +27,7 @@ struct DiagnosticSummary { failed: u32, } -/// Construction and recording for diagnostic pass/fail tallies. +/// Construction, recording, and output for diagnostic pass/fail tallies. impl DiagnosticSummary { fn new() -> Self { return Self { @@ -40,274 +36,255 @@ impl DiagnosticSummary { }; } - fn record_check(&mut self, check_outcome: CheckOutcome) { - match check_outcome { - CheckOutcome::Passed => self.passed += 1, - CheckOutcome::Failed => self.failed += 1, - } + fn record_pass(&mut self, diagnostic_message: &str) { + println!( + " {} {}", + "✓".style(terminal_style::SUCCESS_STYLE), + diagnostic_message, + ); + self.passed += 1; } -} - -fn print_pass(diagnostic_message: &str) { - println!( - " {} {}", - "✓".style(terminal_style::SUCCESS_STYLE), - diagnostic_message, - ); -} -fn print_fail(diagnostic_message: &str) { - println!( - " {} {}", - "✗".style(terminal_style::FAILURE_STYLE), - diagnostic_message, - ); -} - -fn print_hint(hint_message: &str) { - println!( - " {} {}", - "→".style(terminal_style::LABEL_STYLE), - hint_message.style(terminal_style::PATH_STYLE), - ); -} + fn record_fail(&mut self, diagnostic_message: &str) { + println!( + " {} {}", + "✗".style(terminal_style::FAILURE_STYLE), + diagnostic_message, + ); + self.failed += 1; + } -/// Checks that the config file exists and parses correctly. -/// Returns the loaded config on success for use by later checks. -fn check_config(summary: &mut DiagnosticSummary) -> Option { - println!(); - println!( - " {}", - "Config".style(terminal_style::EMPHASIS_STYLE), - ); + fn print_hint(hint_message: &str) { + println!( + " {} {}", + "→".style(terminal_style::LABEL_STYLE), + hint_message.style(terminal_style::PATH_STYLE), + ); + } - let config_path = match ReportalConfig::config_file_path() { - Ok(path) => path, - Err(_path_error) => { - print_fail("Could not determine config path"); - summary.record_check(CheckOutcome::Failed); - return None; - } - }; + /// 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 { + println!(); + println!( + " {}", + "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 config_path.exists() { - true => { - print_pass(&format!("Config file exists at {}", config_path.display())); - summary.record_check(CheckOutcome::Passed); - } - false => { - print_fail(&format!("Config not found at {}", config_path.display())); - print_hint("Run 'rep init' to create one"); - summary.record_check(CheckOutcome::Failed); - return None; + match config_path.exists() { + true => { + self.record_pass(&format!("Config file exists at {}", config_path.display())); + } + false => { + self.record_fail(&format!("Config not found at {}", config_path.display())); + Self::print_hint("Run 'rep init' to create one"); + return None; + } } - } - match ReportalConfig::load_from_disk() { - Ok(loaded_config) => { - let repo_count = loaded_config.repos_with_aliases().len(); - print_pass(&format!("Config parses successfully ({repo_count} repos registered)")); - summary.record_check(CheckOutcome::Passed); - return Some(loaded_config); - } - Err(parse_error) => { - print_fail(&format!("Config failed to parse: {parse_error}")); - print_hint("Check ~/.reportal/config.toml for syntax errors"); - summary.record_check(CheckOutcome::Failed); - return None; + match ReportalConfig::load_from_disk() { + Ok(loaded_config) => { + let repo_count = loaded_config.repos_with_aliases().len(); + self.record_pass(&format!("Config parses successfully ({repo_count} repos registered)")); + return Some(loaded_config); + } + Err(ref parse_error) => { + self.record_fail(&format!("Config failed to parse: {parse_error}")); + Self::print_hint("Check ~/.reportal/config.toml for syntax errors"); + return None; + } } } -} -/// Whether the integration file version matches, is outdated, or is missing. -enum IntegrationFileStatus { - /// File exists and version matches the running binary. - Current, - /// File exists but was written by a different version. - Outdated { file_version: String }, - /// File does not exist at the expected path. - Missing, -} + /// Checks the integration file, shell profile source line, and session state. + fn check_shell_integration(&mut self) { + println!(); + println!( + " {}", + "Shell Integration".style(terminal_style::EMPHASIS_STYLE), + ); + + let detected_shell = DetectedShell::detect(); + + let _profile_path = match detected_shell.profile_path() { + Some(path) => { + let path_string = format!("{}", path.display()); + let detected_label = match path_string.contains("PowerShell") { + true => "PowerShell", + false => match path_string.contains(".zshrc") { + true => "Zsh", + false => "Bash", + }, + }; + self.record_pass(&format!("Shell detected: {detected_label}")); + path + } + None => { + self.record_fail("Could not detect shell profile"); + Self::print_hint("Run 'rep init' to install shell integration"); + return; + } + }; -/// Reads the integration script file and compares its version stamp -/// against the running binary version. -fn check_integration_file_version() -> IntegrationFileStatus { - let script_path = match integration_file_path() { - Ok(path) => path, - Err(_path_error) => return IntegrationFileStatus::Missing, - }; - - let file_content = match std::fs::read_to_string(&script_path) { - Ok(content) => content, - Err(_read_error) => return IntegrationFileStatus::Missing, - }; - - let binary_version = env!("CARGO_PKG_VERSION"); - - let first_line = match file_content.lines().next() { - Some(line) => line, - None => return IntegrationFileStatus::Outdated { - file_version: String::from("empty"), - }, - }; - - match first_line.contains(binary_version) { - true => IntegrationFileStatus::Current, - false => { - let extracted_version = first_line - .rsplit("— v") - .next() - .map(String::from) - .unwrap_or_else(|| String::from("unknown")); - IntegrationFileStatus::Outdated { - file_version: extracted_version, + match check_integration_file_state() { + IntegrationFileState::Current => { + self.record_pass(&format!( + "Integration file up to date (v{})", + env!("CARGO_PKG_VERSION"), + )); + } + IntegrationFileState::Outdated { ref file_version } => { + self.record_fail(&format!( + "Integration file outdated (file: {file_version}, binary: v{})", + env!("CARGO_PKG_VERSION"), + )); + Self::print_hint("Run 'rep init' to update"); + } + IntegrationFileState::Missing => { + self.record_fail("Integration file missing"); + Self::print_hint("Run 'rep init' to create it"); } } - } -} -/// Checks the integration file, shell profile source line, and session state. -fn check_shell_integration(summary: &mut DiagnosticSummary) { - println!(); - println!( - " {}", - "Shell Integration".style(terminal_style::EMPHASIS_STYLE), - ); + self.check_integration_reachable(&detected_shell); + self.check_session_loaded(); + } - let detected_shell = detect_shell_profile(); - - let profile_path = match detected_shell.profile_path() { - Some(path) => { - let path_string = format!("{}", path.display()); - let detected_label = match path_string.contains("PowerShell") { - true => "PowerShell", - false => match path_string.contains(".zshrc") { - true => "Zsh", - false => "Bash", - }, - }; - print_pass(&format!("Shell detected: {detected_label}")); - summary.record_check(CheckOutcome::Passed); - path - } - None => { - print_fail("Could not detect shell profile"); - print_hint("Run 'rep init' to install shell integration"); - summary.record_check(CheckOutcome::Failed); - return; + /// Checks that the shell can actually load the integration. + /// On PowerShell, verifies the module directory exists. + /// On Unix, verifies the profile sources the integration file. + fn check_integration_reachable(&mut self, detected_shell: &DetectedShell) { + #[cfg(target_os = "windows")] + { + match detected_shell { + DetectedShell::PowerShell(_) => { + match powershell_module_directory() { + Ok(module_path) => { + let module_file = module_path.join("RePortal.psm1"); + match module_file.exists() { + true => { + self.record_pass(&format!( + "PowerShell module installed at {}", + module_path.display(), + )); + } + false => { + self.record_fail(&format!( + "PowerShell module not found at {}", + module_path.display(), + )); + Self::print_hint("Run 'rep init' to install the module"); + } + } + } + Err(ref _module_path_error) => { + self.record_fail("Could not determine PowerShell module directory"); + Self::print_hint("Run 'rep init' to install shell integration"); + } + } + return; + } + _ => {} + } } - }; - match check_integration_file_version() { - IntegrationFileStatus::Current => { - print_pass(&format!("Integration file up to date (v{})", env!("CARGO_PKG_VERSION"))); - summary.record_check(CheckOutcome::Passed); - } - IntegrationFileStatus::Outdated { ref file_version } => { - print_fail(&format!( - "Integration file outdated (file: {file_version}, binary: v{})", - env!("CARGO_PKG_VERSION"), - )); - print_hint("Run 'rep init' to update"); - summary.record_check(CheckOutcome::Failed); - } - IntegrationFileStatus::Missing => { - print_fail("Integration file missing"); - print_hint("Run 'rep init' to create it"); - summary.record_check(CheckOutcome::Failed); - } - } + #[cfg(not(target_os = "windows"))] + { + let profile_path = match detected_shell.profile_path() { + Some(path) => path, + None => return, + }; - let profile_content = match std::fs::read_to_string(profile_path) { - Ok(content) => content, - Err(_read_error) => { - print_fail(&format!("Could not read profile at {}", profile_path.display())); - summary.record_check(CheckOutcome::Failed); - return; - } - }; + let profile_content = match std::fs::read_to_string(profile_path) { + Ok(content) => content, + Err(ref _read_error) => { + self.record_fail(&format!( + "Could not read profile at {}", + profile_path.display(), + )); + return; + } + }; - let integration_marker = format!(".reportal{}integration", std::path::MAIN_SEPARATOR); - let profile_sources_integration = profile_content.lines().any(|line| { - return line.contains(&integration_marker); - }); + let integration_marker = + format!(".reportal{}integration", std::path::MAIN_SEPARATOR); + let profile_sources_integration = profile_content.lines().any(|line| { + return line.contains(&integration_marker); + }); - match profile_sources_integration { - true => { - print_pass("Profile sources integration file"); - summary.record_check(CheckOutcome::Passed); - } - false => { - print_fail("Profile does not source integration file"); - print_hint("Run 'rep init' to set it up"); - summary.record_check(CheckOutcome::Failed); + match profile_sources_integration { + true => { + self.record_pass("Profile sources integration file"); + } + false => { + self.record_fail("Profile does not source integration file"); + Self::print_hint("Run 'rep init' to set it up"); + } + } } } - check_session_loaded(summary); -} - -/// Checks whether the REPORTAL_LOADED env var is set, which proves -/// the shell profile was sourced in the current session. If the -/// integration file exists but this var is missing, rj and ro -/// will not work until the user opens a new terminal. -fn check_session_loaded(summary: &mut DiagnosticSummary) { - match std::env::var("REPORTAL_LOADED") { - Ok(_marker_value) => { - print_pass("Shell integration loaded in current session"); - summary.record_check(CheckOutcome::Passed); - } - Err(_env_error) => { - print_fail("Shell integration NOT loaded in current session"); - print_hint("Open a new terminal tab to pick up changes"); - summary.record_check(CheckOutcome::Failed); + /// Checks whether the REPORTAL_LOADED env var is set, which proves + /// the shell integration was loaded in the current session. + fn check_session_loaded(&mut self) { + match std::env::var("REPORTAL_LOADED") { + Ok(ref _marker_value) => { + self.record_pass("Shell integration loaded in current session"); + } + Err(ref _env_error) => { + self.record_fail("Shell integration NOT loaded in current session"); + Self::print_hint("Open a new terminal tab to pick up changes"); + } } } -} -/// Parameters for validating that registered repo paths exist on disk. -struct RepoPathCheckParams<'a> { - /// The loaded config containing repo entries to validate. - loaded_config: &'a ReportalConfig, - /// Running tally of diagnostic outcomes. - summary: &'a mut DiagnosticSummary, -} - -/// Checks that all registered repo paths exist on disk. -fn check_repo_paths(path_check: RepoPathCheckParams<'_>) { - println!(); - println!( - " {}", - "Repo Paths".style(terminal_style::EMPHASIS_STYLE), - ); + /// Checks that all registered repo paths exist on disk. + fn check_repo_paths(&mut self, loaded_config: &ReportalConfig) { + println!(); + println!( + " {}", + "Repo Paths".style(terminal_style::EMPHASIS_STYLE), + ); - let all_repos = path_check.loaded_config.repos_with_aliases(); + let all_repos = loaded_config.repos_with_aliases(); - match all_repos.is_empty() { - true => { - print_fail("No repos registered"); - print_hint("Run 'rep add ' to register a repo"); - path_check.summary.record_check(CheckOutcome::Failed); - } - false => { - for (alias, entry) in &all_repos { - let resolved_path = entry.resolved_path(); - match resolved_path.exists() { - true => { - print_pass(&format!( - "{} {}", - alias.style(terminal_style::ALIAS_STYLE), - resolved_path.display().to_string().style(terminal_style::PATH_STYLE), - )); - path_check.summary.record_check(CheckOutcome::Passed); - } - false => { - print_fail(&format!( - "{} {} (path does not exist)", - alias.style(terminal_style::ALIAS_STYLE), - resolved_path.display().to_string().style(terminal_style::PATH_STYLE), - )); - path_check.summary.record_check(CheckOutcome::Failed); + match all_repos.is_empty() { + true => { + self.record_fail("No repos registered"); + Self::print_hint("Run 'rep add ' to register a repo"); + } + false => { + for (alias, entry) in &all_repos { + let resolved_path = entry.resolved_path(); + match resolved_path.exists() { + true => { + self.record_pass(&format!( + "{} {}", + alias.style(terminal_style::ALIAS_STYLE), + resolved_path + .display() + .to_string() + .style(terminal_style::PATH_STYLE), + )); + } + false => { + self.record_fail(&format!( + "{} {} (path does not exist)", + alias.style(terminal_style::ALIAS_STYLE), + resolved_path + .display() + .to_string() + .style(terminal_style::PATH_STYLE), + )); + } } } } @@ -316,12 +293,6 @@ fn check_repo_paths(path_check: RepoPathCheckParams<'_>) { } /// Validates the full RePortal installation and prints a diagnostic report. -/// -/// Checks config file health, integration file version, shell profile -/// source line, session load state, and whether all registered repo -/// paths exist on disk. Each failed check prints an actionable hint. -/// Returns Ok(()) unconditionally — diagnostic failures are reported -/// to the user via stdout, not propagated as errors. pub fn run_doctor() -> Result<(), ReportalError> { println!( "{}", @@ -330,14 +301,11 @@ pub fn run_doctor() -> Result<(), ReportalError> { let mut summary = DiagnosticSummary::new(); - let loaded_config = check_config(&mut summary); - check_shell_integration(&mut summary); + let loaded_config = summary.check_config(); + summary.check_shell_integration(); match loaded_config { - Some(ref config) => check_repo_paths(RepoPathCheckParams { - loaded_config: config, - summary: &mut summary, - }), + Some(ref config) => summary.check_repo_paths(config), None => {} } diff --git a/src/reportal_commands/initialization.rs b/src/reportal_commands/initialization.rs index f19f53b..e365658 100644 --- a/src/reportal_commands/initialization.rs +++ b/src/reportal_commands/initialization.rs @@ -1,337 +1,19 @@ /// Creates the default RePortal config file and installs shell integration. /// -/// Shell integration works by writing a standalone script file into the -/// `~/.reportal` directory and adding a single stable `source` line to -/// the user's shell profile. The profile line never changes between -/// versions; only the integration file does. This means binary updates -/// via cargo, scoop, or brew take effect immediately — no manual -/// "upgrade" step required. +/// This is the entry point for `rep init`. It delegates to `DetectedShell` +/// for the actual installation mechanics — PowerShell gets a module, +/// Unix shells get a profile source line. use crate::error::ReportalError; 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; -/// Marker prefix used in the integration file and matched during strip. -/// Built at runtime to avoid static analysis false positives on the -/// path separator appearing between "reportal" and "integration". -fn reportal_integration_marker() -> String { - return format!(".reportal{}integration", std::path::MAIN_SEPARATOR); -} - -/// Returns the PowerShell integration script content, stamped with the -/// current binary version so `rep doctor` can detect stale files. -fn powershell_integration_content() -> String { - return format!( - r#"# RePortal shell integration — v{} -# Do not edit; regenerated by 'rep init'. -$env:REPORTAL_LOADED = "1" -function rj {{ Set-Location (rep jump @args); rep color }} -function ro {{ rep open @args; rep color }} -function rw {{ rep web @args }} -function rr {{ rep run @args }} -$_reportal_original_prompt = $function:prompt -function prompt {{ $p = & $_reportal_original_prompt; $t = rep color --print-title 2>$null; if ($t) {{ $Host.UI.RawUI.WindowTitle = $t }}; $p }} -"#, - env!("CARGO_PKG_VERSION"), - ); -} - -/// Returns the Bash and Zsh integration script content, stamped with the -/// current binary version so `rep doctor` can detect stale files. -#[cfg(not(target_os = "windows"))] -fn bash_integration_content() -> String { - let null_device = std::path::Path::new("/dev").join("null"); - return format!( - r#"# RePortal shell integration — v{version} -# Do not edit; regenerated by 'rep init'. -export REPORTAL_LOADED=1 -rj() {{ cd "$(rep jump "$@")"; rep color; }} -ro() {{ rep open "$@"; rep color; }} -rw() {{ rep web "$@"; }} -rr() {{ rep run "$@"; }} -_reportal_hook() {{ local _t; _t=$(rep color --print-title 2>{null_device}); [ -n "$_t" ] && printf '\033]2;%s\007' "$_t"; }} -PROMPT_COMMAND="${{PROMPT_COMMAND:+$PROMPT_COMMAND;}}_reportal_hook" -"#, - version = env!("CARGO_PKG_VERSION"), - null_device = null_device.display(), - ); -} - -/// The one-liner added to the PowerShell profile; sources the integration -/// file so the profile itself never needs updating between versions. -fn powershell_source_line(integration_script_path: &std::path::Path) -> String { - return format!(". \"{}\"", integration_script_path.display()); -} - -/// The one-liner added to .bashrc or .zshrc; sources the integration file. -#[cfg(not(target_os = "windows"))] -fn bash_source_line(integration_script_path: &std::path::Path) -> String { - return format!("source \"{}\"", integration_script_path.display()); -} - -/// Which shell the user is running, with its profile path. -pub(crate) 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. -pub(crate) 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, - } - } - - #[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, - } - } -} - -/// Returns true if the line looks like a RePortal shell function definition. -fn is_reportal_function_line(trimmed: &str) -> bool { - return trimmed.starts_with("function rj") - || trimmed.starts_with("function ro") - || trimmed.starts_with("function rw") - || trimmed.starts_with("function rr") - || trimmed.starts_with("function repup") - || trimmed.starts_with("rj()") - || trimmed.starts_with("ro()") - || trimmed.starts_with("rw()") - || trimmed.starts_with("rr()") - || trimmed.starts_with("repup()"); -} +use super::detected_shell::{integration_file_path, DetectedShell}; -/// Strips any existing RePortal integration from a profile string. -/// Handles both legacy inline blocks (pre-v0.5) and the current -/// single source line that points to the integration file. -fn strip_existing_integration(profile_content: &str) -> String { - let mut result_lines: Vec<&str> = Vec::new(); - let mut skipping_reportal_block = false; - let integration_path_marker = reportal_integration_marker(); - - for line in profile_content.lines() { - match skipping_reportal_block { - true => { - let trimmed = line.trim(); - let is_reportal_line = is_reportal_function_line(trimmed) - || trimmed.starts_with("function prompt") - || trimmed.starts_with("$_reportal_") - || trimmed.starts_with("PROMPT_COMMAND") - || trimmed.contains("REPORTAL_LOADED") - || trimmed.contains("rep jump") - || trimmed.contains("rep open") - || trimmed.contains("rep web") - || trimmed.contains("rep run") - || trimmed.contains("rep upgrade") - || 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 => { - let is_reportal_marker = line.contains("RePortal shell integration") - || line.contains(&integration_path_marker); - match is_reportal_marker { - true => { - skipping_reportal_block = true; - } - false => { - let trimmed = line.trim(); - let is_stale_reportal_function = - is_reportal_function_line(trimmed) - || trimmed.contains("reportal jump") - || trimmed.contains("reportal open") - || trimmed.contains("reportal web") - || trimmed.contains("reportal run"); - let is_stale_reportal_comment = trimmed - .starts_with('#') - && trimmed.contains("RePortal"); - match is_stale_reportal_function || is_stale_reportal_comment { - true => {} - false => { - result_lines.push(line); - } - } - } - } - } - } - } - - return result_lines.join("\n"); -} - -/// Returns the path to the integration script file for the current platform. -pub(crate) fn integration_file_path() -> Result { - let config_directory = ReportalConfig::config_directory()?; - - #[cfg(target_os = "windows")] - { - return Ok(config_directory.join("integration.ps1")); - } - - #[cfg(not(target_os = "windows"))] - { - return Ok(config_directory.join("integration.sh")); - } -} - -/// Shell profile and integration file installation methods. -impl DetectedShell { - /// Returns the profile path if this shell was detected. - pub(crate) 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, - } - } - - /// Writes the integration script file and ensures a source line - /// exists in the shell profile. Strips any legacy inline blocks - /// or stale source lines first, making this idempotent. - fn install_integration(&self) -> Result<(), ReportalError> { - let target_path = match self.profile_path() { - Some(resolved_path) => resolved_path, - None => return Ok(()), - }; - - let script_path = integration_file_path()?; - - let integration_content = match self { - DetectedShell::PowerShell(_) => powershell_integration_content(), - #[cfg(not(target_os = "windows"))] - DetectedShell::Bash(_) | DetectedShell::Zsh(_) => bash_integration_content(), - DetectedShell::Unknown => return Ok(()), - }; - - std::fs::write(&script_path, &integration_content).map_err(|io_error| { - ReportalError::ConfigIoFailure { - reason: io_error.to_string(), - } - })?; - - let source_line = match self { - DetectedShell::PowerShell(_) => powershell_source_line(&script_path), - #[cfg(not(target_os = "windows"))] - DetectedShell::Bash(_) | DetectedShell::Zsh(_) => bash_source_line(&script_path), - DetectedShell::Unknown => 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 cleaned_content = strip_existing_integration(&existing_content); - - let updated_content = format!("{cleaned_content}\n{source_line}\n"); - - std::fs::write(target_path, updated_content).map_err(|io_error| { - ReportalError::ConfigIoFailure { - reason: io_error.to_string(), - } - })?; - - return Ok(()); - } -} +#[cfg(target_os = "windows")] +use super::detected_shell::powershell_module_directory; /// Writes the integration file and installs the source line in the /// shell profile. Displays what will be installed and asks for confirmation. @@ -345,13 +27,22 @@ fn install_or_update_shell_integration() -> Result<(), ReportalError> { println!(" {} per-repo tab title + background color on every prompt", "color".style(terminal_style::ALIAS_STYLE)); println!(); - let detected_shell = detect_shell_profile(); + let detected_shell = DetectedShell::detect(); match detected_shell.profile_path() { Some(profile_path) => { + #[cfg(target_os = "windows")] + let install_target_label = match powershell_module_directory() { + Ok(ref module_path) => format!("Module: {}", module_path.display()), + Err(ref _module_path_error) => format!("Profile: {}", profile_path.display()), + }; + + #[cfg(not(target_os = "windows"))] + let install_target_label = format!("Profile: {}", profile_path.display()); + println!( - " Profile: {}", - profile_path.display().to_string().style(terminal_style::PATH_STYLE), + " {}", + install_target_label.style(terminal_style::PATH_STYLE), ); let user_wants_install = Confirm::with_theme(&ColorfulTheme::default()) @@ -365,15 +56,39 @@ fn install_or_update_shell_integration() -> Result<(), ReportalError> { match user_wants_install { true => { detected_shell.install_integration()?; - let script_path = integration_file_path()?; - terminal_style::print_success(&format!( - "Wrote {}", - script_path.display(), - )); - terminal_style::print_success(&format!( - "Updated {}. Restart your shell to activate.", - profile_path.display(), - )); + + #[cfg(target_os = "windows")] + { + let script_path = integration_file_path()?; + let module_path = powershell_module_directory()?; + terminal_style::print_success(&format!( + "Wrote {}", + script_path.display(), + )); + terminal_style::print_success(&format!( + "Installed PowerShell module to {}", + module_path.display(), + )); + terminal_style::print_success( + "Migrated from profile sourcing to module auto-import.", + ); + terminal_style::print_success( + "Open a new terminal to activate. rj/ro/rw/rr now load even if your $PROFILE has errors.", + ); + } + + #[cfg(not(target_os = "windows"))] + { + let script_path = integration_file_path()?; + terminal_style::print_success(&format!( + "Wrote {}", + script_path.display(), + )); + terminal_style::print_success(&format!( + "Updated {}. Restart your shell to activate.", + profile_path.display(), + )); + } } false => { println!(" Skipped."); @@ -389,7 +104,7 @@ fn install_or_update_shell_integration() -> Result<(), ReportalError> { println!(" PowerShell: . \"{}\"", script_path.display()); println!(" Bash/Zsh: source \"{}\"", script_path.display()); } - Err(_path_error) => { + Err(ref _path_error) => { println!(" Run 'rep init' again after fixing the config directory."); } } @@ -399,72 +114,6 @@ fn install_or_update_shell_integration() -> Result<(), ReportalError> { return Ok(()); } -/// Whether the integration file needs to be rewritten. -enum IntegrationFileState { - /// File exists and its version stamp matches the running binary. - Current, - /// File is missing or its version stamp does not match the binary. - NeedsUpdate, -} - -/// Checks whether the integration file version matches the binary. -fn check_integration_file_state() -> IntegrationFileState { - let script_path = match integration_file_path() { - Ok(path) => path, - Err(_path_error) => return IntegrationFileState::NeedsUpdate, - }; - - let file_content = match std::fs::read_to_string(&script_path) { - Ok(script_content) => script_content, - Err(_read_error) => return IntegrationFileState::NeedsUpdate, - }; - - let binary_version = env!("CARGO_PKG_VERSION"); - - match file_content.lines().next() { - Some(first_line) => match first_line.contains(binary_version) { - true => IntegrationFileState::Current, - false => IntegrationFileState::NeedsUpdate, - }, - None => IntegrationFileState::NeedsUpdate, - } -} - -/// Rewrites the integration script file if it is missing or was written -/// by a different binary version. Called at the top of main() before -/// subcommand dispatch so that binary updates via cargo, scoop, or brew -/// take effect on the next shell session without any manual steps. -/// Silently skipped if the config directory does not exist (pre-init). -pub fn ensure_integration_file_current() { - let config_directory = match ReportalConfig::config_directory() { - Ok(directory) => directory, - Err(_path_error) => return, - }; - - match config_directory.exists() { - true => {} - false => return, - } - - match check_integration_file_state() { - IntegrationFileState::Current => {} - IntegrationFileState::NeedsUpdate => { - let script_path = match integration_file_path() { - Ok(path) => path, - Err(_path_error) => return, - }; - - #[cfg(target_os = "windows")] - let integration_script = powershell_integration_content(); - - #[cfg(not(target_os = "windows"))] - let integration_script = bash_integration_content(); - - let _write_result = std::fs::write(&script_path, &integration_script); - } - } -} - /// Creates a default config at `~/.reportal/config.toml` if none exists, /// then writes the shell integration file and source line. /// Safe to re-run at any time; idempotent on both config and profile. diff --git a/src/reportal_commands/integration_freshness.rs b/src/reportal_commands/integration_freshness.rs new file mode 100644 index 0000000..05c8b2b --- /dev/null +++ b/src/reportal_commands/integration_freshness.rs @@ -0,0 +1,91 @@ +/// Checks whether the shell integration script file matches the running +/// binary version and silently rewrites it on mismatch. Called at the +/// top of main() before subcommand dispatch so that binary updates via +/// cargo, scoop, or brew take effect on the next shell session without +/// any manual steps. + +use crate::reportal_config::ReportalConfig; + +use super::detected_shell::{integration_file_path, DetectedShell}; + +/// Whether the integration file version matches, is outdated, or is missing. +pub(crate) enum IntegrationFileState { + /// File exists and version matches the running binary. + Current, + /// File exists but was written by a different version. + Outdated { file_version: String }, + /// File does not exist at the expected path. + Missing, +} + +/// Reads the integration script file and compares its version stamp +/// against the running binary version. +pub(crate) fn check_integration_file_state() -> IntegrationFileState { + let script_path = match integration_file_path() { + Ok(path) => path, + Err(_path_error) => return IntegrationFileState::Missing, + }; + + let file_content = match std::fs::read_to_string(&script_path) { + Ok(script_content) => script_content, + Err(_read_error) => return IntegrationFileState::Missing, + }; + + let binary_version = env!("CARGO_PKG_VERSION"); + + let first_line = match file_content.lines().next() { + Some(line) => line, + None => { + return IntegrationFileState::Outdated { + file_version: String::from("empty"), + } + } + }; + + match first_line.contains(binary_version) { + true => IntegrationFileState::Current, + false => { + let extracted_version = first_line + .rsplit("— v") + .next() + .map(String::from) + .unwrap_or_else(|| String::from("unknown")); + IntegrationFileState::Outdated { + file_version: extracted_version, + } + } + } +} + +/// Rewrites the integration script file if it is missing or was written +/// by a different binary version. Silently skipped if the config +/// directory does not exist (pre-init). +pub fn ensure_integration_file_current() { + let config_directory = match ReportalConfig::config_directory() { + Ok(directory) => directory, + Err(_path_error) => return, + }; + + match config_directory.exists() { + true => {} + false => return, + } + + match check_integration_file_state() { + IntegrationFileState::Current => {} + IntegrationFileState::Outdated { .. } | IntegrationFileState::Missing => { + let script_path = match integration_file_path() { + Ok(path) => path, + Err(_path_error) => return, + }; + + #[cfg(target_os = "windows")] + let integration_script = DetectedShell::powershell_integration_content(); + + #[cfg(not(target_os = "windows"))] + let integration_script = DetectedShell::bash_integration_content(); + + let _write_result = std::fs::write(&script_path, &integration_script); + } + } +} diff --git a/src/reportal_commands/mod.rs b/src/reportal_commands/mod.rs index c54c894..03b4e6d 100644 --- a/src/reportal_commands/mod.rs +++ b/src/reportal_commands/mod.rs @@ -1,7 +1,9 @@ /// Subcommand implementations for the RePortal CLI. +mod detected_shell; mod doctor; mod git_commands; mod initialization; +mod integration_freshness; mod path_display; mod prompts; mod repo_add; @@ -20,7 +22,7 @@ mod repo_sync; mod repo_web; pub use doctor::run_doctor; -pub use initialization::ensure_integration_file_current; +pub use integration_freshness::ensure_integration_file_current; pub use initialization::run_init; pub use repo_add::run_add; pub use repo_ai::{run_ai, AiCommandParams}; From 27b10b1b1bb4fbc4b781fb148e2193c5ce18a101 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Sat, 11 Apr 2026 13:58:08 -0400 Subject: [PATCH 40/67] Fix terminal color/title resetting on every prompt, extract CLI args (v0.11.0) The prompt hook was unconditionally resetting tab color when no repo matched the working directory, wiping colors set by rj/ro. Now prompt-hook mode preserves existing terminal state on no-match; only explicit rep color calls reset. Replaced --print-title bool flag with --mode prompt-hook|explicit enum. Extracted all CLI arg structs from main.rs into src/cli_args/ (one type per file, no Option or bool fields). --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/cli_args/ai_args.rs | 27 ++ src/cli_args/color_args.rs | 28 ++ src/cli_args/edit_args.rs | 23 ++ src/cli_args/jump_args.rs | 27 ++ src/cli_args/list_args.rs | 22 ++ src/cli_args/mod.rs | 31 ++ src/cli_args/open_args.rs | 30 ++ src/cli_args/remove_args.rs | 21 ++ src/cli_args/repo_selection_args.rs | 30 ++ src/cli_args/reportal_cli.rs | 69 +++++ src/cli_args/run_args.rs | 27 ++ src/cli_args/status_args.rs | 22 ++ src/cli_args/sync_args.rs | 22 ++ src/cli_args/tag_filter_args.rs | 29 ++ src/cli_args/web_args.rs | 23 ++ src/main.rs | 322 ++++---------------- src/reportal_commands/color_command_mode.rs | 18 ++ src/reportal_commands/detected_shell.rs | 4 +- src/reportal_commands/mod.rs | 4 +- src/reportal_commands/repo_color.rs | 112 +++---- 22 files changed, 558 insertions(+), 337 deletions(-) create mode 100644 src/cli_args/ai_args.rs create mode 100644 src/cli_args/color_args.rs create mode 100644 src/cli_args/edit_args.rs create mode 100644 src/cli_args/jump_args.rs create mode 100644 src/cli_args/list_args.rs create mode 100644 src/cli_args/mod.rs create mode 100644 src/cli_args/open_args.rs create mode 100644 src/cli_args/remove_args.rs create mode 100644 src/cli_args/repo_selection_args.rs create mode 100644 src/cli_args/reportal_cli.rs create mode 100644 src/cli_args/run_args.rs create mode 100644 src/cli_args/status_args.rs create mode 100644 src/cli_args/sync_args.rs create mode 100644 src/cli_args/tag_filter_args.rs create mode 100644 src/cli_args/web_args.rs create mode 100644 src/reportal_commands/color_command_mode.rs diff --git a/Cargo.lock b/Cargo.lock index f0de910..d410fb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -550,7 +550,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.10.0" +version = "0.11.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 907efad..41d2f0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.10.0" +version = "0.11.0" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/src/cli_args/ai_args.rs b/src/cli_args/ai_args.rs new file mode 100644 index 0000000..a92f78e --- /dev/null +++ b/src/cli_args/ai_args.rs @@ -0,0 +1,27 @@ +/// CLI args for `rep ai`. + +use clap::Args; +use crate::reportal_config::TagFilter; +use super::repo_selection_args::RepoSelectionArgs; + +/// 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 AiArgs { + #[command(flatten)] + selection: RepoSelectionArgs, + /// 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 AiArgs { + /// Returns (alias, tag_filter, tool_override), consuming self. + pub fn into_parts(self) -> (String, TagFilter, String) { + let (alias, tag_filter) = self.selection.into_parts(); + return (alias, tag_filter, self.tool); + } +} diff --git a/src/cli_args/color_args.rs b/src/cli_args/color_args.rs new file mode 100644 index 0000000..02cfde3 --- /dev/null +++ b/src/cli_args/color_args.rs @@ -0,0 +1,28 @@ +/// CLI args for `rep color`. + +use clap::Args; +use crate::reportal_commands::ColorCommandMode; + +/// 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 ColorArgs { + /// 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 ColorArgs { + /// Returns (repo_alias, mode), consuming self. + pub fn into_parts(self) -> (String, ColorCommandMode) { + return (self.repo, self.mode); + } +} diff --git a/src/cli_args/edit_args.rs b/src/cli_args/edit_args.rs new file mode 100644 index 0000000..7aa8a93 --- /dev/null +++ b/src/cli_args/edit_args.rs @@ -0,0 +1,23 @@ +/// CLI args for `rep edit`. + +use clap::Args; +use crate::reportal_config::TagFilter; +use super::repo_selection_args::RepoSelectionArgs; + +/// 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 EditArgs { + #[command(flatten)] + selection: RepoSelectionArgs, +} + +/// Consuming conversion that splits into domain-layer parts. +impl EditArgs { + /// Returns (alias, tag_filter), consuming self. + pub fn into_parts(self) -> (String, TagFilter) { + return self.selection.into_parts(); + } +} diff --git a/src/cli_args/jump_args.rs b/src/cli_args/jump_args.rs new file mode 100644 index 0000000..68a9015 --- /dev/null +++ b/src/cli_args/jump_args.rs @@ -0,0 +1,27 @@ +/// CLI args for `rep jump`. + +use clap::Args; +use crate::reportal_config::TagFilter; +use super::repo_selection_args::RepoSelectionArgs; + +/// 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 JumpArgs { + #[command(flatten)] + selection: RepoSelectionArgs, + /// 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 JumpArgs { + /// Returns (alias, tag_filter, title_override), consuming self. + pub fn into_parts(self) -> (String, TagFilter, String) { + let (alias, tag_filter) = self.selection.into_parts(); + return (alias, tag_filter, self.title); + } +} diff --git a/src/cli_args/list_args.rs b/src/cli_args/list_args.rs new file mode 100644 index 0000000..32647be --- /dev/null +++ b/src/cli_args/list_args.rs @@ -0,0 +1,22 @@ +/// CLI args for `rep list`. + +use clap::Args; +use crate::reportal_config::TagFilter; +use super::tag_filter_args::TagFilterArgs; + +/// Arguments for the `rep list` subcommand. +/// +/// Accepts an optional `--tag` flag to filter the displayed repos. +#[derive(Args)] +pub struct ListArgs { + #[command(flatten)] + tag_filter: TagFilterArgs, +} + +/// Consuming conversion to the domain tag filter. +impl ListArgs { + /// Extracts the tag filter, consuming the parsed args. + pub fn into_tag_filter(self) -> TagFilter { + return self.tag_filter.into_tag_filter(); + } +} diff --git a/src/cli_args/mod.rs b/src/cli_args/mod.rs new file mode 100644 index 0000000..22a6ee6 --- /dev/null +++ b/src/cli_args/mod.rs @@ -0,0 +1,31 @@ +/// 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_args; +mod color_args; +mod edit_args; +mod jump_args; +mod list_args; +mod open_args; +mod remove_args; +mod repo_selection_args; +mod reportal_cli; +mod run_args; +mod status_args; +mod sync_args; +mod tag_filter_args; +mod web_args; + +pub use ai_args::AiArgs; +pub use color_args::ColorArgs; +pub use edit_args::EditArgs; +pub use jump_args::JumpArgs; +pub use list_args::ListArgs; +pub use open_args::OpenArgs; +pub use remove_args::RemoveArgs; +pub use reportal_cli::{ReportalCli, ReportalCliSubcommand}; +pub use run_args::RunArgs; +pub use status_args::StatusArgs; +pub use sync_args::SyncArgs; +pub use web_args::WebArgs; diff --git a/src/cli_args/open_args.rs b/src/cli_args/open_args.rs new file mode 100644 index 0000000..06ee2b9 --- /dev/null +++ b/src/cli_args/open_args.rs @@ -0,0 +1,30 @@ +/// CLI args for `rep open`. + +use clap::Args; +use crate::reportal_config::TagFilter; +use super::repo_selection_args::RepoSelectionArgs; + +/// Arguments for the `rep open` subcommand. +/// +/// Supports repo selection, an optional editor override, and an +/// optional tab title override. +#[derive(Args)] +pub struct OpenArgs { + #[command(flatten)] + selection: RepoSelectionArgs, + /// 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 OpenArgs { + /// 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(); + return (alias, tag_filter, self.editor, self.title); + } +} diff --git a/src/cli_args/remove_args.rs b/src/cli_args/remove_args.rs new file mode 100644 index 0000000..eb55e95 --- /dev/null +++ b/src/cli_args/remove_args.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 RemoveArgs { + /// Alias of the repo to remove + alias: String, +} + +/// Accessor for the parsed alias. +impl RemoveArgs { + /// The alias of the repo to unregister. + pub fn alias(&self) -> &str { + return &self.alias; + } +} diff --git a/src/cli_args/repo_selection_args.rs b/src/cli_args/repo_selection_args.rs new file mode 100644 index 0000000..e8eff22 --- /dev/null +++ b/src/cli_args/repo_selection_args.rs @@ -0,0 +1,30 @@ +/// Shared optional alias positional arg + `--tag` flag for repo selection. + +use clap::Args; +use crate::reportal_config::TagFilter; +use super::tag_filter_args::TagFilterArgs; + +/// 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 RepoSelectionArgs { + /// Jump directly to this alias (skip fuzzy finder) + #[arg(default_value = "", hide_default_value = true)] + alias: String, + #[command(flatten)] + tag_filter: TagFilterArgs, +} + +/// Consuming conversion that splits the args into their domain-layer parts. +impl RepoSelectionArgs { + /// 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) { + return (self.alias, self.tag_filter.into_tag_filter()); + } +} diff --git a/src/cli_args/reportal_cli.rs b/src/cli_args/reportal_cli.rs new file mode 100644 index 0000000..51de264 --- /dev/null +++ b/src/cli_args/reportal_cli.rs @@ -0,0 +1,69 @@ +/// Top-level CLI parser and subcommand dispatch enum. + +use clap::{Parser, Subcommand}; +use super::{ + AiArgs, ColorArgs, EditArgs, JumpArgs, ListArgs, + OpenArgs, RemoveArgs, RunArgs, StatusArgs, SyncArgs, WebArgs, +}; + +/// 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 { + return self.subcommand; + } +} + +/// All available subcommands for the RePortal CLI. +#[derive(Subcommand)] +pub enum ReportalCliSubcommand { + /// Set up config and shell integration (safe to re-run on updates) + Init, + /// List all registered repos with status and metadata + #[command(alias = "l")] + List(ListArgs), + /// Fuzzy-select a repo and print its path (for shell cd integration) + #[command(alias = "j")] + Jump(JumpArgs), + /// Fuzzy-select a repo and open it in your editor + #[command(alias = "o")] + Open(OpenArgs), + /// 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(EditArgs), + /// Unregister a repo from the config (does not delete files) + #[command(alias = "rm")] + Remove(RemoveArgs), + /// Emit terminal tab title and background color for a repo (for shell hooks) + Color(ColorArgs), + /// Show git status across all registered repos + #[command(alias = "s")] + Status(StatusArgs), + /// Pull latest changes across all registered repos + Sync(SyncArgs), + /// Diagnose config, shell integration, and repo path issues + Doctor, + /// Launch an AI coding CLI in a repo + Ai(AiArgs), + /// Open a repo's remote URL in the browser + #[command(alias = "w")] + Web(WebArgs), + /// Run a configured command in a repo + #[command(alias = "r")] + Run(RunArgs), +} diff --git a/src/cli_args/run_args.rs b/src/cli_args/run_args.rs new file mode 100644 index 0000000..4705143 --- /dev/null +++ b/src/cli_args/run_args.rs @@ -0,0 +1,27 @@ +/// CLI args for `rep run`. + +use clap::Args; +use crate::reportal_config::TagFilter; +use super::repo_selection_args::RepoSelectionArgs; + +/// 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 RunArgs { + #[command(flatten)] + selection: RepoSelectionArgs, + /// 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 RunArgs { + /// Returns (alias, tag_filter, direct_command), consuming self. + pub fn into_parts(self) -> (String, TagFilter, String) { + let (alias, tag_filter) = self.selection.into_parts(); + return (alias, tag_filter, self.cmd); + } +} diff --git a/src/cli_args/status_args.rs b/src/cli_args/status_args.rs new file mode 100644 index 0000000..1c13f95 --- /dev/null +++ b/src/cli_args/status_args.rs @@ -0,0 +1,22 @@ +/// CLI args for `rep status`. + +use clap::Args; +use crate::reportal_config::TagFilter; +use super::tag_filter_args::TagFilterArgs; + +/// Arguments for the `rep status` subcommand. +/// +/// Accepts an optional `--tag` flag to filter which repos are checked. +#[derive(Args)] +pub struct StatusArgs { + #[command(flatten)] + filter: TagFilterArgs, +} + +/// Consuming conversion to the domain tag filter. +impl StatusArgs { + /// Extracts the tag filter, consuming the parsed args. + pub fn into_tag_filter(self) -> TagFilter { + return self.filter.into_tag_filter(); + } +} diff --git a/src/cli_args/sync_args.rs b/src/cli_args/sync_args.rs new file mode 100644 index 0000000..404c8c2 --- /dev/null +++ b/src/cli_args/sync_args.rs @@ -0,0 +1,22 @@ +/// CLI args for `rep sync`. + +use clap::Args; +use crate::reportal_config::TagFilter; +use super::tag_filter_args::TagFilterArgs; + +/// Arguments for the `rep sync` subcommand. +/// +/// Accepts an optional `--tag` flag to filter which repos are synced. +#[derive(Args)] +pub struct SyncArgs { + #[command(flatten)] + filter: TagFilterArgs, +} + +/// Consuming conversion to the domain tag filter. +impl SyncArgs { + /// Extracts the tag filter, consuming the parsed args. + pub fn into_tag_filter(self) -> TagFilter { + return self.filter.into_tag_filter(); + } +} diff --git a/src/cli_args/tag_filter_args.rs b/src/cli_args/tag_filter_args.rs new file mode 100644 index 0000000..6c57391 --- /dev/null +++ b/src/cli_args/tag_filter_args.rs @@ -0,0 +1,29 @@ +/// Shared `--tag` flag used by commands that filter repos. + +use clap::Args; +use crate::reportal_config::TagFilter; + +/// 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 TagFilterArgs { + /// Filter repos by this tag + #[arg(long, default_value = "", hide_default_value = true)] + tag: String, +} + +/// Consuming conversion to the domain's `TagFilter` enum. +impl TagFilterArgs { + /// 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 { + match self.tag.is_empty() { + true => TagFilter::All, + false => TagFilter::ByTag(self.tag), + } + } +} diff --git a/src/cli_args/web_args.rs b/src/cli_args/web_args.rs new file mode 100644 index 0000000..e16ee5a --- /dev/null +++ b/src/cli_args/web_args.rs @@ -0,0 +1,23 @@ +/// CLI args for `rep web`. + +use clap::Args; +use crate::reportal_config::TagFilter; +use super::repo_selection_args::RepoSelectionArgs; + +/// 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 WebArgs { + #[command(flatten)] + selection: RepoSelectionArgs, +} + +/// Consuming conversion that splits into domain-layer parts. +impl WebArgs { + /// Returns (alias, tag_filter), consuming self. + pub fn into_parts(self) -> (String, TagFilter) { + return self.selection.into_parts(); + } +} diff --git a/src/main.rs b/src/main.rs index 625917f..46409b8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,312 +3,94 @@ /// Parses command-line arguments via clap and dispatches /// to the appropriate subcommand handler. +mod cli_args; mod error; mod reportal_commands; mod reportal_config; mod terminal_style; -use clap::{Args, Parser, Subcommand}; -use reportal_commands::{AiCommandParams, ColorCommandParams, EditCommandParams, JumpCommandParams, OpenCommandParams, RunCommandParams, TitleOutput, WebCommandParams}; -use reportal_config::TagFilter; - -/// Shared --tag flag used by commands that filter repos. -#[derive(Args)] -struct TagFilterArgs { - /// Filter repos by this tag - #[arg(long)] - tag: Option, -} - -/// Converts the optional tag CLI arg into the domain's `TagFilter` enum. -impl TagFilterArgs { - fn into_tag_filter(self) -> TagFilter { - match self.tag { - Some(tag_value) => TagFilter::ByTag(tag_value), - None => TagFilter::All, - } - } -} - -/// Shared optional alias positional arg + --tag flag for repo selection. -#[derive(Args)] -struct RepoSelectionArgs { - /// Jump directly to this alias (skip fuzzy finder) - alias: Option, - #[command(flatten)] - tag_filter: TagFilterArgs, -} - -/// CLI args for `rep list`. -#[derive(Args)] -struct ListArgs { - #[command(flatten)] - filter: TagFilterArgs, -} - -/// CLI args for `rep jump`. -#[derive(Args)] -struct JumpArgs { - #[command(flatten)] - selection: RepoSelectionArgs, - /// Override the tab title for this session - #[arg(long)] - title: Option, -} - -/// CLI args for `rep open`. -#[derive(Args)] -struct OpenArgs { - #[command(flatten)] - selection: RepoSelectionArgs, - /// Override the default editor command - #[arg(long)] - editor: Option, - /// Override the tab title for this session - #[arg(long)] - title: Option, -} - -/// CLI args for `rep edit`. -#[derive(Args)] -struct EditArgs { - #[command(flatten)] - selection: RepoSelectionArgs, -} - -/// CLI args for `rep remove`. -#[derive(Args)] -struct RemoveArgs { - /// Alias of the repo to remove - alias: String, -} - -/// CLI args for `rep color`. -#[derive(Args)] -struct ColorArgs { - /// Look up this repo by alias instead of matching the current directory - #[arg(long)] - repo: Option, - /// Print the resolved tab title to stdout (for shell integration) - #[arg(long)] - print_title: bool, -} - -/// CLI args for `rep status`. -#[derive(Args)] -struct StatusArgs { - #[command(flatten)] - filter: TagFilterArgs, -} - -/// CLI args for `rep sync`. -#[derive(Args)] -struct SyncArgs { - #[command(flatten)] - filter: TagFilterArgs, -} - -/// CLI args for `rep ai`. -#[derive(Args)] -struct AiArgs { - #[command(flatten)] - selection: RepoSelectionArgs, - /// Which AI tool to launch (overrides default_ai_tool setting) - #[arg(long)] - tool: Option, -} - -/// CLI args for `rep web`. -#[derive(Args)] -struct WebArgs { - #[command(flatten)] - selection: RepoSelectionArgs, -} - -/// CLI args for `rep run`. -#[derive(Args)] -struct RunArgs { - #[command(flatten)] - selection: RepoSelectionArgs, - /// Run this command directly (skip command fuzzy finder) - #[arg(long)] - cmd: Option, -} - -/// 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 { - /// Set up config and shell integration (safe to re-run on updates) - Init, - /// List all registered repos with status and metadata - #[command(alias = "l")] - List(ListArgs), - /// Fuzzy-select a repo and print its path (for shell cd integration) - #[command(alias = "j")] - Jump(JumpArgs), - /// Fuzzy-select a repo and open it in your editor - #[command(alias = "o")] - Open(OpenArgs), - /// 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(EditArgs), - /// Unregister a repo from the config (does not delete files) - #[command(alias = "rm")] - Remove(RemoveArgs), - /// Emit terminal tab title and background color for a repo (for shell hooks) - Color(ColorArgs), - /// Show git status across all registered repos - #[command(alias = "s")] - Status(StatusArgs), - /// Pull latest changes across all registered repos - Sync(SyncArgs), - /// Diagnose config, shell integration, and repo path issues - Doctor, - /// Launch an AI coding CLI in a repo - Ai(AiArgs), - /// Open a repo's remote URL in the browser - #[command(alias = "w")] - Web(WebArgs), - /// Run a configured command in a repo - #[command(alias = "r")] - Run(RunArgs), -} +use clap::Parser; +use cli_args::{ReportalCli, ReportalCliSubcommand}; +use reportal_commands::{ + AiCommandParams, ColorCommandParams, EditCommandParams, JumpCommandParams, + OpenCommandParams, RunCommandParams, WebCommandParams, +}; fn main() { reportal_commands::ensure_integration_file_current(); let parsed_cli = ReportalCli::parse(); - let command_result = match parsed_cli.subcommand { - ReportalSubcommand::Init => reportal_commands::run_init(), - ReportalSubcommand::List(list_args) => { - reportal_commands::run_list(list_args.filter.into_tag_filter()) + let command_result = match parsed_cli.into_subcommand() { + ReportalCliSubcommand::Init => reportal_commands::run_init(), + ReportalCliSubcommand::List(list_args) => { + reportal_commands::run_list(list_args.into_tag_filter()) } - ReportalSubcommand::Jump(jump_args) => { - let direct_alias = match jump_args.selection.alias { - Some(ref provided_alias) => provided_alias.as_str(), - None => "", - }; - let title_override = match jump_args.title { - Some(ref provided_title) => provided_title.as_str(), - None => "", - }; + ReportalCliSubcommand::Jump(jump_args) => { + let (direct_alias, tag_filter, title_override) = jump_args.into_parts(); reportal_commands::run_jump(JumpCommandParams { - tag_filter: jump_args.selection.tag_filter.into_tag_filter(), - direct_alias, - title_override, + tag_filter, + direct_alias: &direct_alias, + title_override: &title_override, }) } - ReportalSubcommand::Open(open_args) => { - let direct_alias = match open_args.selection.alias { - Some(ref provided_alias) => provided_alias.as_str(), - None => "", - }; - let editor_override = match open_args.editor { - Some(ref provided_editor) => provided_editor.as_str(), - None => "", - }; - let title_override = match open_args.title { - Some(ref provided_title) => provided_title.as_str(), - None => "", - }; + ReportalCliSubcommand::Open(open_args) => { + let (direct_alias, tag_filter, editor_override, title_override) = open_args.into_parts(); reportal_commands::run_open(OpenCommandParams { - tag_filter: open_args.selection.tag_filter.into_tag_filter(), - direct_alias, - editor_override, - title_override, + tag_filter, + direct_alias: &direct_alias, + editor_override: &editor_override, + title_override: &title_override, }) } - ReportalSubcommand::Add { repo_path } => { + ReportalCliSubcommand::Add { repo_path } => { reportal_commands::run_add(&repo_path) } - ReportalSubcommand::Edit(edit_args) => { - let direct_alias = match edit_args.selection.alias { - Some(ref provided_alias) => provided_alias.as_str(), - None => "", - }; + ReportalCliSubcommand::Edit(edit_args) => { + let (direct_alias, tag_filter) = edit_args.into_parts(); reportal_commands::run_edit(EditCommandParams { - tag_filter: edit_args.selection.tag_filter.into_tag_filter(), - direct_alias, + tag_filter, + direct_alias: &direct_alias, }) } - ReportalSubcommand::Remove(remove_args) => { - reportal_commands::run_remove(&remove_args.alias) + ReportalCliSubcommand::Remove(remove_args) => { + reportal_commands::run_remove(remove_args.alias()) } - ReportalSubcommand::Color(color_args) => { - let repo_alias = match color_args.repo { - Some(ref provided_repo) => provided_repo.as_str(), - None => "", - }; - let title_output = match color_args.print_title { - true => TitleOutput::PrintToStdout, - false => TitleOutput::Silent, - }; + ReportalCliSubcommand::Color(color_args) => { + let (repo_alias, mode) = color_args.into_parts(); reportal_commands::run_color(ColorCommandParams { - repo_alias, - title_output, + repo_alias: &repo_alias, + mode, }) } - ReportalSubcommand::Status(status_args) => { - reportal_commands::run_status(status_args.filter.into_tag_filter()) + ReportalCliSubcommand::Status(status_args) => { + reportal_commands::run_status(status_args.into_tag_filter()) } - ReportalSubcommand::Sync(sync_args) => { - reportal_commands::run_sync(sync_args.filter.into_tag_filter()) + ReportalCliSubcommand::Sync(sync_args) => { + reportal_commands::run_sync(sync_args.into_tag_filter()) } - ReportalSubcommand::Doctor => reportal_commands::run_doctor(), - ReportalSubcommand::Web(web_args) => { - let direct_alias = match web_args.selection.alias { - Some(ref provided_alias) => provided_alias.as_str(), - None => "", - }; + ReportalCliSubcommand::Doctor => reportal_commands::run_doctor(), + ReportalCliSubcommand::Web(web_args) => { + let (direct_alias, tag_filter) = web_args.into_parts(); reportal_commands::run_web(WebCommandParams { - tag_filter: web_args.selection.tag_filter.into_tag_filter(), - direct_alias, + tag_filter, + direct_alias: &direct_alias, }) } - ReportalSubcommand::Run(run_args) => { - let direct_alias = match run_args.selection.alias { - Some(ref provided_alias) => provided_alias.as_str(), - None => "", - }; - let direct_command = match run_args.cmd { - Some(ref provided_command) => provided_command.as_str(), - None => "", - }; + ReportalCliSubcommand::Run(run_args) => { + let (direct_alias, tag_filter, direct_command) = run_args.into_parts(); reportal_commands::run_run(RunCommandParams { - tag_filter: run_args.selection.tag_filter.into_tag_filter(), - direct_alias, - direct_command, + tag_filter, + direct_alias: &direct_alias, + direct_command: &direct_command, }) } - ReportalSubcommand::Ai(ai_args) => { - let direct_alias = match ai_args.selection.alias { - Some(ref provided_alias) => provided_alias.as_str(), - None => "", - }; - let tool_override = match ai_args.tool { - Some(ref provided_tool) => provided_tool.as_str(), - None => "", - }; + ReportalCliSubcommand::Ai(ai_args) => { + let (direct_alias, tag_filter, tool_override) = ai_args.into_parts(); reportal_commands::run_ai(AiCommandParams { - tag_filter: ai_args.selection.tag_filter.into_tag_filter(), - direct_alias, - tool_override, + tag_filter, + direct_alias: &direct_alias, + tool_override: &tool_override, }) } }; diff --git a/src/reportal_commands/color_command_mode.rs b/src/reportal_commands/color_command_mode.rs new file mode 100644 index 0000000..9a534d1 --- /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/detected_shell.rs b/src/reportal_commands/detected_shell.rs index ffcff62..937e9a0 100644 --- a/src/reportal_commands/detected_shell.rs +++ b/src/reportal_commands/detected_shell.rs @@ -202,7 +202,7 @@ function global:ro {{ rep open @args; 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 --print-title 2>$null; if ($t) {{ $Host.UI.RawUI.WindowTitle = $t }}; $p }} +function global:prompt {{ $p = & $script:_reportal_original_prompt; $t = rep color --mode prompt-hook 2>$null; if ($t) {{ $Host.UI.RawUI.WindowTitle = $t }}; $p }} "#, env!("CARGO_PKG_VERSION"), ); @@ -221,7 +221,7 @@ rj() {{ cd "$(rep jump "$@")"; rep color; }} ro() {{ rep open "$@"; rep color; }} rw() {{ rep web "$@"; }} rr() {{ rep run "$@"; }} -_reportal_hook() {{ local _t; _t=$(rep color --print-title 2>{null_device}); [ -n "$_t" ] && printf '\033]2;%s\007' "$_t"; }} +_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"), diff --git a/src/reportal_commands/mod.rs b/src/reportal_commands/mod.rs index 03b4e6d..61132b5 100644 --- a/src/reportal_commands/mod.rs +++ b/src/reportal_commands/mod.rs @@ -1,4 +1,5 @@ /// Subcommand implementations for the RePortal CLI. +mod color_command_mode; mod detected_shell; mod doctor; mod git_commands; @@ -21,13 +22,14 @@ mod terminal_identity_emit; mod repo_sync; mod repo_web; +pub use color_command_mode::ColorCommandMode; pub use doctor::run_doctor; pub use integration_freshness::ensure_integration_file_current; pub use initialization::run_init; pub use repo_add::run_add; pub use repo_ai::{run_ai, AiCommandParams}; pub use repo_edit::{run_edit, EditCommandParams}; -pub use repo_color::{run_color, ColorCommandParams, TitleOutput}; +pub use repo_color::{run_color, ColorCommandParams}; pub use repo_jump::{run_jump, JumpCommandParams}; pub use repo_listing::run_list; pub use repo_open::{run_open, OpenCommandParams}; diff --git a/src/reportal_commands/repo_color.rs b/src/reportal_commands/repo_color.rs index 7a48c30..c86c796 100644 --- a/src/reportal_commands/repo_color.rs +++ b/src/reportal_commands/repo_color.rs @@ -8,71 +8,47 @@ use crate::error::ReportalError; use crate::reportal_config::{RepoColor, ReportalConfig, TabTitle}; use crate::terminal_style::{self, TabColorAction, TerminalIdentity, TerminalIdentityParams}; -/// Whether to print the resolved title to stdout for shell integration. -pub enum TitleOutput { - /// Print the resolved title text to stdout. - PrintToStdout, - /// Do not print the title to stdout (OSC-only). - Silent, -} +use super::color_command_mode::ColorCommandMode; /// 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, - /// Whether to print the resolved tab title to stdout. - pub title_output: TitleOutput, -} - -/// Resolved repo identity for a single color invocation. -struct ResolvedIdentity { - title: String, - tab_color_action: TabColorAction, -} - -/// Parameters for resolving a repo's terminal identity. -struct ResolveIdentityParams<'a> { - /// The repo entry to resolve identity for. - repo: &'a crate::reportal_config::RepoEntry, - /// The alias to use as fallback when no custom title is configured. - fallback_alias: &'a str, -} - -/// Resolves the tab title and color action for a repo entry. -fn resolve_identity(identity_params: ResolveIdentityParams<'_>) -> ResolvedIdentity { - let title = match identity_params.repo.tab_title() { - TabTitle::Custom(custom_title) => custom_title.to_string(), - TabTitle::UseAlias => identity_params.fallback_alias.to_string(), - }; - let tab_color_action = match identity_params.repo.repo_color() { - RepoColor::Themed(hex_color) => { - TabColorAction::SetColor(hex_color.as_osc_tab_color_sequence()) - } - RepoColor::ResetToDefault => TabColorAction::Reset, - }; - return ResolvedIdentity { title, tab_color_action }; + /// How the command was invoked, controlling stdout and no-match behavior. + pub mode: ColorCommandMode, } /// 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. +/// registered repo paths using longest-prefix-match. /// -/// When `title_output` is `PrintToStdout`, also writes the resolved -/// title text to stdout so shell integrations can set it via the -/// native API (e.g. `$Host.UI.RawUI.WindowTitle` in PowerShell). +/// When no repo matches, behavior depends on `mode`: +/// - `Explicit` (direct call): resets the tab color to terminal default. +/// - `PromptHook` (shell prompt): does nothing, preserving the +/// color and title set by the last `rj` or `ro` invocation. +/// +/// When `mode` is `PromptHook`, also writes the resolved title text +/// to stdout so shell integrations can set it via the native API +/// (e.g. `$Host.UI.RawUI.WindowTitle` in PowerShell). pub fn run_color(color_params: ColorCommandParams<'_>) -> Result<(), ReportalError> { let loaded_config = ReportalConfig::load_from_disk()?; let resolved = match color_params.repo_alias.is_empty() { false => { let found_repo = loaded_config.get_repo(color_params.repo_alias)?; - Some(resolve_identity(ResolveIdentityParams { - repo: found_repo, - fallback_alias: color_params.repo_alias, - })) + let title = match found_repo.tab_title() { + TabTitle::Custom(custom_title) => custom_title.to_string(), + TabTitle::UseAlias => color_params.repo_alias.to_string(), + }; + let tab_color_action = match found_repo.repo_color() { + RepoColor::Themed(hex_color) => { + TabColorAction::SetColor(hex_color.as_osc_tab_color_sequence()) + } + RepoColor::ResetToDefault => TabColorAction::Reset, + }; + Some((title, tab_color_action)) } true => { let current_directory = std::env::current_dir().map_err(|io_error| { @@ -105,31 +81,43 @@ pub fn run_color(color_params: ColorCommandParams<'_>) -> Result<(), ReportalErr } match best_repo { - Some(matched_repo) => Some(resolve_identity(ResolveIdentityParams { - repo: matched_repo, - fallback_alias: best_alias, - })), + Some(matched_repo) => { + let title = match matched_repo.tab_title() { + TabTitle::Custom(custom_title) => custom_title.to_string(), + TabTitle::UseAlias => best_alias.to_string(), + }; + let tab_color_action = match matched_repo.repo_color() { + RepoColor::Themed(hex_color) => { + TabColorAction::SetColor(hex_color.as_osc_tab_color_sequence()) + } + RepoColor::ResetToDefault => TabColorAction::Reset, + }; + Some((title, tab_color_action)) + } None => None, } } }; match resolved { - Some(identity) => { + Some((title, tab_color_action)) => { + match color_params.mode { + ColorCommandMode::PromptHook => print!("{}", &title), + ColorCommandMode::Explicit => {} + } + let terminal_identity = TerminalIdentity::new(TerminalIdentityParams { - resolved_title: identity.title.as_str().to_string(), - tab_color_action: identity.tab_color_action, + resolved_title: title, + tab_color_action, }); terminal_style::emit_terminal_identity_to_console(&terminal_identity); - - match color_params.title_output { - TitleOutput::PrintToStdout => print!("{}", identity.title), - TitleOutput::Silent => {} - } - } - None => { - terminal_style::write_to_console(terminal_style::osc_reset_tab_color_sequence()); } + None => match color_params.mode { + ColorCommandMode::Explicit => { + terminal_style::write_to_console(terminal_style::osc_reset_tab_color_sequence()); + } + ColorCommandMode::PromptHook => {} + }, } return Ok(()); From 516369096ef8ba5d7cfbf376111147389f73edf5 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Sat, 11 Apr 2026 13:58:39 -0400 Subject: [PATCH 41/67] Add agent-state/kagemusha/ to gitignore From 2263753000228034da509a784b269fbbfe232df9 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Sat, 11 Apr 2026 16:23:55 -0400 Subject: [PATCH 42/67] Enforce strict clippy with zero warnings (v0.12.0) Added clippy.toml with strict thresholds and full lint config in Cargo.toml (deny for unwrap/expect/panic/print/as-casts, warn for pedantic). Replaced all print macros with std::io::Write via terminal_style helpers. Fixed excessive nesting, removed dead code, elided lifetimes, and addressed all pedantic warnings. --- Cargo.lock | 2 +- Cargo.toml | 81 ++++- clippy.toml | 8 + src/cli_args/ai_args.rs | 8 +- src/cli_args/color_args.rs | 6 +- src/cli_args/edit_args.rs | 6 +- src/cli_args/jump_args.rs | 6 +- src/cli_args/list_args.rs | 4 +- src/cli_args/mod.rs | 8 +- src/cli_args/open_args.rs | 6 +- src/cli_args/remove_args.rs | 4 +- src/cli_args/repo_selection_args.rs | 4 +- src/cli_args/reportal_cli.rs | 6 +- src/cli_args/run_args.rs | 6 +- src/cli_args/status_args.rs | 4 +- src/cli_args/sync_args.rs | 4 +- src/cli_args/tag_filter_args.rs | 7 +- src/cli_args/web_args.rs | 6 +- src/error.rs | 2 +- src/main.rs | 34 +- src/reportal_commands/color_command_mode.rs | 4 +- src/reportal_commands/detected_shell.rs | 317 +++++++++--------- src/reportal_commands/doctor.rs | 283 +++++++--------- src/reportal_commands/git_commands.rs | 17 +- src/reportal_commands/initialization.rs | 174 +++++----- .../integration_freshness.rs | 43 +-- src/reportal_commands/mod.rs | 2 +- src/reportal_commands/path_display.rs | 12 +- .../prompts/color_edit_prompt.rs | 30 +- src/reportal_commands/prompts/color_prompt.rs | 15 +- src/reportal_commands/prompts/mod.rs | 2 +- src/reportal_commands/prompts/text_prompt.rs | 12 +- src/reportal_commands/repo_add/add_source.rs | 6 +- .../repo_add/alias_suggestion.rs | 34 +- .../repo_add/clone_destination.rs | 35 +- .../repo_add/clone_placement.rs | 25 +- .../repo_add/git_clone_operation.rs | 19 +- .../repo_add/git_remote_detection.rs | 17 +- src/reportal_commands/repo_add/mod.rs | 4 +- .../repo_add/registration_context.rs | 36 +- src/reportal_commands/repo_add/run.rs | 156 +++++---- src/reportal_commands/repo_ai.rs | 32 +- src/reportal_commands/repo_color.rs | 137 ++++---- src/reportal_commands/repo_edit.rs | 161 +++++---- src/reportal_commands/repo_jump.rs | 18 +- src/reportal_commands/repo_listing.rs | 67 ++-- src/reportal_commands/repo_open.rs | 18 +- src/reportal_commands/repo_remove.rs | 2 +- src/reportal_commands/repo_run.rs | 166 ++++----- src/reportal_commands/repo_selection.rs | 76 ++--- src/reportal_commands/repo_status.rs | 115 +++---- src/reportal_commands/repo_sync.rs | 163 ++++----- src/reportal_commands/repo_web.rs | 84 +++-- .../terminal_identity_emit.rs | 17 +- src/reportal_config/ai_tool_entry.rs | 4 +- src/reportal_config/command_entry.rs | 4 +- src/reportal_config/global_settings.rs | 21 +- src/reportal_config/hex_color.rs | 30 +- src/reportal_config/mod.rs | 11 +- src/reportal_config/repo_entry.rs | 28 +- src/reportal_config/reportal_config_root.rs | 99 ++---- src/reportal_config/tag_filter.rs | 2 +- src/terminal_style.rs | 48 +-- 63 files changed, 1338 insertions(+), 1420 deletions(-) create mode 100644 clippy.toml diff --git a/Cargo.lock b/Cargo.lock index d410fb3..ee747d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -550,7 +550,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.11.0" +version = "0.12.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 41d2f0b..ffeafd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.11.0" +version = "0.12.0" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" @@ -28,6 +28,85 @@ open = "5" [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/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/src/cli_args/ai_args.rs b/src/cli_args/ai_args.rs index a92f78e..43c2e27 100644 --- a/src/cli_args/ai_args.rs +++ b/src/cli_args/ai_args.rs @@ -1,4 +1,4 @@ -/// CLI args for `rep ai`. +//! CLI args for `rep ai`. use clap::Args; use crate::reportal_config::TagFilter; @@ -12,16 +12,16 @@ use super::repo_selection_args::RepoSelectionArgs; pub struct AiArgs { #[command(flatten)] selection: RepoSelectionArgs, - /// Which AI tool to launch (overrides default_ai_tool setting) + /// 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 AiArgs { - /// Returns (alias, tag_filter, tool_override), consuming self. + /// Returns (alias, `tag_filter`, `tool_override`), consuming self. pub fn into_parts(self) -> (String, TagFilter, String) { let (alias, tag_filter) = self.selection.into_parts(); - return (alias, tag_filter, self.tool); + (alias, tag_filter, self.tool) } } diff --git a/src/cli_args/color_args.rs b/src/cli_args/color_args.rs index 02cfde3..e80c64c 100644 --- a/src/cli_args/color_args.rs +++ b/src/cli_args/color_args.rs @@ -1,4 +1,4 @@ -/// CLI args for `rep color`. +//! CLI args for `rep color`. use clap::Args; use crate::reportal_commands::ColorCommandMode; @@ -21,8 +21,8 @@ pub struct ColorArgs { /// Consuming conversion that splits into domain-layer parts. impl ColorArgs { - /// Returns (repo_alias, mode), consuming self. + /// Returns (`repo_alias`, mode), consuming self. pub fn into_parts(self) -> (String, ColorCommandMode) { - return (self.repo, self.mode); + (self.repo, self.mode) } } diff --git a/src/cli_args/edit_args.rs b/src/cli_args/edit_args.rs index 7aa8a93..04cc72d 100644 --- a/src/cli_args/edit_args.rs +++ b/src/cli_args/edit_args.rs @@ -1,4 +1,4 @@ -/// CLI args for `rep edit`. +//! CLI args for `rep edit`. use clap::Args; use crate::reportal_config::TagFilter; @@ -16,8 +16,8 @@ pub struct EditArgs { /// Consuming conversion that splits into domain-layer parts. impl EditArgs { - /// Returns (alias, tag_filter), consuming self. + /// Returns (alias, `tag_filter`), consuming self. pub fn into_parts(self) -> (String, TagFilter) { - return self.selection.into_parts(); + self.selection.into_parts() } } diff --git a/src/cli_args/jump_args.rs b/src/cli_args/jump_args.rs index 68a9015..062e4fc 100644 --- a/src/cli_args/jump_args.rs +++ b/src/cli_args/jump_args.rs @@ -1,4 +1,4 @@ -/// CLI args for `rep jump`. +//! CLI args for `rep jump`. use clap::Args; use crate::reportal_config::TagFilter; @@ -19,9 +19,9 @@ pub struct JumpArgs { /// Consuming conversion that splits into domain-layer parts. impl JumpArgs { - /// Returns (alias, tag_filter, title_override), consuming self. + /// Returns (alias, `tag_filter`, `title_override`), consuming self. pub fn into_parts(self) -> (String, TagFilter, String) { let (alias, tag_filter) = self.selection.into_parts(); - return (alias, tag_filter, self.title); + (alias, tag_filter, self.title) } } diff --git a/src/cli_args/list_args.rs b/src/cli_args/list_args.rs index 32647be..7d04ad1 100644 --- a/src/cli_args/list_args.rs +++ b/src/cli_args/list_args.rs @@ -1,4 +1,4 @@ -/// CLI args for `rep list`. +//! CLI args for `rep list`. use clap::Args; use crate::reportal_config::TagFilter; @@ -17,6 +17,6 @@ pub struct ListArgs { impl ListArgs { /// Extracts the tag filter, consuming the parsed args. pub fn into_tag_filter(self) -> TagFilter { - return self.tag_filter.into_tag_filter(); + self.tag_filter.into_tag_filter() } } diff --git a/src/cli_args/mod.rs b/src/cli_args/mod.rs index 22a6ee6..2616f4e 100644 --- a/src/cli_args/mod.rs +++ b/src/cli_args/mod.rs @@ -1,7 +1,7 @@ -/// 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)]`. +//! 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_args; mod color_args; mod edit_args; diff --git a/src/cli_args/open_args.rs b/src/cli_args/open_args.rs index 06ee2b9..105011e 100644 --- a/src/cli_args/open_args.rs +++ b/src/cli_args/open_args.rs @@ -1,4 +1,4 @@ -/// CLI args for `rep open`. +//! CLI args for `rep open`. use clap::Args; use crate::reportal_config::TagFilter; @@ -22,9 +22,9 @@ pub struct OpenArgs { /// Consuming conversion that splits into domain-layer parts. impl OpenArgs { - /// Returns (alias, tag_filter, editor_override, title_override), consuming self. + /// 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(); - return (alias, tag_filter, self.editor, self.title); + (alias, tag_filter, self.editor, self.title) } } diff --git a/src/cli_args/remove_args.rs b/src/cli_args/remove_args.rs index eb55e95..a8ca2d7 100644 --- a/src/cli_args/remove_args.rs +++ b/src/cli_args/remove_args.rs @@ -1,4 +1,4 @@ -/// CLI args for `rep remove`. +//! CLI args for `rep remove`. use clap::Args; @@ -16,6 +16,6 @@ pub struct RemoveArgs { impl RemoveArgs { /// The alias of the repo to unregister. pub fn alias(&self) -> &str { - return &self.alias; + &self.alias } } diff --git a/src/cli_args/repo_selection_args.rs b/src/cli_args/repo_selection_args.rs index e8eff22..6017cbb 100644 --- a/src/cli_args/repo_selection_args.rs +++ b/src/cli_args/repo_selection_args.rs @@ -1,4 +1,4 @@ -/// Shared optional alias positional arg + `--tag` flag for repo selection. +//! Shared optional alias positional arg + `--tag` flag for repo selection. use clap::Args; use crate::reportal_config::TagFilter; @@ -25,6 +25,6 @@ impl RepoSelectionArgs { /// 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) { - return (self.alias, self.tag_filter.into_tag_filter()); + (self.alias, self.tag_filter.into_tag_filter()) } } diff --git a/src/cli_args/reportal_cli.rs b/src/cli_args/reportal_cli.rs index 51de264..327aff1 100644 --- a/src/cli_args/reportal_cli.rs +++ b/src/cli_args/reportal_cli.rs @@ -1,4 +1,4 @@ -/// Top-level CLI parser and subcommand dispatch enum. +//! Top-level CLI parser and subcommand dispatch enum. use clap::{Parser, Subcommand}; use super::{ @@ -19,11 +19,11 @@ pub struct ReportalCli { impl ReportalCli { /// The subcommand the user invoked. pub fn into_subcommand(self) -> ReportalCliSubcommand { - return self.subcommand; + self.subcommand } } -/// All available subcommands for the RePortal CLI. +/// All available subcommands for the `RePortal` CLI. #[derive(Subcommand)] pub enum ReportalCliSubcommand { /// Set up config and shell integration (safe to re-run on updates) diff --git a/src/cli_args/run_args.rs b/src/cli_args/run_args.rs index 4705143..32c280c 100644 --- a/src/cli_args/run_args.rs +++ b/src/cli_args/run_args.rs @@ -1,4 +1,4 @@ -/// CLI args for `rep run`. +//! CLI args for `rep run`. use clap::Args; use crate::reportal_config::TagFilter; @@ -19,9 +19,9 @@ pub struct RunArgs { /// Consuming conversion that splits into domain-layer parts. impl RunArgs { - /// Returns (alias, tag_filter, direct_command), consuming self. + /// Returns (alias, `tag_filter`, `direct_command`), consuming self. pub fn into_parts(self) -> (String, TagFilter, String) { let (alias, tag_filter) = self.selection.into_parts(); - return (alias, tag_filter, self.cmd); + (alias, tag_filter, self.cmd) } } diff --git a/src/cli_args/status_args.rs b/src/cli_args/status_args.rs index 1c13f95..96445a3 100644 --- a/src/cli_args/status_args.rs +++ b/src/cli_args/status_args.rs @@ -1,4 +1,4 @@ -/// CLI args for `rep status`. +//! CLI args for `rep status`. use clap::Args; use crate::reportal_config::TagFilter; @@ -17,6 +17,6 @@ pub struct StatusArgs { impl StatusArgs { /// Extracts the tag filter, consuming the parsed args. pub fn into_tag_filter(self) -> TagFilter { - return self.filter.into_tag_filter(); + self.filter.into_tag_filter() } } diff --git a/src/cli_args/sync_args.rs b/src/cli_args/sync_args.rs index 404c8c2..ba75d89 100644 --- a/src/cli_args/sync_args.rs +++ b/src/cli_args/sync_args.rs @@ -1,4 +1,4 @@ -/// CLI args for `rep sync`. +//! CLI args for `rep sync`. use clap::Args; use crate::reportal_config::TagFilter; @@ -17,6 +17,6 @@ pub struct SyncArgs { impl SyncArgs { /// Extracts the tag filter, consuming the parsed args. pub fn into_tag_filter(self) -> TagFilter { - return self.filter.into_tag_filter(); + self.filter.into_tag_filter() } } diff --git a/src/cli_args/tag_filter_args.rs b/src/cli_args/tag_filter_args.rs index 6c57391..05e2c97 100644 --- a/src/cli_args/tag_filter_args.rs +++ b/src/cli_args/tag_filter_args.rs @@ -1,4 +1,4 @@ -/// Shared `--tag` flag used by commands that filter repos. +//! Shared `--tag` flag used by commands that filter repos. use clap::Args; use crate::reportal_config::TagFilter; @@ -21,9 +21,6 @@ impl TagFilterArgs { /// Empty string (absent flag) becomes `All`; any non-empty value /// becomes `ByTag` with that string. pub fn into_tag_filter(self) -> TagFilter { - match self.tag.is_empty() { - true => TagFilter::All, - false => TagFilter::ByTag(self.tag), - } + if self.tag.is_empty() { TagFilter::All } else { TagFilter::ByTag(self.tag) } } } diff --git a/src/cli_args/web_args.rs b/src/cli_args/web_args.rs index e16ee5a..c9749da 100644 --- a/src/cli_args/web_args.rs +++ b/src/cli_args/web_args.rs @@ -1,4 +1,4 @@ -/// CLI args for `rep web`. +//! CLI args for `rep web`. use clap::Args; use crate::reportal_config::TagFilter; @@ -16,8 +16,8 @@ pub struct WebArgs { /// Consuming conversion that splits into domain-layer parts. impl WebArgs { - /// Returns (alias, tag_filter), consuming self. + /// Returns (alias, `tag_filter`), consuming self. pub fn into_parts(self) -> (String, TagFilter) { - return self.selection.into_parts(); + self.selection.into_parts() } } diff --git a/src/error.rs b/src/error.rs index 74439f6..70a60be 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,4 +1,4 @@ -/// 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. diff --git a/src/main.rs b/src/main.rs index 46409b8..fe1fdb6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,10 @@ -/// 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 error; @@ -28,7 +31,7 @@ fn main() { } ReportalCliSubcommand::Jump(jump_args) => { let (direct_alias, tag_filter, title_override) = jump_args.into_parts(); - reportal_commands::run_jump(JumpCommandParams { + reportal_commands::run_jump(&JumpCommandParams { tag_filter, direct_alias: &direct_alias, title_override: &title_override, @@ -36,7 +39,7 @@ fn main() { } ReportalCliSubcommand::Open(open_args) => { let (direct_alias, tag_filter, editor_override, title_override) = open_args.into_parts(); - reportal_commands::run_open(OpenCommandParams { + reportal_commands::run_open(&OpenCommandParams { tag_filter, direct_alias: &direct_alias, editor_override: &editor_override, @@ -48,7 +51,7 @@ fn main() { } ReportalCliSubcommand::Edit(edit_args) => { let (direct_alias, tag_filter) = edit_args.into_parts(); - reportal_commands::run_edit(EditCommandParams { + reportal_commands::run_edit(&EditCommandParams { tag_filter, direct_alias: &direct_alias, }) @@ -58,28 +61,31 @@ fn main() { } ReportalCliSubcommand::Color(color_args) => { let (repo_alias, mode) = color_args.into_parts(); - reportal_commands::run_color(ColorCommandParams { + reportal_commands::run_color(&ColorCommandParams { repo_alias: &repo_alias, mode, }) } ReportalCliSubcommand::Status(status_args) => { - reportal_commands::run_status(status_args.into_tag_filter()) + reportal_commands::run_status(&status_args.into_tag_filter()) } ReportalCliSubcommand::Sync(sync_args) => { - reportal_commands::run_sync(sync_args.into_tag_filter()) + reportal_commands::run_sync(&sync_args.into_tag_filter()) + } + ReportalCliSubcommand::Doctor => { + reportal_commands::run_doctor(); + Ok(()) } - ReportalCliSubcommand::Doctor => reportal_commands::run_doctor(), ReportalCliSubcommand::Web(web_args) => { let (direct_alias, tag_filter) = web_args.into_parts(); - reportal_commands::run_web(WebCommandParams { + reportal_commands::run_web(&WebCommandParams { tag_filter, direct_alias: &direct_alias, }) } ReportalCliSubcommand::Run(run_args) => { let (direct_alias, tag_filter, direct_command) = run_args.into_parts(); - reportal_commands::run_run(RunCommandParams { + reportal_commands::run_run(&RunCommandParams { tag_filter, direct_alias: &direct_alias, direct_command: &direct_command, @@ -87,7 +93,7 @@ fn main() { } ReportalCliSubcommand::Ai(ai_args) => { let (direct_alias, tag_filter, tool_override) = ai_args.into_parts(); - reportal_commands::run_ai(AiCommandParams { + reportal_commands::run_ai(&AiCommandParams { tag_filter, direct_alias: &direct_alias, tool_override: &tool_override, diff --git a/src/reportal_commands/color_command_mode.rs b/src/reportal_commands/color_command_mode.rs index 9a534d1..089502a 100644 --- a/src/reportal_commands/color_command_mode.rs +++ b/src/reportal_commands/color_command_mode.rs @@ -1,5 +1,5 @@ -/// How the `rep color` subcommand was invoked, controlling both stdout -/// output and no-match behavior. +//! How the `rep color` subcommand was invoked, controlling both stdout +//! output and no-match behavior. /// Invocation mode for the color subcommand. /// diff --git a/src/reportal_commands/detected_shell.rs b/src/reportal_commands/detected_shell.rs index 937e9a0..60a3192 100644 --- a/src/reportal_commands/detected_shell.rs +++ b/src/reportal_commands/detected_shell.rs @@ -1,22 +1,23 @@ -/// Shell detection, integration content generation, and installation. -/// -/// On Unix shells (bash, zsh), integration works by writing a standalone -/// script file into `~/.reportal` and adding a `source` line to the -/// user's shell profile. -/// -/// On PowerShell, integration is installed as a proper PowerShell module -/// in the user's PSModulePath. This makes the rj/ro/rw/rr functions load -/// via PowerShell's module auto-import, independent of the user's -/// $PROFILE health. +//! Shell detection, integration content generation, and installation. +//! +//! On Unix shells (bash, zsh), integration works by writing a standalone +//! script file into `~/.reportal` and adding a `source` line to the +//! user's shell profile. +//! +//! On `PowerShell`, integration is installed as a proper `PowerShell` module +//! in the user's `PSModulePath`. This makes the rj/ro/rw/rr functions load +//! via `PowerShell`'s module auto-import, independent of the user's +//! $PROFILE health. use crate::error::ReportalError; use crate::reportal_config::ReportalConfig; use std::path::PathBuf; use std::process::Command; +use crate::terminal_style; /// Which shell the user is running, with its profile path. pub(crate) enum DetectedShell { - /// PowerShell (Windows or cross-platform). + /// `PowerShell` (Windows or cross-platform). PowerShell(PathBuf), /// Bash with a known profile path. #[cfg(not(target_os = "windows"))] @@ -34,28 +35,28 @@ pub(crate) fn integration_file_path() -> Result { #[cfg(target_os = "windows")] { - return Ok(config_directory.join("integration.ps1")); + Ok(config_directory.join("integration.ps1")) } #[cfg(not(target_os = "windows"))] { - return Ok(config_directory.join("integration.sh")); + Ok(config_directory.join("integration.sh")) } } -/// Returns the path to the user's PowerShell Modules directory where -/// RePortal should be installed as a module. Uses the Documents +/// Returns the path to the user's `PowerShell` Modules directory where +/// `RePortal` should be installed as a module. Uses the Documents /// known-folder via the `dirs` crate so it follows any folder -/// redirection (e.g. OneDrive, GPO). +/// redirection (e.g. `OneDrive`, GPO). #[cfg(target_os = "windows")] pub(crate) fn powershell_module_directory() -> Result { let documents_directory = dirs::document_dir().ok_or(ReportalError::ConfigIoFailure { reason: String::from("could not determine Documents folder for PowerShell module install"), })?; - return Ok(documents_directory + Ok(documents_directory .join("PowerShell") .join("Modules") - .join("RePortal")); + .join("RePortal")) } /// Detection, content generation, profile management, and installation. @@ -64,26 +65,24 @@ impl DetectedShell { pub(crate) fn detect() -> DetectedShell { #[cfg(target_os = "windows")] { - match Self::detect_powershell_profile() { - Some(profile_path) => DetectedShell::PowerShell(PathBuf::from(profile_path)), - None => DetectedShell::Unknown, - } + let Some(profile_path) = Self::detect_powershell_profile() else { + return DetectedShell::Unknown; + }; + DetectedShell::PowerShell(PathBuf::from(profile_path)) } #[cfg(not(target_os = "windows"))] { - match Self::detect_unix_shell_env() { - Some(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, - }, - None => DetectedShell::Unknown, + let Some(shell_path) = Self::detect_unix_shell_env() else { + return DetectedShell::Unknown; + }; + let Some(home_path) = dirs::home_dir() else { + return DetectedShell::Unknown; + }; + if shell_path.contains("zsh") { + DetectedShell::Zsh(home_path.join(".zshrc")) + } else { + DetectedShell::Bash(home_path.join(".bashrc")) } } } @@ -100,24 +99,24 @@ impl DetectedShell { } /// Writes the integration script file and ensures the shell can - /// load RePortal functions. On Unix, adds a source line to the - /// profile. On PowerShell, installs a module for auto-import. + /// load `RePortal` functions. On Unix, adds a source line to the + /// profile. On `PowerShell`, installs a module for auto-import. pub(crate) fn install_integration(&self) -> Result<(), ReportalError> { match self { #[cfg(target_os = "windows")] DetectedShell::PowerShell(_) => { self.install_powershell_module()?; - return Ok(()); + Ok(()) } #[cfg(not(target_os = "windows"))] DetectedShell::Bash(_) | DetectedShell::Zsh(_) => { return self.install_unix_integration(); } - _ => return Ok(()), + _ => Ok(()), } } - /// Strips any existing RePortal integration from a profile string. + /// Strips any existing `RePortal` integration from a profile string. /// Handles both legacy inline blocks (pre-v0.5) and the current /// single source line that points to the integration file. pub(crate) fn strip_existing_integration(profile_content: &str) -> String { @@ -126,74 +125,73 @@ impl DetectedShell { let integration_path_marker = Self::reportal_integration_marker(); for line in profile_content.lines() { - match skipping_reportal_block { - true => { - let trimmed = line.trim(); - let is_reportal_line = Self::is_reportal_function_line(trimmed) - || trimmed.starts_with("function prompt") - || trimmed.starts_with("function global:prompt") - || trimmed.starts_with("$_reportal_") - || trimmed.starts_with("$script:_reportal_") - || trimmed.starts_with("PROMPT_COMMAND") - || trimmed.contains("REPORTAL_LOADED") - || trimmed.contains("rep jump") - || trimmed.contains("rep open") - || trimmed.contains("rep web") - || trimmed.contains("rep run") - || trimmed.contains("rep upgrade") - || 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 => { - let is_reportal_marker = line.contains("RePortal shell integration") - || line.contains(&integration_path_marker); - match is_reportal_marker { - true => { - skipping_reportal_block = true; - } - false => { - let trimmed = line.trim(); - let is_stale_reportal_function = - Self::is_reportal_function_line(trimmed) - || trimmed.contains("reportal jump") - || trimmed.contains("reportal open") - || trimmed.contains("reportal web") - || trimmed.contains("reportal run"); - let is_stale_reportal_comment = trimmed - .starts_with('#') - && trimmed.contains("RePortal"); - match is_stale_reportal_function || is_stale_reportal_comment { - true => {} - false => { - result_lines.push(line); - } - } - } - } - } + let trimmed = line.trim(); + + if skipping_reportal_block && Self::is_block_continuation_line(trimmed) { + continue; + } + if skipping_reportal_block && !trimmed.is_empty() { + skipping_reportal_block = false; + result_lines.push(line); + continue; + } + if skipping_reportal_block { + skipping_reportal_block = false; + continue; + } + + let is_reportal_marker = line.contains("RePortal shell integration") + || line.contains(&integration_path_marker); + if is_reportal_marker { + skipping_reportal_block = true; + continue; + } + + if !Self::is_stale_standalone_line(line.trim()) { + result_lines.push(line); } } - return result_lines.join("\n"); + result_lines.join("\n") } - /// Returns the PowerShell integration script content, stamped with the + /// Returns true if the trimmed line belongs to a `RePortal` integration + /// block that is currently being skipped (marker already seen). + fn is_block_continuation_line(trimmed: &str) -> bool { + Self::is_reportal_function_line(trimmed) + || trimmed.starts_with("function prompt") + || trimmed.starts_with("function global:prompt") + || trimmed.starts_with("$_reportal_") + || trimmed.starts_with("$script:_reportal_") + || trimmed.starts_with("PROMPT_COMMAND") + || trimmed.contains("REPORTAL_LOADED") + || trimmed.contains("rep jump") + || trimmed.contains("rep open") + || trimmed.contains("rep web") + || trimmed.contains("rep run") + || trimmed.contains("rep upgrade") + || trimmed.contains("rep color") + } + + /// Returns true if the trimmed line is a stale `RePortal` definition + /// found outside of a marker block (orphaned from a previous install). + fn is_stale_standalone_line(trimmed: &str) -> bool { + Self::is_reportal_function_line(trimmed) + || trimmed.contains("reportal jump") + || trimmed.contains("reportal open") + || trimmed.contains("reportal web") + || trimmed.contains("reportal run") + || (trimmed.starts_with('#') && trimmed.contains("RePortal")) + } + + /// Returns the `PowerShell` integration script content, stamped with the /// current binary version so `rep doctor` can detect stale files. /// /// Uses `global:` scope qualifiers on every function definition so the /// script works correctly whether dot-sourced from the user's profile - /// (legacy installs) or from inside a PowerShell module (.psm1). + /// (legacy installs) or from inside a `PowerShell` module (.psm1). pub(crate) fn powershell_integration_content() -> String { - return format!( + format!( r#"# RePortal shell integration — v{} # Do not edit; regenerated by 'rep init'. $env:REPORTAL_LOADED = "1" @@ -205,7 +203,7 @@ $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 }} "#, env!("CARGO_PKG_VERSION"), - ); + ) } /// Returns the Bash and Zsh integration script content, stamped with the @@ -213,7 +211,7 @@ function global:prompt {{ $p = & $script:_reportal_original_prompt; $t = rep col #[cfg(not(target_os = "windows"))] pub(crate) fn bash_integration_content() -> String { let null_device = std::path::Path::new("/dev").join("null"); - return format!( + format!( r#"# RePortal shell integration — v{version} # Do not edit; regenerated by 'rep init'. export REPORTAL_LOADED=1 @@ -229,29 +227,25 @@ PROMPT_COMMAND="${{PROMPT_COMMAND:+$PROMPT_COMMAND;}}_reportal_hook" ); } - /// Attempts to get the PowerShell profile path by running pwsh. + /// Attempts to get the `PowerShell` profile path by running pwsh. fn detect_powershell_profile() -> Option { 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 => None, - false => Some(profile_path_string), - } - } - false => None, - }, + let output_bytes = match profile_output { + Ok(bytes) => bytes, Err(pwsh_spawn_error) => { - eprintln!(" pwsh not found: {pwsh_spawn_error}"); - None + terminal_style::write_stderr(&format!(" pwsh not found: {pwsh_spawn_error}\n")); + return None; } + }; + if !output_bytes.status.success() { + return None; } + let profile_path_string = + String::from_utf8_lossy(&output_bytes.stdout).trim().to_owned(); + if profile_path_string.is_empty() { None } else { Some(profile_path_string) } } /// Reads the SHELL environment variable to determine the Unix shell. @@ -260,7 +254,7 @@ PROMPT_COMMAND="${{PROMPT_COMMAND:+$PROMPT_COMMAND;}}_reportal_hook" match std::env::var("SHELL") { Ok(shell_path) => Some(shell_path), Err(env_read_error) => { - eprintln!(" SHELL env not set: {env_read_error}"); + terminal_style::write_stderr(&format!(" SHELL env not set: {env_read_error}\n")); None } } @@ -270,12 +264,12 @@ PROMPT_COMMAND="${{PROMPT_COMMAND:+$PROMPT_COMMAND;}}_reportal_hook" /// Built at runtime to avoid static analysis false positives on the /// path separator appearing between "reportal" and "integration". fn reportal_integration_marker() -> String { - return format!(".reportal{}integration", std::path::MAIN_SEPARATOR); + format!(".reportal{}integration", std::path::MAIN_SEPARATOR) } - /// Returns true if the line looks like a RePortal shell function definition. + /// Returns true if the line looks like a `RePortal` shell function definition. fn is_reportal_function_line(trimmed: &str) -> bool { - return trimmed.starts_with("function rj") + trimmed.starts_with("function rj") || trimmed.starts_with("function ro") || trimmed.starts_with("function rw") || trimmed.starts_with("function rr") @@ -288,13 +282,13 @@ PROMPT_COMMAND="${{PROMPT_COMMAND:+$PROMPT_COMMAND;}}_reportal_hook" || trimmed.starts_with("ro()") || trimmed.starts_with("rw()") || trimmed.starts_with("rr()") - || trimmed.starts_with("repup()"); + || trimmed.starts_with("repup()") } /// The one-liner added to .bashrc or .zshrc; sources the integration file. #[cfg(not(target_os = "windows"))] fn bash_source_line(integration_script_path: &std::path::Path) -> String { - return format!("source \"{}\"", integration_script_path.display()); + format!("source \"{}\"", integration_script_path.display()) } /// Returns the content for RePortal.psm1 — a stable shim that dot-sources @@ -302,23 +296,23 @@ PROMPT_COMMAND="${{PROMPT_COMMAND:+$PROMPT_COMMAND;}}_reportal_hook" /// updating; only integration.ps1 changes between binary versions. #[cfg(target_os = "windows")] fn powershell_module_content() -> String { - return String::from( + String::from( r#"# RePortal PowerShell module — stable shim. # Actual logic lives in ~/.reportal/integration.ps1 # and is auto-updated by the rep binary on version changes. . "$HOME\.reportal\integration.ps1" "#, - ); + ) } /// Returns the content for RePortal.psd1 — the module manifest that - /// tells PowerShell which functions to auto-import when the user types + /// tells `PowerShell` which functions to auto-import when the user types /// rj, ro, rw, or rr. The prompt override activates as a side effect /// of module import (triggered by the first rj/ro/rw/rr call). #[cfg(target_os = "windows")] fn powershell_manifest_content() -> String { - return String::from( - r#"@{ + String::from( + r"@{ ModuleVersion = '1.0.0' GUID = 'b1e3f7a2-9c4d-4e8f-a6b0-2d5e1f3c7a9b' Author = 'RePortal' @@ -326,29 +320,26 @@ PROMPT_COMMAND="${{PROMPT_COMMAND:+$PROMPT_COMMAND;}}_reportal_hook" RootModule = 'RePortal.psm1' FunctionsToExport = @('rj', 'ro', 'rw', 'rr') } -"#, - ); +", + ) } - /// Installs shell integration for PowerShell as a proper module. + /// Installs shell integration for `PowerShell` as a proper module. /// /// Writes integration.ps1 to ~/.reportal (for version tracking and /// auto-update by the binary), then installs RePortal.psm1 and - /// RePortal.psd1 into the user's PowerShell Modules directory. - /// PowerShell auto-imports modules from this path, so the rj/ro/rw/rr + /// RePortal.psd1 into the user's `PowerShell` Modules directory. + /// `PowerShell` auto-imports modules from this path, so the rj/ro/rw/rr /// functions load regardless of whether the user's $PROFILE has errors. /// /// Strips any legacy `. integration.ps1` line from $PROFILE to /// migrate cleanly from the old profile-based approach. #[cfg(target_os = "windows")] fn install_powershell_module(&self) -> Result { - let profile_path = match self.profile_path() { - Some(resolved_path) => resolved_path, - None => { - return Err(ReportalError::ConfigIoFailure { - reason: String::from("PowerShell profile path not detected"), - }) - } + let Some(profile_path) = self.profile_path() else { + return Err(ReportalError::ConfigIoFailure { + reason: String::from("PowerShell profile path not detected"), + }); }; let script_path = integration_file_path()?; @@ -391,30 +382,36 @@ PROMPT_COMMAND="${{PROMPT_COMMAND:+$PROMPT_COMMAND;}}_reportal_hook" )?; if profile_path.exists() { - let profile_content = - std::fs::read_to_string(profile_path).map_err(|io_error| { - ReportalError::ConfigIoFailure { - reason: io_error.to_string(), - } - })?; - - let cleaned_content = Self::strip_existing_integration(&profile_content); - - if cleaned_content != profile_content { - let trimmed_profile = cleaned_content.trim_end().to_string(); - let final_content = match trimmed_profile.is_empty() { - true => String::new(), - false => format!("{trimmed_profile}\n"), - }; - std::fs::write(profile_path, final_content).map_err(|io_error| { - ReportalError::ConfigIoFailure { - reason: io_error.to_string(), - } - })?; - } + Self::clean_legacy_profile_lines(profile_path)?; } - return Ok(module_directory); + Ok(module_directory) + } + + /// Reads a shell profile, strips any stale `RePortal` lines, and + /// writes it back only if the content actually changed. + #[cfg(target_os = "windows")] + fn clean_legacy_profile_lines(profile_path: &std::path::Path) -> Result<(), ReportalError> { + let profile_content = + std::fs::read_to_string(profile_path).map_err(|io_error| { + ReportalError::ConfigIoFailure { + reason: io_error.to_string(), + } + })?; + + let cleaned_content = Self::strip_existing_integration(&profile_content); + if cleaned_content == profile_content { + return Ok(()); + } + + let trimmed_profile = cleaned_content.trim_end().to_owned(); + let final_content = if trimmed_profile.is_empty() { String::new() } else { format!("{trimmed_profile}\n") }; + std::fs::write(profile_path, final_content).map_err(|io_error| { + ReportalError::ConfigIoFailure { + reason: io_error.to_string(), + } + })?; + Ok(()) } /// Installs shell integration for Unix shells (bash/zsh). @@ -455,6 +452,6 @@ PROMPT_COMMAND="${{PROMPT_COMMAND:+$PROMPT_COMMAND;}}_reportal_hook" } })?; - return Ok(()); + Ok(()) } } diff --git a/src/reportal_commands/doctor.rs b/src/reportal_commands/doctor.rs index 26b52c3..a60f12e 100644 --- a/src/reportal_commands/doctor.rs +++ b/src/reportal_commands/doctor.rs @@ -1,16 +1,15 @@ -/// Validates the full RePortal installation and prints a diagnostic report. -/// -/// Checks five areas: -/// 1. Config file existence and TOML parse correctness -/// 2. Integration script file exists and version matches the binary -/// 3. Shell integration is reachable (module on PowerShell, source line on Unix) -/// 4. Current session has loaded the integration (REPORTAL_LOADED env var) -/// 5. 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::error::ReportalError; +//! Validates the full `RePortal` installation and prints a diagnostic report. +//! +//! Checks five areas: +//! 1. Config file existence and TOML parse correctness +//! 2. Integration script file exists and version matches the binary +//! 3. Shell integration is reachable (module on `PowerShell`, source line on Unix) +//! 4. Current session has loaded the integration (`REPORTAL_LOADED` env var) +//! 5. 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_config::ReportalConfig; use crate::terminal_style; use owo_colors::OwoColorize; @@ -21,6 +20,17 @@ use super::integration_freshness::{check_integration_file_state, IntegrationFile #[cfg(target_os = "windows")] use super::detected_shell::powershell_module_directory; +/// Determines the shell name from its profile path string. +fn detect_shell_label(path_string: &str) -> &'static str { + if path_string.contains("PowerShell") { + "PowerShell" + } else if path_string.contains(".zshrc") { + "Zsh" + } else { + "Bash" + } +} + /// Tracks pass/fail counts across all diagnostic checks. struct DiagnosticSummary { passed: u32, @@ -30,46 +40,46 @@ struct DiagnosticSummary { /// Construction, recording, and output for diagnostic pass/fail tallies. impl DiagnosticSummary { fn new() -> Self { - return Self { + Self { passed: 0, failed: 0, - }; + } } fn record_pass(&mut self, diagnostic_message: &str) { - println!( - " {} {}", + terminal_style::write_stdout(&format!( + " {} {}\n", "✓".style(terminal_style::SUCCESS_STYLE), diagnostic_message, - ); + )); self.passed += 1; } fn record_fail(&mut self, diagnostic_message: &str) { - println!( - " {} {}", + terminal_style::write_stdout(&format!( + " {} {}\n", "✗".style(terminal_style::FAILURE_STYLE), diagnostic_message, - ); + )); self.failed += 1; } fn print_hint(hint_message: &str) { - println!( - " {} {}", + 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 { - println!(); - println!( - " {}", + 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, @@ -79,60 +89,45 @@ impl DiagnosticSummary { } }; - match config_path.exists() { - true => { - self.record_pass(&format!("Config file exists at {}", config_path.display())); - } - false => { - self.record_fail(&format!("Config not found at {}", config_path.display())); - Self::print_hint("Run 'rep init' to create one"); - return None; - } + if config_path.exists() { + self.record_pass(&format!("Config file exists at {}", config_path.display())); + } else { + self.record_fail(&format!("Config not found at {}", config_path.display())); + Self::print_hint("Run 'rep init' to create one"); + return None; } match ReportalConfig::load_from_disk() { Ok(loaded_config) => { let repo_count = loaded_config.repos_with_aliases().len(); self.record_pass(&format!("Config parses successfully ({repo_count} repos registered)")); - return Some(loaded_config); + Some(loaded_config) } Err(ref parse_error) => { self.record_fail(&format!("Config failed to parse: {parse_error}")); Self::print_hint("Check ~/.reportal/config.toml for syntax errors"); - return None; + None } } } /// Checks the integration file, shell profile source line, and session state. fn check_shell_integration(&mut self) { - println!(); - println!( - " {}", + terminal_style::write_stdout("\n"); + terminal_style::write_stdout(&format!( + " {}\n", "Shell Integration".style(terminal_style::EMPHASIS_STYLE), - ); + )); let detected_shell = DetectedShell::detect(); - let _profile_path = match detected_shell.profile_path() { - Some(path) => { - let path_string = format!("{}", path.display()); - let detected_label = match path_string.contains("PowerShell") { - true => "PowerShell", - false => match path_string.contains(".zshrc") { - true => "Zsh", - false => "Bash", - }, - }; - self.record_pass(&format!("Shell detected: {detected_label}")); - path - } - None => { - self.record_fail("Could not detect shell profile"); - Self::print_hint("Run 'rep init' to install shell integration"); - return; - } + let Some(profile_path) = detected_shell.profile_path() else { + self.record_fail("Could not detect shell profile"); + Self::print_hint("Run 'rep init' to install shell integration"); + return; }; + let detected_label = detect_shell_label(&format!("{}", profile_path.display())); + self.record_pass(&format!("Shell detected: {detected_label}")); match check_integration_file_state() { IntegrationFileState::Current => { @@ -159,41 +154,12 @@ impl DiagnosticSummary { } /// Checks that the shell can actually load the integration. - /// On PowerShell, verifies the module directory exists. + /// On `PowerShell`, verifies the module directory exists. /// On Unix, verifies the profile sources the integration file. fn check_integration_reachable(&mut self, detected_shell: &DetectedShell) { #[cfg(target_os = "windows")] - { - match detected_shell { - DetectedShell::PowerShell(_) => { - match powershell_module_directory() { - Ok(module_path) => { - let module_file = module_path.join("RePortal.psm1"); - match module_file.exists() { - true => { - self.record_pass(&format!( - "PowerShell module installed at {}", - module_path.display(), - )); - } - false => { - self.record_fail(&format!( - "PowerShell module not found at {}", - module_path.display(), - )); - Self::print_hint("Run 'rep init' to install the module"); - } - } - } - Err(ref _module_path_error) => { - self.record_fail("Could not determine PowerShell module directory"); - Self::print_hint("Run 'rep init' to install shell integration"); - } - } - return; - } - _ => {} - } + if let DetectedShell::PowerShell(_) = detected_shell { + self.check_powershell_module(); } #[cfg(not(target_os = "windows"))] @@ -217,22 +183,45 @@ impl DiagnosticSummary { let integration_marker = format!(".reportal{}integration", std::path::MAIN_SEPARATOR); let profile_sources_integration = profile_content.lines().any(|line| { - return line.contains(&integration_marker); + line.contains(&integration_marker) }); - match profile_sources_integration { - true => { - self.record_pass("Profile sources integration file"); - } - false => { - self.record_fail("Profile does not source integration file"); - Self::print_hint("Run 'rep init' to set it up"); - } + if profile_sources_integration { + self.record_pass("Profile sources integration file"); + } else { + self.record_fail("Profile does not source integration file"); + Self::print_hint("Run 'rep init' to set it up"); } } } - /// Checks whether the REPORTAL_LOADED env var is set, which proves + /// Verifies the `PowerShell` module directory contains `RePortal.psm1`. + #[cfg(target_os = "windows")] + fn check_powershell_module(&mut self) { + let module_path = match powershell_module_directory() { + Ok(path) => path, + Err(ref _module_path_error) => { + self.record_fail("Could not determine PowerShell module directory"); + Self::print_hint("Run 'rep init' to install shell integration"); + return; + } + }; + let module_file = module_path.join("RePortal.psm1"); + if module_file.exists() { + self.record_pass(&format!( + "PowerShell module installed at {}", + module_path.display(), + )); + } else { + self.record_fail(&format!( + "PowerShell module not found at {}", + module_path.display(), + )); + Self::print_hint("Run 'rep init' to install the module"); + } + } + + /// Checks whether the `REPORTAL_LOADED` env var is set, which proves /// the shell integration was loaded in the current session. fn check_session_loaded(&mut self) { match std::env::var("REPORTAL_LOADED") { @@ -248,68 +237,57 @@ impl DiagnosticSummary { /// Checks that all registered repo paths exist on disk. fn check_repo_paths(&mut self, loaded_config: &ReportalConfig) { - println!(); - println!( - " {}", + 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(); - match all_repos.is_empty() { - true => { - self.record_fail("No repos registered"); - Self::print_hint("Run 'rep add ' to register a repo"); - } - false => { - for (alias, entry) in &all_repos { - let resolved_path = entry.resolved_path(); - match resolved_path.exists() { - true => { - self.record_pass(&format!( - "{} {}", - alias.style(terminal_style::ALIAS_STYLE), - resolved_path - .display() - .to_string() - .style(terminal_style::PATH_STYLE), - )); - } - false => { - self.record_fail(&format!( - "{} {} (path does not exist)", - alias.style(terminal_style::ALIAS_STYLE), - resolved_path - .display() - .to_string() - .style(terminal_style::PATH_STYLE), - )); - } - } - } - } + if all_repos.is_empty() { + self.record_fail("No repos registered"); + Self::print_hint("Run 'rep add ' to register a repo"); + return; + } + + for (alias, entry) in &all_repos { + self.check_single_repo(alias, entry); + } + } + + /// Validates that a single repo path exists and records the diagnostic. + fn check_single_repo(&mut self, alias: &str, entry: &crate::reportal_config::RepoEntry) { + let resolved_path = entry.resolved_path(); + let path_display = resolved_path.display().to_string(); + let styled_alias = 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)")); } } } -/// Validates the full RePortal installation and prints a diagnostic report. -pub fn run_doctor() -> Result<(), ReportalError> { - println!( - "{}", +/// Validates the full `RePortal` installation and prints a diagnostic report. +/// Checks config file parsing, shell integration, 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(); - match loaded_config { - Some(ref config) => summary.check_repo_paths(config), - None => {} - } + if let Some(ref config) = loaded_config { summary.check_repo_paths(config) } - println!(); + terminal_style::write_stdout("\n"); match summary.failed { 0 => { terminal_style::print_success(&format!( @@ -318,14 +296,13 @@ pub fn run_doctor() -> Result<(), ReportalError> { )); } _failure_count => { - println!( - " {} {} passed, {} failed", + 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), - ); + )); } } - return Ok(()); } diff --git a/src/reportal_commands/git_commands.rs b/src/reportal_commands/git_commands.rs index f63a7f2..7565334 100644 --- a/src/reportal_commands/git_commands.rs +++ b/src/reportal_commands/git_commands.rs @@ -1,4 +1,4 @@ -/// Shared git command execution used by status, sync, and web. +//! Shared git command execution used by status, sync, and web. use std::path::PathBuf; use std::process::Command; @@ -18,28 +18,25 @@ pub enum GitCommandOutcome { pub struct GitCommandParams<'a> { /// The resolved filesystem path to run git in. pub repo_path: &'a PathBuf, - /// The git subcommand and its arguments (e.g. ["status", "--porcelain"]). + /// 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 +/// 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: GitCommandParams<'_>) -> GitCommandOutcome { +pub fn run_git_command(git_command_params: &GitCommandParams<'_>) -> 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) => match output.status.success() { - true => GitCommandOutcome::Output( - String::from_utf8_lossy(&output.stdout).trim().to_string(), - ), - false => GitCommandOutcome::NonZeroExit, - }, + 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 e365658..54df0c8 100644 --- a/src/reportal_commands/initialization.rs +++ b/src/reportal_commands/initialization.rs @@ -1,8 +1,8 @@ -/// Creates the default RePortal config file and installs shell integration. -/// -/// This is the entry point for `rep init`. It delegates to `DetectedShell` -/// for the actual installation mechanics — PowerShell gets a module, -/// Unix shells get a profile source line. +//! Creates the default `RePortal` config file and installs shell integration. +//! +//! This is the entry point for `rep init`. It delegates to `DetectedShell` +//! for the actual installation mechanics — `PowerShell` gets a module, +//! Unix shells get a profile source line. use crate::error::ReportalError; use crate::reportal_config::ReportalConfig; @@ -15,103 +15,86 @@ use super::detected_shell::{integration_file_path, DetectedShell}; #[cfg(target_os = "windows")] use super::detected_shell::powershell_module_directory; +/// Prints success messages after shell integration installation. +#[cfg(target_os = "windows")] +fn print_post_install_messages(_profile_path: &std::path::Path) -> Result<(), ReportalError> { + let script_path = integration_file_path()?; + let module_path = powershell_module_directory()?; + terminal_style::print_success(&format!("Wrote {}", script_path.display())); + terminal_style::print_success(&format!("Installed PowerShell module to {}", module_path.display())); + terminal_style::print_success("Migrated from profile sourcing to module auto-import."); + terminal_style::print_success("Open a new terminal to activate. rj/ro/rw/rr now load even if your $PROFILE has errors."); + Ok(()) +} + +/// Prints success messages after shell integration installation. +#[cfg(not(target_os = "windows"))] +fn print_post_install_messages(profile_path: &std::path::Path) -> Result<(), ReportalError> { + let script_path = integration_file_path()?; + terminal_style::print_success(&format!("Wrote {}", script_path.display())); + terminal_style::print_success(&format!("Updated {}. Restart your shell to activate.", profile_path.display())); + Ok(()) +} + /// Writes the integration file and installs the source line in the /// shell profile. Displays what will be installed and asks for confirmation. 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!(" {} open a repo in the browser", "rw".style(terminal_style::ALIAS_STYLE)); - println!(" {} run a configured command in a repo", "rr".style(terminal_style::ALIAS_STYLE)); - println!(" {} per-repo tab title + background color on every prompt", "color".style(terminal_style::ALIAS_STYLE)); - println!(); + terminal_style::write_stdout("\n"); + terminal_style::write_stdout(&format!(" {} Shell integration:\n", ">>".style(terminal_style::LABEL_STYLE))); + terminal_style::write_stdout(&format!(" {} jump to a repo (cd)\n", "rj".style(terminal_style::ALIAS_STYLE))); + terminal_style::write_stdout(&format!(" {} open a repo in your editor\n", "ro".style(terminal_style::ALIAS_STYLE))); + terminal_style::write_stdout(&format!(" {} open a repo in the browser\n", "rw".style(terminal_style::ALIAS_STYLE))); + terminal_style::write_stdout(&format!(" {} run a configured command in a repo\n", "rr".style(terminal_style::ALIAS_STYLE))); + terminal_style::write_stdout(&format!(" {} per-repo tab title + background color on every prompt\n", "color".style(terminal_style::ALIAS_STYLE))); + terminal_style::write_stdout("\n"); let detected_shell = DetectedShell::detect(); - match detected_shell.profile_path() { - Some(profile_path) => { - #[cfg(target_os = "windows")] - let install_target_label = match powershell_module_directory() { - Ok(ref module_path) => format!("Module: {}", module_path.display()), - Err(ref _module_path_error) => format!("Profile: {}", profile_path.display()), - }; - - #[cfg(not(target_os = "windows"))] - let install_target_label = format!("Profile: {}", profile_path.display()); + if let Some(profile_path) = detected_shell.profile_path() { + #[cfg(target_os = "windows")] + let install_target_label = match powershell_module_directory() { + Ok(ref module_path) => format!("Module: {}", module_path.display()), + Err(ref _module_path_error) => format!("Profile: {}", profile_path.display()), + }; - println!( - " {}", - install_target_label.style(terminal_style::PATH_STYLE), - ); + #[cfg(not(target_os = "windows"))] + let install_target_label = format!("Profile: {}", profile_path.display()); - 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(), - })?; + terminal_style::write_stdout(&format!( + " {}\n", + install_target_label.style(terminal_style::PATH_STYLE), + )); - match user_wants_install { - true => { - detected_shell.install_integration()?; + 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(), + })?; - #[cfg(target_os = "windows")] - { - let script_path = integration_file_path()?; - let module_path = powershell_module_directory()?; - terminal_style::print_success(&format!( - "Wrote {}", - script_path.display(), - )); - terminal_style::print_success(&format!( - "Installed PowerShell module to {}", - module_path.display(), - )); - terminal_style::print_success( - "Migrated from profile sourcing to module auto-import.", - ); - terminal_style::print_success( - "Open a new terminal to activate. rj/ro/rw/rr now load even if your $PROFILE has errors.", - ); - } - - #[cfg(not(target_os = "windows"))] - { - let script_path = integration_file_path()?; - terminal_style::print_success(&format!( - "Wrote {}", - script_path.display(), - )); - terminal_style::print_success(&format!( - "Updated {}. Restart your shell to activate.", - profile_path.display(), - )); - } - } - false => { - println!(" Skipped."); - } - } + if user_wants_install { + detected_shell.install_integration()?; + print_post_install_messages(profile_path)?; + } else { + terminal_style::write_stdout(" Skipped.\n"); } - None => { - println!(" Could not detect your shell profile."); - println!(" Add this to your profile manually:"); - println!(); - match integration_file_path() { - Ok(script_path) => { - println!(" PowerShell: . \"{}\"", script_path.display()); - println!(" Bash/Zsh: source \"{}\"", script_path.display()); - } - Err(ref _path_error) => { - println!(" Run 'rep init' again after fixing the config directory."); - } + } else { + terminal_style::write_stdout(" Could not detect your shell profile.\n"); + terminal_style::write_stdout(" Add this to your profile manually:\n"); + terminal_style::write_stdout("\n"); + match integration_file_path() { + Ok(script_path) => { + terminal_style::write_stdout(&format!(" PowerShell: . \"{}\"\n", script_path.display())); + terminal_style::write_stdout(&format!(" Bash/Zsh: source \"{}\"\n", script_path.display())); + } + Err(ref _path_error) => { + terminal_style::write_stdout(" Run 'rep init' again after fixing the config directory.\n"); } } } - return Ok(()); + Ok(()) } /// Creates a default config at `~/.reportal/config.toml` if none exists, @@ -119,16 +102,13 @@ fn install_or_update_shell_integration() -> Result<(), ReportalError> { /// Safe to re-run at any time; idempotent on both config and profile. pub fn run_init() -> Result<(), ReportalError> { let config_path = ReportalConfig::config_file_path()?; - match config_path.exists() { - true => { - println!("Config already exists at {}", config_path.display()); - println!("Use 'reportal add' to register repos."); - } - false => { - let default_config = ReportalConfig::create_default(); - default_config.save_to_disk()?; - terminal_style::print_success(&format!("Created config at {}", config_path.display())); - } + if config_path.exists() { + terminal_style::write_stdout(&format!("Config already exists at {}\n", config_path.display())); + terminal_style::write_stdout("Use 'reportal add' to register repos.\n"); + } 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() diff --git a/src/reportal_commands/integration_freshness.rs b/src/reportal_commands/integration_freshness.rs index 05c8b2b..2940333 100644 --- a/src/reportal_commands/integration_freshness.rs +++ b/src/reportal_commands/integration_freshness.rs @@ -1,8 +1,8 @@ -/// Checks whether the shell integration script file matches the running -/// binary version and silently rewrites it on mismatch. Called at the -/// top of main() before subcommand dispatch so that binary updates via -/// cargo, scoop, or brew take effect on the next shell session without -/// any manual steps. +//! Checks whether the shell integration script file matches the running +//! binary version and silently rewrites it on mismatch. Called at the +//! top of `main()` before subcommand dispatch so that binary updates via +//! cargo, scoop, or brew take effect on the next shell session without +//! any manual steps. use crate::reportal_config::ReportalConfig; @@ -33,26 +33,18 @@ pub(crate) fn check_integration_file_state() -> IntegrationFileState { let binary_version = env!("CARGO_PKG_VERSION"); - let first_line = match file_content.lines().next() { - Some(line) => line, - None => { - return IntegrationFileState::Outdated { - file_version: String::from("empty"), - } - } + let Some(first_line) = file_content.lines().next() else { + return IntegrationFileState::Outdated { + file_version: String::from("empty"), + }; }; - match first_line.contains(binary_version) { - true => IntegrationFileState::Current, - false => { - let extracted_version = first_line - .rsplit("— v") - .next() - .map(String::from) - .unwrap_or_else(|| String::from("unknown")); - IntegrationFileState::Outdated { - file_version: extracted_version, - } + if first_line.contains(binary_version) { IntegrationFileState::Current } else { + let extracted_version = first_line + .rsplit("— v") + .next().map_or_else(|| String::from("unknown"), String::from); + IntegrationFileState::Outdated { + file_version: extracted_version, } } } @@ -66,10 +58,7 @@ pub fn ensure_integration_file_current() { Err(_path_error) => return, }; - match config_directory.exists() { - true => {} - false => return, - } + if !config_directory.exists() { return } match check_integration_file_state() { IntegrationFileState::Current => {} diff --git a/src/reportal_commands/mod.rs b/src/reportal_commands/mod.rs index 61132b5..50cf5ba 100644 --- a/src/reportal_commands/mod.rs +++ b/src/reportal_commands/mod.rs @@ -1,4 +1,4 @@ -/// Subcommand implementations for the RePortal CLI. +//! Subcommand implementations for the `RePortal` CLI. mod color_command_mode; mod detected_shell; mod doctor; diff --git a/src/reportal_commands/path_display.rs b/src/reportal_commands/path_display.rs index 41f6f9f..6b5d238 100644 --- a/src/reportal_commands/path_display.rs +++ b/src/reportal_commands/path_display.rs @@ -1,4 +1,4 @@ -/// Shared path display helper for commands that print after repo selection. +//! Shared path display helper for commands that print after repo selection. use crate::reportal_config::{PathVisibility, ReportalConfig}; use crate::terminal_style; @@ -7,7 +7,7 @@ use std::path::PathBuf; /// Parameters for conditionally printing the selected repo's path. pub struct SelectedPathDisplayParams<'a> { - /// The loaded config (provides path_on_select and path_display_format). + /// 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, @@ -18,18 +18,18 @@ pub struct SelectedPathDisplayParams<'a> { /// 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: SelectedPathDisplayParams<'_>) { +pub fn print_selected_path_if_visible(display_params: &SelectedPathDisplayParams<'_>) { 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); - eprintln!( - " {} {}", + 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/prompts/color_edit_prompt.rs b/src/reportal_commands/prompts/color_edit_prompt.rs index ebf31ba..5f07800 100644 --- a/src/reportal_commands/prompts/color_edit_prompt.rs +++ b/src/reportal_commands/prompts/color_edit_prompt.rs @@ -18,29 +18,29 @@ pub struct ColorEditPromptParams<'a> { /// 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: ColorEditPromptParams<'_>) -> Result { +pub fn prompt_for_color_edit(color_edit_params: &ColorEditPromptParams<'_>) -> 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_string()) + .default(color_edit_params.current_default.to_owned()) .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(color_edit_params.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()); - } - }, + 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_prompt.rs b/src/reportal_commands/prompts/color_prompt.rs index 680a83f..84f05f4 100644 --- a/src/reportal_commands/prompts/color_prompt.rs +++ b/src/reportal_commands/prompts/color_prompt.rs @@ -25,14 +25,11 @@ pub fn prompt_for_color(prompt_theme: &ColorfulTheme) -> Result 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()); - } - }, - } + 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 index 2964455..cd36002 100644 --- a/src/reportal_commands/prompts/mod.rs +++ b/src/reportal_commands/prompts/mod.rs @@ -1,4 +1,4 @@ -/// Shared interactive prompt helpers for add and edit commands. +//! Shared interactive prompt helpers for add and edit commands. mod color_edit_prompt; mod color_edit_result; mod color_prompt; diff --git a/src/reportal_commands/prompts/text_prompt.rs b/src/reportal_commands/prompts/text_prompt.rs index cfeb732..35a0a0d 100644 --- a/src/reportal_commands/prompts/text_prompt.rs +++ b/src/reportal_commands/prompts/text_prompt.rs @@ -17,16 +17,16 @@ pub struct TextPromptParams<'a> { /// /// 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: TextPromptParams<'_>) -> Result { +pub fn prompt_for_text(text_prompt_params: &TextPromptParams<'_>) -> 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_string()) + .default(text_prompt_params.default_value.to_owned()) .interact_text() .map_err(|prompt_error| ReportalError::ConfigIoFailure { reason: prompt_error.to_string(), })?; - return Ok(entered_text); + Ok(entered_text) } /// Splits a comma-separated string into trimmed, non-empty tag strings. @@ -34,9 +34,9 @@ pub fn prompt_for_text(text_prompt_params: TextPromptParams<'_>) -> Result Vec { - return raw_input + raw_input .split(',') - .map(|tag_segment| tag_segment.trim().to_string()) + .map(|tag_segment| tag_segment.trim().to_owned()) .filter(|trimmed_tag| !trimmed_tag.is_empty()) - .collect(); + .collect() } diff --git a/src/reportal_commands/repo_add/add_source.rs b/src/reportal_commands/repo_add/add_source.rs index 1d38429..f162004 100644 --- a/src/reportal_commands/repo_add/add_source.rs +++ b/src/reportal_commands/repo_add/add_source.rs @@ -13,10 +13,10 @@ 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.ends_with(".git") + || raw_input.to_ascii_lowercase().ends_with(".git") { - AddSource::GitUrl(raw_input.to_string()) + AddSource::GitUrl(raw_input.to_owned()) } else { - AddSource::LocalPath(raw_input.to_string()) + 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 index fed5d43..400d553 100644 --- a/src/reportal_commands/repo_add/alias_suggestion.rs +++ b/src/reportal_commands/repo_add/alias_suggestion.rs @@ -12,29 +12,25 @@ pub enum AliasSuggestion { /// Extracts a suggested alias from the last segment of a filesystem path. pub 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, - } + 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"); - 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, + 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 index ca5505a..4f52e57 100644 --- a/src/reportal_commands/repo_add/clone_destination.rs +++ b/src/reportal_commands/repo_add/clone_destination.rs @@ -22,19 +22,19 @@ pub enum CloneDestination { pub fn prompt_clone_destination(loaded_config: &ReportalConfig) -> Result { let prompt_theme = terminal_style::reportal_prompt_theme(); - let sibling_directories = clone_placement::collect_registered_parent_directories(loaded_config); - let child_directories = clone_placement::collect_registered_repo_directories(loaded_config); + 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_string()]; + 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_string()); + 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_string()); + placement_labels.push("Child of existing repo".to_owned()); placement_has_child = true; } @@ -55,12 +55,11 @@ pub fn prompt_clone_destination(loaded_config: &ReportalConfig) -> Result 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()) + format!("{near_alias} ({})", parent_path.display()) }) .collect(); @@ -73,18 +72,18 @@ pub fn prompt_clone_destination(loaded_config: &ReportalConfig) -> Result 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), + 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()) + format!("{inside_alias} ({})", repo_path.display()) }) .collect(); @@ -97,11 +96,11 @@ pub fn prompt_clone_destination(loaded_config: &ReportalConfig) -> Result 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), + 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 index 1fd1a99..ea451ce 100644 --- a/src/reportal_commands/repo_add/clone_placement.rs +++ b/src/reportal_commands/repo_add/clone_placement.rs @@ -5,33 +5,32 @@ 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<(String, PathBuf)> { +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<(String, PathBuf)> = Vec::new(); + let mut labeled_directories: Vec<(&str, 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 => {} + 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<(String, PathBuf)> { +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.to_string(), repo.resolved_path())) + .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 index e6be345..77209bb 100644 --- a/src/reportal_commands/repo_add/git_clone_operation.rs +++ b/src/reportal_commands/repo_add/git_clone_operation.rs @@ -15,15 +15,15 @@ pub struct GitCloneOperation<'a> { } /// Spawns `git clone ` in the target directory. -impl<'a> GitCloneOperation<'a> { +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> { - println!( - " {} {}", + 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]) @@ -31,14 +31,11 @@ impl<'a> GitCloneOperation<'a> { .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), - }), - }, + 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), + 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 index 2f504ac..65cd1a5 100644 --- a/src/reportal_commands/repo_add/git_remote_detection.rs +++ b/src/reportal_commands/repo_add/git_remote_detection.rs @@ -1,6 +1,7 @@ //! Detects git remote URLs from local repo directories. use std::process::Command; +use crate::terminal_style; /// Whether a git remote was found in the target directory. pub enum GitRemoteDetection { @@ -21,18 +22,12 @@ pub fn detect_git_remote(directory_path: &str) -> GitRemoteDetection { .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, - }, + 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) => { - eprintln!(" git not available: {}", 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 index 67f9810..caef76c 100644 --- a/src/reportal_commands/repo_add/mod.rs +++ b/src/reportal_commands/repo_add/mod.rs @@ -1,5 +1,5 @@ -/// Interactively registers a new repo in the RePortal config. -/// Supports both local paths and git URLs (clones first, then registers). +//! 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; diff --git a/src/reportal_commands/repo_add/registration_context.rs b/src/reportal_commands/repo_add/registration_context.rs index a2504db..0e71343 100644 --- a/src/reportal_commands/repo_add/registration_context.rs +++ b/src/reportal_commands/repo_add/registration_context.rs @@ -21,26 +21,26 @@ pub struct RegistrationContext<'a> { } /// Prompts the user for metadata then registers the repo in config. -impl<'a> RegistrationContext<'a> { +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 repo_alias = prompts::prompt_for_text(TextPromptParams { + let repo_alias = prompts::prompt_for_text(&TextPromptParams { prompt_theme: &prompt_theme, label: "Alias", default_value: &self.suggested_alias, })?; - let repo_description = prompts::prompt_for_text(TextPromptParams { + let repo_description = prompts::prompt_for_text(&TextPromptParams { prompt_theme: &prompt_theme, label: "Description", default_value: "", })?; - let tags_input = prompts::prompt_for_text(TextPromptParams { + let tags_input = prompts::prompt_for_text(&TextPromptParams { prompt_theme: &prompt_theme, label: "Tags (comma-separated)", default_value: "", @@ -48,13 +48,13 @@ impl<'a> RegistrationContext<'a> { let parsed_tags = prompts::parse_comma_separated_tags(&tags_input); - let repo_remote = prompts::prompt_for_text(TextPromptParams { + let repo_remote = prompts::prompt_for_text(&TextPromptParams { prompt_theme: &prompt_theme, label: "Remote URL", default_value: &self.detected_remote, })?; - let tab_title = prompts::prompt_for_text(TextPromptParams { + let tab_title = prompts::prompt_for_text(&TextPromptParams { prompt_theme: &prompt_theme, label: "Tab title (empty = use alias)", default_value: "", @@ -62,33 +62,33 @@ impl<'a> RegistrationContext<'a> { let repo_color = prompts::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), self.filesystem_path.style(terminal_style::PATH_STYLE)); + terminal_style::write_stdout("\n"); + terminal_style::write_stdout(&format!(" {} {}\n", "Alias:".style(terminal_style::LABEL_STYLE), repo_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() { - println!(" {} {}", "Desc:".style(terminal_style::LABEL_STYLE), repo_description); + terminal_style::write_stdout(&format!(" {} {}\n", "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)); + 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() { - println!(" {} {}", "Remote:".style(terminal_style::LABEL_STYLE), repo_remote.style(terminal_style::PATH_STYLE)); + terminal_style::write_stdout(&format!(" {} {}\n", "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)); + 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) => { - println!(" {} {}", "Color:".style(terminal_style::LABEL_STYLE), hex_color.raw_value()); + terminal_style::write_stdout(&format!(" {} {}\n", "Color:".style(terminal_style::LABEL_STYLE), hex_color.raw_value())); } ColorPromptResult::Skipped => {} } - println!(); + terminal_style::write_stdout("\n"); - let display_alias = repo_alias.as_str().to_string(); + let display_alias = repo_alias.as_str().to_owned(); let mut builder = RepoRegistrationBuilder::start(repo_alias) - .repo_path(self.filesystem_path.to_string()) + .repo_path(self.filesystem_path.to_owned()) .repo_description(repo_description) .repo_tags(parsed_tags) .repo_remote(repo_remote); @@ -108,7 +108,7 @@ impl<'a> RegistrationContext<'a> { self.loaded_config.add_repo(validated_registration)?; self.loaded_config.save_to_disk()?; - terminal_style::print_success(&format!("Registered '{}'", display_alias)); + 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 index ecb4d83..13ffa99 100644 --- a/src/reportal_commands/repo_add/run.rs +++ b/src/reportal_commands/repo_add/run.rs @@ -14,88 +14,96 @@ use super::git_clone_operation::GitCloneOperation; use super::git_remote_detection::{detect_git_remote, GitRemoteDetection}; use super::registration_context::RegistrationContext; -/// 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_from_disk()?; +/// 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())) +} - 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(), - }; - let registration = RegistrationContext { - loaded_config: &mut loaded_config, - filesystem_path: &local_path, - suggested_alias, - detected_remote, - }; - registration.collect_metadata_and_register() - } - 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(), - }; +/// 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() +} - println!(); - println!( - " {} {}", - "From:".style(terminal_style::LABEL_STYLE), - git_url.style(terminal_style::PATH_STYLE), - ); - println!(); +/// 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(), + }; - let clone_destination = prompt_clone_destination(&loaded_config)?; - let prompt_theme = terminal_style::reportal_prompt_theme(); + 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_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, - }; + let clone_destination = prompt_clone_destination(loaded_config)?; + let prompt_theme = terminal_style::reportal_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_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: &git_url, - target_directory: &clone_parent, - }; - clone_operation.run_git_clone()?; + 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 cloned_repo_path = clone_parent.join(&suggested_alias); + let final_repo_path = cloned_repo_path.display().to_string(); - let registration = RegistrationContext { - loaded_config: &mut loaded_config, - filesystem_path: &final_repo_path, - suggested_alias, - detected_remote: git_url, - }; - registration.collect_metadata_and_register() - } + 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_from_disk()?; + + 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 index 239ce92..420813a 100644 --- a/src/reportal_commands/repo_ai.rs +++ b/src/reportal_commands/repo_ai.rs @@ -1,4 +1,4 @@ -/// Fuzzy-selects a repo and launches an AI coding CLI in it. +//! Fuzzy-selects a repo and launches an AI coding CLI in it. use crate::error::ReportalError; use crate::reportal_config::{ReportalConfig, TagFilter}; @@ -25,36 +25,28 @@ pub struct AiCommandParams<'a> { /// 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: AiCommandParams<'_>) -> Result<(), ReportalError> { +pub fn run_ai(ai_params: &AiCommandParams<'_>) -> Result<(), ReportalError> { let loaded_config = ReportalConfig::load_from_disk()?; let ai_tools = loaded_config.ai_tools_list(); - match ai_tools.is_empty() { - true => return Err(ReportalError::NoAiToolsConfigured), - false => {} - } + if ai_tools.is_empty() { return Err(ReportalError::NoAiToolsConfigured) } - let tool_name = match ai_params.tool_override.is_empty() { - false => ai_params.tool_override, - true => { - let default_tool = loaded_config.default_ai_tool(); - match default_tool.is_empty() { - true => return Err(ReportalError::NoDefaultAiTool), - false => default_tool, - } - } - }; + 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 selected = repo_selection::select_repo(SelectedRepoParams { + let selection_params = SelectedRepoParams { 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(TerminalIdentityEmitParams { + terminal_identity_emit::emit_repo_terminal_identity(&TerminalIdentityEmitParams { selected_alias: selected.repo_alias(), selected_repo: selected.repo_config(), title_override: "", @@ -98,5 +90,5 @@ pub fn run_ai(ai_params: AiCommandParams<'_>) -> Result<(), ReportalError> { reason: format!("process exited unexpectedly: {wait_error}"), })?; - return Ok(()); + Ok(()) } diff --git a/src/reportal_commands/repo_color.rs b/src/reportal_commands/repo_color.rs index c86c796..bbd2d0e 100644 --- a/src/reportal_commands/repo_color.rs +++ b/src/reportal_commands/repo_color.rs @@ -1,8 +1,8 @@ -/// 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`. +//! 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}; @@ -18,6 +18,56 @@ pub struct ColorCommandParams<'a> { pub mode: ColorCommandMode, } +/// Resolves the tab title from a repo's config, falling back to the alias. +fn resolve_tab_title(tab_title: &TabTitle, fallback_alias: &str) -> String { + match tab_title { + TabTitle::Custom(custom_title) => custom_title.to_owned(), + TabTitle::UseAlias => fallback_alias.to_owned(), + } +} + +/// Converts a repo's color config into the appropriate tab color action. +fn resolve_tab_color(repo_color: &RepoColor) -> TabColorAction { + match repo_color { + RepoColor::Themed(hex_color) => TabColorAction::SetColor(hex_color.as_osc_tab_color_sequence()), + RepoColor::ResetToDefault => TabColorAction::Reset, + } +} + +/// Finds the best-matching repo for the current working directory using +/// longest-prefix-match against all registered repo paths. +fn resolve_from_working_directory(loaded_config: &ReportalConfig) -> Result, ReportalError> { + 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: Option<&crate::reportal_config::RepoEntry> = None; + + for (alias, repo) in &all_repos { + let repo_path = repo.resolved_path(); + if !current_directory.starts_with(&repo_path) { + continue; + } + let path_length = repo_path.as_os_str().len(); + if path_length > best_match_length { + best_match_length = path_length; + best_alias = alias.as_str(); + best_repo = Some(repo); + } + } + + Ok(best_repo.map(|matched_repo| { + let title = resolve_tab_title(matched_repo.tab_title(), best_alias); + let tab_color_action = resolve_tab_color(matched_repo.repo_color()); + (title, tab_color_action) + })) +} + /// Emits OSC sequences for a repo's terminal identity. /// /// If `repo_alias` is provided, looks up that repo directly. @@ -31,78 +81,23 @@ pub struct ColorCommandParams<'a> { /// /// When `mode` is `PromptHook`, also writes the resolved title text /// to stdout so shell integrations can set it via the native API -/// (e.g. `$Host.UI.RawUI.WindowTitle` in PowerShell). -pub fn run_color(color_params: ColorCommandParams<'_>) -> Result<(), ReportalError> { +/// (e.g. `$Host.UI.RawUI.WindowTitle` in `PowerShell`). +pub fn run_color(color_params: &ColorCommandParams<'_>) -> Result<(), ReportalError> { let loaded_config = ReportalConfig::load_from_disk()?; - let resolved = match color_params.repo_alias.is_empty() { - false => { - let found_repo = loaded_config.get_repo(color_params.repo_alias)?; - let title = match found_repo.tab_title() { - TabTitle::Custom(custom_title) => custom_title.to_string(), - TabTitle::UseAlias => color_params.repo_alias.to_string(), - }; - let tab_color_action = match found_repo.repo_color() { - RepoColor::Themed(hex_color) => { - TabColorAction::SetColor(hex_color.as_osc_tab_color_sequence()) - } - RepoColor::ResetToDefault => TabColorAction::Reset, - }; - Some((title, tab_color_action)) - } - 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 title = match matched_repo.tab_title() { - TabTitle::Custom(custom_title) => custom_title.to_string(), - TabTitle::UseAlias => best_alias.to_string(), - }; - let tab_color_action = match matched_repo.repo_color() { - RepoColor::Themed(hex_color) => { - TabColorAction::SetColor(hex_color.as_osc_tab_color_sequence()) - } - RepoColor::ResetToDefault => TabColorAction::Reset, - }; - Some((title, tab_color_action)) - } - None => None, - } - } + let resolved = if color_params.repo_alias.is_empty() { + resolve_from_working_directory(&loaded_config)? + } else { + let found_repo = loaded_config.get_repo(color_params.repo_alias)?; + let title = resolve_tab_title(found_repo.tab_title(), color_params.repo_alias); + let tab_color_action = resolve_tab_color(found_repo.repo_color()); + Some((title, tab_color_action)) }; match resolved { Some((title, tab_color_action)) => { match color_params.mode { - ColorCommandMode::PromptHook => print!("{}", &title), + ColorCommandMode::PromptHook => terminal_style::write_stdout(&title.clone()), ColorCommandMode::Explicit => {} } @@ -120,5 +115,5 @@ pub fn run_color(color_params: ColorCommandParams<'_>) -> Result<(), ReportalErr }, } - return Ok(()); + Ok(()) } diff --git a/src/reportal_commands/repo_edit.rs b/src/reportal_commands/repo_edit.rs index 673c8d7..750f008 100644 --- a/src/reportal_commands/repo_edit.rs +++ b/src/reportal_commands/repo_edit.rs @@ -21,73 +21,130 @@ pub struct EditCommandParams<'a> { /// Truncates a string to a reasonable menu-display length, returning /// the first 50 characters if the input exceeds that limit. fn truncate_for_menu(display_text: &str) -> &str { - match display_text.len() > 50 { - true => &display_text[..50], - false => display_text, + if display_text.len() > 50 { + &display_text[..50] + } else { + display_text } } +/// Prompts for a new tab title, saves to disk, and returns the updated value. +fn apply_title_edit( + loaded_config: &mut ReportalConfig, + selected_alias: &str, + prompt_theme: &dialoguer::theme::ColorfulTheme, + current_title: &str, +) -> Result { + let new_title = prompts::prompt_for_text(&TextPromptParams { + prompt_theme, + label: "Tab title (empty = use alias)", + default_value: current_title, + })?; + let resolved_title = if new_title.is_empty() { + TabTitle::UseAlias + } else { + TabTitle::Custom(new_title) + }; + let repo_to_update = loaded_config.get_repo_mut(selected_alias)?; + repo_to_update.set_tab_title(resolved_title); + loaded_config.save_to_disk()?; + let updated = match loaded_config.get_repo(selected_alias)?.tab_title() { + TabTitle::Custom(custom_title) => custom_title.to_owned(), + TabTitle::UseAlias => String::new(), + }; + Ok(updated) +} + +/// Prompts for a color edit, saves to disk, and returns the updated hex string. +fn apply_color_edit( + loaded_config: &mut ReportalConfig, + selected_alias: &str, + prompt_theme: &dialoguer::theme::ColorfulTheme, + current_color_hex: &str, +) -> Result { + let color_edit_result = prompts::prompt_for_color_edit(&ColorEditPromptParams { + prompt_theme, + current_default: 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 = loaded_config.get_repo_mut(selected_alias)?; + repo_to_update.set_repo_color(resolved_color); + loaded_config.save_to_disk()?; + let updated = match loaded_config.get_repo(selected_alias)?.repo_color() { + RepoColor::Themed(hex_color) => hex_color.raw_value().to_owned(), + RepoColor::ResetToDefault => String::new(), + }; + Ok(updated) +} + /// 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. -pub fn run_edit(command_params: EditCommandParams<'_>) -> Result<(), ReportalError> { +pub fn run_edit(command_params: &EditCommandParams<'_>) -> Result<(), ReportalError> { let mut loaded_config = ReportalConfig::load_from_disk()?; let selected_alias = { - let selection = repo_selection::select_repo(SelectedRepoParams { + let selection_params = SelectedRepoParams { loaded_config: &loaded_config, direct_alias: command_params.direct_alias, tag_filter: &command_params.tag_filter, prompt_label: "Edit repo", - })?; - selection.repo_alias().to_string() + }; + let selection = repo_selection::select_repo(&selection_params)?; + selection.repo_alias().to_owned() }; - let repo_path_display = loaded_config.get_repo(&selected_alias)?.raw_path().to_string(); + let repo_path_display = loaded_config.get_repo(&selected_alias)?.raw_path().to_owned(); - println!(); - println!( - " {} {}", + terminal_style::write_stdout("\n"); + terminal_style::write_stdout(&format!( + " {} {}\n", "Editing:".style(terminal_style::LABEL_STYLE), selected_alias.style(terminal_style::ALIAS_STYLE), - ); - println!( - " {} {}", + )); + terminal_style::write_stdout(&format!( + " {} {}\n", "Path:".style(terminal_style::LABEL_STYLE), repo_path_display.style(terminal_style::PATH_STYLE), - ); + )); let prompt_theme = terminal_style::reportal_prompt_theme(); let repo_entry = loaded_config.get_repo(&selected_alias)?; - let mut current_description = repo_entry.description().to_string(); + let mut current_description = repo_entry.description().to_owned(); let mut current_tags_csv = repo_entry.tags().join(", "); let mut current_title = match repo_entry.tab_title() { - TabTitle::Custom(custom_title) => custom_title.to_string(), + TabTitle::Custom(custom_title) => custom_title.to_owned(), TabTitle::UseAlias => String::new(), }; let mut current_color_hex = match repo_entry.repo_color() { - RepoColor::Themed(hex_color) => hex_color.raw_value().to_string(), + RepoColor::Themed(hex_color) => hex_color.raw_value().to_owned(), RepoColor::ResetToDefault => String::new(), }; loop { - println!(); + terminal_style::write_stdout("\n"); let description_label = format!("Description: {}", truncate_for_menu(¤t_description)); - let tags_label = match current_tags_csv.is_empty() { - true => "Tags: (none)".to_string(), - false => format!("Tags: {}", truncate_for_menu(¤t_tags_csv)), + let tags_label = if current_tags_csv.is_empty() { + "Tags: (none)".to_owned() + } else { + format!("Tags: {}", truncate_for_menu(¤t_tags_csv)) }; - let title_label = match current_title.is_empty() { - true => "Title: (use alias)".to_string(), - false => format!("Title: {}", current_title), + let title_label = if current_title.is_empty() { + "Title: (use alias)".to_owned() + } else { + format!("Title: {current_title}") }; - let color_label = match current_color_hex.is_empty() { - true => "Color: (none)".to_string(), - false => format!("Color: {}", current_color_hex), + let color_label = if current_color_hex.is_empty() { + "Color: (none)".to_owned() + } else { + format!("Color: {current_color_hex}") }; - let menu_labels = vec![description_label, tags_label, title_label, color_label, "Done".to_string()]; + let menu_labels = vec![description_label, tags_label, title_label, color_label, "Done".to_owned()]; let chosen_index = Select::with_theme(&prompt_theme) .with_prompt("Pick a field to edit") @@ -99,10 +156,9 @@ pub fn run_edit(command_params: EditCommandParams<'_>) -> Result<(), ReportalErr })?; match chosen_index { - None => break, - Some(4..) => break, + None | Some(4..) => break, Some(0) => { - let new_description = prompts::prompt_for_text(TextPromptParams { + let new_description = prompts::prompt_for_text(&TextPromptParams { prompt_theme: &prompt_theme, label: "Description", default_value: ¤t_description, @@ -110,10 +166,11 @@ pub fn run_edit(command_params: EditCommandParams<'_>) -> Result<(), ReportalErr let repo_to_update = loaded_config.get_repo_mut(&selected_alias)?; repo_to_update.set_description(new_description); loaded_config.save_to_disk()?; - current_description = loaded_config.get_repo(&selected_alias)?.description().to_string(); + current_description.clear(); + current_description.push_str(loaded_config.get_repo(&selected_alias)?.description()); } Some(1) => { - let tags_input = prompts::prompt_for_text(TextPromptParams { + let tags_input = prompts::prompt_for_text(&TextPromptParams { prompt_theme: &prompt_theme, label: "Tags (comma-separated)", default_value: ¤t_tags_csv, @@ -125,45 +182,15 @@ pub fn run_edit(command_params: EditCommandParams<'_>) -> Result<(), ReportalErr current_tags_csv = loaded_config.get_repo(&selected_alias)?.tags().join(", "); } Some(2) => { - let new_title = prompts::prompt_for_text(TextPromptParams { - prompt_theme: &prompt_theme, - label: "Tab title (empty = use alias)", - default_value: ¤t_title, - })?; - let resolved_title = match new_title.is_empty() { - true => TabTitle::UseAlias, - false => TabTitle::Custom(new_title), - }; - let repo_to_update = loaded_config.get_repo_mut(&selected_alias)?; - repo_to_update.set_tab_title(resolved_title); - loaded_config.save_to_disk()?; - current_title = match loaded_config.get_repo(&selected_alias)?.tab_title() { - TabTitle::Custom(custom_title) => custom_title.to_string(), - TabTitle::UseAlias => String::new(), - }; + current_title = apply_title_edit(&mut loaded_config, &selected_alias, &prompt_theme, ¤t_title)?; } Some(3) => { - let color_edit_result = prompts::prompt_for_color_edit(ColorEditPromptParams { - prompt_theme: &prompt_theme, - current_default: ¤t_color_hex, - })?; - let resolved_color = match color_edit_result { - ColorEditResult::Provided(hex_color) => RepoColor::Themed(hex_color), - ColorEditResult::Unchanged(hex_color) => RepoColor::Themed(hex_color), - ColorEditResult::Cleared => RepoColor::ResetToDefault, - }; - let repo_to_update = loaded_config.get_repo_mut(&selected_alias)?; - repo_to_update.set_repo_color(resolved_color); - loaded_config.save_to_disk()?; - current_color_hex = match loaded_config.get_repo(&selected_alias)?.repo_color() { - RepoColor::Themed(hex_color) => hex_color.raw_value().to_string(), - RepoColor::ResetToDefault => String::new(), - }; + current_color_hex = apply_color_edit(&mut loaded_config, &selected_alias, &prompt_theme, ¤t_color_hex)?; } } terminal_style::print_success("Saved"); } - return Ok(()); + Ok(()) } diff --git a/src/reportal_commands/repo_jump.rs b/src/reportal_commands/repo_jump.rs index db5e83e..1528c0e 100644 --- a/src/reportal_commands/repo_jump.rs +++ b/src/reportal_commands/repo_jump.rs @@ -1,4 +1,4 @@ -/// 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::{ReportalConfig, TagFilter}; @@ -7,6 +7,7 @@ use crate::reportal_commands::repo_selection::{self, SelectedRepoParams}; use crate::reportal_commands::terminal_identity_emit::{ self, TerminalIdentityEmitParams, }; +use crate::terminal_style; /// All parameters needed to run the jump command. pub struct JumpCommandParams<'a> { @@ -25,17 +26,18 @@ pub struct JumpCommandParams<'a> { /// 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> { +pub fn run_jump(jump_params: &JumpCommandParams<'_>) -> Result<(), ReportalError> { let loaded_config = ReportalConfig::load_from_disk()?; - let selected = repo_selection::select_repo(SelectedRepoParams { + let selection_params = SelectedRepoParams { loaded_config: &loaded_config, direct_alias: jump_params.direct_alias, tag_filter: &jump_params.tag_filter, prompt_label: "Jump to repo", - })?; + }; + let selected = repo_selection::select_repo(&selection_params)?; - terminal_identity_emit::emit_repo_terminal_identity(TerminalIdentityEmitParams { + terminal_identity_emit::emit_repo_terminal_identity(&TerminalIdentityEmitParams { selected_alias: selected.repo_alias(), selected_repo: selected.repo_config(), title_override: jump_params.title_override, @@ -44,12 +46,12 @@ pub fn run_jump(jump_params: JumpCommandParams<'_>) -> Result<(), ReportalError> 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()); - path_display::print_selected_path_if_visible(SelectedPathDisplayParams { + path_display::print_selected_path_if_visible(&SelectedPathDisplayParams { 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 7e5742d..6fd7eb4 100644 --- a/src/reportal_commands/repo_listing.rs +++ b/src/reportal_commands/repo_listing.rs @@ -1,4 +1,4 @@ -/// Lists all registered repos with their status, description, and tags. +//! Lists all registered repos with their status, description, and tags. use crate::error::ReportalError; use crate::reportal_config::{ReportalConfig, TagFilter}; @@ -15,79 +15,76 @@ pub fn run_list(tag_filter: TagFilter) -> Result<(), ReportalError> { 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."), + TagFilter::ByTag(target_tag) => terminal_style::write_stdout(&format!("No repos found with tag '{target_tag}'\n")), + TagFilter::All => terminal_style::write_stdout("No repos registered. Use 'reportal add ' to add one.\n"), } return Ok(()); } - println!(); - println!( - " {}", + terminal_style::write_stdout("\n"); + terminal_style::write_stdout(&format!( + " {}\n", "RePortal".style(terminal_style::EMPHASIS_STYLE) - ); - println!(); + )); + terminal_style::write_stdout("\n"); for (alias, repo) in &matching_repos { let directory_exists = repo.resolved_path().exists(); let swatch_style = terminal_style::swatch_style_for_repo_color(repo.repo_color())?; let uppercase_alias = alias.to_uppercase(); - println!( - " {} {}", + terminal_style::write_stdout(&format!( + " {} {}\n", "██".style(swatch_style), uppercase_alias.style(terminal_style::ALIAS_STYLE), - ); + )); - println!( - " {} {}", + terminal_style::write_stdout(&format!( + " {} {}\n", "Path:".style(terminal_style::LABEL_STYLE), repo.raw_path().style(terminal_style::PATH_STYLE), - ); + )); if !repo.description().is_empty() { - println!( - " {} {}", + terminal_style::write_stdout(&format!( + " {} {}\n", "Desc:".style(terminal_style::LABEL_STYLE), repo.description(), - ); + )); } if !repo.aliases().is_empty() { let formatted_aliases = repo.aliases().join(", "); - println!( - " {} {}", + terminal_style::write_stdout(&format!( + " {} {}\n", "Aliases:".style(terminal_style::LABEL_STYLE), formatted_aliases.style(terminal_style::PATH_STYLE), - ); + )); } if !repo.tags().is_empty() { let formatted_tags = repo.tags().join(", "); - println!( - " {} {}", + terminal_style::write_stdout(&format!( + " {} {}\n", "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!( - " {} {}", + 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, - ); + )); - println!(); + terminal_style::write_stdout("\n"); } - println!( - " {} repos total", + terminal_style::write_stdout(&format!( + " {} repos total\n", matching_repos.len().style(terminal_style::EMPHASIS_STYLE), - ); - println!(); + )); + terminal_style::write_stdout("\n"); Ok(()) } diff --git a/src/reportal_commands/repo_open.rs b/src/reportal_commands/repo_open.rs index af6d425..f67b86d 100644 --- a/src/reportal_commands/repo_open.rs +++ b/src/reportal_commands/repo_open.rs @@ -1,4 +1,4 @@ -/// 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, ReportalConfig, TagFilter}; @@ -29,17 +29,18 @@ pub struct OpenCommandParams<'a> { /// 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> { +pub fn run_open(open_params: &OpenCommandParams<'_>) -> Result<(), ReportalError> { let loaded_config = ReportalConfig::load_from_disk()?; - let selected = repo_selection::select_repo(SelectedRepoParams { + let selection_params = SelectedRepoParams { loaded_config: &loaded_config, direct_alias: open_params.direct_alias, tag_filter: &open_params.tag_filter, prompt_label: "Open in editor", - })?; + }; + let selected = repo_selection::select_repo(&selection_params)?; - terminal_identity_emit::emit_repo_terminal_identity(TerminalIdentityEmitParams { + terminal_identity_emit::emit_repo_terminal_identity(&TerminalIdentityEmitParams { selected_alias: selected.repo_alias(), selected_repo: selected.repo_config(), title_override: open_params.title_override, @@ -47,10 +48,7 @@ pub fn run_open(open_params: OpenCommandParams<'_>) -> Result<(), ReportalError> 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")] let spawn_result = Command::new("cmd") @@ -81,5 +79,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..beb2349 100644 --- a/src/reportal_commands/repo_remove.rs +++ b/src/reportal_commands/repo_remove.rs @@ -1,4 +1,4 @@ -/// 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; diff --git a/src/reportal_commands/repo_run.rs b/src/reportal_commands/repo_run.rs index 322179e..957853d 100644 --- a/src/reportal_commands/repo_run.rs +++ b/src/reportal_commands/repo_run.rs @@ -1,4 +1,4 @@ -/// Fuzzy-selects a repo and runs a user-defined command in it. +//! Fuzzy-selects a repo and runs a user-defined command in it. use crate::error::ReportalError; use crate::reportal_config::{CommandEntry, ReportalConfig, TagFilter}; @@ -49,7 +49,7 @@ struct ResolveCommandParams<'a> { /// which command to run (direct or fuzzy-selected). /// /// Repo-level commands override global commands with the same name. -fn resolve_command(resolve_params: ResolveCommandParams<'_>) -> Result { +fn resolve_command(resolve_params: &ResolveCommandParams<'_>) -> Result { let mut merged: Vec> = Vec::new(); for (name, entry) in resolve_params.global_commands { @@ -73,78 +73,60 @@ fn resolve_command(resolve_params: ResolveCommandParams<'_>) -> Result {} - false => { - merged.push(MergedCommandEntry { - name, - shell_command: entry.shell_command(), - description: entry.description(), - }); - } + if !already_merged { + merged.push(MergedCommandEntry { + name, + shell_command: entry.shell_command(), + description: entry.description(), + }); } } - match merged.is_empty() { - true => return Err(ReportalError::NoCommandsAvailable), - false => {} - } - - match resolve_params.direct_command.is_empty() { - false => { - let found = merged.iter().find(|entry| entry.name == resolve_params.direct_command); - match found { - Some(entry) => { - return Ok(ResolvedCommand { - command_name: entry.name.to_string(), - shell_command: entry.shell_command.to_string(), - }); - } - None => { - return Err(ReportalError::CommandNotFound { - command_name: resolve_params.direct_command.to_string(), - }); - } - } - } - true => { - let display_labels: Vec = merged - .iter() - .map(|entry| { - let mut label = entry.name.to_string(); - match entry.description.is_empty() { - true => { - label.push_str(&format!(" — {}", entry.shell_command)); - } - false => { - label.push_str(&format!(" — {}", entry.description)); - } - } - return 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(), - })?; - - match selected_index { - Some(chosen_index) => match merged.get(chosen_index) { - Some(entry) => { - return Ok(ResolvedCommand { - command_name: entry.name.to_string(), - shell_command: entry.shell_command.to_string(), - }); - } - None => return Err(ReportalError::SelectionCancelled), - }, - None => return Err(ReportalError::SelectionCancelled), - } - } + 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(), + }), + ) } } @@ -154,23 +136,24 @@ fn resolve_command(resolve_params: ResolveCommandParams<'_>) -> Result.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: RunCommandParams<'_>) -> Result<(), ReportalError> { +pub fn run_run(run_params: &RunCommandParams<'_>) -> Result<(), ReportalError> { let loaded_config = ReportalConfig::load_from_disk()?; - let selected = repo_selection::select_repo(SelectedRepoParams { + let selection_params = SelectedRepoParams { 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(TerminalIdentityEmitParams { + terminal_identity_emit::emit_repo_terminal_identity(&TerminalIdentityEmitParams { selected_alias: selected.repo_alias(), selected_repo: selected.repo_config(), title_override: "", }); - let resolved_command = resolve_command(ResolveCommandParams { + let resolved_command = resolve_command(&ResolveCommandParams { global_commands: loaded_config.global_commands(), repo_commands: selected.repo_config().repo_commands(), direct_command: run_params.direct_command, @@ -213,25 +196,22 @@ pub fn run_run(run_params: RunCommandParams<'_>) -> Result<(), ReportalError> { reason: format!("process exited unexpectedly: {wait_error}"), })?; - match exit_status.success() { - true => {} - false => { - 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 - )); - } + 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 + )); } } } - return Ok(()); + Ok(()) } diff --git a/src/reportal_commands/repo_selection.rs b/src/reportal_commands/repo_selection.rs index 5e39bb5..83adcac 100644 --- a/src/reportal_commands/repo_selection.rs +++ b/src/reportal_commands/repo_selection.rs @@ -1,10 +1,11 @@ -/// Shared repo fuzzy-selection helper used across jump, open, ai, edit, and web. +//! Shared repo fuzzy-selection helper used across jump, open, ai, edit, and web. use crate::error::ReportalError; use crate::reportal_config::{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 @@ -42,6 +43,12 @@ pub struct SelectedRepoParams<'a> { 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. @@ -49,16 +56,16 @@ pub struct SelectedRepoParams<'a> { /// 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: SelectedRepoParams<'a>) -> Result, ReportalError> { - match selection_params.direct_alias.is_empty() { - false => { - let found_repo = selection_params.loaded_config.get_repo(selection_params.direct_alias)?; - return Ok(SelectedRepo { - repo_alias: selection_params.direct_alias, - repo_config: found_repo, - }); - } - true => { +pub fn select_repo<'a>(selection_params: &'a SelectedRepoParams<'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 { + repo_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); @@ -81,30 +88,21 @@ pub fn select_repo<'a>(selection_params: SelectedRepoParams<'a>) -> Result {} - false => { - let aliases_joined = repo.aliases().join(", "); - label.push_str(&format!(" ({aliases_joined})")); - } + if !repo.aliases().is_empty() { + let aliases_joined = repo.aliases().join(", "); + push_formatted(&mut label, format_args!(" ({aliases_joined})")); } - match repo.description().is_empty() { - true => {} - false => { - label.push_str(&format!(" — {}", repo.description())); - } + if !repo.description().is_empty() { + push_formatted(&mut label, format_args!(" — {}", repo.description())); } - match repo.tags().is_empty() { - true => {} - false => { - let tags_display = repo.tags().join(", "); - label.push_str(&format!(" [{}]", tags_display)); - } + if !repo.tags().is_empty() { + let tags_display = repo.tags().join(", "); + push_formatted(&mut label, format_args!(" [{tags_display}]")); } - return label; + label }) .collect(); @@ -118,18 +116,12 @@ pub fn select_repo<'a>(selection_params: SelectedRepoParams<'a>) -> Result match matching_repos.get(chosen_index) { - Some((chosen_alias, chosen_repo)) => { - return Ok(SelectedRepo { - repo_alias: chosen_alias.as_str(), - repo_config: *chosen_repo, - }); - } - None => return Err(ReportalError::SelectionCancelled), - }, - None => return Err(ReportalError::SelectionCancelled), - } - } + 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 { + repo_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 935e1d8..4a885bb 100644 --- a/src/reportal_commands/repo_status.rs +++ b/src/reportal_commands/repo_status.rs @@ -94,41 +94,31 @@ fn parse_usize(raw_text: &str) -> UsizeParse { /// Reads the current branch name from a repo directory. fn read_branch_name(repo_path: &PathBuf) -> String { - match git_commands::run_git_command(GitCommandParams { + match git_commands::run_git_command(&GitCommandParams { 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 git_commands::run_git_command(GitCommandParams { + match git_commands::run_git_command(&GitCommandParams { 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)) => { @@ -143,7 +133,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 git_commands::run_git_command(GitCommandParams { + match git_commands::run_git_command(&GitCommandParams { repo_path, git_subcommand_args: &["rev-list", "--left-right", "--count", "HEAD...@{upstream}"], }) { @@ -151,47 +141,43 @@ 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 git_commands::run_git_command(GitCommandParams { + match git_commands::run_git_command(&GitCommandParams { 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); +fn collect_repo_status(status_collection_params: &StatusCollectionParams<'_>) -> 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_string(), - presence: RepoPresence::Present(GitInfo { - branch, - working_tree, - upstream_delta, - last_commit_age, - }), - } + 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(), - presence: RepoPresence::Missing, - }, - } + } else { RepoGitStatus { + alias: status_collection_params.alias.to_owned(), + presence: RepoPresence::Missing, + } } } /// Formats the upstream delta into a string like "2↑ 1↓" or "synced". @@ -207,21 +193,18 @@ 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> { +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); + let matching_repos = loaded_config.repos_matching_tag_filter(tag_filter); if matching_repos.is_empty() { return Err(ReportalError::NoReposMatchFilter); @@ -231,7 +214,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(&StatusCollectionParams { alias, repo_path: &resolved, }) @@ -245,12 +228,8 @@ pub fn run_status(tag_filter: TagFilter) -> Result<(), ReportalError> { match &repo_status.presence { RepoPresence::Present(git_info) => { let status_text = match &git_info.working_tree { - WorkingTreeState::Clean => { - "clean".style(terminal_style::SUCCESS_STYLE).to_string() - } - WorkingTreeState::Dirty => { - "dirty".style(terminal_style::FAILURE_STYLE).to_string() - } + WorkingTreeState::Clean => "clean".style(terminal_style::SUCCESS_STYLE).to_string(), + WorkingTreeState::Dirty => "dirty".style(terminal_style::FAILURE_STYLE).to_string(), }; let upstream_text = format_upstream_delta(&git_info.upstream_delta); @@ -279,7 +258,7 @@ pub fn run_status(tag_filter: TagFilter) -> Result<(), ReportalError> { } } - println!("{table}"); + terminal_style::write_stdout(&format!("{table}\n")); let dirty_count = statuses .iter() @@ -300,35 +279,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 0611cfc..e206ab6 100644 --- a/src/reportal_commands/repo_sync.rs +++ b/src/reportal_commands/repo_sync.rs @@ -40,16 +40,12 @@ struct RepoSyncResult { /// Checks whether the working tree is clean enough to safely pull. fn check_pull_readiness(repo_path: &PathBuf) -> PullReadiness { - match git_commands::run_git_command(GitCommandParams { + match git_commands::run_git_command(&GitCommandParams { 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, } } @@ -61,15 +57,12 @@ 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(); - SyncOutcome::Pulled(stdout) - } - false => { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - SyncOutcome::PullFailed(stderr) - } + Ok(output) => if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + SyncOutcome::Pulled(stdout) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + SyncOutcome::PullFailed(stderr) }, Err(spawn_error) => SyncOutcome::PullFailed(spawn_error.to_string()), } @@ -77,29 +70,61 @@ fn pull_repo(repo_path: &PathBuf) -> SyncOutcome { /// 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, + )); } } } @@ -107,9 +132,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> { +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); + let matching_repos = loaded_config.repos_matching_tag_filter(tag_filter); if matching_repos.is_empty() { return Err(ReportalError::NoReposMatchFilter); @@ -122,62 +147,22 @@ pub fn run_sync(tag_filter: TagFilter) -> Result<(), ReportalError> { for (alias, repo) in &matching_repos { let resolved = repo.resolved_path(); let sync_result = sync_single_repo(alias, &resolved); + print_sync_result(&sync_result); match &sync_result.outcome { - SyncOutcome::Pulled(output) => { - pulled_count += 1; - match output.contains("Already up to date") { - true => { - println!( - " {} {} up to date", - sync_result.alias.style(terminal_style::ALIAS_STYLE), - "✓".style(terminal_style::SUCCESS_STYLE), - ); - } - false => { - println!( - " {} {} pulled", - sync_result.alias.style(terminal_style::ALIAS_STYLE), - "↓".style(terminal_style::SUCCESS_STYLE), - ); - } - } - } - SyncOutcome::SkippedDirty => { - skipped_count += 1; - eprintln!( - " {} {} skipped (dirty working tree)", - sync_result.alias.style(terminal_style::ALIAS_STYLE), - "!".style(terminal_style::FAILURE_STYLE), - ); - } - SyncOutcome::Missing => { - failed_count += 1; - eprintln!( - " {} {} missing on disk", - sync_result.alias.style(terminal_style::ALIAS_STYLE), - "✗".style(terminal_style::FAILURE_STYLE), - ); - } - SyncOutcome::PullFailed(error_message) => { - failed_count += 1; - eprintln!( - " {} {} pull failed: {}", - sync_result.alias.style(terminal_style::ALIAS_STYLE), - "✗".style(terminal_style::FAILURE_STYLE), - error_message, - ); - } + SyncOutcome::Pulled(_) => pulled_count += 1, + SyncOutcome::SkippedDirty => skipped_count += 1, + SyncOutcome::Missing | SyncOutcome::PullFailed(_) => failed_count += 1, } } - println!(); - println!( - " {} pulled, {} skipped, {} failed", + 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), - ); + )); - return Ok(()); + Ok(()) } diff --git a/src/reportal_commands/repo_web.rs b/src/reportal_commands/repo_web.rs index 30b4362..aa390a8 100644 --- a/src/reportal_commands/repo_web.rs +++ b/src/reportal_commands/repo_web.rs @@ -1,4 +1,4 @@ -/// Fuzzy-selects a repo and opens its remote URL in the default browser. +//! Fuzzy-selects a repo and opens its remote URL in the default browser. use crate::error::ReportalError; use crate::reportal_config::{ReportalConfig, TagFilter}; @@ -35,28 +35,28 @@ enum RemoteResolution { /// /// Strips the trailing `.git` suffix for a cleaner browser URL. fn remote_url_to_browser_url(remote_url: &str) -> String { - let https_url = match remote_url.starts_with("git@") { - true => { - let without_prefix = &remote_url["git@".len()..]; + 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}") - } - false => match remote_url.starts_with("ssh://") { - true => { - let without_scheme = &remote_url["ssh://".len()..]; - let without_user = match without_scheme.find('@') { - Some(at_position) => &without_scheme[at_position + 1..], - None => without_scheme, - }; - format!("https://{without_user}") - } - false => remote_url.to_string(), }, - }; - - match https_url.strip_suffix(".git") { - Some(without_git_suffix) => without_git_suffix.to_string(), - None => https_url, + ); + + 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 } } @@ -68,16 +68,10 @@ fn detect_git_remote(repo_path: &std::path::Path) -> RemoteResolution { .output(); match git_output { - Ok(output) => match output.status.success() { - true => { - let trimmed = String::from_utf8_lossy(&output.stdout).trim().to_string(); - match trimmed.is_empty() { - true => RemoteResolution::NotFound, - false => RemoteResolution::Resolved(trimmed), - } - } - false => RemoteResolution::NotFound, - }, + 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, } } @@ -88,33 +82,31 @@ fn detect_git_remote(repo_path: &std::path::Path) -> RemoteResolution { /// 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: WebCommandParams<'_>) -> Result<(), ReportalError> { +pub fn run_web(web_params: &WebCommandParams<'_>) -> Result<(), ReportalError> { let loaded_config = ReportalConfig::load_from_disk()?; - let selected = repo_selection::select_repo(SelectedRepoParams { + let selection_params = SelectedRepoParams { 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(TerminalIdentityEmitParams { + terminal_identity_emit::emit_repo_terminal_identity(&TerminalIdentityEmitParams { selected_alias: selected.repo_alias(), selected_repo: selected.repo_config(), title_override: "", }); - let raw_remote_url = match selected.repo_config().remote().is_empty() { - false => selected.repo_config().remote().to_string(), - true => match detect_git_remote(&selected.repo_config().resolved_path()) { - RemoteResolution::Resolved(detected_url) => detected_url, - RemoteResolution::NotFound => { - return Err(ReportalError::NoRemoteUrl { - alias: selected.repo_alias().to_string(), - }); - } - }, - }; + 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.repo_alias().to_owned(), + }); + } + } } else { selected.repo_config().remote().to_owned() }; let browser_url = remote_url_to_browser_url(&raw_remote_url); @@ -127,7 +119,7 @@ pub fn run_web(web_params: WebCommandParams<'_>) -> Result<(), ReportalError> { browser_url.style(terminal_style::PATH_STYLE), )); - return Ok(()); + Ok(()) } #[cfg(test)] diff --git a/src/reportal_commands/terminal_identity_emit.rs b/src/reportal_commands/terminal_identity_emit.rs index 4e64598..0c76a0d 100644 --- a/src/reportal_commands/terminal_identity_emit.rs +++ b/src/reportal_commands/terminal_identity_emit.rs @@ -1,4 +1,4 @@ -/// Emits OSC terminal identity sequences (tab title + color) for a selected repo. +//! 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, TerminalIdentityParams}; @@ -18,13 +18,14 @@ pub struct TerminalIdentityEmitParams<'a> { /// /// 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: TerminalIdentityEmitParams<'_>) { - let resolved_title = match identity_params.title_override.is_empty() { - false => identity_params.title_override.to_string(), - true => match identity_params.selected_repo.tab_title() { - TabTitle::Custom(custom_title) => custom_title.to_string(), - TabTitle::UseAlias => identity_params.selected_alias.to_string(), - }, +pub fn emit_repo_terminal_identity(identity_params: &TerminalIdentityEmitParams<'_>) { + 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() { diff --git a/src/reportal_config/ai_tool_entry.rs b/src/reportal_config/ai_tool_entry.rs index 6e4b6da..985c2d4 100644 --- a/src/reportal_config/ai_tool_entry.rs +++ b/src/reportal_config/ai_tool_entry.rs @@ -1,5 +1,5 @@ -/// AI coding CLI tool registry entries stored in the `[ai_tools.*]` -/// sections of `config.toml`. +//! AI coding CLI tool registry entries stored in the `[ai_tools.*]` +//! sections of `config.toml`. use serde::{Deserialize, Serialize}; diff --git a/src/reportal_config/command_entry.rs b/src/reportal_config/command_entry.rs index cbef93d..f3d9cde 100644 --- a/src/reportal_config/command_entry.rs +++ b/src/reportal_config/command_entry.rs @@ -1,5 +1,5 @@ -/// User-defined command entries stored in the `[commands.*]` -/// sections of `config.toml`. +//! User-defined command entries stored in the `[commands.*]` +//! sections of `config.toml`. use serde::{Deserialize, Serialize}; diff --git a/src/reportal_config/global_settings.rs b/src/reportal_config/global_settings.rs index b2db255..8d89784 100644 --- a/src/reportal_config/global_settings.rs +++ b/src/reportal_config/global_settings.rs @@ -1,7 +1,8 @@ -/// Global settings that apply across all repos, stored in the -/// `[settings]` section of `config.toml`. +//! Global settings that apply across all repos, stored in the +//! `[settings]` section of `config.toml`. use serde::{Deserialize, Serialize}; +use crate::terminal_style; /// How repo paths are displayed in output after selecting a repo. #[derive(Debug, Deserialize, Serialize)] @@ -22,21 +23,21 @@ impl PathDisplayFormat { pub fn format_path(&self, absolute_path: &std::path::PathBuf) -> String { match self { PathDisplayFormat::Absolute => { - return absolute_path.display().to_string(); + 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(), - } + relative_result.map_or_else( + || absolute_path.display().to_string(), + |relative_path| relative_path.display().to_string(), + ) } Err(cwd_read_error) => { - eprintln!(" Could not read working directory: {cwd_read_error}"); - return absolute_path.display().to_string(); + terminal_style::write_stderr(&format!(" Could not read working directory: {cwd_read_error}\n")); + absolute_path.display().to_string() } } } @@ -56,7 +57,7 @@ pub enum PathVisibility { /// Returns the default editor command when none is configured. pub fn default_editor_command() -> String { - "cursor".to_string() + "cursor".to_owned() } /// Returns the default path visibility (show). diff --git a/src/reportal_config/hex_color.rs b/src/reportal_config/hex_color.rs index d09eaff..0249e29 100644 --- a/src/reportal_config/hex_color.rs +++ b/src/reportal_config/hex_color.rs @@ -1,7 +1,7 @@ -/// 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. +//! 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; @@ -21,19 +21,19 @@ impl HexColor { let trimmed = raw.trim(); if trimmed.len() != 7 || !trimmed.starts_with('#') { return Err(ReportalError::InvalidColor { - value: raw.to_string(), + 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_string(), + value: raw.to_owned(), }); } - return Ok(Self { - value: trimmed.to_string(), - }); + Ok(Self { + value: trimmed.to_owned(), + }) } /// The raw hex string as stored (e.g. `#1a1a2e`). @@ -56,11 +56,11 @@ impl HexColor { 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])?; - return Ok((red, green, blue)); + 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 + /// 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 { @@ -68,20 +68,20 @@ impl HexColor { let red_hex = &hex_digits[0..2]; let green_hex = &hex_digits[2..4]; let blue_hex = &hex_digits[4..6]; - return format!( + format!( "\x1b]4;264;rgb:{red_hex}/{green_hex}/{blue_hex}\x07" - ); + ) } } -/// Serializes a HexColor as its raw `#RRGGBB` string for TOML output. +/// 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. +/// 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)?; diff --git a/src/reportal_config/mod.rs b/src/reportal_config/mod.rs index 7430d9b..d36053e 100644 --- a/src/reportal_config/mod.rs +++ b/src/reportal_config/mod.rs @@ -1,7 +1,7 @@ -/// 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. +//! 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 command_entry; @@ -11,9 +11,8 @@ mod reportal_config_root; mod repo_entry; mod tag_filter; -pub use ai_tool_entry::AiToolEntry; pub use command_entry::CommandEntry; -pub use global_settings::{PathDisplayFormat, PathVisibility, ReportalSettings}; +pub use global_settings::PathVisibility; pub use hex_color::HexColor; pub use repo_entry::{RepoColor, RepoEntry, RepoRegistrationBuilder, TabTitle}; pub use reportal_config_root::ReportalConfig; diff --git a/src/reportal_config/repo_entry.rs b/src/reportal_config/repo_entry.rs index 2f255b8..6aeeff5 100644 --- a/src/reportal_config/repo_entry.rs +++ b/src/reportal_config/repo_entry.rs @@ -1,5 +1,5 @@ -/// Repository entry types: `RepoEntry`, `RepoRegistrationBuilder`, -/// `TabTitle`, and `RepoColor`. +//! Repository entry types: `RepoEntry`, `RepoRegistrationBuilder`, +//! `TabTitle`, and `RepoColor`. use crate::error::ReportalError; use crate::reportal_config::command_entry::CommandEntry; @@ -29,10 +29,7 @@ impl Default for TabTitle { 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)), - } + if raw.is_empty() { Ok(TabTitle::UseAlias) } else { Ok(TabTitle::Custom(raw)) } } } @@ -57,12 +54,9 @@ impl Default for RepoColor { 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)); - } + if raw.is_empty() { Ok(RepoColor::ResetToDefault) } else { + let parsed = HexColor::parse(&raw).map_err(serde::de::Error::custom)?; + Ok(RepoColor::Themed(parsed)) } } } @@ -243,21 +237,21 @@ impl RepoRegistrationBuilder { 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(), + field: "alias".to_owned(), + reason: "must not be empty".to_owned(), }); } if self.repo_path.trim().is_empty() { return Err(ReportalError::ValidationFailure { - field: "path".to_string(), - reason: "must not be empty".to_string(), + 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_string(), + field: "path".to_owned(), reason: format!("{} does not exist", resolved.display()), }); } diff --git a/src/reportal_config/reportal_config_root.rs b/src/reportal_config/reportal_config_root.rs index 61ffe9d..f62a944 100644 --- a/src/reportal_config/reportal_config_root.rs +++ b/src/reportal_config/reportal_config_root.rs @@ -1,5 +1,5 @@ -/// Top-level RePortal config: load/save, repo queries, AI tool registry, -/// and settings mutation. +//! 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; @@ -14,7 +14,7 @@ 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_string(), + reason: "Could not determine home directory".to_owned(), }) } @@ -35,9 +35,9 @@ pub struct ReportalConfig { commands: BTreeMap, } -/// Loading, saving, querying, and mutating the RePortal config file. +/// Loading, saving, querying, and mutating the `RePortal` config file. impl ReportalConfig { - /// Returns the directory where RePortal stores its config. + /// Returns the directory where `RePortal` stores its config. pub fn config_directory() -> Result { Ok(resolve_home_directory()?.join(".reportal")) } @@ -73,10 +73,8 @@ impl ReportalConfig { 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(), - } + std::fs::create_dir_all(&parent_directory).map_err(|io_error| ReportalError::ConfigIoFailure { + reason: io_error.to_string(), })?; } let serialized_toml = @@ -128,28 +126,24 @@ impl ReportalConfig { /// Checks the primary key first, then walks all repos checking their /// `aliases` field. Returns `RepoNotFound` if no match is found. pub fn get_repo(&self, alias: &str) -> Result<&RepoEntry, ReportalError> { - match self.repos.get(alias) { - Some(found_repo) => return Ok(found_repo), - None => { - for (_primary_key, repo_entry) in &self.repos { - let has_matching_alias = repo_entry.aliases().iter().any(|alt| alt == alias); - match has_matching_alias { - true => return Ok(repo_entry), - false => {} - } - } - return Err(ReportalError::RepoNotFound { - alias: alias.to_string(), - }); - } + if let Some(found_repo) = self.repos.get(alias) { + return Ok(found_repo); } + let alias_match = self.repos.values() + .find(|repo_entry| repo_entry.aliases().iter().any(|alt| alt == alias)); + alias_match.map_or_else( + || Err(ReportalError::RepoNotFound { + alias: alias.to_owned(), + }), + Ok, + ) } /// Returns a mutable reference to a repo by its primary key. /// Returns `RepoNotFound` if the alias is not a primary key. pub fn get_repo_mut(&mut self, alias: &str) -> Result<&mut RepoEntry, ReportalError> { self.repos.get_mut(alias).ok_or_else(|| ReportalError::RepoNotFound { - alias: alias.to_string(), + alias: alias.to_owned(), }) } @@ -170,7 +164,7 @@ impl ReportalConfig { /// 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(), + alias: alias.to_owned(), }) } @@ -182,7 +176,7 @@ impl ReportalConfig { /// 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_string(), + tool_name: tool_name.to_owned(), }) } @@ -191,68 +185,27 @@ impl ReportalConfig { self.ai_tools.iter().collect() } - /// Inserts or replaces an AI tool entry in the registry. - /// Takes a (name, entry) pair, same shape as `ai_tools_list()` returns. - pub fn set_ai_tool(&mut self, registration: (String, AiToolEntry)) { - let (tool_name, tool_entry) = registration; - self.ai_tools.insert(tool_name, tool_entry); - } - - /// Removes an AI tool by name. Returns `AiToolNotFound` if missing. - pub fn remove_ai_tool(&mut self, tool_name: &str) -> Result { - self.ai_tools.remove(tool_name).ok_or_else(|| ReportalError::AiToolNotFound { - tool_name: tool_name.to_string(), - }) - } - /// Returns all globally registered commands with their names. pub fn global_commands(&self) -> &BTreeMap { &self.commands } - /// Looks up a global command by name. Returns `CommandNotFound` if missing. - pub fn get_global_command(&self, command_name: &str) -> Result<&CommandEntry, ReportalError> { - self.commands.get(command_name).ok_or_else(|| ReportalError::CommandNotFound { - command_name: command_name.to_string(), - }) - } - - /// Updates the default editor command in settings. - pub fn set_default_editor(&mut self, editor_command: String) { - self.settings.default_editor = editor_command; - } - - /// Updates the default AI tool name in settings. - pub fn set_default_ai_tool(&mut self, tool_name: String) { - self.settings.default_ai_tool = tool_name; - } - - /// Updates the default clone root path in settings. - pub fn set_default_clone_root(&mut self, clone_root: String) { - self.settings.default_clone_root = clone_root; - } - - /// Returns the configured default clone root path. - pub fn default_clone_root(&self) -> &str { - &self.settings.default_clone_root - } - /// 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(), + default_editor: "cursor".to_owned(), + default_clone_root: "~/dev".to_owned(), path_on_select: PathVisibility::Show, path_display_format: PathDisplayFormat::Absolute, - default_ai_tool: "aider".to_string(), + default_ai_tool: "aider".to_owned(), }, repos: BTreeMap::new(), commands: BTreeMap::new(), ai_tools: BTreeMap::from([ - ("aider".to_string(), AiToolEntry::with_executable("aider".to_string())), - ("aider".to_string(), AiToolEntry::with_executable("aider".to_string())), - ("aider".to_string(), AiToolEntry::with_executable("aider".to_string())), + ("aider".to_owned(), AiToolEntry::with_executable("aider".to_owned())), + ("aider".to_owned(), AiToolEntry::with_executable("aider".to_owned())), + ("aider".to_owned(), AiToolEntry::with_executable("aider".to_owned())), ]), } } diff --git a/src/reportal_config/tag_filter.rs b/src/reportal_config/tag_filter.rs index 2b2a58c..06fefe9 100644 --- a/src/reportal_config/tag_filter.rs +++ b/src/reportal_config/tag_filter.rs @@ -1,4 +1,4 @@ -/// Filtering repos by tag for list, status, sync, and fuzzy-select commands. +//! 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)] diff --git a/src/terminal_style.rs b/src/terminal_style.rs index 6b92d36..8b9cd0f 100644 --- a/src/terminal_style.rs +++ b/src/terminal_style.rs @@ -1,7 +1,7 @@ -/// 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}; @@ -33,7 +33,7 @@ pub const EMPHASIS_STYLE: Style = Style::new().bold(); /// /// 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. +/// 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; @@ -41,7 +41,7 @@ pub fn reportal_prompt_theme() -> 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_string()).for_stderr().cyan(), + prompt_prefix: console::style("?".to_owned()).for_stderr().cyan(), prompt_style: ConsoleStyle::new().for_stderr().bold(), ..dialoguer::theme::ColorfulTheme::default() } @@ -54,33 +54,43 @@ pub fn swatch_style_for_repo_color(repo_color: &crate::reportal_config::RepoColo match repo_color { crate::reportal_config::RepoColor::Themed(hex_color) => { let (red, green, blue) = hex_color.as_rgb_bytes()?; - return Ok(Style::new().truecolor(red, green, blue)); + Ok(Style::new().truecolor(red, green, blue)) } crate::reportal_config::RepoColor::ResetToDefault => { - return Ok(DEFAULT_SWATCH_STYLE); + 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 104;264 escape sequence that resets the Windows Terminal -/// tab color strip (FRAME_BACKGROUND) back to the profile default. +/// tab color strip (`FRAME_BACKGROUND`) back to the profile default. pub fn osc_reset_tab_color_sequence() -> &'static str { - return "\x1b]104;264\x07"; + "\x1b]104;264\x07" } /// What to do with the terminal tab color strip when jumping to a repo. @@ -105,10 +115,10 @@ impl TerminalIdentity { /// tab color action. Called after the title fallback chain /// (flag > config > alias) has been resolved. pub fn new(identity_params: TerminalIdentityParams) -> Self { - return Self { + Self { resolved_title: identity_params.resolved_title, tab_color_action: identity_params.tab_color_action, - }; + } } /// The final tab title after resolving the flag > config > alias chain. @@ -155,7 +165,7 @@ pub fn write_to_console(text: &str) { } /// Writes OSC sequences directly to the console, bypassing both stdout -/// and stderr. This is necessary because PowerShell captures stdout in +/// 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 @@ -176,9 +186,9 @@ pub fn emit_terminal_identity_to_console(identity: &TerminalIdentity) { match console_handle { Ok(mut console) => { let title_sequence = osc_tab_title_sequence(identity.resolved_title()); - let color_sequence = match identity.tab_color_action() { - TabColorAction::SetColor(osc_sequence) => osc_sequence.to_string(), - TabColorAction::Reset => osc_reset_tab_color_sequence().to_string(), + 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()); From e3e25c49c1d1918492d04e9fe87b1efe3a9fa80a Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Wed, 15 Apr 2026 12:02:35 -0400 Subject: [PATCH 43/67] Add VSCode/Cursor .code-workspace integration (v0.13.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reportal now owns [workspaces.] as the single source of truth for VSCode/Cursor workspaces. The .code-workspace file on disk is a derived artifact regenerated from the member repos' current paths whenever they change, so moving a repo automatically updates every workspace that references it. Commands: - rep workspace list / show / create / delete - rep workspace add-repo / remove-repo / open - rep edit now exposes a Path field that regenerates every containing workspace on mutation - rep remove refuses to unregister a repo that is still a member of any workspace (RepoIsWorkspaceMember error) Parse-merge-write for .code-workspace files uses jsonc-parser's CST API so unknown top-level fields (settings, extensions, launch, tasks) and JSONC comments round-trip byte-stable across regeneration — reportal only touches the folders array. Structural refactors unblocked by the new feature: - Split repo_entry.rs into repo_entry / tab_title / repo_color / repo_registration_builder to satisfy the 1-primary-type-per-file rule, enabling the new set_raw_path setter - Extract rep edit helpers into repo_edit_mutator.rs so the command file stays within its free-function budget Tests (19 total): - Builder validation (empty name, whitespace, empty repo list, valid construction, member order preservation) - JSONC folder parsing (ordered extraction, missing property, non-object root, missing/non-string path, comments) - CST round-trip preservation of settings / extensions / launch and JSONC comments across regeneration --- Cargo.lock | 9 +- Cargo.toml | 3 +- README.md | 33 +- src/cli_args/mod.rs | 4 + src/cli_args/reportal_cli.rs | 5 +- src/cli_args/workspace_args.rs | 248 ++++++++++++ src/code_workspace/code_workspace_file.rs | 354 ++++++++++++++++++ .../code_workspace_folder_entry.rs | 58 +++ src/code_workspace/folder_parsing.rs | 219 +++++++++++ src/code_workspace/mod.rs | 22 ++ src/error.rs | 62 +++ src/main.rs | 4 + src/reportal_commands/mod.rs | 16 + src/reportal_commands/repo_edit.rs | 171 ++------- src/reportal_commands/repo_edit_mutator.rs | 267 +++++++++++++ src/reportal_commands/repo_remove.rs | 34 +- src/reportal_commands/workspace_create.rs | 52 +++ src/reportal_commands/workspace_delete.rs | 30 ++ src/reportal_commands/workspace_dispatch.rs | 53 +++ src/reportal_commands/workspace_listing.rs | 75 ++++ src/reportal_commands/workspace_members.rs | 122 ++++++ src/reportal_commands/workspace_open.rs | 56 +++ src/reportal_commands/workspace_operations.rs | 154 ++++++++ src/reportal_commands/workspace_show.rs | 69 ++++ src/reportal_config/mod.rs | 14 +- src/reportal_config/repo_color.rs | 36 ++ src/reportal_config/repo_entry.rs | 211 ++--------- .../repo_registration_builder.rs | 140 +++++++ src/reportal_config/reportal_config_root.rs | 172 ++++++++- src/reportal_config/tab_title.rs | 34 ++ src/reportal_config/workspace_entry.rs | 76 ++++ .../workspace_registration_builder.rs | 170 +++++++++ 32 files changed, 2652 insertions(+), 321 deletions(-) create mode 100644 src/cli_args/workspace_args.rs create mode 100644 src/code_workspace/code_workspace_file.rs create mode 100644 src/code_workspace/code_workspace_folder_entry.rs create mode 100644 src/code_workspace/folder_parsing.rs create mode 100644 src/code_workspace/mod.rs create mode 100644 src/reportal_commands/repo_edit_mutator.rs create mode 100644 src/reportal_commands/workspace_create.rs create mode 100644 src/reportal_commands/workspace_delete.rs create mode 100644 src/reportal_commands/workspace_dispatch.rs create mode 100644 src/reportal_commands/workspace_listing.rs create mode 100644 src/reportal_commands/workspace_members.rs create mode 100644 src/reportal_commands/workspace_open.rs create mode 100644 src/reportal_commands/workspace_operations.rs create mode 100644 src/reportal_commands/workspace_show.rs create mode 100644 src/reportal_config/repo_color.rs create mode 100644 src/reportal_config/repo_registration_builder.rs create mode 100644 src/reportal_config/tab_title.rs create mode 100644 src/reportal_config/workspace_entry.rs create mode 100644 src/reportal_config/workspace_registration_builder.rs diff --git a/Cargo.lock b/Cargo.lock index ee747d6..00ffa42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -365,6 +365,12 @@ 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 = "8c2e5dea04f54739ca5694ee06e4448ffda065a55b3427d2b131bd5ea697ea2c" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -550,7 +556,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.12.0" +version = "0.13.0" dependencies = [ "anyhow", "clap", @@ -558,6 +564,7 @@ dependencies = [ "console", "dialoguer", "dirs 5.0.1", + "jsonc-parser", "open", "owo-colors", "pathdiff", diff --git a/Cargo.toml b/Cargo.toml index ffeafd1..84ebcd3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.12.0" +version = "0.13.0" 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" diff --git a/README.md b/README.md index 64af70c..8e45224 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,13 @@ ro | `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 member repos | +| `rep workspace show ` | | Show a workspace's description, file path, and resolved member paths | +| `rep workspace create --repos a,b,c` | | Register a new workspace and write its `.code-workspace` file | +| `rep workspace delete ` | `rep ws rm` | Unregister a workspace (leaves the `.code-workspace` file on disk) | +| `rep workspace add-repo ` | | Add a repo to a workspace and regenerate its file | +| `rep workspace remove-repo ` | | Remove a repo from a workspace and regenerate its file | +| `rep workspace open ` | | Open a workspace in your default editor | | `rep doctor` | | Diagnose config, shell integration, and repo path issues | ## Shell integration @@ -208,6 +215,27 @@ args = [] | `args` | no | `[]` | Extra arguments passed on every launch | +### Workspaces + +A workspace is a named group of repos that open together as one VSCode/Cursor window. RePortal owns the `[workspaces.]` table as the single source of truth, and the `.code-workspace` file on disk is a derived artifact generated from the member repos' current paths. + +```toml +[workspaces.backend] +repos = ["api", "worker", "db-migrations"] # ordered — controls sidebar order +description = "Jakuta backend services" +path = "" # empty = ~/.reportal/workspaces/backend.code-workspace +``` + +| Field | Required | Default | What it controls | +|-------|----------|---------|-----------------| +| `repos` | yes | — | Ordered list of repo aliases that belong to this workspace. Order matches the editor sidebar. Each alias must be a registered repo — dangling references are rejected on config load | +| `description` | no | `""` | Human-readable description shown in `rep workspace list` | +| `path` | no | `""` | Filesystem path for the `.code-workspace` file. Empty falls back to `~/.reportal/workspaces/.code-workspace` | + +**Regeneration is automatic.** When you change a repo's path via `rep edit`, every workspace that contains it regenerates its `.code-workspace` file so the folder entries reflect the new location. User-authored fields inside the file (`settings`, `extensions`, `launch`, `tasks`, plus any JSONC comments) round-trip byte-stable across regeneration — RePortal only touches the `folders` array. + +**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`: @@ -275,8 +303,11 @@ PROMPT_COMMAND='rep color 2>/dev/null' - [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 Description/Tags/Title/Color individually, loop back) +- [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`) +- [ ] Workspace-tree grouping in `rep list` (workspaces become the tree root, tags become a secondary filter) +- [ ] 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 diff --git a/src/cli_args/mod.rs b/src/cli_args/mod.rs index 2616f4e..b695922 100644 --- a/src/cli_args/mod.rs +++ b/src/cli_args/mod.rs @@ -16,6 +16,7 @@ mod status_args; mod sync_args; mod tag_filter_args; mod web_args; +mod workspace_args; pub use ai_args::AiArgs; pub use color_args::ColorArgs; @@ -29,3 +30,6 @@ pub use run_args::RunArgs; pub use status_args::StatusArgs; pub use sync_args::SyncArgs; pub use web_args::WebArgs; +pub use workspace_args::{ + WorkspaceArgs, WorkspaceArgsCreateParts, WorkspaceArgsMemberEditParts, WorkspaceArgsSubcommand, +}; diff --git a/src/cli_args/reportal_cli.rs b/src/cli_args/reportal_cli.rs index 327aff1..375d7fd 100644 --- a/src/cli_args/reportal_cli.rs +++ b/src/cli_args/reportal_cli.rs @@ -3,7 +3,7 @@ use clap::{Parser, Subcommand}; use super::{ AiArgs, ColorArgs, EditArgs, JumpArgs, ListArgs, - OpenArgs, RemoveArgs, RunArgs, StatusArgs, SyncArgs, WebArgs, + OpenArgs, RemoveArgs, RunArgs, StatusArgs, SyncArgs, WebArgs, WorkspaceArgs, }; /// A fast CLI tool for jumping between and managing your dev repos. @@ -66,4 +66,7 @@ pub enum ReportalCliSubcommand { /// Run a configured command in a repo #[command(alias = "r")] Run(RunArgs), + /// Manage VSCode/Cursor `.code-workspace` definitions + #[command(alias = "ws")] + Workspace(WorkspaceArgs), } diff --git a/src/cli_args/workspace_args.rs b/src/cli_args/workspace_args.rs new file mode 100644 index 0000000..da1889b --- /dev/null +++ b/src/cli_args/workspace_args.rs @@ -0,0 +1,248 @@ +//! 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`, `repo_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 WorkspaceArgs { + /// The workspace action the user invoked. + #[command(subcommand)] + action: WorkspaceArgsSubcommand, +} + +/// Consuming accessor for the workspace subcommand. +impl WorkspaceArgs { + /// Extracts the action variant, consuming the parsed args. + #[must_use] + pub fn into_action(self) -> WorkspaceArgsSubcommand { + 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 `repo_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. + repo_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.repo_alias + } +} + +/// Payload for actions that target one workspace by name and +/// carry no other arguments: `show`, `delete`, `open`. +#[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 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) -> WorkspaceArgsMemberEditParts { + WorkspaceArgsMemberEditParts { + workspace_name: self.target.into_name(), + repo_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 WorkspaceArgsMemberEditParts { + /// The workspace whose membership is being edited. + workspace_name: String, + /// The repo being added to or removed from the workspace. + repo_alias: String, +} + +/// Destructuring accessors for the member-edit parts. +impl WorkspaceArgsMemberEditParts { + /// 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 repo_alias(&self) -> &str { + &self.repo_alias + } +} + +/// Payload for `rep workspace create`, which declares a new +/// workspace with an initial member list, description, and +/// optional custom file path. +#[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 generated + /// `.code-workspace` file. Defaults to + /// `~/.reportal/workspaces/.code-workspace`. + #[arg(long, default_value = "")] + file_path: String, +} + +/// 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) -> WorkspaceArgsCreateParts { + WorkspaceArgsCreateParts { + workspace_name: self.target.into_name(), + repo_aliases: self.repos, + description: self.description, + custom_file_path: self.file_path, + } + } +} + +/// Owned named-field result of `WorkspaceArgsCreate::into_parts`. +/// +/// Returned instead of a bare tuple so call sites never confuse +/// the four string-typed fields. +pub struct WorkspaceArgsCreateParts { + /// 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, +} + +/// Accessors for the create-action parts. +impl WorkspaceArgsCreateParts { + /// 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 + } +} + +/// Subcommands under `rep workspace`. +#[derive(Subcommand)] +pub enum WorkspaceArgsSubcommand { + /// 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 its + /// `.code-workspace` file on disk. + Show(WorkspaceArgsNameOnly), + /// Create a new workspace and generate its `.code-workspace` + /// file under `~/.reportal/workspaces/` unless a custom path + /// is given. + Create(WorkspaceArgsCreate), + /// Delete a workspace from the config. Does NOT delete the + /// `.code-workspace` file on disk — that is the user's to + /// manage so an accidental `rep workspace delete` cannot + /// destroy an open editor session. + #[command(alias = "rm")] + Delete(WorkspaceArgsNameOnly), + /// Add a repo alias to an existing workspace and regenerate + /// its `.code-workspace` file. + AddRepo(WorkspaceArgsMemberEdit), + /// Remove a repo alias from an existing workspace and + /// regenerate its `.code-workspace` file. + RemoveRepo(WorkspaceArgsMemberEdit), + /// Open a workspace in the configured default editor by + /// running ` `. + Open(WorkspaceArgsNameOnly), +} diff --git a/src/code_workspace/code_workspace_file.rs b/src/code_workspace/code_workspace_file.rs new file mode 100644 index 0000000..11a6fd1 --- /dev/null +++ b/src/code_workspace/code_workspace_file.rs @@ -0,0 +1,354 @@ +//! 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::ParseOptions; +use jsonc_parser::cst::{CstInputValue, CstRootNode}; +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, +} + +/// 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(), + } + } + + /// 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, + }) + } + + /// 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 + } + + /// Replaces the folder list with entries built from the given + /// absolute paths, in order. + /// + /// The original file text and every top-level field outside + /// `folders` are untouched so user-authored settings, + /// extensions, launch configs, and JSONC comments round-trip + /// across the regeneration. This is the single mutation the + /// parse-merge-write path performs. + 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(); + } + + /// 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); + } + } + 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(()) + } +} + +/// 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..0cbc4bd --- /dev/null +++ b/src/code_workspace/folder_parsing.rs @@ -0,0 +1,219 @@ +//! 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::ParseOptions; +use jsonc_parser::cst::{CstNode, CstRootNode}; +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..8059a82 --- /dev/null +++ b/src/code_workspace/mod.rs @@ -0,0 +1,22 @@ +//! 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 70a60be..8d44704 100644 --- a/src/error.rs +++ b/src/error.rs @@ -120,4 +120,66 @@ pub enum ReportalError { /// 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, + }, + + /// 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 fe1fdb6..98317c1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ //! to the appropriate subcommand handler. mod cli_args; +mod code_workspace; mod error; mod reportal_commands; mod reportal_config; @@ -99,6 +100,9 @@ fn main() { tool_override: &tool_override, }) } + ReportalCliSubcommand::Workspace(workspace_args) => { + reportal_commands::dispatch_workspace_subcommand(workspace_args) + } }; match command_result { diff --git a/src/reportal_commands/mod.rs b/src/reportal_commands/mod.rs index 50cf5ba..4277e13 100644 --- a/src/reportal_commands/mod.rs +++ b/src/reportal_commands/mod.rs @@ -11,6 +11,7 @@ mod repo_add; mod repo_ai; mod repo_color; mod repo_edit; +mod repo_edit_mutator; mod repo_jump; mod repo_listing; mod repo_open; @@ -21,6 +22,14 @@ mod repo_status; mod terminal_identity_emit; mod repo_sync; mod repo_web; +mod workspace_create; +mod workspace_delete; +mod workspace_dispatch; +mod workspace_listing; +mod workspace_members; +mod workspace_open; +mod workspace_operations; +mod workspace_show; pub use color_command_mode::ColorCommandMode; pub use doctor::run_doctor; @@ -38,3 +47,10 @@ pub use repo_run::{run_run, RunCommandParams}; pub use repo_status::run_status; pub use repo_sync::run_sync; pub use repo_web::{run_web, WebCommandParams}; +pub use workspace_create::run_workspace_create; +pub use workspace_delete::run_workspace_delete; +pub use workspace_dispatch::dispatch_workspace_subcommand; +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_show::run_workspace_show; diff --git a/src/reportal_commands/repo_edit.rs b/src/reportal_commands/repo_edit.rs index 750f008..cc40ac5 100644 --- a/src/reportal_commands/repo_edit.rs +++ b/src/reportal_commands/repo_edit.rs @@ -1,9 +1,7 @@ //! Interactively edits an existing repo's metadata via a field menu. use crate::error::ReportalError; -use crate::reportal_commands::prompts::{ - self, ColorEditPromptParams, ColorEditResult, TextPromptParams, -}; +use crate::reportal_commands::repo_edit_mutator::RepoEditFieldMutator; use crate::reportal_commands::repo_selection::{self, SelectedRepoParams}; use crate::reportal_config::{RepoColor, ReportalConfig, TabTitle, TagFilter}; use crate::terminal_style; @@ -18,71 +16,20 @@ pub struct EditCommandParams<'a> { pub direct_alias: &'a str, } -/// Truncates a string to a reasonable menu-display length, returning -/// the first 50 characters if the input exceeds that limit. -fn truncate_for_menu(display_text: &str) -> &str { - if display_text.len() > 50 { - &display_text[..50] - } else { - display_text - } -} - -/// Prompts for a new tab title, saves to disk, and returns the updated value. -fn apply_title_edit( - loaded_config: &mut ReportalConfig, - selected_alias: &str, - prompt_theme: &dialoguer::theme::ColorfulTheme, - current_title: &str, -) -> Result { - let new_title = prompts::prompt_for_text(&TextPromptParams { - prompt_theme, - label: "Tab title (empty = use alias)", - default_value: current_title, - })?; - let resolved_title = if new_title.is_empty() { - TabTitle::UseAlias - } else { - TabTitle::Custom(new_title) - }; - let repo_to_update = loaded_config.get_repo_mut(selected_alias)?; - repo_to_update.set_tab_title(resolved_title); - loaded_config.save_to_disk()?; - let updated = match loaded_config.get_repo(selected_alias)?.tab_title() { - TabTitle::Custom(custom_title) => custom_title.to_owned(), - TabTitle::UseAlias => String::new(), - }; - Ok(updated) -} - -/// Prompts for a color edit, saves to disk, and returns the updated hex string. -fn apply_color_edit( - loaded_config: &mut ReportalConfig, - selected_alias: &str, - prompt_theme: &dialoguer::theme::ColorfulTheme, - current_color_hex: &str, -) -> Result { - let color_edit_result = prompts::prompt_for_color_edit(&ColorEditPromptParams { - prompt_theme, - current_default: 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 = loaded_config.get_repo_mut(selected_alias)?; - repo_to_update.set_repo_color(resolved_color); - loaded_config.save_to_disk()?; - let updated = match loaded_config.get_repo(selected_alias)?.repo_color() { - RepoColor::Themed(hex_color) => hex_color.raw_value().to_owned(), - RepoColor::ResetToDefault => String::new(), - }; - Ok(updated) -} - /// 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: &EditCommandParams<'_>) -> Result<(), ReportalError> { let mut loaded_config = ReportalConfig::load_from_disk()?; @@ -97,7 +44,18 @@ pub fn run_edit(command_params: &EditCommandParams<'_>) -> Result<(), ReportalEr selection.repo_alias().to_owned() }; - let repo_path_display = loaded_config.get_repo(&selected_alias)?.raw_path().to_owned(); + 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 initial_color_hex = match initial_repo.repo_color() { + RepoColor::Themed(hex_color) => hex_color.raw_value().to_owned(), + RepoColor::ResetToDefault => String::new(), + }; terminal_style::write_stdout("\n"); terminal_style::write_stdout(&format!( @@ -108,44 +66,24 @@ pub fn run_edit(command_params: &EditCommandParams<'_>) -> Result<(), ReportalEr terminal_style::write_stdout(&format!( " {} {}\n", "Path:".style(terminal_style::LABEL_STYLE), - repo_path_display.style(terminal_style::PATH_STYLE), + initial_raw_path.style(terminal_style::PATH_STYLE), )); let prompt_theme = terminal_style::reportal_prompt_theme(); - - let repo_entry = loaded_config.get_repo(&selected_alias)?; - let mut current_description = repo_entry.description().to_owned(); - let mut current_tags_csv = repo_entry.tags().join(", "); - let mut current_title = match repo_entry.tab_title() { - TabTitle::Custom(custom_title) => custom_title.to_owned(), - TabTitle::UseAlias => String::new(), - }; - let mut current_color_hex = match repo_entry.repo_color() { - RepoColor::Themed(hex_color) => hex_color.raw_value().to_owned(), - RepoColor::ResetToDefault => String::new(), + 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, }; loop { terminal_style::write_stdout("\n"); - - let description_label = format!("Description: {}", truncate_for_menu(¤t_description)); - let tags_label = if current_tags_csv.is_empty() { - "Tags: (none)".to_owned() - } else { - format!("Tags: {}", truncate_for_menu(¤t_tags_csv)) - }; - let title_label = if current_title.is_empty() { - "Title: (use alias)".to_owned() - } else { - format!("Title: {current_title}") - }; - let color_label = if current_color_hex.is_empty() { - "Color: (none)".to_owned() - } else { - format!("Color: {current_color_hex}") - }; - let menu_labels = vec![description_label, tags_label, title_label, color_label, "Done".to_owned()]; - + 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) @@ -154,41 +92,14 @@ pub fn run_edit(command_params: &EditCommandParams<'_>) -> Result<(), ReportalEr .map_err(|select_error| ReportalError::ConfigIoFailure { reason: select_error.to_string(), })?; - match chosen_index { - None | Some(4..) => break, - Some(0) => { - let new_description = prompts::prompt_for_text(&TextPromptParams { - prompt_theme: &prompt_theme, - label: "Description", - default_value: ¤t_description, - })?; - let repo_to_update = loaded_config.get_repo_mut(&selected_alias)?; - repo_to_update.set_description(new_description); - loaded_config.save_to_disk()?; - current_description.clear(); - current_description.push_str(loaded_config.get_repo(&selected_alias)?.description()); - } - Some(1) => { - let tags_input = prompts::prompt_for_text(&TextPromptParams { - prompt_theme: &prompt_theme, - label: "Tags (comma-separated)", - default_value: ¤t_tags_csv, - })?; - let new_tags = prompts::parse_comma_separated_tags(&tags_input); - let repo_to_update = loaded_config.get_repo_mut(&selected_alias)?; - repo_to_update.set_tags(new_tags); - loaded_config.save_to_disk()?; - current_tags_csv = loaded_config.get_repo(&selected_alias)?.tags().join(", "); - } - Some(2) => { - current_title = apply_title_edit(&mut loaded_config, &selected_alias, &prompt_theme, ¤t_title)?; - } - Some(3) => { - current_color_hex = apply_color_edit(&mut loaded_config, &selected_alias, &prompt_theme, ¤t_color_hex)?; - } + 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"); } diff --git a/src/reportal_commands/repo_edit_mutator.rs b/src/reportal_commands/repo_edit_mutator.rs new file mode 100644 index 0000000..0054979 --- /dev/null +++ b/src/reportal_commands/repo_edit_mutator.rs @@ -0,0 +1,267 @@ +//! 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, ColorEditPromptParams, ColorEditResult, TextPromptParams, +}; +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(&TextPromptParams { + 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(&TextPromptParams { + 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(&TextPromptParams { + 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(&TextPromptParams { + 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(&ColorEditPromptParams { + 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_remove.rs b/src/reportal_commands/repo_remove.rs index beb2349..1cdfb67 100644 --- a/src/reportal_commands/repo_remove.rs +++ b/src/reportal_commands/repo_remove.rs @@ -4,12 +4,40 @@ 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. +/// # 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(repo_alias: &str) -> Result<(), ReportalError> { let mut loaded_config = ReportalConfig::load_from_disk()?; + + let containing_workspaces = loaded_config.workspaces_containing_repo(repo_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: repo_alias.to_owned(), + affected_workspaces: joined_workspace_names, + }); + } + let removed_entry = loaded_config.remove_repo(repo_alias)?; loaded_config.save_to_disk()?; terminal_style::print_success(&format!("Removed '{}' ({})", repo_alias, removed_entry.raw_path())); diff --git a/src/reportal_commands/workspace_create.rs b/src/reportal_commands/workspace_create.rs new file mode 100644 index 0000000..8a0a4f1 --- /dev/null +++ b/src/reportal_commands/workspace_create.rs @@ -0,0 +1,52 @@ +//! Creates a new workspace and writes its `.code-workspace` file. + +use crate::cli_args::WorkspaceArgsCreateParts; +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; + +/// Registers a new workspace with the given members and writes the +/// initial `.code-workspace` file from the current member paths. +/// +/// 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` and +/// the file would never be produced. +/// +/// # 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 I/O errors the regeneration path +/// surfaces. +pub fn run_workspace_create( + create_parts: &WorkspaceArgsCreateParts, +) -> Result<(), ReportalError> { + let mut loaded_config = ReportalConfig::load_from_disk()?; + + 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(create_parts.custom_file_path().to_owned()) + .build()?; + + loaded_config.add_workspace(validated_registration)?; + loaded_config.save_to_disk()?; + + let regenerator = WorkspaceRegenerator::for_config(&loaded_config); + let written_file_path = regenerator.regenerate_workspace_file(create_parts.workspace_name())?; + + terminal_style::print_success(&format!( + "Created workspace {} at {}", + create_parts.workspace_name().style(terminal_style::ALIAS_STYLE), + written_file_path.display().to_string().style(terminal_style::PATH_STYLE), + )); + Ok(()) +} diff --git a/src/reportal_commands/workspace_delete.rs b/src/reportal_commands/workspace_delete.rs new file mode 100644 index 0000000..b1e6ac6 --- /dev/null +++ b/src/reportal_commands/workspace_delete.rs @@ -0,0 +1,30 @@ +//! Removes a workspace from config without touching the on-disk file. + +use crate::error::ReportalError; +use crate::reportal_config::ReportalConfig; +use crate::terminal_style; +use owo_colors::OwoColorize; + +/// Unregisters the named workspace from config. +/// +/// Does NOT delete the on-disk `.code-workspace` file. Deletion +/// would break an open editor session holding the file and is a +/// destructive action the user should perform deliberately through +/// the filesystem, not as a side effect of a config mutation. +/// +/// # Errors +/// +/// Returns [`ReportalError::WorkspaceNotFound`] if no workspace +/// with that name is registered, or the config I/O errors that the +/// load and save paths surface. +pub fn run_workspace_delete(workspace_name: &str) -> Result<(), ReportalError> { + let mut loaded_config = ReportalConfig::load_from_disk()?; + loaded_config.remove_workspace(workspace_name)?; + loaded_config.save_to_disk()?; + + terminal_style::print_success(&format!( + "Removed workspace {} from config (the `.code-workspace` file on disk was not deleted)", + workspace_name.style(terminal_style::ALIAS_STYLE), + )); + Ok(()) +} diff --git a/src/reportal_commands/workspace_dispatch.rs b/src/reportal_commands/workspace_dispatch.rs new file mode 100644 index 0000000..080f270 --- /dev/null +++ b/src/reportal_commands/workspace_dispatch.rs @@ -0,0 +1,53 @@ +//! Dispatch layer for `rep workspace` subcommands. +//! +//! Routes a parsed `WorkspaceArgs` 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::{WorkspaceArgs, WorkspaceArgsSubcommand}; +use crate::error::ReportalError; +use crate::reportal_commands::{ + run_workspace_add_repo, run_workspace_create, run_workspace_delete, run_workspace_list, + run_workspace_open, run_workspace_remove_repo, run_workspace_show, +}; + +/// Dispatches a parsed `rep workspace` invocation to the matching +/// subcommand handler. +/// +/// Taking ownership of `WorkspaceArgs` 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: WorkspaceArgs, +) -> Result<(), ReportalError> { + match parsed_workspace_args.into_action() { + WorkspaceArgsSubcommand::List => run_workspace_list(), + WorkspaceArgsSubcommand::Show(name_only) => { + run_workspace_show(&name_only.into_workspace_name()) + } + WorkspaceArgsSubcommand::Create(create_args) => { + run_workspace_create(&create_args.into_parts()) + } + WorkspaceArgsSubcommand::Delete(name_only) => { + run_workspace_delete(&name_only.into_workspace_name()) + } + WorkspaceArgsSubcommand::AddRepo(member_edit) => { + run_workspace_add_repo(&member_edit.into_parts()) + } + WorkspaceArgsSubcommand::RemoveRepo(member_edit) => { + run_workspace_remove_repo(&member_edit.into_parts()) + } + WorkspaceArgsSubcommand::Open(name_only) => { + run_workspace_open(&name_only.into_workspace_name()) + } + } +} diff --git a/src/reportal_commands/workspace_listing.rs b/src/reportal_commands/workspace_listing.rs new file mode 100644 index 0000000..6e83f26 --- /dev/null +++ b/src/reportal_commands/workspace_listing.rs @@ -0,0 +1,75 @@ +//! 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_config::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::ConfigNotFound`] / +/// [`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_from_disk()?; + 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"); + + 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(), + )); + } + + 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..a31afa8 --- /dev/null +++ b/src/reportal_commands/workspace_members.rs @@ -0,0 +1,122 @@ +//! Add-repo and remove-repo subcommands for workspace membership. + +use crate::cli_args::WorkspaceArgsMemberEditParts; +use crate::error::ReportalError; +use crate::reportal_commands::workspace_operations::WorkspaceRegenerator; +use crate::reportal_config::ReportalConfig; +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. +/// +/// Refuses to add a repo alias that is not registered so the +/// on-load validation pass cannot later reject a config that this +/// command produced. Refuses to add a duplicate so the membership +/// list remains a set in practice even though it is serialized as +/// an ordered `Vec` for sidebar-order control. +/// +/// # Errors +/// +/// Returns [`ReportalError::WorkspaceNotFound`] if the workspace +/// name 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: &WorkspaceArgsMemberEditParts, +) -> Result<(), ReportalError> { + let mut loaded_config = ReportalConfig::load_from_disk()?; + loaded_config.get_repo(member_edit.repo_alias())?; + + let target_workspace = loaded_config.get_workspace_mut(member_edit.workspace_name())?; + if target_workspace.contains_repo(member_edit.repo_alias()) { + return Err(ReportalError::ValidationFailure { + field: "workspace member".to_owned(), + reason: format!( + "repo '{}' is already a member of workspace '{}'", + member_edit.repo_alias(), + member_edit.workspace_name(), + ), + }); + } + let mut updated_member_aliases = target_workspace.repo_aliases().to_vec(); + updated_member_aliases.push(member_edit.repo_alias().to_owned()); + target_workspace.set_repo_aliases(updated_member_aliases); + loaded_config.save_to_disk()?; + + let regenerator = WorkspaceRegenerator::for_config(&loaded_config); + regenerator.regenerate_workspace_file(member_edit.workspace_name())?; + + terminal_style::print_success(&format!( + "Added {} to workspace {}", + member_edit.repo_alias().style(terminal_style::ALIAS_STYLE), + member_edit.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. +/// +/// Refuses to remove a repo that is not currently a member so the +/// caller gets a clear error instead of a silent no-op, which +/// would mask a typo in the alias. Refuses to leave the workspace +/// empty because an empty workspace has no meaning and the +/// validation on the `WorkspaceRegistrationBuilder` rejects that +/// shape — the two rules must agree. +/// +/// # Errors +/// +/// Returns [`ReportalError::WorkspaceNotFound`] if the workspace +/// name 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: &WorkspaceArgsMemberEditParts, +) -> Result<(), ReportalError> { + let mut loaded_config = ReportalConfig::load_from_disk()?; + let target_workspace = loaded_config.get_workspace_mut(member_edit.workspace_name())?; + if !target_workspace.contains_repo(member_edit.repo_alias()) { + return Err(ReportalError::ValidationFailure { + field: "workspace member".to_owned(), + reason: format!( + "repo '{}' is not a member of workspace '{}'", + member_edit.repo_alias(), + member_edit.workspace_name(), + ), + }); + } + let remaining_member_aliases: Vec = target_workspace + .repo_aliases() + .iter() + .filter(|existing_alias| existing_alias.as_str() != member_edit.repo_alias()) + .cloned() + .collect(); + if remaining_member_aliases.is_empty() { + return Err(ReportalError::ValidationFailure { + field: "workspace member".to_owned(), + reason: format!( + "removing '{}' would leave workspace '{}' with zero members; delete the workspace instead", + member_edit.repo_alias(), + member_edit.workspace_name(), + ), + }); + } + target_workspace.set_repo_aliases(remaining_member_aliases); + loaded_config.save_to_disk()?; + + let regenerator = WorkspaceRegenerator::for_config(&loaded_config); + regenerator.regenerate_workspace_file(member_edit.workspace_name())?; + + terminal_style::print_success(&format!( + "Removed {} from workspace {}", + member_edit.repo_alias().style(terminal_style::ALIAS_STYLE), + member_edit.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..4a3fac7 --- /dev/null +++ b/src/reportal_commands/workspace_open.rs @@ -0,0 +1,56 @@ +//! 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_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. +/// +/// The regeneration step runs unconditionally so the file the +/// editor loads reflects the current member repo paths, which is +/// required for the invariant that opening a workspace always +/// produces a window grouping the live paths rather than whatever +/// was written the last time `create` or a membership edit ran. +/// +/// # Errors +/// +/// Returns [`ReportalError::WorkspaceNotFound`] if the name is +/// unknown, [`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(workspace_name: &str) -> Result<(), ReportalError> { + let loaded_config = ReportalConfig::load_from_disk()?; + loaded_config.get_workspace(workspace_name)?; + + let regenerator = WorkspaceRegenerator::for_config(&loaded_config); + let workspace_file_path = regenerator.regenerate_workspace_file(workspace_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 {}", + workspace_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..e789a05 --- /dev/null +++ b/src/reportal_commands/workspace_operations.rs @@ -0,0 +1,154 @@ +//! Regeneration of `.code-workspace` files from the current config. +//! +//! `WorkspaceRegenerator` is the single chokepoint through which +//! every `rep workspace` subcommand writes a `.code-workspace` file, +//! so the resolution of member repo aliases to absolute paths and +//! the decision of where the file lives on disk cannot drift between +//! callers. The struct holds a borrowed reference to +//! `ReportalConfig` so each helper method carries the registry +//! context via `&self` and takes at most one additional argument, +//! which is the shape required by the project's param-count rules. + +use crate::code_workspace::CodeWorkspaceFile; +use crate::error::ReportalError; +use crate::reportal_config::{ReportalConfig, WorkspaceEntry}; +use std::path::PathBuf; + +/// Regenerates `.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 write +/// the resulting `.code-workspace` file to 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 } + } + + /// Regenerates the on-disk `.code-workspace` file for the named + /// workspace and returns the resolved file path. + /// + /// Loads the existing file if present so the parse-merge-write + /// path preserves user-authored top-level fields (settings, + /// extensions, launch, tasks, comments) byte-for-byte, replaces + /// only the `folders` array with entries built from the current + /// absolute paths of the member repos in declared order, and + /// writes the mutated document back. Creates the parent + /// directory if the default location under + /// `~/.reportal/workspaces/` does not yet exist. + /// + /// # Errors + /// + /// Returns [`ReportalError::WorkspaceNotFound`] if the workspace + /// name is not registered, [`ReportalError::RepoNotFound`] if any + /// member alias does not resolve to a registered repo, + /// [`ReportalError::ConfigIoFailure`] if the home directory + /// cannot be resolved for the default path, or + /// [`ReportalError::CodeWorkspaceIoFailure`] / + /// [`ReportalError::CodeWorkspaceParseFailure`] if reading, + /// parsing, or writing the file fails. + pub fn regenerate_workspace_file( + &self, + workspace_name: &str, + ) -> Result { + let target_workspace = self.config_registry.get_workspace(workspace_name)?; + let member_absolute_paths = self.resolve_member_repo_paths(target_workspace)?; + let workspace_file_path = self.resolve_workspace_file_path(workspace_name)?; + let mut code_workspace_document = + CodeWorkspaceFile::load_or_empty(&workspace_file_path)?; + code_workspace_document.set_folder_paths(&member_absolute_paths); + code_workspace_document.write_to_disk(&workspace_file_path)?; + Ok(workspace_file_path) + } + + /// Resolves every member repo alias in the given workspace to + /// its absolute filesystem path, preserving the declared order. + /// + /// The ordering is load-bearing because it determines the + /// folder order in the editor sidebar, so callers that later + /// write these paths into the `folders` array get the same + /// visual ordering the user declared in config. + /// + /// # Errors + /// + /// Returns [`ReportalError::RepoNotFound`] at the first alias + /// that does not resolve against the repo registry. + fn resolve_member_repo_paths( + &self, + target_workspace: &WorkspaceEntry, + ) -> Result, ReportalError> { + let member_aliases = target_workspace.repo_aliases(); + let mut resolved_paths = Vec::with_capacity(member_aliases.len()); + for member_alias in member_aliases { + let member_repo = self.config_registry.get_repo(member_alias)?; + resolved_paths.push(member_repo.resolved_path()); + } + Ok(resolved_paths) + } + + /// Resolves the on-disk location of the `.code-workspace` file + /// for the named workspace. + /// + /// Honors an explicit `path` field if the workspace entry sets + /// one, expanding a leading `~` against the user home directory. + /// Falls back to the default location + /// `~/.reportal/workspaces/.code-workspace` when the field + /// is empty so workspaces created without a custom path land in + /// a predictable directory reportal fully owns. + /// + /// # Errors + /// + /// Returns [`ReportalError::WorkspaceNotFound`] if the name does + /// not match a registered workspace, or + /// [`ReportalError::ConfigIoFailure`] if the home directory is + /// needed for the default location and cannot be resolved. + fn resolve_workspace_file_path( + &self, + workspace_name: &str, + ) -> Result { + let target_workspace = self.config_registry.get_workspace(workspace_name)?; + let raw_path = target_workspace.raw_workspace_file_path(); + if raw_path.is_empty() { + return default_workspace_file_location(workspace_name); + } + let expanded_path = shellexpand::tilde(raw_path); + Ok(PathBuf::from(expanded_path.as_ref())) + } +} + +/// Computes the default on-disk location for a workspace file when +/// the workspace entry does not declare a custom path. +/// +/// The path is `~/.reportal/workspaces/.code-workspace`, which +/// places every default-located workspace in a single directory +/// under reportal's config root so discovery, cleanup, and manual +/// inspection are all straightforward. +/// +/// # Errors +/// +/// Returns [`ReportalError::ConfigIoFailure`] if the home directory +/// cannot be resolved, because without a home directory there is no +/// well-defined default location and silently falling back to a +/// relative path would produce an unpredictable file on disk. +fn default_workspace_file_location(workspace_name: &str) -> Result { + let home_directory = + dirs::home_dir().ok_or_else(|| ReportalError::ConfigIoFailure { + reason: "Could not determine home directory".to_owned(), + })?; + Ok(home_directory + .join(".reportal") + .join("workspaces") + .join(format!("{workspace_name}.code-workspace"))) +} diff --git a/src/reportal_commands/workspace_show.rs b/src/reportal_commands/workspace_show.rs new file mode 100644 index 0000000..51ee9c3 --- /dev/null +++ b/src/reportal_commands/workspace_show.rs @@ -0,0 +1,69 @@ +//! Shows a single workspace's details, resolved member paths, and +//! the on-disk location of its `.code-workspace` file. + +use crate::error::ReportalError; +use crate::reportal_commands::workspace_operations::WorkspaceRegenerator; +use crate::reportal_config::ReportalConfig; +use crate::terminal_style; +use owo_colors::OwoColorize; + +/// Prints the named workspace's description, member aliases with +/// their resolved absolute paths, and the `.code-workspace` file +/// location that `rep workspace open` would launch. +/// +/// Regenerates the `.code-workspace` file before printing so the +/// shown file is guaranteed to match the current config state. +/// Without this, `show` could report stale folder entries when a +/// member repo's path changed since the last regeneration. +/// +/// # Errors +/// +/// Returns [`ReportalError::WorkspaceNotFound`] if the name has no +/// matching entry, [`ReportalError::RepoNotFound`] if any member +/// alias does not resolve, or the config / file I/O errors that +/// the load and regeneration paths surface. +pub fn run_workspace_show(workspace_name: &str) -> Result<(), ReportalError> { + let loaded_config = ReportalConfig::load_from_disk()?; + let target_workspace = loaded_config.get_workspace(workspace_name)?; + + let regenerator = WorkspaceRegenerator::for_config(&loaded_config); + let workspace_file_path = regenerator.regenerate_workspace_file(workspace_name)?; + + terminal_style::write_stdout("\n"); + terminal_style::write_stdout(&format!( + " {}\n", + workspace_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(), + )); + } + + 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_alias in target_workspace.repo_aliases() { + let member_repo = loaded_config.get_repo(member_alias)?; + let resolved_member_path = member_repo.resolved_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), + )); + } + + terminal_style::write_stdout("\n"); + Ok(()) +} diff --git a/src/reportal_config/mod.rs b/src/reportal_config/mod.rs index d36053e..093160f 100644 --- a/src/reportal_config/mod.rs +++ b/src/reportal_config/mod.rs @@ -7,13 +7,23 @@ mod ai_tool_entry; mod command_entry; mod global_settings; mod hex_color; -mod reportal_config_root; +mod repo_color; mod repo_entry; +mod repo_registration_builder; +mod reportal_config_root; +mod tab_title; mod tag_filter; +mod workspace_entry; +mod workspace_registration_builder; pub use command_entry::CommandEntry; pub use global_settings::PathVisibility; pub use hex_color::HexColor; -pub use repo_entry::{RepoColor, RepoEntry, RepoRegistrationBuilder, TabTitle}; +pub use repo_color::RepoColor; +pub use repo_entry::RepoEntry; +pub use repo_registration_builder::RepoRegistrationBuilder; pub use reportal_config_root::ReportalConfig; +pub use tab_title::TabTitle; pub use tag_filter::TagFilter; +pub use workspace_entry::WorkspaceEntry; +pub use workspace_registration_builder::WorkspaceRegistrationBuilder; diff --git a/src/reportal_config/repo_color.rs b/src/reportal_config/repo_color.rs new file mode 100644 index 0000000..8996018 --- /dev/null +++ b/src/reportal_config/repo_color.rs @@ -0,0 +1,36 @@ +//! 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), +} + +/// 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/repo_entry.rs b/src/reportal_config/repo_entry.rs index 6aeeff5..6d9a57c 100644 --- a/src/reportal_config/repo_entry.rs +++ b/src/reportal_config/repo_entry.rs @@ -1,95 +1,49 @@ -//! Repository entry types: `RepoEntry`, `RepoRegistrationBuilder`, -//! `TabTitle`, and `RepoColor`. +//! A single registered repository with its metadata and accessors. -use crate::error::ReportalError; use crate::reportal_config::command_entry::CommandEntry; -use crate::reportal_config::hex_color::HexColor; +use crate::reportal_config::repo_color::RepoColor; +use crate::reportal_config::tab_title::TabTitle; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::path::PathBuf; -/// 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), -} - -/// Defaults to using the repo alias as the tab title. -impl Default for TabTitle { - fn default() -> Self { - TabTitle::UseAlias - } -} - -/// 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)) } - } -} - -/// 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), -} - -/// Defaults to resetting the terminal background color. -impl Default for RepoColor { - fn default() -> Self { - RepoColor::ResetToDefault - } -} - -/// 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)) - } - } -} - /// 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. - path: String, + pub(super) path: String, /// Human-readable description of what this repo is. #[serde(default)] - description: String, + pub(super) description: String, /// Tags for filtering and grouping repos. #[serde(default)] - tags: Vec, + pub(super) tags: Vec, /// Git remote URL for cloning on other machines. #[serde(default)] - remote: String, + pub(super) remote: String, /// Alternative names that can be used to jump to this repo directly. #[serde(default)] - aliases: Vec, + pub(super) aliases: Vec, /// Custom tab title shown in the terminal when jumping to this repo. #[serde(default)] - title: TabTitle, + pub(super) title: TabTitle, /// Terminal background color applied via OSC 11 when jumping to this repo. #[serde(default)] - color: RepoColor, + pub(super) color: RepoColor, /// Per-repo commands: same table format as global commands. #[serde(default)] - commands: BTreeMap, + pub(super) commands: BTreeMap, } -/// Accessors and path resolution for a registered repository entry. +/// 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 { @@ -137,6 +91,20 @@ impl RepoEntry { &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; @@ -157,114 +125,3 @@ impl RepoEntry { self.color = new_color; } } - -/// 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, -} - -/// 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_owned(), - reason: "must not be empty".to_owned(), - }); - } - 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(), - }; - Ok((self.alias, validated_entry)) - } -} diff --git a/src/reportal_config/repo_registration_builder.rs b/src/reportal_config/repo_registration_builder.rs new file mode 100644 index 0000000..27c80ee --- /dev/null +++ b/src/reportal_config/repo_registration_builder.rs @@ -0,0 +1,140 @@ +//! 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_color::RepoColor; +use crate::reportal_config::repo_entry::RepoEntry; +use crate::reportal_config::tab_title::TabTitle; +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(), + }); + } + 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(), + }; + Ok((self.alias, validated_entry)) + } +} diff --git a/src/reportal_config/reportal_config_root.rs b/src/reportal_config/reportal_config_root.rs index f62a944..4fc8a4a 100644 --- a/src/reportal_config/reportal_config_root.rs +++ b/src/reportal_config/reportal_config_root.rs @@ -7,6 +7,7 @@ use crate::reportal_config::command_entry::CommandEntry; use crate::reportal_config::global_settings::{PathDisplayFormat, PathVisibility, ReportalSettings}; 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; @@ -33,6 +34,14 @@ pub struct ReportalConfig { /// 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. @@ -47,10 +56,20 @@ impl ReportalConfig { Ok(Self::config_directory()?.join("config.toml")) } - /// Loads and parses the config from disk. + /// Loads, parses, and validates the config from disk. + /// + /// After successful TOML parsing, runs the workspace reference + /// check so any workspace that points at a repo no longer + /// registered is rejected at startup rather than silently + /// producing broken `.code-workspace` files on a later regen. + /// + /// # Errors /// /// Returns `ConfigNotFound` if the file does not exist, - /// or `ConfigParseFailure` if the TOML is malformed. + /// `ConfigIoFailure` if the file cannot be read, + /// `ConfigParseFailure` if the TOML is malformed, or + /// `WorkspaceHasDanglingRepo` if a workspace references an + /// unknown repo alias. pub fn load_from_disk() -> Result { let file_path = Self::config_file_path()?; if !file_path.exists() { @@ -63,9 +82,48 @@ impl ReportalConfig { reason: io_error.to_string(), } })?; - toml::from_str(&toml_content).map_err(|parse_error| ReportalError::ConfigParseFailure { - reason: parse_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()?; + Ok(parsed_config) + } + + /// Validates that every workspace's repo alias list references + /// only registered repos. + /// + /// Runs on every config load so a hand-edited TOML that drops a + /// repo without cleaning up workspace membership is rejected at + /// startup. Prevents the failure mode where `.code-workspace` + /// files would later be regenerated pointing at a non-existent + /// repo path. Uses the canonical repo registry keys only, not + /// the repo `aliases` field, because workspace membership is + /// stored against the canonical key. + /// + /// # Errors + /// + /// Returns [`ReportalError::WorkspaceHasDanglingRepo`] at the + /// first dangling reference encountered, identifying both the + /// workspace and the missing alias so the user can fix either + /// side of the broken reference. + pub fn validate_workspace_references(&self) -> Result<(), ReportalError> { + let all_pairs = self.workspaces.iter().flat_map(|(workspace_name, workspace)| { + workspace + .repo_aliases() + .iter() + .map(move |member_alias| (workspace_name, member_alias)) + }); + for (workspace_name, member_alias) in all_pairs { + 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. @@ -168,6 +226,109 @@ impl ReportalConfig { }) } + /// Returns all registered workspaces with their names, for iteration. + /// + /// The list reflects the `BTreeMap` iteration order so display + /// and tree rendering are deterministic across runs. + #[must_use] + pub fn workspaces_with_names(&self) -> Vec<(&String, &WorkspaceEntry)> { + self.workspaces.iter().collect() + } + + /// Looks up a workspace by name. + /// + /// # Errors + /// + /// Returns [`ReportalError::WorkspaceNotFound`] if the name has + /// no matching workspace entry. + pub fn get_workspace(&self, workspace_name: &str) -> Result<&WorkspaceEntry, ReportalError> { + self.workspaces.get(workspace_name).ok_or_else(|| ReportalError::WorkspaceNotFound { + workspace_name: workspace_name.to_owned(), + }) + } + + /// Returns a mutable reference to a workspace by name. + /// + /// # Errors + /// + /// Returns [`ReportalError::WorkspaceNotFound`] if the name has + /// no matching workspace entry. + pub fn get_workspace_mut( + &mut self, + workspace_name: &str, + ) -> Result<&mut WorkspaceEntry, ReportalError> { + self.workspaces.get_mut(workspace_name).ok_or_else(|| ReportalError::WorkspaceNotFound { + workspace_name: workspace_name.to_owned(), + }) + } + + /// Registers a new workspace from a validated builder result. + /// + /// Runs the dangling-reference check 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`] if the + /// name is taken, or [`ReportalError::WorkspaceHasDanglingRepo`] + /// if any of the new entry's repo aliases is not registered. + 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.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. + /// + /// This is the reverse index that makes repo path changes + /// correct: when `rep edit` changes a repo's path, every + /// workspace containing that repo must regenerate its + /// `.code-workspace` file so the folder entries stay aligned + /// with reality. Without this lookup, path changes silently + /// strand workspace files pointing at the old location. + #[must_use] + pub fn workspaces_containing_repo( + &self, + repo_alias: &str, + ) -> Vec<(&String, &WorkspaceEntry)> { + self.workspaces + .iter() + .filter(|(_, workspace)| workspace.contains_repo(repo_alias)) + .collect() + } + /// Returns the configured default AI tool name, if set. pub fn default_ai_tool(&self) -> &str { &self.settings.default_ai_tool @@ -202,6 +363,7 @@ impl ReportalConfig { }, repos: BTreeMap::new(), commands: BTreeMap::new(), + workspaces: BTreeMap::new(), ai_tools: BTreeMap::from([ ("aider".to_owned(), AiToolEntry::with_executable("aider".to_owned())), ("aider".to_owned(), AiToolEntry::with_executable("aider".to_owned())), 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/workspace_entry.rs b/src/reportal_config/workspace_entry.rs new file mode 100644 index 0000000..03b5780 --- /dev/null +++ b/src/reportal_config/workspace_entry.rs @@ -0,0 +1,76 @@ +//! Workspace entry: a named group of repos that open together as a +//! VSCode/Cursor `.code-workspace` file. + +use serde::{Deserialize, Serialize}; + +/// A single registered VSCode/Cursor workspace definition. +/// +/// Declares which repos 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 +/// this entry and the member repos' current paths. +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct WorkspaceEntry { + /// Ordered list of repo aliases that belong to this workspace. + /// + /// Each alias must resolve to a registered repo in `[repos.*]`. + /// Validation runs on config load and rejects dangling references. + pub(super) repos: Vec, + /// Human-readable description of what this workspace is for. + #[serde(default)] + pub(super) description: String, + /// Filesystem path where the `.code-workspace` file is written. + /// + /// May contain `~` for home. When empty, the default location is + /// `~/.reportal/workspaces/.code-workspace`. + #[serde(default)] + pub(super) path: String, +} + +/// Accessors for a workspace entry. +impl WorkspaceEntry { + /// Ordered list of repo aliases that belong to this workspace. + /// + /// Order is preserved from the config so the generated + /// `.code-workspace` file's `folders` array matches what the user + /// declared, which controls the visual ordering in the editor's + /// sidebar. + #[must_use] + pub fn repo_aliases(&self) -> &[String] { + &self.repos + } + + /// Human-readable description of this workspace's purpose. + #[must_use] + pub fn description(&self) -> &str { + &self.description + } + + /// The raw `.code-workspace` file path as stored in config, before + /// tilde expansion. An empty string signals that the default + /// location under `~/.reportal/workspaces/` should be used. + #[must_use] + pub fn raw_workspace_file_path(&self) -> &str { + &self.path + } + + /// Whether the given repo alias is a member of this workspace. + /// + /// Used by the reverse-index lookup that finds every workspace + /// containing a repo — required so repo path changes can trigger + /// regeneration of every affected `.code-workspace` file. + #[must_use] + pub fn contains_repo(&self, repo_alias: &str) -> bool { + self.repos.iter().any(|alias| alias == repo_alias) + } + + /// Replaces the ordered repo alias list with a new membership set. + /// + /// Callers must ensure the new aliases all resolve to registered + /// repos; the config-level validation will reject dangling + /// references on the next save/load cycle. + pub fn set_repo_aliases(&mut self, new_repo_aliases: Vec) { + self.repos = new_repo_aliases; + } + +} diff --git a/src/reportal_config/workspace_registration_builder.rs b/src/reportal_config/workspace_registration_builder.rs new file mode 100644 index 0000000..cd53c9e --- /dev/null +++ b/src/reportal_config/workspace_registration_builder.rs @@ -0,0 +1,170 @@ +//! Step-by-step builder for constructing a validated workspace entry. + +use crate::error::ReportalError; +use crate::reportal_config::workspace_entry::WorkspaceEntry; + +/// 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, + /// 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, +} + +/// 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(), + } + } + + /// 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 + } + + /// 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 or the repo alias list is empty. + 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(), + }); + } + let validated_entry = WorkspaceEntry { + repos: self.repo_aliases, + description: self.workspace_description, + path: self.workspace_file_path, + }; + Ok((self.workspace_name, validated_entry)) + } +} + +#[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", + ); + } +} From a19824a6ad88076b600431c10bfa4340ca31e619 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Wed, 15 Apr 2026 12:25:10 -0400 Subject: [PATCH 44/67] Workspace-tree grouping in rep list (v0.14.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rep list now groups repos by workspace membership as the tree root. Workspaces answer "what am I working on" (explicit user grouping), tags answer "what kind of repo is this" (classification) — they are orthogonal axes that compose as an AND intersection via --tag and --workspace filters. Correctness decisions: - Multi-workspace repos render under every containing workspace, not deduped. Deduping would silently hide user-declared multi-membership. - The summary line reports distinct repo count (not sum of sections) so multi-workspace repos do not exaggerate the total. - Repos in zero workspaces render under a synthetic (unassigned) section at the bottom so no repo is ever hidden. - --workspace suppresses the unassigned section because the query is scoped to one workspace; unknown names error with WorkspaceNotFound rather than silent empty output. - Within each section, repos render in the order declared in the workspace's `repos` field — the sidebar order the user chose. New files: - reportal_config/workspace_filter.rs — WorkspaceFilter enum - cli_args/workspace_filter_args.rs — shared --workspace flag - reportal_commands/repo_tree_grouping.rs — tree construction - reportal_commands/repo_tree_workspace_section.rs — section struct - reportal_commands/repo_listing_renderer.rs — tree render surface (split from repo_listing.rs to keep grouping unit-testable without a terminal, and to let the render format evolve independently of the tree-building invariants) Tests (8 new, 27 total): - unassigned placement - multi-workspace rendering + distinct count - workspace filter scoping - unknown workspace name error - tag filter restricting members within workspaces - empty-workspace-after-tag-filter omission - member order preservation - tag + workspace filter AND intersection --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 7 +- src/cli_args/list_args.rs | 56 +- src/cli_args/mod.rs | 3 +- src/cli_args/workspace_filter_args.rs | 36 ++ src/main.rs | 2 +- src/reportal_commands/mod.rs | 3 + src/reportal_commands/repo_listing.rs | 116 ++-- .../repo_listing_renderer.rs | 190 +++++++ src/reportal_commands/repo_tree_grouping.rs | 524 ++++++++++++++++++ .../repo_tree_workspace_section.rs | 47 ++ src/reportal_config/mod.rs | 2 + src/reportal_config/workspace_filter.rs | 20 + 14 files changed, 912 insertions(+), 98 deletions(-) create mode 100644 src/cli_args/workspace_filter_args.rs create mode 100644 src/reportal_commands/repo_listing_renderer.rs create mode 100644 src/reportal_commands/repo_tree_grouping.rs create mode 100644 src/reportal_commands/repo_tree_workspace_section.rs create mode 100644 src/reportal_config/workspace_filter.rs diff --git a/Cargo.lock b/Cargo.lock index 00ffa42..697bd33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -556,7 +556,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.13.0" +version = "0.14.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 84ebcd3..66eab09 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.13.0" +version = "0.14.0" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/README.md b/README.md index 8e45224..c22eefe 100644 --- a/README.md +++ b/README.md @@ -99,8 +99,9 @@ ro | Command | Alias | What it does | |---------|-------|-------------| | `rep init` | | Creates config and installs shell integration (safe to re-run on updates) | -| `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 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 (also matches `aliases` field) | | `rep jump --title "Debug"` | | Override the terminal tab title for this session | @@ -306,7 +307,7 @@ PROMPT_COMMAND='rep color 2>/dev/null' - [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`) -- [ ] Workspace-tree grouping in `rep list` (workspaces become the tree root, tags become a secondary filter) +- [x] Workspace-tree grouping in `rep list` (workspaces as the tree root, `--workspace` as a first-class filter composable with `--tag`) - [ ] 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) diff --git a/src/cli_args/list_args.rs b/src/cli_args/list_args.rs index 7d04ad1..2023ee0 100644 --- a/src/cli_args/list_args.rs +++ b/src/cli_args/list_args.rs @@ -1,22 +1,64 @@ //! CLI args for `rep list`. -use clap::Args; -use crate::reportal_config::TagFilter; use super::tag_filter_args::TagFilterArgs; +use super::workspace_filter_args::WorkspaceFilterArgs; +use crate::reportal_config::{TagFilter, WorkspaceFilter}; +use clap::Args; /// Arguments for the `rep list` subcommand. /// -/// Accepts an optional `--tag` flag to filter the displayed repos. +/// 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 ListArgs { + /// Optional `--tag` flag for tag-based filtering. #[command(flatten)] tag_filter: TagFilterArgs, + /// Optional `--workspace` flag for workspace-membership + /// filtering, orthogonal to `--tag`. + #[command(flatten)] + workspace_filter: WorkspaceFilterArgs, } -/// Consuming conversion to the domain tag filter. +/// Consuming accessors for `ListArgs`. impl ListArgs { - /// Extracts the tag filter, consuming the parsed args. - pub fn into_tag_filter(self) -> TagFilter { - self.tag_filter.into_tag_filter() + /// 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) -> ListArgsFilterParts { + ListArgsFilterParts { + tag_filter: self.tag_filter.into_tag_filter(), + workspace_filter: self.workspace_filter.into_workspace_filter(), + } + } +} + +/// Owned named-field result of `ListArgs::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 ListArgsFilterParts { + /// 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 ListArgsFilterParts { + /// 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 index b695922..a21b1c3 100644 --- a/src/cli_args/mod.rs +++ b/src/cli_args/mod.rs @@ -17,12 +17,13 @@ mod sync_args; mod tag_filter_args; mod web_args; mod workspace_args; +mod workspace_filter_args; pub use ai_args::AiArgs; pub use color_args::ColorArgs; pub use edit_args::EditArgs; pub use jump_args::JumpArgs; -pub use list_args::ListArgs; +pub use list_args::{ListArgs, ListArgsFilterParts}; pub use open_args::OpenArgs; pub use remove_args::RemoveArgs; pub use reportal_cli::{ReportalCli, ReportalCliSubcommand}; diff --git a/src/cli_args/workspace_filter_args.rs b/src/cli_args/workspace_filter_args.rs new file mode 100644 index 0000000..f60f29b --- /dev/null +++ b/src/cli_args/workspace_filter_args.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 WorkspaceFilterArgs { + /// 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 WorkspaceFilterArgs { + /// 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/main.rs b/src/main.rs index 98317c1..7d75692 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,7 +28,7 @@ fn main() { let command_result = match parsed_cli.into_subcommand() { ReportalCliSubcommand::Init => reportal_commands::run_init(), ReportalCliSubcommand::List(list_args) => { - reportal_commands::run_list(list_args.into_tag_filter()) + reportal_commands::run_list(&list_args.into_filter_parts()) } ReportalCliSubcommand::Jump(jump_args) => { let (direct_alias, tag_filter, title_override) = jump_args.into_parts(); diff --git a/src/reportal_commands/mod.rs b/src/reportal_commands/mod.rs index 4277e13..abe7d8c 100644 --- a/src/reportal_commands/mod.rs +++ b/src/reportal_commands/mod.rs @@ -14,6 +14,9 @@ mod repo_edit; mod repo_edit_mutator; mod repo_jump; mod repo_listing; +mod repo_listing_renderer; +mod repo_tree_grouping; +mod repo_tree_workspace_section; mod repo_open; mod repo_remove; mod repo_run; diff --git a/src/reportal_commands/repo_listing.rs b/src/reportal_commands/repo_listing.rs index 6fd7eb4..728f66b 100644 --- a/src/reportal_commands/repo_listing.rs +++ b/src/reportal_commands/repo_listing.rs @@ -1,90 +1,38 @@ -//! Lists all registered repos with their status, description, and tags. +//! Lists registered repos grouped by workspace membership. +use crate::cli_args::ListArgsFilterParts; 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, RepoTreeGroupingParams}; +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> { +/// 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: &ListArgsFilterParts) -> 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) => terminal_style::write_stdout(&format!("No repos found with tag '{target_tag}'\n")), - TagFilter::All => terminal_style::write_stdout("No repos registered. Use 'reportal add ' to add one.\n"), - } - return Ok(()); - } - - terminal_style::write_stdout("\n"); - terminal_style::write_stdout(&format!( - " {}\n", - "RePortal".style(terminal_style::EMPHASIS_STYLE) - )); - terminal_style::write_stdout("\n"); - - for (alias, repo) in &matching_repos { - let directory_exists = repo.resolved_path().exists(); - - let swatch_style = terminal_style::swatch_style_for_repo_color(repo.repo_color())?; - let uppercase_alias = 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.raw_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 !repo.aliases().is_empty() { - let formatted_aliases = repo.aliases().join(", "); - terminal_style::write_stdout(&format!( - " {} {}\n", - "Aliases:".style(terminal_style::LABEL_STYLE), - formatted_aliases.style(terminal_style::PATH_STYLE), - )); - } - - if !repo.tags().is_empty() { - let formatted_tags = repo.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"); - } - - terminal_style::write_stdout(&format!( - " {} repos total\n", - matching_repos.len().style(terminal_style::EMPHASIS_STYLE), - )); - terminal_style::write_stdout("\n"); - Ok(()) + let tree_grouping = RepoTreeGrouping::build(&RepoTreeGroupingParams { + 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..ada7c2b --- /dev/null +++ b/src/reportal_commands/repo_listing_renderer.rs @@ -0,0 +1,190 @@ +//! 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::ListArgsFilterParts; +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: &ListArgsFilterParts) -> 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: &ListArgsFilterParts) { + 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: &(&String, &RepoEntry)) -> Result<(), ReportalError> { + let (repo_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 = repo_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_tree_grouping.rs b/src/reportal_commands/repo_tree_grouping.rs new file mode 100644 index 0000000..531b384 --- /dev/null +++ b/src/reportal_commands/repo_tree_grouping.rs @@ -0,0 +1,524 @@ +//! 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 String, &'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: &RepoTreeGroupingParams<'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.as_str()); + } + } + for (orphan_alias, _orphan_entry) in &unassigned_repos { + distinct_aliases.insert(orphan_alias.as_str()); + } + 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 String, &'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 RepoTreeGroupingParams<'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 `RepoTreeGroupingParams`, 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> RepoTreeGroupingParams<'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 String, &'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 String, &'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 String, &'config RepoEntry)> = Vec::new(); + if workspace_restriction_active { + return unassigned_repos; + } + for (repo_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(repo_alias); + if containing_workspaces.is_empty() { + unassigned_repos.push((repo_alias, 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 + /// `WorkspaceFilterArgs::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 `TagFilterArgs::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(&RepoTreeGroupingParams { + 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(|(repo_alias, _repo_entry)| repo_alias.as_str()) + .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..3687c98 --- /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 String, &'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 String, &'config RepoEntry)] { + &self.member_repos + } +} diff --git a/src/reportal_config/mod.rs b/src/reportal_config/mod.rs index 093160f..687f615 100644 --- a/src/reportal_config/mod.rs +++ b/src/reportal_config/mod.rs @@ -14,6 +14,7 @@ mod reportal_config_root; mod tab_title; mod tag_filter; mod workspace_entry; +mod workspace_filter; mod workspace_registration_builder; pub use command_entry::CommandEntry; @@ -26,4 +27,5 @@ pub use reportal_config_root::ReportalConfig; pub use tab_title::TabTitle; pub use tag_filter::TagFilter; pub use workspace_entry::WorkspaceEntry; +pub use workspace_filter::WorkspaceFilter; pub use workspace_registration_builder::WorkspaceRegistrationBuilder; 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), +} From f5063ed8e98f6073c5f06440ba8f30bb61423a75 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Fri, 17 Apr 2026 16:37:11 -0400 Subject: [PATCH 45/67] Workspace aliases + unified alias resolver (v0.14.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `aliases` field to workspaces and makes every `rep workspace` subcommand resolve alias→canonical before downstream file ops, so short names like `rep workspace open vn` work without building `vn.code-workspace`. The lookup logic for repos and workspaces is now one shared `resolve_canonical_key` over a `HasAliases` trait — no more duplicate "try key then walk aliases" loops. Fixes a latent footgun where `get_repo_mut` accepted canonical keys only while `get_repo` already accepted aliases. Cross-namespace alias collisions (workspace alias vs. repo name/alias or vs. another workspace's name/alias) are rejected at config load and on `add_workspace` via a new `WorkspaceAliasConflict` error. --- Cargo.toml | 2 +- README.md | 2 + src/cli_args/workspace_args.rs | 21 +- src/error.rs | 24 + src/reportal_commands/repo_ai.rs | 4 +- src/reportal_commands/repo_selection.rs | 2 +- src/reportal_commands/workspace_create.rs | 1 + src/reportal_commands/workspace_delete.rs | 15 +- src/reportal_commands/workspace_listing.rs | 10 +- src/reportal_commands/workspace_members.rs | 57 +- src/reportal_commands/workspace_open.rs | 27 +- src/reportal_commands/workspace_show.rs | 32 +- src/reportal_config/alias_collision_query.rs | 47 ++ src/reportal_config/has_aliases.rs | 121 +++++ src/reportal_config/mod.rs | 3 + src/reportal_config/repo_entry.rs | 12 +- src/reportal_config/reportal_config_root.rs | 498 +++++++++++++++--- src/reportal_config/workspace_entry.rs | 16 + .../workspace_registration_builder.rs | 144 ++++- 19 files changed, 892 insertions(+), 146 deletions(-) create mode 100644 src/reportal_config/alias_collision_query.rs create mode 100644 src/reportal_config/has_aliases.rs diff --git a/Cargo.toml b/Cargo.toml index 66eab09..d60c9d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.14.0" +version = "0.14.1" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/README.md b/README.md index c22eefe..6da04a5 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,7 @@ A workspace is a named group of repos that open together as one VSCode/Cursor wi repos = ["api", "worker", "db-migrations"] # ordered — controls sidebar order description = "Jakuta backend services" path = "" # empty = ~/.reportal/workspaces/backend.code-workspace +aliases = ["be", "back"] # short names accepted by rep workspace ``` | Field | Required | Default | What it controls | @@ -232,6 +233,7 @@ path = "" # empty = ~/.reportal/workspaces/b | `repos` | yes | — | Ordered list of repo aliases that belong to this workspace. Order matches the editor sidebar. Each alias must be a registered repo — dangling references are rejected on config load | | `description` | no | `""` | Human-readable description shown in `rep workspace list` | | `path` | no | `""` | Filesystem path for the `.code-workspace` file. Empty falls back to `~/.reportal/workspaces/.code-workspace` | +| `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 | **Regeneration is automatic.** When you change a repo's path via `rep edit`, every workspace that contains it regenerates its `.code-workspace` file so the folder entries reflect the new location. User-authored fields inside the file (`settings`, `extensions`, `launch`, `tasks`, plus any JSONC comments) round-trip byte-stable across regeneration — RePortal only touches the `folders` array. diff --git a/src/cli_args/workspace_args.rs b/src/cli_args/workspace_args.rs index da1889b..860c1b2 100644 --- a/src/cli_args/workspace_args.rs +++ b/src/cli_args/workspace_args.rs @@ -133,8 +133,8 @@ impl WorkspaceArgsMemberEditParts { } /// Payload for `rep workspace create`, which declares a new -/// workspace with an initial member list, description, and -/// optional custom file path. +/// 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. @@ -153,6 +153,10 @@ pub struct WorkspaceArgsCreate { /// `~/.reportal/workspaces/.code-workspace`. #[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. @@ -166,6 +170,7 @@ impl WorkspaceArgsCreate { repo_aliases: self.repos, description: self.description, custom_file_path: self.file_path, + workspace_aliases: self.aliases, } } } @@ -173,7 +178,7 @@ impl WorkspaceArgsCreate { /// Owned named-field result of `WorkspaceArgsCreate::into_parts`. /// /// Returned instead of a bare tuple so call sites never confuse -/// the four string-typed fields. +/// the string-typed fields. pub struct WorkspaceArgsCreateParts { /// Name for the new workspace. workspace_name: String, @@ -184,6 +189,9 @@ pub struct WorkspaceArgsCreateParts { /// 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. @@ -214,6 +222,13 @@ impl WorkspaceArgsCreateParts { 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`. diff --git a/src/error.rs b/src/error.rs index 8d44704..33df755 100644 --- a/src/error.rs +++ b/src/error.rs @@ -148,6 +148,30 @@ pub enum ReportalError { 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 { diff --git a/src/reportal_commands/repo_ai.rs b/src/reportal_commands/repo_ai.rs index 420813a..17f77c6 100644 --- a/src/reportal_commands/repo_ai.rs +++ b/src/reportal_commands/repo_ai.rs @@ -28,8 +28,8 @@ pub struct AiCommandParams<'a> { pub fn run_ai(ai_params: &AiCommandParams<'_>) -> Result<(), ReportalError> { let loaded_config = ReportalConfig::load_from_disk()?; - let ai_tools = loaded_config.ai_tools_list(); - if ai_tools.is_empty() { return Err(ReportalError::NoAiToolsConfigured) } + 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(); diff --git a/src/reportal_commands/repo_selection.rs b/src/reportal_commands/repo_selection.rs index 83adcac..198aa6a 100644 --- a/src/reportal_commands/repo_selection.rs +++ b/src/reportal_commands/repo_selection.rs @@ -1,7 +1,7 @@ //! Shared repo fuzzy-selection helper used across jump, open, ai, edit, and web. use crate::error::ReportalError; -use crate::reportal_config::{RepoEntry, ReportalConfig, TagFilter}; +use crate::reportal_config::{HasAliases, RepoEntry, ReportalConfig, TagFilter}; use crate::terminal_style; use dialoguer::FuzzySelect; use owo_colors::OwoColorize; diff --git a/src/reportal_commands/workspace_create.rs b/src/reportal_commands/workspace_create.rs index 8a0a4f1..1b9ecf2 100644 --- a/src/reportal_commands/workspace_create.rs +++ b/src/reportal_commands/workspace_create.rs @@ -35,6 +35,7 @@ pub fn run_workspace_create( .repo_aliases(create_parts.repo_aliases().to_vec()) .workspace_description(create_parts.description().to_owned()) .workspace_file_path(create_parts.custom_file_path().to_owned()) + .workspace_aliases(create_parts.workspace_aliases().to_vec()) .build()?; loaded_config.add_workspace(validated_registration)?; diff --git a/src/reportal_commands/workspace_delete.rs b/src/reportal_commands/workspace_delete.rs index b1e6ac6..5b7a1f9 100644 --- a/src/reportal_commands/workspace_delete.rs +++ b/src/reportal_commands/workspace_delete.rs @@ -7,24 +7,25 @@ use owo_colors::OwoColorize; /// Unregisters the named workspace from config. /// -/// Does NOT delete the on-disk `.code-workspace` file. Deletion -/// would break an open editor session holding the file and is a -/// destructive action the user should perform deliberately through +/// Accepts either the canonical workspace key or any declared +/// alias. Does NOT delete the on-disk `.code-workspace` file — +/// that is a destructive action the user should perform through /// the filesystem, not as a side effect of a config mutation. /// /// # Errors /// /// Returns [`ReportalError::WorkspaceNotFound`] if no workspace -/// with that name is registered, or the config I/O errors that the +/// matches the name or alias, or the config I/O errors that the /// load and save paths surface. -pub fn run_workspace_delete(workspace_name: &str) -> Result<(), ReportalError> { +pub fn run_workspace_delete(alias_or_canonical: &str) -> Result<(), ReportalError> { let mut loaded_config = ReportalConfig::load_from_disk()?; - loaded_config.remove_workspace(workspace_name)?; + let canonical_name = loaded_config.resolve_workspace_canonical_name(alias_or_canonical)?; + loaded_config.remove_workspace(&canonical_name)?; loaded_config.save_to_disk()?; terminal_style::print_success(&format!( "Removed workspace {} from config (the `.code-workspace` file on disk was not deleted)", - workspace_name.style(terminal_style::ALIAS_STYLE), + canonical_name.style(terminal_style::ALIAS_STYLE), )); Ok(()) } diff --git a/src/reportal_commands/workspace_listing.rs b/src/reportal_commands/workspace_listing.rs index 6e83f26..a5fafe8 100644 --- a/src/reportal_commands/workspace_listing.rs +++ b/src/reportal_commands/workspace_listing.rs @@ -5,7 +5,7 @@ //! a glance which workspaces exist and what each one groups together. use crate::error::ReportalError; -use crate::reportal_config::ReportalConfig; +use crate::reportal_config::{HasAliases, ReportalConfig}; use crate::terminal_style; use owo_colors::OwoColorize; @@ -57,6 +57,14 @@ pub fn run_workspace_list() -> Result<(), ReportalError> { )); } + 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), + )); + } + let formatted_member_list = workspace_entry.repo_aliases().join(", "); terminal_style::write_stdout(&format!( " {} {}\n", diff --git a/src/reportal_commands/workspace_members.rs b/src/reportal_commands/workspace_members.rs index a31afa8..b630521 100644 --- a/src/reportal_commands/workspace_members.rs +++ b/src/reportal_commands/workspace_members.rs @@ -11,17 +11,15 @@ use owo_colors::OwoColorize; /// regenerates the `.code-workspace` file from the post-mutation /// state. /// -/// Refuses to add a repo alias that is not registered so the -/// on-load validation pass cannot later reject a config that this -/// command produced. Refuses to add a duplicate so the membership -/// list remains a set in practice even though it is serialized as -/// an ordered `Vec` for sidebar-order control. +/// 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 does not exist, [`ReportalError::RepoNotFound`] if the -/// repo alias is not registered, or +/// 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. @@ -30,15 +28,16 @@ pub fn run_workspace_add_repo( ) -> Result<(), ReportalError> { let mut loaded_config = ReportalConfig::load_from_disk()?; loaded_config.get_repo(member_edit.repo_alias())?; + let canonical_workspace_name = + loaded_config.resolve_workspace_canonical_name(member_edit.workspace_name())?; - let target_workspace = loaded_config.get_workspace_mut(member_edit.workspace_name())?; + let target_workspace = loaded_config.get_workspace_mut(&canonical_workspace_name)?; if target_workspace.contains_repo(member_edit.repo_alias()) { return Err(ReportalError::ValidationFailure { field: "workspace member".to_owned(), reason: format!( - "repo '{}' is already a member of workspace '{}'", + "repo '{}' is already a member of workspace '{canonical_workspace_name}'", member_edit.repo_alias(), - member_edit.workspace_name(), ), }); } @@ -48,12 +47,12 @@ pub fn run_workspace_add_repo( loaded_config.save_to_disk()?; let regenerator = WorkspaceRegenerator::for_config(&loaded_config); - regenerator.regenerate_workspace_file(member_edit.workspace_name())?; + regenerator.regenerate_workspace_file(&canonical_workspace_name)?; terminal_style::print_success(&format!( "Added {} to workspace {}", member_edit.repo_alias().style(terminal_style::ALIAS_STYLE), - member_edit.workspace_name().style(terminal_style::ALIAS_STYLE), + canonical_workspace_name.style(terminal_style::ALIAS_STYLE), )); Ok(()) } @@ -62,32 +61,33 @@ pub fn run_workspace_add_repo( /// regenerates the `.code-workspace` file from the post-mutation /// state. /// -/// Refuses to remove a repo that is not currently a member so the -/// caller gets a clear error instead of a silent no-op, which -/// would mask a typo in the alias. Refuses to leave the workspace -/// empty because an empty workspace has no meaning and the -/// validation on the `WorkspaceRegistrationBuilder` rejects that -/// shape — the two rules must agree. +/// 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 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. +/// 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: &WorkspaceArgsMemberEditParts, ) -> Result<(), ReportalError> { let mut loaded_config = ReportalConfig::load_from_disk()?; - let target_workspace = loaded_config.get_workspace_mut(member_edit.workspace_name())?; + 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.repo_alias()) { return Err(ReportalError::ValidationFailure { field: "workspace member".to_owned(), reason: format!( - "repo '{}' is not a member of workspace '{}'", + "repo '{}' is not a member of workspace '{canonical_workspace_name}'", member_edit.repo_alias(), - member_edit.workspace_name(), ), }); } @@ -101,9 +101,8 @@ pub fn run_workspace_remove_repo( return Err(ReportalError::ValidationFailure { field: "workspace member".to_owned(), reason: format!( - "removing '{}' would leave workspace '{}' with zero members; delete the workspace instead", + "removing '{}' would leave workspace '{canonical_workspace_name}' with zero members; delete the workspace instead", member_edit.repo_alias(), - member_edit.workspace_name(), ), }); } @@ -111,12 +110,12 @@ pub fn run_workspace_remove_repo( loaded_config.save_to_disk()?; let regenerator = WorkspaceRegenerator::for_config(&loaded_config); - regenerator.regenerate_workspace_file(member_edit.workspace_name())?; + regenerator.regenerate_workspace_file(&canonical_workspace_name)?; terminal_style::print_success(&format!( "Removed {} from workspace {}", member_edit.repo_alias().style(terminal_style::ALIAS_STYLE), - member_edit.workspace_name().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 index 4a3fac7..fe37655 100644 --- a/src/reportal_commands/workspace_open.rs +++ b/src/reportal_commands/workspace_open.rs @@ -10,25 +10,24 @@ use std::process::Command; /// Regenerates the workspace's `.code-workspace` file and launches /// the configured default editor against it. /// -/// The regeneration step runs unconditionally so the file the -/// editor loads reflects the current member repo paths, which is -/// required for the invariant that opening a workspace always -/// produces a window grouping the live paths rather than whatever -/// was written the last time `create` or a membership edit ran. +/// Accepts either the canonical workspace key or any declared +/// alias; resolves to canonical first so the regenerator's default +/// file location uses the canonical name rather than the user's +/// short input. /// /// # Errors /// -/// Returns [`ReportalError::WorkspaceNotFound`] if the name is -/// unknown, [`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(workspace_name: &str) -> Result<(), ReportalError> { +/// Returns [`ReportalError::WorkspaceNotFound`] if the name or +/// alias is unknown, [`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_from_disk()?; - loaded_config.get_workspace(workspace_name)?; + let canonical_name = loaded_config.resolve_workspace_canonical_name(alias_or_canonical)?; let regenerator = WorkspaceRegenerator::for_config(&loaded_config); - let workspace_file_path = regenerator.regenerate_workspace_file(workspace_name)?; + let workspace_file_path = regenerator.regenerate_workspace_file(&canonical_name)?; let editor_command = loaded_config.default_editor(); @@ -49,7 +48,7 @@ pub fn run_workspace_open(workspace_name: &str) -> Result<(), ReportalError> { terminal_style::print_success(&format!( "Opened workspace {} in {}", - workspace_name.style(terminal_style::ALIAS_STYLE), + canonical_name.style(terminal_style::ALIAS_STYLE), editor_command.style(terminal_style::ALIAS_STYLE), )); Ok(()) diff --git a/src/reportal_commands/workspace_show.rs b/src/reportal_commands/workspace_show.rs index 51ee9c3..da37b0f 100644 --- a/src/reportal_commands/workspace_show.rs +++ b/src/reportal_commands/workspace_show.rs @@ -3,7 +3,7 @@ use crate::error::ReportalError; use crate::reportal_commands::workspace_operations::WorkspaceRegenerator; -use crate::reportal_config::ReportalConfig; +use crate::reportal_config::{HasAliases, ReportalConfig}; use crate::terminal_style; use owo_colors::OwoColorize; @@ -11,28 +11,32 @@ use owo_colors::OwoColorize; /// their resolved absolute paths, and the `.code-workspace` file /// location that `rep workspace open` would launch. /// +/// Accepts either the canonical workspace key or any declared +/// alias: the first step resolves to the canonical name so every +/// downstream call (regenerator, header display) uses the same key +/// and derived values like the default file path stay consistent. +/// /// Regenerates the `.code-workspace` file before printing so the /// shown file is guaranteed to match the current config state. -/// Without this, `show` could report stale folder entries when a -/// member repo's path changed since the last regeneration. /// /// # Errors /// /// Returns [`ReportalError::WorkspaceNotFound`] if the name has no -/// matching entry, [`ReportalError::RepoNotFound`] if any member -/// alias does not resolve, or the config / file I/O errors that -/// the load and regeneration paths surface. -pub fn run_workspace_show(workspace_name: &str) -> Result<(), ReportalError> { +/// matching entry or alias, [`ReportalError::RepoNotFound`] if any +/// member alias does not resolve, or the config / file I/O errors +/// that the load and regeneration paths surface. +pub fn run_workspace_show(alias_or_canonical: &str) -> Result<(), ReportalError> { let loaded_config = ReportalConfig::load_from_disk()?; - let target_workspace = loaded_config.get_workspace(workspace_name)?; + 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_file_path = regenerator.regenerate_workspace_file(workspace_name)?; + let workspace_file_path = regenerator.regenerate_workspace_file(&canonical_name)?; terminal_style::write_stdout("\n"); terminal_style::write_stdout(&format!( " {}\n", - workspace_name.to_uppercase().style(terminal_style::ALIAS_STYLE), + canonical_name.to_uppercase().style(terminal_style::ALIAS_STYLE), )); if !target_workspace.description().is_empty() { @@ -43,6 +47,14 @@ pub fn run_workspace_show(workspace_name: &str) -> Result<(), ReportalError> { )); } + 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", "File:".style(terminal_style::LABEL_STYLE), 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/has_aliases.rs b/src/reportal_config/has_aliases.rs new file mode 100644 index 0000000..29c7a45 --- /dev/null +++ b/src/reportal_config/has_aliases.rs @@ -0,0 +1,121 @@ +//! 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/mod.rs b/src/reportal_config/mod.rs index 687f615..7d427ae 100644 --- a/src/reportal_config/mod.rs +++ b/src/reportal_config/mod.rs @@ -4,8 +4,10 @@ //! 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_color; mod repo_entry; @@ -19,6 +21,7 @@ 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_color::RepoColor; pub use repo_entry::RepoEntry; diff --git a/src/reportal_config/repo_entry.rs b/src/reportal_config/repo_entry.rs index 6d9a57c..902461a 100644 --- a/src/reportal_config/repo_entry.rs +++ b/src/reportal_config/repo_entry.rs @@ -1,6 +1,7 @@ //! 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::repo_color::RepoColor; use crate::reportal_config::tab_title::TabTitle; use serde::{Deserialize, Serialize}; @@ -66,11 +67,6 @@ impl RepoEntry { &self.tags } - /// Alternative names that can be used to jump to this repo directly. - pub fn aliases(&self) -> &[String] { - &self.aliases - } - /// Git remote URL for cloning on other machines (may be empty). pub fn remote(&self) -> &str { &self.remote @@ -125,3 +121,9 @@ impl RepoEntry { self.color = new_color; } } + +impl HasAliases for RepoEntry { + fn aliases(&self) -> &[String] { + &self.aliases + } +} diff --git a/src/reportal_config/reportal_config_root.rs b/src/reportal_config/reportal_config_root.rs index 4fc8a4a..a6c414b 100644 --- a/src/reportal_config/reportal_config_root.rs +++ b/src/reportal_config/reportal_config_root.rs @@ -3,8 +3,10 @@ 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::{HasAliases, resolve_canonical_key}; use crate::reportal_config::repo_entry::RepoEntry; use crate::reportal_config::tag_filter::TagFilter; use crate::reportal_config::workspace_entry::WorkspaceEntry; @@ -59,17 +61,17 @@ impl ReportalConfig { /// Loads, parses, and validates the config from disk. /// /// After successful TOML parsing, runs the workspace reference - /// check so any workspace that points at a repo no longer - /// registered is rejected at startup rather than silently - /// producing broken `.code-workspace` files on a later regen. + /// check and the alias-collision pass so no dangling member or + /// ambiguous short name reaches command dispatch. /// /// # Errors /// /// Returns `ConfigNotFound` if the file does not exist, /// `ConfigIoFailure` if the file cannot be read, - /// `ConfigParseFailure` if the TOML is malformed, or + /// `ConfigParseFailure` if the TOML is malformed, /// `WorkspaceHasDanglingRepo` if a workspace references an - /// unknown repo alias. + /// unknown repo alias, or `WorkspaceAliasConflict` if any + /// workspace's name or alias collides with another entry. pub fn load_from_disk() -> Result { let file_path = Self::config_file_path()?; if !file_path.exists() { @@ -88,26 +90,140 @@ impl ReportalConfig { } })?; 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. /// - /// Runs on every config load so a hand-edited TOML that drops a - /// repo without cleaning up workspace membership is rejected at - /// startup. Prevents the failure mode where `.code-workspace` - /// files would later be regenerated pointing at a non-existent - /// repo path. Uses the canonical repo registry keys only, not - /// the repo `aliases` field, because workspace membership is - /// stored against the canonical key. - /// /// # Errors /// /// Returns [`ReportalError::WorkspaceHasDanglingRepo`] at the - /// first dangling reference encountered, identifying both the - /// workspace and the missing alias so the user can fix either - /// side of the broken reference. + /// first dangling reference encountered. pub fn validate_workspace_references(&self) -> Result<(), ReportalError> { let all_pairs = self.workspaces.iter().flat_map(|(workspace_name, workspace)| { workspace @@ -180,29 +296,47 @@ impl ReportalConfig { self.repos.iter().collect() } - /// Looks up a repo by its primary key or any of its alternative aliases. - /// Checks the primary key first, then walks all repos checking their - /// `aliases` field. Returns `RepoNotFound` if no match is found. - pub fn get_repo(&self, alias: &str) -> Result<&RepoEntry, ReportalError> { - if let Some(found_repo) = self.repos.get(alias) { - return Ok(found_repo); - } - let alias_match = self.repos.values() - .find(|repo_entry| repo_entry.aliases().iter().any(|alt| alt == alias)); - alias_match.map_or_else( - || Err(ReportalError::RepoNotFound { - alias: alias.to_owned(), - }), - Ok, - ) - } - - /// Returns a mutable reference to a repo by its primary key. - /// Returns `RepoNotFound` if the alias is not a primary key. - pub fn get_repo_mut(&mut self, alias: &str) -> Result<&mut RepoEntry, ReportalError> { - self.repos.get_mut(alias).ok_or_else(|| ReportalError::RepoNotFound { - alias: alias.to_owned(), - }) + /// 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. @@ -227,52 +361,92 @@ impl ReportalConfig { } /// Returns all registered workspaces with their names, for iteration. - /// - /// The list reflects the `BTreeMap` iteration order so display - /// and tree rendering are deterministic across runs. #[must_use] pub fn workspaces_with_names(&self) -> Vec<(&String, &WorkspaceEntry)> { self.workspaces.iter().collect() } - /// Looks up a workspace by name. + /// 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 name has - /// no matching workspace entry. - pub fn get_workspace(&self, workspace_name: &str) -> Result<&WorkspaceEntry, ReportalError> { - self.workspaces.get(workspace_name).ok_or_else(|| ReportalError::WorkspaceNotFound { - workspace_name: workspace_name.to_owned(), - }) + /// 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(), + }) } - /// Returns a mutable reference to a workspace by name. + /// Looks up a workspace by canonical key or any declared alias. /// /// # Errors /// - /// Returns [`ReportalError::WorkspaceNotFound`] if the name has - /// no matching workspace entry. + /// 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, - workspace_name: &str, + alias_or_canonical: &str, ) -> Result<&mut WorkspaceEntry, ReportalError> { - self.workspaces.get_mut(workspace_name).ok_or_else(|| ReportalError::WorkspaceNotFound { - workspace_name: workspace_name.to_owned(), - }) + 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 the dangling-reference check before accepting the new - /// entry so an insert that would leave the config invalid is - /// rejected at mutation time, not at the next load. + /// 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`] if the - /// name is taken, or [`ReportalError::WorkspaceHasDanglingRepo`] - /// if any of the new entry's repo aliases is not registered. + /// 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), @@ -291,6 +465,13 @@ impl ReportalConfig { }); } } + 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(()) } @@ -311,13 +492,6 @@ impl ReportalConfig { } /// Returns every workspace that contains the given repo alias. - /// - /// This is the reverse index that makes repo path changes - /// correct: when `rep edit` changes a repo's path, every - /// workspace containing that repo must regenerate its - /// `.code-workspace` file so the folder entries stay aligned - /// with reality. Without this lookup, path changes silently - /// strand workspace files pointing at the old location. #[must_use] pub fn workspaces_containing_repo( &self, @@ -341,8 +515,9 @@ impl ReportalConfig { }) } - /// Returns all registered AI tools with their names. - pub fn ai_tools_list(&self) -> Vec<(&String, &AiToolEntry)> { + /// 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() } @@ -372,3 +547,184 @@ impl ReportalConfig { } } } + +#[cfg(test)] +mod tests { + use super::*; + 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(), + } + } + + fn make_workspace(member_repo_aliases: Vec, declared_aliases: Vec) -> WorkspaceEntry { + let (_, entry) = WorkspaceRegistrationBuilder::start("placeholder".to_owned()) + .repo_aliases(member_repo_aliases) + .workspace_aliases(declared_aliases) + .build() + .expect("valid builder must succeed"); + entry + } + + fn config_with_repo_and_workspace( + repo_canonical: &str, + repo_aliases: Vec, + workspace_canonical: &str, + workspace_aliases: Vec, + ) -> ReportalConfig { + let mut config = ReportalConfig::create_default(); + config.repos.insert(repo_canonical.to_owned(), make_repo(repo_aliases)); + config.workspaces.insert( + workspace_canonical.to_owned(), + make_workspace(vec![repo_canonical.to_owned()], workspace_aliases), + ); + config + } + + #[test] + fn resolve_workspace_canonical_name_matches_alias() { + 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").expect("must resolve"), + "venoble", + ); + assert_eq!( + config.resolve_workspace_canonical_name("noble").expect("must resolve"), + "venoble", + ); + assert_eq!( + config.resolve_workspace_canonical_name("venoble").expect("must resolve"), + "venoble", + ); + } + + #[test] + fn resolve_workspace_canonical_name_unknown_fails() { + 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 { .. }))); + } + + #[test] + fn get_workspace_resolves_via_alias() { + let config = config_with_repo_and_workspace( + "app", + vec![], + "venoble", + vec!["vn".to_owned()], + ); + let resolved = config.get_workspace("vn").expect("alias must resolve"); + assert_eq!(resolved.repo_aliases(), &["app"]); + } + + #[test] + fn get_workspace_mut_resolves_via_alias() { + let mut config = config_with_repo_and_workspace( + "app", + vec![], + "venoble", + vec!["vn".to_owned()], + ); + let resolved_mut = config.get_workspace_mut("vn").expect("alias must resolve"); + resolved_mut.set_repo_aliases(vec!["app".to_owned(), "worker".to_owned()]); + let re_read = config.get_workspace("venoble").expect("canonical must resolve"); + assert_eq!(re_read.repo_aliases(), &["app", "worker"]); + } + + #[test] + fn get_repo_mut_now_resolves_via_alias() { + 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").expect("alias must resolve for mut"); + resolved_mut.set_description("mutated".to_owned()); + assert_eq!( + config.get_repo("venoble-app").expect("canonical must resolve").description(), + "mutated", + ); + } + + #[test] + fn alias_collision_workspace_alias_equals_peer_workspace_canonical_name() { + 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 { .. }))); + } + + #[test] + fn alias_collision_workspace_alias_equals_repo_canonical_key() { + 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 { .. }))); + } + + #[test] + fn alias_collision_workspace_alias_equals_repo_alias() { + 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 { .. }))); + } + + #[test] + fn alias_collision_workspace_name_equals_repo_canonical_key() { + 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 { .. }))); + } + + #[test] + fn clean_config_passes_alias_collision_validation() { + let config = config_with_repo_and_workspace( + "app", + vec!["a".to_owned()], + "venoble", + vec!["vn".to_owned()], + ); + assert!(config.validate_alias_collisions().is_ok()); + } +} diff --git a/src/reportal_config/workspace_entry.rs b/src/reportal_config/workspace_entry.rs index 03b5780..3202a7e 100644 --- a/src/reportal_config/workspace_entry.rs +++ b/src/reportal_config/workspace_entry.rs @@ -1,6 +1,7 @@ //! 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 serde::{Deserialize, Serialize}; /// A single registered VSCode/Cursor workspace definition. @@ -25,6 +26,16 @@ pub struct WorkspaceEntry { /// `~/.reportal/workspaces/.code-workspace`. #[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, } /// Accessors for a workspace entry. @@ -72,5 +83,10 @@ impl WorkspaceEntry { pub fn set_repo_aliases(&mut self, new_repo_aliases: Vec) { self.repos = new_repo_aliases; } +} +impl HasAliases for WorkspaceEntry { + fn aliases(&self) -> &[String] { + &self.aliases + } } diff --git a/src/reportal_config/workspace_registration_builder.rs b/src/reportal_config/workspace_registration_builder.rs index cd53c9e..5d00836 100644 --- a/src/reportal_config/workspace_registration_builder.rs +++ b/src/reportal_config/workspace_registration_builder.rs @@ -15,12 +15,15 @@ use crate::reportal_config::workspace_entry::WorkspaceEntry; pub struct WorkspaceRegistrationBuilder { /// Config key (workspace name) for the entry under construction. workspace_name: String, - /// Alias list collected so far. + /// 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. @@ -37,6 +40,7 @@ impl WorkspaceRegistrationBuilder { repo_aliases: Vec::new(), workspace_description: String::new(), workspace_file_path: String::new(), + workspace_aliases: Vec::new(), } } @@ -68,6 +72,21 @@ impl WorkspaceRegistrationBuilder { 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 @@ -80,7 +99,9 @@ impl WorkspaceRegistrationBuilder { /// # Errors /// /// Returns [`ReportalError::ValidationFailure`] when the - /// workspace name is empty or the repo alias list is empty. + /// 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 { @@ -94,15 +115,74 @@ impl WorkspaceRegistrationBuilder { reason: "at least one repo alias is required".to_owned(), }); } + validate_alias_list_shape(&self.workspace_aliases, &self.workspace_name)?; let validated_entry = WorkspaceEntry { repos: self.repo_aliases, description: self.workspace_description, path: self.workspace_file_path, + aliases: self.workspace_aliases, }; 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(()) +} + #[cfg(test)] mod tests { use super::*; @@ -167,4 +247,64 @@ mod tests { "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:?}", + ); + } } From da24f0d5ffcb8cb9945a0009fbd27c00204c2293 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Fri, 17 Apr 2026 17:06:54 -0400 Subject: [PATCH 46/67] Inline-path workspace members (v0.14.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspaces can now mix registered-repo references with inline filesystem paths. The TOML form `{ path = "..." }` declares a member that bypasses the repo registry entirely — useful when the user drives a workspace through reportal but does not want the individual folders registered as top-level repos. Existing configs whose `repos` array holds bare strings continue to load unchanged via an untagged serde representation on the new `WorkspaceMember` enum. Trade-off: inline-path members are immune to the path-change reverse index, so moving an inline-path folder requires editing the config by hand. The dangling-reference validator and the `rep remove` guard skip inline-path members for the same reason. --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 10 +- .../repo_listing_renderer.rs | 2 +- src/reportal_commands/repo_tree_grouping.rs | 20 +-- .../repo_tree_workspace_section.rs | 4 +- src/reportal_commands/workspace_members.rs | 27 ++- src/reportal_commands/workspace_operations.rs | 22 ++- src/reportal_commands/workspace_show.rs | 36 ++-- src/reportal_config/mod.rs | 2 + src/reportal_config/reportal_config_root.rs | 162 +++++++++++------- src/reportal_config/workspace_entry.rs | 88 +++++++--- src/reportal_config/workspace_member.rs | 78 +++++++++ .../workspace_registration_builder.rs | 7 +- 14 files changed, 327 insertions(+), 135 deletions(-) create mode 100644 src/reportal_config/workspace_member.rs diff --git a/Cargo.lock b/Cargo.lock index 697bd33..9e0730b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -556,7 +556,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.14.0" +version = "0.14.1" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index d60c9d3..800d1e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.14.1" +version = "0.14.2" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/README.md b/README.md index 6da04a5..dbc8f5a 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,11 @@ A workspace is a named group of repos that open together as one VSCode/Cursor wi ```toml [workspaces.backend] -repos = ["api", "worker", "db-migrations"] # ordered — controls sidebar order +repos = [ + "api", # registered-repo reference + "worker", + { path = "~/dev/db-migrations" }, # inline filesystem path (no repo registry entry needed) +] description = "Jakuta backend services" path = "" # empty = ~/.reportal/workspaces/backend.code-workspace aliases = ["be", "back"] # short names accepted by rep workspace @@ -230,12 +234,12 @@ aliases = ["be", "back"] # short names accepted by rep work | Field | Required | Default | What it controls | |-------|----------|---------|-----------------| -| `repos` | yes | — | Ordered list of repo aliases that belong to this workspace. Order matches the editor sidebar. Each alias must be a registered repo — dangling references are rejected on config load | +| `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. 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 `.code-workspace` file. Empty falls back to `~/.reportal/workspaces/.code-workspace` | | `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 | -**Regeneration is automatic.** When you change a repo's path via `rep edit`, every workspace that contains it regenerates its `.code-workspace` file so the folder entries reflect the new location. User-authored fields inside the file (`settings`, `extensions`, `launch`, `tasks`, plus any JSONC comments) round-trip byte-stable across regeneration — RePortal only touches the `folders` array. +**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 regenerates its `.code-workspace` file. 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 file (`settings`, `extensions`, `launch`, `tasks`, plus any JSONC comments) round-trip byte-stable across regeneration — RePortal only touches the `folders` array. **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. diff --git a/src/reportal_commands/repo_listing_renderer.rs b/src/reportal_commands/repo_listing_renderer.rs index ada7c2b..adb092a 100644 --- a/src/reportal_commands/repo_listing_renderer.rs +++ b/src/reportal_commands/repo_listing_renderer.rs @@ -144,7 +144,7 @@ impl<'tree> RepoListingRenderer<'tree> { /// `(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: &(&String, &RepoEntry)) -> Result<(), ReportalError> { + fn render_repo_leaf(repo_pair: &(&str, &RepoEntry)) -> Result<(), ReportalError> { let (repo_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())?; diff --git a/src/reportal_commands/repo_tree_grouping.rs b/src/reportal_commands/repo_tree_grouping.rs index 531b384..561795c 100644 --- a/src/reportal_commands/repo_tree_grouping.rs +++ b/src/reportal_commands/repo_tree_grouping.rs @@ -39,7 +39,7 @@ pub struct RepoTreeGrouping<'config> { /// 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 String, &'config RepoEntry)>, + 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, @@ -71,11 +71,11 @@ impl<'config> RepoTreeGrouping<'config> { 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.as_str()); + distinct_aliases.insert(*member_alias); } } for (orphan_alias, _orphan_entry) in &unassigned_repos { - distinct_aliases.insert(orphan_alias.as_str()); + distinct_aliases.insert(*orphan_alias); } let distinct_repo_count = distinct_aliases.len(); Ok(Self { @@ -95,7 +95,7 @@ impl<'config> RepoTreeGrouping<'config> { /// 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 String, &'config RepoEntry)] { + pub fn unassigned_repos(&self) -> &[(&'config str, &'config RepoEntry)] { &self.unassigned_repos } @@ -204,10 +204,10 @@ impl<'config> RepoTreeGroupingParams<'config> { fn resolve_surviving_members( &self, workspace_entry: &'config crate::reportal_config::WorkspaceEntry, - ) -> Result, ReportalError> { + ) -> 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 String, &'config RepoEntry)> = Vec::new(); + 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 @@ -229,12 +229,12 @@ impl<'config> RepoTreeGroupingParams<'config> { /// 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 String, &'config RepoEntry)> { + 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 String, &'config RepoEntry)> = Vec::new(); + let mut unassigned_repos: Vec<(&'config str, &'config RepoEntry)> = Vec::new(); if workspace_restriction_active { return unassigned_repos; } @@ -250,7 +250,7 @@ impl<'config> RepoTreeGroupingParams<'config> { let containing_workspaces = self.loaded_config.workspaces_containing_repo(repo_alias); if containing_workspaces.is_empty() { - unassigned_repos.push((repo_alias, repo_entry)); + unassigned_repos.push((repo_alias.as_str(), repo_entry)); } } unassigned_repos @@ -478,7 +478,7 @@ mod tests { let observed_member_order: Vec<&str> = tree_grouping.workspace_sections()[0] .member_repos() .iter() - .map(|(repo_alias, _repo_entry)| repo_alias.as_str()) + .map(|(repo_alias, _repo_entry)| *repo_alias) .collect(); assert_eq!( observed_member_order, diff --git a/src/reportal_commands/repo_tree_workspace_section.rs b/src/reportal_commands/repo_tree_workspace_section.rs index 3687c98..6bbcd4d 100644 --- a/src/reportal_commands/repo_tree_workspace_section.rs +++ b/src/reportal_commands/repo_tree_workspace_section.rs @@ -20,7 +20,7 @@ pub struct WorkspaceSection<'config> { /// 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 String, &'config RepoEntry)>, + pub(super) member_repos: Vec<(&'config str, &'config RepoEntry)>, } /// Accessors for a workspace section. @@ -41,7 +41,7 @@ impl<'config> WorkspaceSection<'config> { /// The ordered list of member repos that survived the active /// filters for this listing. #[must_use] - pub fn member_repos(&self) -> &[(&'config String, &'config RepoEntry)] { + pub fn member_repos(&self) -> &[(&'config str, &'config RepoEntry)] { &self.member_repos } } diff --git a/src/reportal_commands/workspace_members.rs b/src/reportal_commands/workspace_members.rs index b630521..92fdef0 100644 --- a/src/reportal_commands/workspace_members.rs +++ b/src/reportal_commands/workspace_members.rs @@ -3,7 +3,9 @@ use crate::cli_args::WorkspaceArgsMemberEditParts; use crate::error::ReportalError; use crate::reportal_commands::workspace_operations::WorkspaceRegenerator; -use crate::reportal_config::ReportalConfig; +use crate::reportal_config::{ + ReportalConfig, WorkspaceMember, WorkspaceMemberAliasLookup, +}; use crate::terminal_style; use owo_colors::OwoColorize; @@ -41,9 +43,11 @@ pub fn run_workspace_add_repo( ), }); } - let mut updated_member_aliases = target_workspace.repo_aliases().to_vec(); - updated_member_aliases.push(member_edit.repo_alias().to_owned()); - target_workspace.set_repo_aliases(updated_member_aliases); + let mut updated_members = target_workspace.members().to_vec(); + updated_members.push(WorkspaceMember::RegisteredRepo( + member_edit.repo_alias().to_owned(), + )); + target_workspace.set_members(updated_members); loaded_config.save_to_disk()?; let regenerator = WorkspaceRegenerator::for_config(&loaded_config); @@ -91,13 +95,18 @@ pub fn run_workspace_remove_repo( ), }); } - let remaining_member_aliases: Vec = target_workspace - .repo_aliases() + let remaining_members: Vec = target_workspace + .members() .iter() - .filter(|existing_alias| existing_alias.as_str() != member_edit.repo_alias()) + .filter(|member| match member.registered_repo_alias() { + WorkspaceMemberAliasLookup::Matches(existing_alias) => { + existing_alias != member_edit.repo_alias() + } + WorkspaceMemberAliasLookup::NotARepoReference => true, + }) .cloned() .collect(); - if remaining_member_aliases.is_empty() { + if remaining_members.is_empty() { return Err(ReportalError::ValidationFailure { field: "workspace member".to_owned(), reason: format!( @@ -106,7 +115,7 @@ pub fn run_workspace_remove_repo( ), }); } - target_workspace.set_repo_aliases(remaining_member_aliases); + target_workspace.set_members(remaining_members); loaded_config.save_to_disk()?; let regenerator = WorkspaceRegenerator::for_config(&loaded_config); diff --git a/src/reportal_commands/workspace_operations.rs b/src/reportal_commands/workspace_operations.rs index e789a05..88f4346 100644 --- a/src/reportal_commands/workspace_operations.rs +++ b/src/reportal_commands/workspace_operations.rs @@ -11,7 +11,9 @@ use crate::code_workspace::CodeWorkspaceFile; use crate::error::ReportalError; -use crate::reportal_config::{ReportalConfig, WorkspaceEntry}; +use crate::reportal_config::{ + ReportalConfig, WorkspaceEntry, WorkspaceMember, +}; use std::path::PathBuf; /// Regenerates `.code-workspace` files from a borrowed config. @@ -89,11 +91,19 @@ impl<'config_lifetime> WorkspaceRegenerator<'config_lifetime> { &self, target_workspace: &WorkspaceEntry, ) -> Result, ReportalError> { - let member_aliases = target_workspace.repo_aliases(); - let mut resolved_paths = Vec::with_capacity(member_aliases.len()); - for member_alias in member_aliases { - let member_repo = self.config_registry.get_repo(member_alias)?; - resolved_paths.push(member_repo.resolved_path()); + let declared_members = target_workspace.members(); + let mut resolved_paths = Vec::with_capacity(declared_members.len()); + for member in declared_members { + match member { + WorkspaceMember::RegisteredRepo(repo_alias) => { + let member_repo = self.config_registry.get_repo(repo_alias)?; + resolved_paths.push(member_repo.resolved_path()); + } + WorkspaceMember::InlinePath { path } => { + let expanded = shellexpand::tilde(path); + resolved_paths.push(PathBuf::from(expanded.as_ref())); + } + } } Ok(resolved_paths) } diff --git a/src/reportal_commands/workspace_show.rs b/src/reportal_commands/workspace_show.rs index da37b0f..eebc572 100644 --- a/src/reportal_commands/workspace_show.rs +++ b/src/reportal_commands/workspace_show.rs @@ -3,7 +3,7 @@ use crate::error::ReportalError; use crate::reportal_commands::workspace_operations::WorkspaceRegenerator; -use crate::reportal_config::{HasAliases, ReportalConfig}; +use crate::reportal_config::{HasAliases, ReportalConfig, WorkspaceMember}; use crate::terminal_style; use owo_colors::OwoColorize; @@ -65,15 +65,31 @@ pub fn run_workspace_show(alias_or_canonical: &str) -> Result<(), ReportalError> " {}\n", "Members:".style(terminal_style::LABEL_STYLE), )); - for member_alias in target_workspace.repo_aliases() { - let member_repo = loaded_config.get_repo(member_alias)?; - let resolved_member_path = member_repo.resolved_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), - )); + 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(); + 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), + )); + } + WorkspaceMember::InlinePath { path } => { + let expanded = shellexpand::tilde(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), + )); + } + } } terminal_style::write_stdout("\n"); diff --git a/src/reportal_config/mod.rs b/src/reportal_config/mod.rs index 7d427ae..7c85ded 100644 --- a/src/reportal_config/mod.rs +++ b/src/reportal_config/mod.rs @@ -17,6 +17,7 @@ mod tab_title; mod tag_filter; mod workspace_entry; mod workspace_filter; +mod workspace_member; mod workspace_registration_builder; pub use command_entry::CommandEntry; @@ -31,4 +32,5 @@ 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/reportal_config_root.rs b/src/reportal_config/reportal_config_root.rs index a6c414b..9598f88 100644 --- a/src/reportal_config/reportal_config_root.rs +++ b/src/reportal_config/reportal_config_root.rs @@ -225,18 +225,14 @@ impl ReportalConfig { /// Returns [`ReportalError::WorkspaceHasDanglingRepo`] at the /// first dangling reference encountered. pub fn validate_workspace_references(&self) -> Result<(), ReportalError> { - let all_pairs = self.workspaces.iter().flat_map(|(workspace_name, workspace)| { - workspace - .repo_aliases() - .iter() - .map(move |member_alias| (workspace_name, member_alias)) - }); - for (workspace_name, member_alias) in all_pairs { - if !self.repos.contains_key(member_alias) { - return Err(ReportalError::WorkspaceHasDanglingRepo { - workspace_name: workspace_name.to_owned(), - missing_alias: member_alias.to_owned(), - }); + 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(()) @@ -551,6 +547,7 @@ impl ReportalConfig { #[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 { @@ -566,13 +563,15 @@ mod tests { } } - fn make_workspace(member_repo_aliases: Vec, declared_aliases: Vec) -> WorkspaceEntry { + 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() - .expect("valid builder must succeed"); - entry + .build()?; + Ok(entry) } fn config_with_repo_and_workspace( @@ -580,117 +579,116 @@ mod tests { repo_aliases: Vec, workspace_canonical: &str, workspace_aliases: Vec, - ) -> ReportalConfig { + ) -> Result { let mut config = ReportalConfig::create_default(); config.repos.insert(repo_canonical.to_owned(), make_repo(repo_aliases)); - config.workspaces.insert( - workspace_canonical.to_owned(), - make_workspace(vec![repo_canonical.to_owned()], workspace_aliases), - ); - config + 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() { + 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").expect("must resolve"), - "venoble", - ); - assert_eq!( - config.resolve_workspace_canonical_name("noble").expect("must resolve"), - "venoble", - ); - assert_eq!( - config.resolve_workspace_canonical_name("venoble").expect("must resolve"), - "venoble", - ); + )?; + 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() { - let config = config_with_repo_and_workspace("app", vec![], "venoble", vec![]); + 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() { + 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").expect("alias must resolve"); - assert_eq!(resolved.repo_aliases(), &["app"]); + )?; + let resolved = config.get_workspace("vn")?; + assert_eq!(resolved.repo_aliases(), vec!["app"]); + Ok(()) } #[test] - fn get_workspace_mut_resolves_via_alias() { + 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").expect("alias must resolve"); - resolved_mut.set_repo_aliases(vec!["app".to_owned(), "worker".to_owned()]); - let re_read = config.get_workspace("venoble").expect("canonical must resolve"); - assert_eq!(re_read.repo_aliases(), &["app", "worker"]); + )?; + 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() { + 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").expect("alias must resolve for mut"); + let resolved_mut = config.get_repo_mut("vna")?; resolved_mut.set_description("mutated".to_owned()); - assert_eq!( - config.get_repo("venoble-app").expect("canonical must resolve").description(), - "mutated", - ); + assert_eq!(config.get_repo("venoble-app")?.description(), "mutated"); + Ok(()) } #[test] - fn alias_collision_workspace_alias_equals_peer_workspace_canonical_name() { + 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()]), + make_workspace(vec!["app".to_owned()], vec!["backend".to_owned()])?, ); config.workspaces.insert( "backend".to_owned(), - make_workspace(vec!["app".to_owned()], vec![]), + 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() { + 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()]), + 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() { + fn alias_collision_workspace_alias_equals_repo_alias() -> Result<(), ReportalError> { let mut config = ReportalConfig::create_default(); config.repos.insert( "venoble-app".to_owned(), @@ -698,33 +696,67 @@ mod tests { ); config.workspaces.insert( "venoble".to_owned(), - make_workspace(vec!["venoble-app".to_owned()], vec!["vna".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() { + 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![]), + 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(), + } } #[test] - fn clean_config_passes_alias_collision_validation() { + 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/workspace_entry.rs b/src/reportal_config/workspace_entry.rs index 3202a7e..470d530 100644 --- a/src/reportal_config/workspace_entry.rs +++ b/src/reportal_config/workspace_entry.rs @@ -2,21 +2,31 @@ //! VSCode/Cursor `.code-workspace` file. use crate::reportal_config::has_aliases::HasAliases; +use crate::reportal_config::workspace_member::{ + WorkspaceMember, WorkspaceMemberAliasLookup, +}; use serde::{Deserialize, Serialize}; /// A single registered VSCode/Cursor workspace definition. /// -/// Declares which repos 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 -/// this entry and the member repos' current paths. +/// 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 repo aliases that belong to this workspace. + /// Ordered list of members — each is either a registered-repo + /// reference or an inline filesystem path. /// - /// Each alias must resolve to a registered repo in `[repos.*]`. - /// Validation runs on config load and rejects dangling references. - pub(super) repos: Vec, + /// 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, @@ -38,19 +48,36 @@ pub struct WorkspaceEntry { pub(super) aliases: Vec, } -/// Accessors for a workspace entry. +/// Accessors and mutators for a workspace entry. impl WorkspaceEntry { - /// Ordered list of repo aliases that belong to this workspace. + /// Ordered list of every member in this workspace. /// - /// Order is preserved from the config so the generated - /// `.code-workspace` file's `folders` array matches what the user - /// declared, which controls the visual ordering in the editor's - /// sidebar. + /// 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 repo_aliases(&self) -> &[String] { + 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 { @@ -65,23 +92,32 @@ impl WorkspaceEntry { &self.path } - /// Whether the given repo alias is a member of this workspace. + /// Whether the given repo alias is a registered-repo member of + /// this workspace. /// - /// Used by the reverse-index lookup that finds every workspace - /// containing a repo — required so repo path changes can trigger - /// regeneration of every affected `.code-workspace` file. + /// 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, repo_alias: &str) -> bool { - self.repos.iter().any(|alias| alias == repo_alias) + self.repos.iter().any(|member| { + matches!( + member.registered_repo_alias(), + WorkspaceMemberAliasLookup::Matches(alias) if alias == repo_alias + ) + }) } - /// Replaces the ordered repo alias list with a new membership set. + /// Replaces the ordered member list with a new one. /// - /// Callers must ensure the new aliases all resolve to registered - /// repos; the config-level validation will reject dangling - /// references on the next save/load cycle. - pub fn set_repo_aliases(&mut self, new_repo_aliases: Vec) { - self.repos = new_repo_aliases; + /// 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; } } diff --git a/src/reportal_config/workspace_member.rs b/src/reportal_config/workspace_member.rs new file mode 100644 index 0000000..089ee95 --- /dev/null +++ b/src/reportal_config/workspace_member.rs @@ -0,0 +1,78 @@ +//! 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 index 5d00836..7f8e7b9 100644 --- a/src/reportal_config/workspace_registration_builder.rs +++ b/src/reportal_config/workspace_registration_builder.rs @@ -2,6 +2,7 @@ use crate::error::ReportalError; use crate::reportal_config::workspace_entry::WorkspaceEntry; +use crate::reportal_config::workspace_member::WorkspaceMember; /// Chainable builder that assembles a `WorkspaceEntry` from separate /// field inputs and validates name/membership invariants on `build()`. @@ -117,7 +118,11 @@ impl WorkspaceRegistrationBuilder { } validate_alias_list_shape(&self.workspace_aliases, &self.workspace_name)?; let validated_entry = WorkspaceEntry { - repos: self.repo_aliases, + repos: self + .repo_aliases + .into_iter() + .map(WorkspaceMember::RegisteredRepo) + .collect(), description: self.workspace_description, path: self.workspace_file_path, aliases: self.workspace_aliases, From 0ad546dbc88e9a44a040695fb68c355c8d3dd43f Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Fri, 17 Apr 2026 17:47:16 -0400 Subject: [PATCH 47/67] Update README roadmap for v0.14.1 (aliases) and v0.14.2 (inline paths) --- Cargo.lock | 2 +- README.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 9e0730b..6b10d00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -556,7 +556,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.14.1" +version = "0.14.2" dependencies = [ "anyhow", "clap", diff --git a/README.md b/README.md index dbc8f5a..4c15764 100644 --- a/README.md +++ b/README.md @@ -314,6 +314,8 @@ PROMPT_COMMAND='rep color 2>/dev/null' - [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 - [ ] 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) From 50125b6f1aa2a9a41b7f9da37c53956febf290ca Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Fri, 17 Apr 2026 18:16:00 -0400 Subject: [PATCH 48/67] Unified rj/ro with workspace fallthrough + rjw/row shell wrappers (v0.14.3) `rj ` and `ro ` now fall through to workspace aliases when no repo matches: - rj venoble cd's to the workspace file's parent dir (common-ancestor folder) - ro venoble launches the editor against the .code-workspace file Unknown aliases still surface as RepoNotFound because that's the user-facing concept the shell wrapper advertises. Real I/O errors from either registry propagate rather than being silently folded into "not found". New `rep workspace jump` subcommand + `rjw` / `row` shell wrappers for the workspace-only variant, for cases where the user wants to force workspace resolution and refuse any repo-alias match. v0.14.1's cross-namespace collision validator already guarantees there's never a repo and workspace sharing an alias, so the fall- through order is a documentation concern, not a correctness one. --- Cargo.toml | 2 +- README.md | 7 +- src/cli_args/workspace_args.rs | 3 + src/reportal_commands/detected_shell.rs | 10 ++ src/reportal_commands/direct_alias_router.rs | 144 ++++++++++++++++++ src/reportal_commands/mod.rs | 3 + src/reportal_commands/repo_jump.rs | 45 +++++- src/reportal_commands/repo_open.rs | 49 +++++- src/reportal_commands/workspace_dispatch.rs | 7 +- src/reportal_commands/workspace_jump.rs | 32 ++++ src/reportal_commands/workspace_operations.rs | 2 +- 11 files changed, 285 insertions(+), 19 deletions(-) create mode 100644 src/reportal_commands/direct_alias_router.rs create mode 100644 src/reportal_commands/workspace_jump.rs diff --git a/Cargo.toml b/Cargo.toml index 800d1e9..80c4742 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.14.2" +version = "0.14.3" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/README.md b/README.md index 4c15764..dfdb463 100644 --- a/README.md +++ b/README.md @@ -145,9 +145,11 @@ On **Bash/Zsh**, `rep init` writes a standalone script to `~/.reportal/integrati | 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 to the workspace's common-ancestor folder) | | `ro` | Fuzzy-select a repo and open it in your editor | -| `ro my-api` | Open a repo directly by alias | +| `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 to the workspace file's parent dir | +| `row my-ws` | Workspace-only open — launches the editor on the `.code-workspace` file | | `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 | @@ -316,6 +318,7 @@ PROMPT_COMMAND='rep color 2>/dev/null' - [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 - [ ] 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) diff --git a/src/cli_args/workspace_args.rs b/src/cli_args/workspace_args.rs index 860c1b2..77425be 100644 --- a/src/cli_args/workspace_args.rs +++ b/src/cli_args/workspace_args.rs @@ -260,4 +260,7 @@ pub enum WorkspaceArgsSubcommand { /// Open a workspace in the configured default editor by /// running ` `. Open(WorkspaceArgsNameOnly), + /// Print the workspace's `.code-workspace` file parent + /// directory so the `rjw` shell wrapper can cd there. + Jump(WorkspaceArgsNameOnly), } diff --git a/src/reportal_commands/detected_shell.rs b/src/reportal_commands/detected_shell.rs index 60a3192..6e6cc1f 100644 --- a/src/reportal_commands/detected_shell.rs +++ b/src/reportal_commands/detected_shell.rs @@ -197,6 +197,8 @@ impl DetectedShell { $env:REPORTAL_LOADED = "1" function global:rj {{ Set-Location (rep jump @args); rep color }} function global:ro {{ rep open @args; rep color }} +function global:rjw {{ Set-Location (rep workspace jump @args); rep color }} +function global:row {{ rep workspace open @args; rep color }} function global:rw {{ rep web @args }} function global:rr {{ rep run @args }} $script:_reportal_original_prompt = $function:global:prompt @@ -217,6 +219,8 @@ function global:prompt {{ $p = & $script:_reportal_original_prompt; $t = rep col export REPORTAL_LOADED=1 rj() {{ cd "$(rep jump "$@")"; rep color; }} ro() {{ rep open "$@"; rep color; }} +rjw() {{ cd "$(rep workspace jump "$@")"; 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"; }} @@ -271,15 +275,21 @@ PROMPT_COMMAND="${{PROMPT_COMMAND:+$PROMPT_COMMAND;}}_reportal_hook" fn is_reportal_function_line(trimmed: &str) -> bool { trimmed.starts_with("function rj") || trimmed.starts_with("function ro") + || trimmed.starts_with("function rjw") + || trimmed.starts_with("function row") || trimmed.starts_with("function rw") || trimmed.starts_with("function rr") || trimmed.starts_with("function repup") || trimmed.starts_with("function global:rj") || trimmed.starts_with("function global:ro") + || trimmed.starts_with("function global:rjw") + || trimmed.starts_with("function global:row") || trimmed.starts_with("function global:rw") || trimmed.starts_with("function global:rr") || trimmed.starts_with("rj()") || trimmed.starts_with("ro()") + || trimmed.starts_with("rjw()") + || trimmed.starts_with("row()") || trimmed.starts_with("rw()") || trimmed.starts_with("rr()") || trimmed.starts_with("repup()") diff --git a/src/reportal_commands/direct_alias_router.rs b/src/reportal_commands/direct_alias_router.rs new file mode 100644 index 0000000..475115e --- /dev/null +++ b/src/reportal_commands/direct_alias_router.rs @@ -0,0 +1,144 @@ +//! 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, SelectedPathDisplayParams}; +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 `.code-workspace` file parent + /// directory to stdout so the `rj` shell wrapper cd's there. + /// + /// The parent directory is the common ancestor the user + /// declared when they chose the workspace file location, which + /// is the predictable cd target for a multi-folder workspace — + /// no single member directory is correct when the workspace + /// contains three sibling repos. + /// + /// # Errors + /// + /// Returns [`ReportalError::WorkspaceNotFound`] if the workspace + /// disappears between alias resolution and file-path resolution, + /// or [`ReportalError::ConfigIoFailure`] if the default + /// `~/.reportal/workspaces/` location needs the home directory + /// and it cannot be resolved. + pub fn jump_to_workspace_parent( + &self, + canonical_workspace_name: &str, + ) -> Result<(), ReportalError> { + let regenerator = WorkspaceRegenerator::for_config(self.loaded_config); + let workspace_file_path = + regenerator.resolve_workspace_file_path(canonical_workspace_name)?; + let target_directory = match workspace_file_path.parent() { + Some(parent_directory) => parent_directory.to_path_buf(), + None => std::path::PathBuf::from("."), + }; + let formatted_path = self + .loaded_config + .path_display_format() + .format_path(&target_directory); + terminal_style::write_stdout(&formatted_path); + path_display::print_selected_path_if_visible(&SelectedPathDisplayParams { + loaded_config: self.loaded_config, + resolved_path: &target_directory, + }); + Ok(()) + } +} diff --git a/src/reportal_commands/mod.rs b/src/reportal_commands/mod.rs index abe7d8c..530c4bf 100644 --- a/src/reportal_commands/mod.rs +++ b/src/reportal_commands/mod.rs @@ -1,6 +1,7 @@ //! Subcommand implementations for the `RePortal` CLI. mod color_command_mode; mod detected_shell; +mod direct_alias_router; mod doctor; mod git_commands; mod initialization; @@ -28,6 +29,7 @@ mod repo_web; mod workspace_create; mod workspace_delete; mod workspace_dispatch; +mod workspace_jump; mod workspace_listing; mod workspace_members; mod workspace_open; @@ -53,6 +55,7 @@ pub use repo_web::{run_web, WebCommandParams}; 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; diff --git a/src/reportal_commands/repo_jump.rs b/src/reportal_commands/repo_jump.rs index 1528c0e..38fec25 100644 --- a/src/reportal_commands/repo_jump.rs +++ b/src/reportal_commands/repo_jump.rs @@ -1,12 +1,15 @@ //! Fuzzy-selects a repo and prints its path for shell `cd` integration. use crate::error::ReportalError; -use crate::reportal_config::{ReportalConfig, TagFilter}; +use crate::reportal_commands::direct_alias_router::{ + DirectAliasRouter, DirectAliasRouterOutcome, +}; use crate::reportal_commands::path_display::{self, SelectedPathDisplayParams}; use crate::reportal_commands::repo_selection::{self, SelectedRepoParams}; use crate::reportal_commands::terminal_identity_emit::{ self, TerminalIdentityEmitParams, }; +use crate::reportal_config::{ReportalConfig, TagFilter}; use crate::terminal_style; /// All parameters needed to run the jump command. @@ -22,13 +25,45 @@ 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. +/// 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: &JumpCommandParams<'_>) -> Result<(), ReportalError> { let loaded_config = ReportalConfig::load_from_disk()?; + if !jump_params.direct_alias.is_empty() { + let router = DirectAliasRouter::for_config(&loaded_config); + match router.classify(jump_params.direct_alias)? { + DirectAliasRouterOutcome::RegisteredRepo => { + /* fall through to repo jump flow */ + } + DirectAliasRouterOutcome::Workspace(canonical_workspace_name) => { + return router.jump_to_workspace_parent(&canonical_workspace_name); + } + DirectAliasRouterOutcome::Unknown => { + return Err(ReportalError::RepoNotFound { + alias: jump_params.direct_alias.to_owned(), + }); + } + } + } + let selection_params = SelectedRepoParams { loaded_config: &loaded_config, direct_alias: jump_params.direct_alias, diff --git a/src/reportal_commands/repo_open.rs b/src/reportal_commands/repo_open.rs index f67b86d..55d66ca 100644 --- a/src/reportal_commands/repo_open.rs +++ b/src/reportal_commands/repo_open.rs @@ -1,12 +1,16 @@ //! Fuzzy-selects a repo and opens it in the configured editor. use crate::error::ReportalError; -use crate::reportal_config::{PathVisibility, ReportalConfig, TagFilter}; -use crate::terminal_style; +use crate::reportal_commands::direct_alias_router::{ + DirectAliasRouter, DirectAliasRouterOutcome, +}; use crate::reportal_commands::repo_selection::{self, SelectedRepoParams}; use crate::reportal_commands::terminal_identity_emit::{ self, TerminalIdentityEmitParams, }; +use crate::reportal_commands::run_workspace_open; +use crate::reportal_config::{PathVisibility, ReportalConfig, TagFilter}; +use crate::terminal_style; use owo_colors::OwoColorize; use std::process::Command; @@ -22,16 +26,45 @@ 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, 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. /// -/// 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. +/// 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: &OpenCommandParams<'_>) -> Result<(), ReportalError> { let loaded_config = ReportalConfig::load_from_disk()?; + if !open_params.direct_alias.is_empty() { + let router = DirectAliasRouter::for_config(&loaded_config); + match router.classify(open_params.direct_alias)? { + DirectAliasRouterOutcome::RegisteredRepo => { + /* fall through to repo open flow */ + } + DirectAliasRouterOutcome::Workspace(canonical_workspace_name) => { + return run_workspace_open(&canonical_workspace_name); + } + DirectAliasRouterOutcome::Unknown => { + return Err(ReportalError::RepoNotFound { + alias: open_params.direct_alias.to_owned(), + }); + } + } + } + let selection_params = SelectedRepoParams { loaded_config: &loaded_config, direct_alias: open_params.direct_alias, diff --git a/src/reportal_commands/workspace_dispatch.rs b/src/reportal_commands/workspace_dispatch.rs index 080f270..4277e30 100644 --- a/src/reportal_commands/workspace_dispatch.rs +++ b/src/reportal_commands/workspace_dispatch.rs @@ -8,8 +8,8 @@ use crate::cli_args::{WorkspaceArgs, WorkspaceArgsSubcommand}; use crate::error::ReportalError; use crate::reportal_commands::{ - run_workspace_add_repo, run_workspace_create, run_workspace_delete, run_workspace_list, - run_workspace_open, run_workspace_remove_repo, run_workspace_show, + run_workspace_add_repo, run_workspace_create, run_workspace_delete, run_workspace_jump, + run_workspace_list, run_workspace_open, run_workspace_remove_repo, run_workspace_show, }; /// Dispatches a parsed `rep workspace` invocation to the matching @@ -49,5 +49,8 @@ pub fn dispatch_workspace_subcommand( WorkspaceArgsSubcommand::Open(name_only) => { run_workspace_open(&name_only.into_workspace_name()) } + WorkspaceArgsSubcommand::Jump(name_only) => { + run_workspace_jump(&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..43df48b --- /dev/null +++ b/src/reportal_commands/workspace_jump.rs @@ -0,0 +1,32 @@ +//! 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_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. +/// +/// This is the workspace-only counterpart to `rep jump`; it never +/// falls through to the repo registry. For the unified `rj` +/// behavior where either a repo or workspace alias works, use +/// `rep jump`. 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 the name or +/// alias matches no registered workspace, 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_from_disk()?; + let canonical_workspace_name = + 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_operations.rs b/src/reportal_commands/workspace_operations.rs index 88f4346..b67d250 100644 --- a/src/reportal_commands/workspace_operations.rs +++ b/src/reportal_commands/workspace_operations.rs @@ -124,7 +124,7 @@ impl<'config_lifetime> WorkspaceRegenerator<'config_lifetime> { /// not match a registered workspace, or /// [`ReportalError::ConfigIoFailure`] if the home directory is /// needed for the default location and cannot be resolved. - fn resolve_workspace_file_path( + pub fn resolve_workspace_file_path( &self, workspace_name: &str, ) -> Result { From 2e1b391e6818173ba7e5449af4ab284d1e3f19cf Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Fri, 17 Apr 2026 18:16:19 -0400 Subject: [PATCH 49/67] chore: update Cargo.lock for v0.14.3 --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 6b10d00..bbb754e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -556,7 +556,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.14.2" +version = "0.14.3" dependencies = [ "anyhow", "clap", From 36e1aa68c06bbe02052a665a600d142f67d88a40 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Fri, 17 Apr 2026 18:29:12 -0400 Subject: [PATCH 50/67] Unified fuzzy finder for rj/ro + rjw/row no-args (v0.14.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rj` / `ro` no-arg fuzzy finder now lists workspaces alongside repos so the user's "go somewhere" mental model works across both namespaces. Workspace rows are distinguished visually by a ▣▣ prefix swatch and a trailing [workspace] suffix. `rjw` / `row` without arguments fuzzy-select over workspaces only instead of erroring on missing arg — mirrors the no-arg behavior of `rj` / `ro`. New `workspace_selection` and `target_selection` helpers keep the fuzzy rendering logic in one place each, so repo-only `rep ai` / `rep edit` / `rep web` don't accidentally gain workspace rows. Workspace rows skip the fuzzy list when `--tag` is used because workspaces do not carry tags and including them in a tag-filtered list would imply they match the filter. --- Cargo.toml | 2 +- README.md | 1 + src/cli_args/workspace_args.rs | 34 ++++- src/error.rs | 4 + src/reportal_commands/mod.rs | 2 + src/reportal_commands/repo_jump.rs | 24 ++- src/reportal_commands/repo_open.rs | 25 ++- src/reportal_commands/target_selection.rs | 153 +++++++++++++++++++ src/reportal_commands/workspace_dispatch.rs | 8 +- src/reportal_commands/workspace_jump.rs | 34 +++-- src/reportal_commands/workspace_open.rs | 28 ++-- src/reportal_commands/workspace_selection.rs | 71 +++++++++ 12 files changed, 347 insertions(+), 39 deletions(-) create mode 100644 src/reportal_commands/target_selection.rs create mode 100644 src/reportal_commands/workspace_selection.rs diff --git a/Cargo.toml b/Cargo.toml index 80c4742..14dde9f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.14.3" +version = "0.14.4" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/README.md b/README.md index dfdb463..d1f1e72 100644 --- a/README.md +++ b/README.md @@ -319,6 +319,7 @@ PROMPT_COMMAND='rep color 2>/dev/null' - [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 - [ ] 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) diff --git a/src/cli_args/workspace_args.rs b/src/cli_args/workspace_args.rs index 77425be..9a3252d 100644 --- a/src/cli_args/workspace_args.rs +++ b/src/cli_args/workspace_args.rs @@ -258,9 +258,37 @@ pub enum WorkspaceArgsSubcommand { /// regenerate its `.code-workspace` file. RemoveRepo(WorkspaceArgsMemberEdit), /// Open a workspace in the configured default editor by - /// running ` `. - Open(WorkspaceArgsNameOnly), + /// running ` `. Without a + /// workspace name, presents a fuzzy finder. + Open(WorkspaceArgsOptionalNameOnly), /// Print the workspace's `.code-workspace` file parent /// directory so the `rjw` shell wrapper can cd there. - Jump(WorkspaceArgsNameOnly), + /// Without a workspace name, presents a fuzzy finder. + Jump(WorkspaceArgsOptionalNameOnly), +} + +/// 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/error.rs b/src/error.rs index 33df755..ac39c56 100644 --- a/src/error.rs +++ b/src/error.rs @@ -89,6 +89,10 @@ pub enum ReportalError { #[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 { diff --git a/src/reportal_commands/mod.rs b/src/reportal_commands/mod.rs index 530c4bf..bacbe8d 100644 --- a/src/reportal_commands/mod.rs +++ b/src/reportal_commands/mod.rs @@ -26,6 +26,8 @@ mod repo_status; mod terminal_identity_emit; mod repo_sync; mod repo_web; +mod target_selection; +mod workspace_selection; mod workspace_create; mod workspace_delete; mod workspace_dispatch; diff --git a/src/reportal_commands/repo_jump.rs b/src/reportal_commands/repo_jump.rs index 38fec25..dbb158b 100644 --- a/src/reportal_commands/repo_jump.rs +++ b/src/reportal_commands/repo_jump.rs @@ -6,6 +6,9 @@ use crate::reportal_commands::direct_alias_router::{ }; use crate::reportal_commands::path_display::{self, SelectedPathDisplayParams}; use crate::reportal_commands::repo_selection::{self, SelectedRepoParams}; +use crate::reportal_commands::target_selection::{ + self, SelectedTarget, SelectedTargetParams, +}; use crate::reportal_commands::terminal_identity_emit::{ self, TerminalIdentityEmitParams, }; @@ -47,11 +50,24 @@ pub struct JumpCommandParams<'a> { pub fn run_jump(jump_params: &JumpCommandParams<'_>) -> Result<(), ReportalError> { let loaded_config = ReportalConfig::load_from_disk()?; - if !jump_params.direct_alias.is_empty() { + let resolved_repo_alias: String = if jump_params.direct_alias.is_empty() { + let target_params = SelectedTargetParams { + 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); + } + } + } else { let router = DirectAliasRouter::for_config(&loaded_config); match router.classify(jump_params.direct_alias)? { DirectAliasRouterOutcome::RegisteredRepo => { - /* fall through to repo jump flow */ + jump_params.direct_alias.to_owned() } DirectAliasRouterOutcome::Workspace(canonical_workspace_name) => { return router.jump_to_workspace_parent(&canonical_workspace_name); @@ -62,11 +78,11 @@ pub fn run_jump(jump_params: &JumpCommandParams<'_>) -> Result<(), ReportalError }); } } - } + }; let selection_params = SelectedRepoParams { loaded_config: &loaded_config, - direct_alias: jump_params.direct_alias, + direct_alias: &resolved_repo_alias, tag_filter: &jump_params.tag_filter, prompt_label: "Jump to repo", }; diff --git a/src/reportal_commands/repo_open.rs b/src/reportal_commands/repo_open.rs index 55d66ca..3e66561 100644 --- a/src/reportal_commands/repo_open.rs +++ b/src/reportal_commands/repo_open.rs @@ -5,10 +5,13 @@ use crate::reportal_commands::direct_alias_router::{ DirectAliasRouter, DirectAliasRouterOutcome, }; use crate::reportal_commands::repo_selection::{self, SelectedRepoParams}; +use crate::reportal_commands::run_workspace_open; +use crate::reportal_commands::target_selection::{ + self, SelectedTarget, SelectedTargetParams, +}; use crate::reportal_commands::terminal_identity_emit::{ self, TerminalIdentityEmitParams, }; -use crate::reportal_commands::run_workspace_open; use crate::reportal_config::{PathVisibility, ReportalConfig, TagFilter}; use crate::terminal_style; use owo_colors::OwoColorize; @@ -48,11 +51,23 @@ pub struct OpenCommandParams<'a> { pub fn run_open(open_params: &OpenCommandParams<'_>) -> Result<(), ReportalError> { let loaded_config = ReportalConfig::load_from_disk()?; - if !open_params.direct_alias.is_empty() { + let resolved_repo_alias: String = if open_params.direct_alias.is_empty() { + let target_params = SelectedTargetParams { + 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); + } + } + } else { let router = DirectAliasRouter::for_config(&loaded_config); match router.classify(open_params.direct_alias)? { DirectAliasRouterOutcome::RegisteredRepo => { - /* fall through to repo open flow */ + open_params.direct_alias.to_owned() } DirectAliasRouterOutcome::Workspace(canonical_workspace_name) => { return run_workspace_open(&canonical_workspace_name); @@ -63,11 +78,11 @@ pub fn run_open(open_params: &OpenCommandParams<'_>) -> Result<(), ReportalError }); } } - } + }; let selection_params = SelectedRepoParams { loaded_config: &loaded_config, - direct_alias: open_params.direct_alias, + direct_alias: &resolved_repo_alias, tag_filter: &open_params.tag_filter, prompt_label: "Open in editor", }; diff --git a/src/reportal_commands/target_selection.rs b/src/reportal_commands/target_selection.rs new file mode 100644 index 0000000..31e79ca --- /dev/null +++ b/src/reportal_commands/target_selection.rs @@ -0,0 +1,153 @@ +//! 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 SelectedTargetParams<'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: &SelectedTargetParams<'_>, +) -> 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 (repo_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} {repo_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.to_string())) + } + 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.to_string())) + } + None => Err(ReportalError::SelectionCancelled), + } + } +} diff --git a/src/reportal_commands/workspace_dispatch.rs b/src/reportal_commands/workspace_dispatch.rs index 4277e30..a033095 100644 --- a/src/reportal_commands/workspace_dispatch.rs +++ b/src/reportal_commands/workspace_dispatch.rs @@ -46,11 +46,11 @@ pub fn dispatch_workspace_subcommand( WorkspaceArgsSubcommand::RemoveRepo(member_edit) => { run_workspace_remove_repo(&member_edit.into_parts()) } - WorkspaceArgsSubcommand::Open(name_only) => { - run_workspace_open(&name_only.into_workspace_name()) + WorkspaceArgsSubcommand::Open(optional_name) => { + run_workspace_open(&optional_name.into_optional_workspace_name()) } - WorkspaceArgsSubcommand::Jump(name_only) => { - run_workspace_jump(&name_only.into_workspace_name()) + WorkspaceArgsSubcommand::Jump(optional_name) => { + run_workspace_jump(&optional_name.into_optional_workspace_name()) } } } diff --git a/src/reportal_commands/workspace_jump.rs b/src/reportal_commands/workspace_jump.rs index 43df48b..c678a6e 100644 --- a/src/reportal_commands/workspace_jump.rs +++ b/src/reportal_commands/workspace_jump.rs @@ -3,30 +3,38 @@ 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. /// -/// This is the workspace-only counterpart to `rep jump`; it never -/// falls through to the repo registry. For the unified `rj` -/// behavior where either a repo or workspace alias works, use -/// `rep jump`. 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. +/// 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 the name or -/// alias matches no registered workspace, or -/// [`ReportalError::ConfigIoFailure`] if the default workspace -/// file location needs the home directory and it cannot be -/// resolved. +/// 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_from_disk()?; - let canonical_workspace_name = - loaded_config.resolve_workspace_canonical_name(alias_or_canonical)?; + 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_open.rs b/src/reportal_commands/workspace_open.rs index fe37655..c3b3f24 100644 --- a/src/reportal_commands/workspace_open.rs +++ b/src/reportal_commands/workspace_open.rs @@ -2,6 +2,7 @@ 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; @@ -11,20 +12,29 @@ use std::process::Command; /// the configured default editor against it. /// /// Accepts either the canonical workspace key or any declared -/// alias; resolves to canonical first so the regenerator's default -/// file location uses the canonical name rather than the user's -/// short input. +/// 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 the name or -/// alias is unknown, [`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. +/// 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_from_disk()?; - let canonical_name = loaded_config.resolve_workspace_canonical_name(alias_or_canonical)?; + 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)?; diff --git a/src/reportal_commands/workspace_selection.rs b/src/reportal_commands/workspace_selection.rs new file mode 100644 index 0000000..e198964 --- /dev/null +++ b/src/reportal_commands/workspace_selection.rs @@ -0,0 +1,71 @@ +//! 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.to_string()) + } + None => Err(ReportalError::SelectionCancelled), + } +} From fba5a232ec9c09377925d1e6a3a98047cf993a3a Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Fri, 17 Apr 2026 18:30:10 -0400 Subject: [PATCH 51/67] chore: update Cargo.lock for v0.14.4 --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index bbb754e..0d3fa67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -556,7 +556,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.14.3" +version = "0.14.4" dependencies = [ "anyhow", "clap", From 82cd24160254b96ca67ef317cc0edf863ce34e46 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Sat, 18 Apr 2026 18:32:19 -0400 Subject: [PATCH 52/67] Fix bash integration compile error on Rust 1.95 (v0.14.5) Stray trailing semicolon on format!() in bash_integration_content() made the function return () instead of String. Rust 1.95 now flags this as a hard error, breaking `cargo install reportal`. Co-Authored-By: AI Opus 4.7 (1M context) --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/reportal_commands/detected_shell.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0d3fa67..38cbf2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -556,7 +556,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.14.4" +version = "0.14.5" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 14dde9f..d2d62a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.14.4" +version = "0.14.5" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/src/reportal_commands/detected_shell.rs b/src/reportal_commands/detected_shell.rs index 6e6cc1f..dc8d70e 100644 --- a/src/reportal_commands/detected_shell.rs +++ b/src/reportal_commands/detected_shell.rs @@ -228,7 +228,7 @@ PROMPT_COMMAND="${{PROMPT_COMMAND:+$PROMPT_COMMAND;}}_reportal_hook" "#, version = env!("CARGO_PKG_VERSION"), null_device = null_device.display(), - ); + ) } /// Attempts to get the `PowerShell` profile path by running pwsh. From 86c95d76328728df894b873be5d086a82bdbc845 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Sat, 18 Apr 2026 19:58:43 -0400 Subject: [PATCH 53/67] Redesign `rep init` as idiomatic shell eval (v0.15.0) BREAKING: `rep init ` now requires a shell argument and prints shell code to stdout instead of writing files and prompting. Users add `eval "$(rep init zsh)"` to their rc file, matching the starship/zoxide/direnv/mise pattern. - Config bootstrapped lazily on first command (no more "run init" error) - Removed ~/.reportal/integration.sh disk writes and profile prompt - Works identically in TTY shells, CI, sandboxes, WSL Co-Authored-By: AI Opus 4.7 (1M context) --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 33 +- src/cli_args/init_args.rs | 35 ++ src/cli_args/mod.rs | 2 + src/cli_args/reportal_cli.rs | 20 +- src/error.rs | 7 - src/main.rs | 7 +- src/reportal_commands/detected_shell.rs | 467 ------------------ src/reportal_commands/doctor.rs | 159 +----- src/reportal_commands/initialization.rs | 134 +---- .../integration_freshness.rs | 80 --- src/reportal_commands/mod.rs | 4 +- src/reportal_commands/repo_add/run.rs | 2 +- src/reportal_commands/repo_ai.rs | 2 +- src/reportal_commands/repo_color.rs | 2 +- src/reportal_commands/repo_edit.rs | 2 +- src/reportal_commands/repo_jump.rs | 2 +- src/reportal_commands/repo_listing.rs | 2 +- src/reportal_commands/repo_open.rs | 2 +- src/reportal_commands/repo_remove.rs | 2 +- src/reportal_commands/repo_run.rs | 2 +- src/reportal_commands/repo_status.rs | 2 +- src/reportal_commands/repo_sync.rs | 2 +- src/reportal_commands/repo_web.rs | 2 +- src/reportal_commands/shell_integration.rs | 55 +++ src/reportal_commands/workspace_create.rs | 2 +- src/reportal_commands/workspace_delete.rs | 2 +- src/reportal_commands/workspace_jump.rs | 2 +- src/reportal_commands/workspace_listing.rs | 5 +- src/reportal_commands/workspace_members.rs | 4 +- src/reportal_commands/workspace_open.rs | 2 +- src/reportal_commands/workspace_show.rs | 2 +- src/reportal_config/reportal_config_root.rs | 29 +- 34 files changed, 218 insertions(+), 861 deletions(-) create mode 100644 src/cli_args/init_args.rs delete mode 100644 src/reportal_commands/detected_shell.rs delete mode 100644 src/reportal_commands/integration_freshness.rs create mode 100644 src/reportal_commands/shell_integration.rs diff --git a/Cargo.lock b/Cargo.lock index 38cbf2e..99a6940 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -556,7 +556,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.14.5" +version = "0.15.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index d2d62a1..d6ad9a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.14.5" +version = "0.15.0" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/README.md b/README.md index d1f1e72..50c95c2 100644 --- a/README.md +++ b/README.md @@ -74,10 +74,29 @@ cargo install --path . ## Quick start +Wire the shell integration into your rc file — same pattern as `starship`, `zoxide`, `direnv`, and `mise`: + +**Zsh** (`~/.zshrc`): + +```zsh +eval "$(rep init zsh)" +``` + +**Bash** (`~/.bashrc`): + ```bash -# Set up config + shell shortcuts (rj, ro) -rep init +eval "$(rep init bash)" +``` + +**PowerShell** (`$PROFILE`): +```powershell +Invoke-Expression (& rep init powershell | Out-String) +``` + +That's it — no disk writes, no profile editing, no prompts. The integration code is regenerated every shell session, so a `cargo install reportal` upgrade takes effect on the next terminal you open. 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 @@ -98,7 +117,7 @@ ro | Command | Alias | What it does | |---------|-------|-------------| -| `rep init` | | Creates config and installs shell integration (safe to re-run on updates) | +| `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) | @@ -138,9 +157,7 @@ ro ## Shell integration -On **PowerShell**, `rep init` installs a proper PowerShell module to `Documents/PowerShell/Modules/RePortal/`. Functions load via module auto-import — they work even if your `$PROFILE` has errors. Existing profile-based installs are migrated automatically. - -On **Bash/Zsh**, `rep init` writes a standalone script to `~/.reportal/integration.sh` and adds a source line to your shell profile. The profile line never changes between versions; updating the binary is all you need. +`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 | |----------|-------------| @@ -155,7 +172,7 @@ On **Bash/Zsh**, `rep init` writes a standalone script to `~/.reportal/integrati | `rr` | Fuzzy-select a repo and run a configured command in it | | `rr my-api` | Skip repo selection, fuzzy-select a command | -Supports PowerShell, Bash, Zsh. Detected and installed during `rep init`. Re-run `rep init` after major updates to regenerate integration files. +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. ## Config @@ -217,6 +234,7 @@ args = [] | `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 @@ -320,6 +338,7 @@ PROMPT_COMMAND='rep color 2>/dev/null' - [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 - [ ] 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) diff --git a/src/cli_args/init_args.rs b/src/cli_args/init_args.rs new file mode 100644 index 0000000..44174b7 --- /dev/null +++ b/src/cli_args/init_args.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 InitArgs { + /// Shell to generate integration code for + shell: InitShell, +} + +/// Shells that `rep init` can generate integration code for. +#[derive(Clone, Copy, Debug, ValueEnum)] +pub enum InitShell { + /// 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 InitArgs { + /// The shell whose integration code should be emitted. + pub fn shell(&self) -> InitShell { + self.shell + } +} diff --git a/src/cli_args/mod.rs b/src/cli_args/mod.rs index a21b1c3..034ebd8 100644 --- a/src/cli_args/mod.rs +++ b/src/cli_args/mod.rs @@ -5,6 +5,7 @@ mod ai_args; mod color_args; mod edit_args; +mod init_args; mod jump_args; mod list_args; mod open_args; @@ -22,6 +23,7 @@ mod workspace_filter_args; pub use ai_args::AiArgs; pub use color_args::ColorArgs; pub use edit_args::EditArgs; +pub use init_args::{InitArgs, InitShell}; pub use jump_args::JumpArgs; pub use list_args::{ListArgs, ListArgsFilterParts}; pub use open_args::OpenArgs; diff --git a/src/cli_args/reportal_cli.rs b/src/cli_args/reportal_cli.rs index 375d7fd..61738c3 100644 --- a/src/cli_args/reportal_cli.rs +++ b/src/cli_args/reportal_cli.rs @@ -2,7 +2,7 @@ use clap::{Parser, Subcommand}; use super::{ - AiArgs, ColorArgs, EditArgs, JumpArgs, ListArgs, + AiArgs, ColorArgs, EditArgs, InitArgs, JumpArgs, ListArgs, OpenArgs, RemoveArgs, RunArgs, StatusArgs, SyncArgs, WebArgs, WorkspaceArgs, }; @@ -26,8 +26,22 @@ impl ReportalCli { /// All available subcommands for the `RePortal` CLI. #[derive(Subcommand)] pub enum ReportalCliSubcommand { - /// Set up config and shell integration (safe to re-run on updates) - Init, + /// 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(InitArgs), /// List all registered repos with status and metadata #[command(alias = "l")] List(ListArgs), diff --git a/src/error.rs b/src/error.rs index ac39c56..cf5ee37 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,13 +1,6 @@ //! 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 { diff --git a/src/main.rs b/src/main.rs index 7d75692..e5d6cd1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,12 +21,13 @@ use reportal_commands::{ }; fn main() { - reportal_commands::ensure_integration_file_current(); - let parsed_cli = ReportalCli::parse(); let command_result = match parsed_cli.into_subcommand() { - ReportalCliSubcommand::Init => reportal_commands::run_init(), + ReportalCliSubcommand::Init(init_args) => { + reportal_commands::run_init(init_args.shell()); + Ok(()) + } ReportalCliSubcommand::List(list_args) => { reportal_commands::run_list(&list_args.into_filter_parts()) } diff --git a/src/reportal_commands/detected_shell.rs b/src/reportal_commands/detected_shell.rs deleted file mode 100644 index dc8d70e..0000000 --- a/src/reportal_commands/detected_shell.rs +++ /dev/null @@ -1,467 +0,0 @@ -//! Shell detection, integration content generation, and installation. -//! -//! On Unix shells (bash, zsh), integration works by writing a standalone -//! script file into `~/.reportal` and adding a `source` line to the -//! user's shell profile. -//! -//! On `PowerShell`, integration is installed as a proper `PowerShell` module -//! in the user's `PSModulePath`. This makes the rj/ro/rw/rr functions load -//! via `PowerShell`'s module auto-import, independent of the user's -//! $PROFILE health. - -use crate::error::ReportalError; -use crate::reportal_config::ReportalConfig; -use std::path::PathBuf; -use std::process::Command; -use crate::terminal_style; - -/// Which shell the user is running, with its profile path. -pub(crate) 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, -} - -/// Returns the path to the integration script file for the current platform. -pub(crate) fn integration_file_path() -> Result { - let config_directory = ReportalConfig::config_directory()?; - - #[cfg(target_os = "windows")] - { - Ok(config_directory.join("integration.ps1")) - } - - #[cfg(not(target_os = "windows"))] - { - Ok(config_directory.join("integration.sh")) - } -} - -/// Returns the path to the user's `PowerShell` Modules directory where -/// `RePortal` should be installed as a module. Uses the Documents -/// known-folder via the `dirs` crate so it follows any folder -/// redirection (e.g. `OneDrive`, GPO). -#[cfg(target_os = "windows")] -pub(crate) fn powershell_module_directory() -> Result { - let documents_directory = dirs::document_dir().ok_or(ReportalError::ConfigIoFailure { - reason: String::from("could not determine Documents folder for PowerShell module install"), - })?; - Ok(documents_directory - .join("PowerShell") - .join("Modules") - .join("RePortal")) -} - -/// Detection, content generation, profile management, and installation. -impl DetectedShell { - /// Detects the current shell and its profile path. - pub(crate) fn detect() -> DetectedShell { - #[cfg(target_os = "windows")] - { - let Some(profile_path) = Self::detect_powershell_profile() else { - return DetectedShell::Unknown; - }; - DetectedShell::PowerShell(PathBuf::from(profile_path)) - } - - #[cfg(not(target_os = "windows"))] - { - let Some(shell_path) = Self::detect_unix_shell_env() else { - return DetectedShell::Unknown; - }; - let Some(home_path) = dirs::home_dir() else { - return DetectedShell::Unknown; - }; - if shell_path.contains("zsh") { - DetectedShell::Zsh(home_path.join(".zshrc")) - } else { - DetectedShell::Bash(home_path.join(".bashrc")) - } - } - } - - /// Returns the profile path if this shell was detected. - pub(crate) 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, - } - } - - /// Writes the integration script file and ensures the shell can - /// load `RePortal` functions. On Unix, adds a source line to the - /// profile. On `PowerShell`, installs a module for auto-import. - pub(crate) fn install_integration(&self) -> Result<(), ReportalError> { - match self { - #[cfg(target_os = "windows")] - DetectedShell::PowerShell(_) => { - self.install_powershell_module()?; - Ok(()) - } - #[cfg(not(target_os = "windows"))] - DetectedShell::Bash(_) | DetectedShell::Zsh(_) => { - return self.install_unix_integration(); - } - _ => Ok(()), - } - } - - /// Strips any existing `RePortal` integration from a profile string. - /// Handles both legacy inline blocks (pre-v0.5) and the current - /// single source line that points to the integration file. - pub(crate) fn strip_existing_integration(profile_content: &str) -> String { - let mut result_lines: Vec<&str> = Vec::new(); - let mut skipping_reportal_block = false; - let integration_path_marker = Self::reportal_integration_marker(); - - for line in profile_content.lines() { - let trimmed = line.trim(); - - if skipping_reportal_block && Self::is_block_continuation_line(trimmed) { - continue; - } - if skipping_reportal_block && !trimmed.is_empty() { - skipping_reportal_block = false; - result_lines.push(line); - continue; - } - if skipping_reportal_block { - skipping_reportal_block = false; - continue; - } - - let is_reportal_marker = line.contains("RePortal shell integration") - || line.contains(&integration_path_marker); - if is_reportal_marker { - skipping_reportal_block = true; - continue; - } - - if !Self::is_stale_standalone_line(line.trim()) { - result_lines.push(line); - } - } - - result_lines.join("\n") - } - - /// Returns true if the trimmed line belongs to a `RePortal` integration - /// block that is currently being skipped (marker already seen). - fn is_block_continuation_line(trimmed: &str) -> bool { - Self::is_reportal_function_line(trimmed) - || trimmed.starts_with("function prompt") - || trimmed.starts_with("function global:prompt") - || trimmed.starts_with("$_reportal_") - || trimmed.starts_with("$script:_reportal_") - || trimmed.starts_with("PROMPT_COMMAND") - || trimmed.contains("REPORTAL_LOADED") - || trimmed.contains("rep jump") - || trimmed.contains("rep open") - || trimmed.contains("rep web") - || trimmed.contains("rep run") - || trimmed.contains("rep upgrade") - || trimmed.contains("rep color") - } - - /// Returns true if the trimmed line is a stale `RePortal` definition - /// found outside of a marker block (orphaned from a previous install). - fn is_stale_standalone_line(trimmed: &str) -> bool { - Self::is_reportal_function_line(trimmed) - || trimmed.contains("reportal jump") - || trimmed.contains("reportal open") - || trimmed.contains("reportal web") - || trimmed.contains("reportal run") - || (trimmed.starts_with('#') && trimmed.contains("RePortal")) - } - - /// Returns the `PowerShell` integration script content, stamped with the - /// current binary version so `rep doctor` can detect stale files. - /// - /// Uses `global:` scope qualifiers on every function definition so the - /// script works correctly whether dot-sourced from the user's profile - /// (legacy installs) or from inside a `PowerShell` module (.psm1). - pub(crate) fn powershell_integration_content() -> String { - format!( - r#"# RePortal shell integration — v{} -# Do not edit; regenerated by 'rep init'. -$env:REPORTAL_LOADED = "1" -function global:rj {{ Set-Location (rep jump @args); rep color }} -function global:ro {{ rep open @args; rep color }} -function global:rjw {{ Set-Location (rep workspace jump @args); rep color }} -function global:row {{ rep workspace open @args; 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 }} -"#, - env!("CARGO_PKG_VERSION"), - ) - } - - /// Returns the Bash and Zsh integration script content, stamped with the - /// current binary version so `rep doctor` can detect stale files. - #[cfg(not(target_os = "windows"))] - pub(crate) fn bash_integration_content() -> String { - let null_device = std::path::Path::new("/dev").join("null"); - format!( - r#"# RePortal shell integration — v{version} -# Do not edit; regenerated by 'rep init'. -export REPORTAL_LOADED=1 -rj() {{ cd "$(rep jump "$@")"; rep color; }} -ro() {{ rep open "$@"; rep color; }} -rjw() {{ cd "$(rep workspace jump "$@")"; 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"), - null_device = null_device.display(), - ) - } - - /// Attempts to get the `PowerShell` profile path by running pwsh. - fn detect_powershell_profile() -> Option { - let profile_output = Command::new("pwsh") - .args(["-NoProfile", "-Command", "echo $PROFILE"]) - .output(); - - let output_bytes = match profile_output { - Ok(bytes) => bytes, - Err(pwsh_spawn_error) => { - terminal_style::write_stderr(&format!(" pwsh not found: {pwsh_spawn_error}\n")); - return None; - } - }; - if !output_bytes.status.success() { - return None; - } - let profile_path_string = - String::from_utf8_lossy(&output_bytes.stdout).trim().to_owned(); - if profile_path_string.is_empty() { None } else { Some(profile_path_string) } - } - - /// Reads the SHELL environment variable to determine the Unix shell. - #[cfg(not(target_os = "windows"))] - fn detect_unix_shell_env() -> Option { - match std::env::var("SHELL") { - Ok(shell_path) => Some(shell_path), - Err(env_read_error) => { - terminal_style::write_stderr(&format!(" SHELL env not set: {env_read_error}\n")); - None - } - } - } - - /// Marker prefix used in the integration file and matched during strip. - /// Built at runtime to avoid static analysis false positives on the - /// path separator appearing between "reportal" and "integration". - fn reportal_integration_marker() -> String { - format!(".reportal{}integration", std::path::MAIN_SEPARATOR) - } - - /// Returns true if the line looks like a `RePortal` shell function definition. - fn is_reportal_function_line(trimmed: &str) -> bool { - trimmed.starts_with("function rj") - || trimmed.starts_with("function ro") - || trimmed.starts_with("function rjw") - || trimmed.starts_with("function row") - || trimmed.starts_with("function rw") - || trimmed.starts_with("function rr") - || trimmed.starts_with("function repup") - || trimmed.starts_with("function global:rj") - || trimmed.starts_with("function global:ro") - || trimmed.starts_with("function global:rjw") - || trimmed.starts_with("function global:row") - || trimmed.starts_with("function global:rw") - || trimmed.starts_with("function global:rr") - || trimmed.starts_with("rj()") - || trimmed.starts_with("ro()") - || trimmed.starts_with("rjw()") - || trimmed.starts_with("row()") - || trimmed.starts_with("rw()") - || trimmed.starts_with("rr()") - || trimmed.starts_with("repup()") - } - - /// The one-liner added to .bashrc or .zshrc; sources the integration file. - #[cfg(not(target_os = "windows"))] - fn bash_source_line(integration_script_path: &std::path::Path) -> String { - format!("source \"{}\"", integration_script_path.display()) - } - - /// Returns the content for RePortal.psm1 — a stable shim that dot-sources - /// the versioned integration.ps1 file. The module itself never needs - /// updating; only integration.ps1 changes between binary versions. - #[cfg(target_os = "windows")] - fn powershell_module_content() -> String { - String::from( - r#"# RePortal PowerShell module — stable shim. -# Actual logic lives in ~/.reportal/integration.ps1 -# and is auto-updated by the rep binary on version changes. -. "$HOME\.reportal\integration.ps1" -"#, - ) - } - - /// Returns the content for RePortal.psd1 — the module manifest that - /// tells `PowerShell` which functions to auto-import when the user types - /// rj, ro, rw, or rr. The prompt override activates as a side effect - /// of module import (triggered by the first rj/ro/rw/rr call). - #[cfg(target_os = "windows")] - fn powershell_manifest_content() -> String { - String::from( - r"@{ - ModuleVersion = '1.0.0' - GUID = 'b1e3f7a2-9c4d-4e8f-a6b0-2d5e1f3c7a9b' - Author = 'RePortal' - Description = 'RePortal shell integration — repo jump, open, web, run' - RootModule = 'RePortal.psm1' - FunctionsToExport = @('rj', 'ro', 'rw', 'rr') -} -", - ) - } - - /// Installs shell integration for `PowerShell` as a proper module. - /// - /// Writes integration.ps1 to ~/.reportal (for version tracking and - /// auto-update by the binary), then installs RePortal.psm1 and - /// RePortal.psd1 into the user's `PowerShell` Modules directory. - /// `PowerShell` auto-imports modules from this path, so the rj/ro/rw/rr - /// functions load regardless of whether the user's $PROFILE has errors. - /// - /// Strips any legacy `. integration.ps1` line from $PROFILE to - /// migrate cleanly from the old profile-based approach. - #[cfg(target_os = "windows")] - fn install_powershell_module(&self) -> Result { - let Some(profile_path) = self.profile_path() else { - return Err(ReportalError::ConfigIoFailure { - reason: String::from("PowerShell profile path not detected"), - }); - }; - - let script_path = integration_file_path()?; - let integration_content = Self::powershell_integration_content(); - - std::fs::write(&script_path, &integration_content).map_err(|io_error| { - ReportalError::ConfigIoFailure { - reason: io_error.to_string(), - } - })?; - - let module_directory = powershell_module_directory()?; - - std::fs::create_dir_all(&module_directory).map_err(|io_error| { - ReportalError::ConfigIoFailure { - reason: format!( - "failed to create module directory {}: {}", - module_directory.display(), - io_error, - ), - } - })?; - - let module_file_path = module_directory.join("RePortal.psm1"); - std::fs::write(&module_file_path, Self::powershell_module_content()).map_err( - |io_error| ReportalError::ConfigIoFailure { - reason: format!("failed to write {}: {}", module_file_path.display(), io_error), - }, - )?; - - let manifest_file_path = module_directory.join("RePortal.psd1"); - std::fs::write(&manifest_file_path, Self::powershell_manifest_content()).map_err( - |io_error| ReportalError::ConfigIoFailure { - reason: format!( - "failed to write {}: {}", - manifest_file_path.display(), - io_error, - ), - }, - )?; - - if profile_path.exists() { - Self::clean_legacy_profile_lines(profile_path)?; - } - - Ok(module_directory) - } - - /// Reads a shell profile, strips any stale `RePortal` lines, and - /// writes it back only if the content actually changed. - #[cfg(target_os = "windows")] - fn clean_legacy_profile_lines(profile_path: &std::path::Path) -> Result<(), ReportalError> { - let profile_content = - std::fs::read_to_string(profile_path).map_err(|io_error| { - ReportalError::ConfigIoFailure { - reason: io_error.to_string(), - } - })?; - - let cleaned_content = Self::strip_existing_integration(&profile_content); - if cleaned_content == profile_content { - return Ok(()); - } - - let trimmed_profile = cleaned_content.trim_end().to_owned(); - let final_content = if trimmed_profile.is_empty() { String::new() } else { format!("{trimmed_profile}\n") }; - std::fs::write(profile_path, final_content).map_err(|io_error| { - ReportalError::ConfigIoFailure { - reason: io_error.to_string(), - } - })?; - Ok(()) - } - - /// Installs shell integration for Unix shells (bash/zsh). - /// Writes the integration script and adds a source line to the profile. - #[cfg(not(target_os = "windows"))] - fn install_unix_integration(&self) -> Result<(), ReportalError> { - let target_path = match self.profile_path() { - Some(resolved_path) => resolved_path, - None => return Ok(()), - }; - - let script_path = integration_file_path()?; - let integration_content = Self::bash_integration_content(); - - std::fs::write(&script_path, &integration_content).map_err(|io_error| { - ReportalError::ConfigIoFailure { - reason: io_error.to_string(), - } - })?; - - let source_line = Self::bash_source_line(&script_path); - - 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 cleaned_content = Self::strip_existing_integration(&existing_content); - let updated_content = format!("{cleaned_content}\n{source_line}\n"); - - std::fs::write(target_path, updated_content).map_err(|io_error| { - ReportalError::ConfigIoFailure { - reason: io_error.to_string(), - } - })?; - - Ok(()) - } -} diff --git a/src/reportal_commands/doctor.rs b/src/reportal_commands/doctor.rs index a60f12e..fa18366 100644 --- a/src/reportal_commands/doctor.rs +++ b/src/reportal_commands/doctor.rs @@ -1,11 +1,9 @@ //! Validates the full `RePortal` installation and prints a diagnostic report. //! -//! Checks five areas: +//! Checks three areas: //! 1. Config file existence and TOML parse correctness -//! 2. Integration script file exists and version matches the binary -//! 3. Shell integration is reachable (module on `PowerShell`, source line on Unix) -//! 4. Current session has loaded the integration (`REPORTAL_LOADED` env var) -//! 5. All registered repo paths exist on disk +//! 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. @@ -14,23 +12,6 @@ use crate::reportal_config::ReportalConfig; use crate::terminal_style; use owo_colors::OwoColorize; -use super::detected_shell::DetectedShell; -use super::integration_freshness::{check_integration_file_state, IntegrationFileState}; - -#[cfg(target_os = "windows")] -use super::detected_shell::powershell_module_directory; - -/// Determines the shell name from its profile path string. -fn detect_shell_label(path_string: &str) -> &'static str { - if path_string.contains("PowerShell") { - "PowerShell" - } else if path_string.contains(".zshrc") { - "Zsh" - } else { - "Bash" - } -} - /// Tracks pass/fail counts across all diagnostic checks. struct DiagnosticSummary { passed: u32, @@ -89,29 +70,25 @@ impl DiagnosticSummary { } }; - if config_path.exists() { - self.record_pass(&format!("Config file exists at {}", config_path.display())); - } else { - self.record_fail(&format!("Config not found at {}", config_path.display())); - Self::print_hint("Run 'rep init' to create one"); - return None; - } - - match ReportalConfig::load_from_disk() { + match ReportalConfig::load_or_initialize() { Ok(loaded_config) => { let repo_count = loaded_config.repos_with_aliases().len(); - self.record_pass(&format!("Config parses successfully ({repo_count} repos registered)")); + 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 parse: {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 integration file, shell profile source line, and session state. + /// 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!( @@ -119,118 +96,15 @@ impl DiagnosticSummary { "Shell Integration".style(terminal_style::EMPHASIS_STYLE), )); - let detected_shell = DetectedShell::detect(); - - let Some(profile_path) = detected_shell.profile_path() else { - self.record_fail("Could not detect shell profile"); - Self::print_hint("Run 'rep init' to install shell integration"); - return; - }; - let detected_label = detect_shell_label(&format!("{}", profile_path.display())); - self.record_pass(&format!("Shell detected: {detected_label}")); - - match check_integration_file_state() { - IntegrationFileState::Current => { - self.record_pass(&format!( - "Integration file up to date (v{})", - env!("CARGO_PKG_VERSION"), - )); - } - IntegrationFileState::Outdated { ref file_version } => { - self.record_fail(&format!( - "Integration file outdated (file: {file_version}, binary: v{})", - env!("CARGO_PKG_VERSION"), - )); - Self::print_hint("Run 'rep init' to update"); - } - IntegrationFileState::Missing => { - self.record_fail("Integration file missing"); - Self::print_hint("Run 'rep init' to create it"); - } - } - - self.check_integration_reachable(&detected_shell); - self.check_session_loaded(); - } - - /// Checks that the shell can actually load the integration. - /// On `PowerShell`, verifies the module directory exists. - /// On Unix, verifies the profile sources the integration file. - fn check_integration_reachable(&mut self, detected_shell: &DetectedShell) { - #[cfg(target_os = "windows")] - if let DetectedShell::PowerShell(_) = detected_shell { - self.check_powershell_module(); - } - - #[cfg(not(target_os = "windows"))] - { - let profile_path = match detected_shell.profile_path() { - Some(path) => path, - None => return, - }; - - let profile_content = match std::fs::read_to_string(profile_path) { - Ok(content) => content, - Err(ref _read_error) => { - self.record_fail(&format!( - "Could not read profile at {}", - profile_path.display(), - )); - return; - } - }; - - let integration_marker = - format!(".reportal{}integration", std::path::MAIN_SEPARATOR); - let profile_sources_integration = profile_content.lines().any(|line| { - line.contains(&integration_marker) - }); - - if profile_sources_integration { - self.record_pass("Profile sources integration file"); - } else { - self.record_fail("Profile does not source integration file"); - Self::print_hint("Run 'rep init' to set it up"); - } - } - } - - /// Verifies the `PowerShell` module directory contains `RePortal.psm1`. - #[cfg(target_os = "windows")] - fn check_powershell_module(&mut self) { - let module_path = match powershell_module_directory() { - Ok(path) => path, - Err(ref _module_path_error) => { - self.record_fail("Could not determine PowerShell module directory"); - Self::print_hint("Run 'rep init' to install shell integration"); - return; - } - }; - let module_file = module_path.join("RePortal.psm1"); - if module_file.exists() { - self.record_pass(&format!( - "PowerShell module installed at {}", - module_path.display(), - )); - } else { - self.record_fail(&format!( - "PowerShell module not found at {}", - module_path.display(), - )); - Self::print_hint("Run 'rep init' to install the module"); - } - } - - /// Checks whether the `REPORTAL_LOADED` env var is set, which proves - /// the shell integration was loaded in the current session. - fn check_session_loaded(&mut self) { match std::env::var("REPORTAL_LOADED") { Ok(ref _marker_value) => { self.record_pass("Shell integration loaded in current session"); } Err(ref _env_error) => { self.record_fail("Shell integration NOT loaded in current session"); - Self::print_hint("Open a new terminal tab to pick up changes"); + Self::print_hint( + "Add eval \"$(rep init zsh)\" (or bash/powershell) to your shell rc file", + ); } } } @@ -272,8 +146,8 @@ impl DiagnosticSummary { } /// Validates the full `RePortal` installation and prints a diagnostic report. -/// Checks config file parsing, shell integration, and repo path existence, -/// then prints a pass/fail summary to stdout. +/// 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", @@ -304,5 +178,4 @@ pub fn run_doctor() { )); } } - } diff --git a/src/reportal_commands/initialization.rs b/src/reportal_commands/initialization.rs index 54df0c8..14a6e13 100644 --- a/src/reportal_commands/initialization.rs +++ b/src/reportal_commands/initialization.rs @@ -1,115 +1,23 @@ -//! Creates the default `RePortal` config file and installs shell integration. +//! Implements `rep init `. //! -//! This is the entry point for `rep init`. It delegates to `DetectedShell` -//! for the actual installation mechanics — `PowerShell` gets a module, -//! Unix shells get a profile source line. - -use crate::error::ReportalError; -use crate::reportal_config::ReportalConfig; -use crate::terminal_style; -use dialoguer::{theme::ColorfulTheme, Confirm}; -use owo_colors::OwoColorize; - -use super::detected_shell::{integration_file_path, DetectedShell}; - -#[cfg(target_os = "windows")] -use super::detected_shell::powershell_module_directory; - -/// Prints success messages after shell integration installation. -#[cfg(target_os = "windows")] -fn print_post_install_messages(_profile_path: &std::path::Path) -> Result<(), ReportalError> { - let script_path = integration_file_path()?; - let module_path = powershell_module_directory()?; - terminal_style::print_success(&format!("Wrote {}", script_path.display())); - terminal_style::print_success(&format!("Installed PowerShell module to {}", module_path.display())); - terminal_style::print_success("Migrated from profile sourcing to module auto-import."); - terminal_style::print_success("Open a new terminal to activate. rj/ro/rw/rr now load even if your $PROFILE has errors."); - Ok(()) -} - -/// Prints success messages after shell integration installation. -#[cfg(not(target_os = "windows"))] -fn print_post_install_messages(profile_path: &std::path::Path) -> Result<(), ReportalError> { - let script_path = integration_file_path()?; - terminal_style::print_success(&format!("Wrote {}", script_path.display())); - terminal_style::print_success(&format!("Updated {}. Restart your shell to activate.", profile_path.display())); - Ok(()) -} - -/// Writes the integration file and installs the source line in the -/// shell profile. Displays what will be installed and asks for confirmation. -fn install_or_update_shell_integration() -> Result<(), ReportalError> { - terminal_style::write_stdout("\n"); - terminal_style::write_stdout(&format!(" {} Shell integration:\n", ">>".style(terminal_style::LABEL_STYLE))); - terminal_style::write_stdout(&format!(" {} jump to a repo (cd)\n", "rj".style(terminal_style::ALIAS_STYLE))); - terminal_style::write_stdout(&format!(" {} open a repo in your editor\n", "ro".style(terminal_style::ALIAS_STYLE))); - terminal_style::write_stdout(&format!(" {} open a repo in the browser\n", "rw".style(terminal_style::ALIAS_STYLE))); - terminal_style::write_stdout(&format!(" {} run a configured command in a repo\n", "rr".style(terminal_style::ALIAS_STYLE))); - terminal_style::write_stdout(&format!(" {} per-repo tab title + background color on every prompt\n", "color".style(terminal_style::ALIAS_STYLE))); - terminal_style::write_stdout("\n"); - - let detected_shell = DetectedShell::detect(); - - if let Some(profile_path) = detected_shell.profile_path() { - #[cfg(target_os = "windows")] - let install_target_label = match powershell_module_directory() { - Ok(ref module_path) => format!("Module: {}", module_path.display()), - Err(ref _module_path_error) => format!("Profile: {}", profile_path.display()), - }; - - #[cfg(not(target_os = "windows"))] - let install_target_label = format!("Profile: {}", profile_path.display()); - - terminal_style::write_stdout(&format!( - " {}\n", - install_target_label.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(), - })?; - - if user_wants_install { - detected_shell.install_integration()?; - print_post_install_messages(profile_path)?; - } else { - terminal_style::write_stdout(" Skipped.\n"); - } - } else { - terminal_style::write_stdout(" Could not detect your shell profile.\n"); - terminal_style::write_stdout(" Add this to your profile manually:\n"); - terminal_style::write_stdout("\n"); - match integration_file_path() { - Ok(script_path) => { - terminal_style::write_stdout(&format!(" PowerShell: . \"{}\"\n", script_path.display())); - terminal_style::write_stdout(&format!(" Bash/Zsh: source \"{}\"\n", script_path.display())); - } - Err(ref _path_error) => { - terminal_style::write_stdout(" Run 'rep init' again after fixing the config directory.\n"); - } - } - } - - Ok(()) -} - -/// Creates a default config at `~/.reportal/config.toml` if none exists, -/// then writes the shell integration file and source line. -/// Safe to re-run at any time; idempotent on both config and profile. -pub fn run_init() -> Result<(), ReportalError> { - let config_path = ReportalConfig::config_file_path()?; - if config_path.exists() { - terminal_style::write_stdout(&format!("Config already exists at {}\n", config_path.display())); - terminal_style::write_stdout("Use 'reportal add' to register repos.\n"); - } 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() +//! 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::InitShell; + +use super::shell_integration::{bash_integration_content, powershell_integration_content}; + +/// Emits the shell integration script for the requested shell to +/// stdout and returns. All integration content is generated in-memory +/// from the running binary's version, which guarantees that a binary +/// update takes effect in the next shell session with no manual step. +pub fn run_init(shell: InitShell) { + let integration_script = match shell { + InitShell::Zsh | InitShell::Bash => bash_integration_content(), + InitShell::Powershell => powershell_integration_content(), + }; + print!("{integration_script}"); } diff --git a/src/reportal_commands/integration_freshness.rs b/src/reportal_commands/integration_freshness.rs deleted file mode 100644 index 2940333..0000000 --- a/src/reportal_commands/integration_freshness.rs +++ /dev/null @@ -1,80 +0,0 @@ -//! Checks whether the shell integration script file matches the running -//! binary version and silently rewrites it on mismatch. Called at the -//! top of `main()` before subcommand dispatch so that binary updates via -//! cargo, scoop, or brew take effect on the next shell session without -//! any manual steps. - -use crate::reportal_config::ReportalConfig; - -use super::detected_shell::{integration_file_path, DetectedShell}; - -/// Whether the integration file version matches, is outdated, or is missing. -pub(crate) enum IntegrationFileState { - /// File exists and version matches the running binary. - Current, - /// File exists but was written by a different version. - Outdated { file_version: String }, - /// File does not exist at the expected path. - Missing, -} - -/// Reads the integration script file and compares its version stamp -/// against the running binary version. -pub(crate) fn check_integration_file_state() -> IntegrationFileState { - let script_path = match integration_file_path() { - Ok(path) => path, - Err(_path_error) => return IntegrationFileState::Missing, - }; - - let file_content = match std::fs::read_to_string(&script_path) { - Ok(script_content) => script_content, - Err(_read_error) => return IntegrationFileState::Missing, - }; - - let binary_version = env!("CARGO_PKG_VERSION"); - - let Some(first_line) = file_content.lines().next() else { - return IntegrationFileState::Outdated { - file_version: String::from("empty"), - }; - }; - - if first_line.contains(binary_version) { IntegrationFileState::Current } else { - let extracted_version = first_line - .rsplit("— v") - .next().map_or_else(|| String::from("unknown"), String::from); - IntegrationFileState::Outdated { - file_version: extracted_version, - } - } -} - -/// Rewrites the integration script file if it is missing or was written -/// by a different binary version. Silently skipped if the config -/// directory does not exist (pre-init). -pub fn ensure_integration_file_current() { - let config_directory = match ReportalConfig::config_directory() { - Ok(directory) => directory, - Err(_path_error) => return, - }; - - if !config_directory.exists() { return } - - match check_integration_file_state() { - IntegrationFileState::Current => {} - IntegrationFileState::Outdated { .. } | IntegrationFileState::Missing => { - let script_path = match integration_file_path() { - Ok(path) => path, - Err(_path_error) => return, - }; - - #[cfg(target_os = "windows")] - let integration_script = DetectedShell::powershell_integration_content(); - - #[cfg(not(target_os = "windows"))] - let integration_script = DetectedShell::bash_integration_content(); - - let _write_result = std::fs::write(&script_path, &integration_script); - } - } -} diff --git a/src/reportal_commands/mod.rs b/src/reportal_commands/mod.rs index bacbe8d..4fb995d 100644 --- a/src/reportal_commands/mod.rs +++ b/src/reportal_commands/mod.rs @@ -1,13 +1,12 @@ //! Subcommand implementations for the `RePortal` CLI. mod color_command_mode; -mod detected_shell; mod direct_alias_router; mod doctor; mod git_commands; mod initialization; -mod integration_freshness; mod path_display; mod prompts; +mod shell_integration; mod repo_add; mod repo_ai; mod repo_color; @@ -40,7 +39,6 @@ mod workspace_show; pub use color_command_mode::ColorCommandMode; pub use doctor::run_doctor; -pub use integration_freshness::ensure_integration_file_current; pub use initialization::run_init; pub use repo_add::run_add; pub use repo_ai::{run_ai, AiCommandParams}; diff --git a/src/reportal_commands/repo_add/run.rs b/src/reportal_commands/repo_add/run.rs index 13ffa99..77131bc 100644 --- a/src/reportal_commands/repo_add/run.rs +++ b/src/reportal_commands/repo_add/run.rs @@ -100,7 +100,7 @@ fn add_remote_repo(loaded_config: &mut ReportalConfig, git_url: &str) -> Result< /// 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_from_disk()?; + 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), diff --git a/src/reportal_commands/repo_ai.rs b/src/reportal_commands/repo_ai.rs index 17f77c6..7309b54 100644 --- a/src/reportal_commands/repo_ai.rs +++ b/src/reportal_commands/repo_ai.rs @@ -26,7 +26,7 @@ pub struct AiCommandParams<'a> { /// (--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: &AiCommandParams<'_>) -> Result<(), ReportalError> { - let loaded_config = ReportalConfig::load_from_disk()?; + 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) } diff --git a/src/reportal_commands/repo_color.rs b/src/reportal_commands/repo_color.rs index bbd2d0e..d472ded 100644 --- a/src/reportal_commands/repo_color.rs +++ b/src/reportal_commands/repo_color.rs @@ -83,7 +83,7 @@ fn resolve_from_working_directory(loaded_config: &ReportalConfig) -> Result) -> Result<(), ReportalError> { - let loaded_config = ReportalConfig::load_from_disk()?; + let loaded_config = ReportalConfig::load_or_initialize()?; let resolved = if color_params.repo_alias.is_empty() { resolve_from_working_directory(&loaded_config)? diff --git a/src/reportal_commands/repo_edit.rs b/src/reportal_commands/repo_edit.rs index cc40ac5..d2c48ec 100644 --- a/src/reportal_commands/repo_edit.rs +++ b/src/reportal_commands/repo_edit.rs @@ -31,7 +31,7 @@ pub struct EditCommandParams<'a> { /// Returns whatever the underlying selection, prompt, config I/O, /// validation, or workspace regeneration call surfaces. pub fn run_edit(command_params: &EditCommandParams<'_>) -> Result<(), ReportalError> { - let mut loaded_config = ReportalConfig::load_from_disk()?; + let mut loaded_config = ReportalConfig::load_or_initialize()?; let selected_alias = { let selection_params = SelectedRepoParams { diff --git a/src/reportal_commands/repo_jump.rs b/src/reportal_commands/repo_jump.rs index dbb158b..edc27b8 100644 --- a/src/reportal_commands/repo_jump.rs +++ b/src/reportal_commands/repo_jump.rs @@ -48,7 +48,7 @@ pub struct JumpCommandParams<'a> { /// 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: &JumpCommandParams<'_>) -> Result<(), ReportalError> { - let loaded_config = ReportalConfig::load_from_disk()?; + let loaded_config = ReportalConfig::load_or_initialize()?; let resolved_repo_alias: String = if jump_params.direct_alias.is_empty() { let target_params = SelectedTargetParams { diff --git a/src/reportal_commands/repo_listing.rs b/src/reportal_commands/repo_listing.rs index 728f66b..ca893b8 100644 --- a/src/reportal_commands/repo_listing.rs +++ b/src/reportal_commands/repo_listing.rs @@ -27,7 +27,7 @@ use crate::reportal_config::ReportalConfig; /// output would mask typos), or config I/O errors from the /// underlying load path. pub fn run_list(filter_parts: &ListArgsFilterParts) -> Result<(), ReportalError> { - let loaded_config = ReportalConfig::load_from_disk()?; + let loaded_config = ReportalConfig::load_or_initialize()?; let tree_grouping = RepoTreeGrouping::build(&RepoTreeGroupingParams { loaded_config: &loaded_config, tag_filter: filter_parts.tag_filter(), diff --git a/src/reportal_commands/repo_open.rs b/src/reportal_commands/repo_open.rs index 3e66561..fa4f64f 100644 --- a/src/reportal_commands/repo_open.rs +++ b/src/reportal_commands/repo_open.rs @@ -49,7 +49,7 @@ pub struct OpenCommandParams<'a> { /// cannot be spawned, or any config / file I/O errors the /// underlying paths surface. pub fn run_open(open_params: &OpenCommandParams<'_>) -> Result<(), ReportalError> { - let loaded_config = ReportalConfig::load_from_disk()?; + let loaded_config = ReportalConfig::load_or_initialize()?; let resolved_repo_alias: String = if open_params.direct_alias.is_empty() { let target_params = SelectedTargetParams { diff --git a/src/reportal_commands/repo_remove.rs b/src/reportal_commands/repo_remove.rs index 1cdfb67..7450fb5 100644 --- a/src/reportal_commands/repo_remove.rs +++ b/src/reportal_commands/repo_remove.rs @@ -23,7 +23,7 @@ use crate::terminal_style; /// member of any workspace, or the config I/O errors that the load /// and save paths surface. pub fn run_remove(repo_alias: &str) -> Result<(), ReportalError> { - let mut loaded_config = ReportalConfig::load_from_disk()?; + let mut loaded_config = ReportalConfig::load_or_initialize()?; let containing_workspaces = loaded_config.workspaces_containing_repo(repo_alias); if !containing_workspaces.is_empty() { diff --git a/src/reportal_commands/repo_run.rs b/src/reportal_commands/repo_run.rs index 957853d..19abfef 100644 --- a/src/reportal_commands/repo_run.rs +++ b/src/reportal_commands/repo_run.rs @@ -137,7 +137,7 @@ fn resolve_command(resolve_params: &ResolveCommandParams<'_>) -> Result) -> Result<(), ReportalError> { - let loaded_config = ReportalConfig::load_from_disk()?; + let loaded_config = ReportalConfig::load_or_initialize()?; let selection_params = SelectedRepoParams { loaded_config: &loaded_config, diff --git a/src/reportal_commands/repo_status.rs b/src/reportal_commands/repo_status.rs index 4a885bb..cea66b6 100644 --- a/src/reportal_commands/repo_status.rs +++ b/src/reportal_commands/repo_status.rs @@ -203,7 +203,7 @@ fn format_upstream_delta(upstream_delta: &UpstreamDelta) -> String { /// 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 loaded_config = ReportalConfig::load_or_initialize()?; let matching_repos = loaded_config.repos_matching_tag_filter(tag_filter); if matching_repos.is_empty() { diff --git a/src/reportal_commands/repo_sync.rs b/src/reportal_commands/repo_sync.rs index e206ab6..b6ef78a 100644 --- a/src/reportal_commands/repo_sync.rs +++ b/src/reportal_commands/repo_sync.rs @@ -133,7 +133,7 @@ fn print_sync_result(sync_result: &RepoSyncResult) { /// 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 loaded_config = ReportalConfig::load_or_initialize()?; let matching_repos = loaded_config.repos_matching_tag_filter(tag_filter); if matching_repos.is_empty() { diff --git a/src/reportal_commands/repo_web.rs b/src/reportal_commands/repo_web.rs index aa390a8..04960b5 100644 --- a/src/reportal_commands/repo_web.rs +++ b/src/reportal_commands/repo_web.rs @@ -83,7 +83,7 @@ fn detect_git_remote(repo_path: &std::path::Path) -> RemoteResolution { /// Converts SSH remotes to HTTPS URLs for browser compatibility. /// Emits tab title and color before opening the browser. pub fn run_web(web_params: &WebCommandParams<'_>) -> Result<(), ReportalError> { - let loaded_config = ReportalConfig::load_from_disk()?; + let loaded_config = ReportalConfig::load_or_initialize()?; let selection_params = SelectedRepoParams { loaded_config: &loaded_config, diff --git a/src/reportal_commands/shell_integration.rs b/src/reportal_commands/shell_integration.rs new file mode 100644 index 0000000..c6d4c12 --- /dev/null +++ b/src/reportal_commands/shell_integration.rs @@ -0,0 +1,55 @@ +//! 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 via `eval "$(rep init zsh)"`. +//! 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. + +/// Returns the `PowerShell` integration script content, stamped with +/// the running binary's version. +/// +/// Uses `global:` scope qualifiers on every function definition so +/// the script works identically whether the user pipes it into +/// `Invoke-Expression` from `$PROFILE`, dot-sources it into a module, +/// or feeds it through any other loading mechanism. +pub fn powershell_integration_content() -> String { + format!( + r#"# RePortal shell integration — v{} +# Generated by 'rep init powershell'. +$env:REPORTAL_LOADED = "1" +function global:rj {{ Set-Location (rep jump @args); rep color }} +function global:ro {{ rep open @args; rep color }} +function global:rjw {{ Set-Location (rep workspace jump @args); rep color }} +function global:row {{ rep workspace open @args; 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 }} +"#, + env!("CARGO_PKG_VERSION"), + ) +} + +/// Returns the Bash and Zsh integration script content, stamped with +/// the running binary's version. +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=1 +rj() {{ cd "$(rep jump "$@")"; rep color; }} +ro() {{ rep open "$@"; rep color; }} +rjw() {{ cd "$(rep workspace jump "$@")"; 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"), + null_device = null_device.display(), + ) +} diff --git a/src/reportal_commands/workspace_create.rs b/src/reportal_commands/workspace_create.rs index 1b9ecf2..5a5db5e 100644 --- a/src/reportal_commands/workspace_create.rs +++ b/src/reportal_commands/workspace_create.rs @@ -27,7 +27,7 @@ use owo_colors::OwoColorize; pub fn run_workspace_create( create_parts: &WorkspaceArgsCreateParts, ) -> Result<(), ReportalError> { - let mut loaded_config = ReportalConfig::load_from_disk()?; + let mut loaded_config = ReportalConfig::load_or_initialize()?; let validated_registration = WorkspaceRegistrationBuilder::start( create_parts.workspace_name().to_owned(), diff --git a/src/reportal_commands/workspace_delete.rs b/src/reportal_commands/workspace_delete.rs index 5b7a1f9..0fa596b 100644 --- a/src/reportal_commands/workspace_delete.rs +++ b/src/reportal_commands/workspace_delete.rs @@ -18,7 +18,7 @@ use owo_colors::OwoColorize; /// matches the name or alias, or the config I/O errors that the /// load and save paths surface. pub fn run_workspace_delete(alias_or_canonical: &str) -> Result<(), ReportalError> { - let mut loaded_config = ReportalConfig::load_from_disk()?; + let mut loaded_config = ReportalConfig::load_or_initialize()?; let canonical_name = loaded_config.resolve_workspace_canonical_name(alias_or_canonical)?; loaded_config.remove_workspace(&canonical_name)?; loaded_config.save_to_disk()?; diff --git a/src/reportal_commands/workspace_jump.rs b/src/reportal_commands/workspace_jump.rs index c678a6e..5b958e6 100644 --- a/src/reportal_commands/workspace_jump.rs +++ b/src/reportal_commands/workspace_jump.rs @@ -29,7 +29,7 @@ use crate::reportal_config::ReportalConfig; /// 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_from_disk()?; + let loaded_config = ReportalConfig::load_or_initialize()?; let canonical_workspace_name = if alias_or_canonical.is_empty() { workspace_selection::select_workspace(&loaded_config)? } else { diff --git a/src/reportal_commands/workspace_listing.rs b/src/reportal_commands/workspace_listing.rs index a5fafe8..5619bb9 100644 --- a/src/reportal_commands/workspace_listing.rs +++ b/src/reportal_commands/workspace_listing.rs @@ -18,14 +18,13 @@ use owo_colors::OwoColorize; /// /// # Errors /// -/// Returns [`ReportalError::ConfigNotFound`] / -/// [`ReportalError::ConfigParseFailure`] / +/// 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_from_disk()?; + let loaded_config = ReportalConfig::load_or_initialize()?; let registered_workspaces = loaded_config.workspaces_with_names(); if registered_workspaces.is_empty() { diff --git a/src/reportal_commands/workspace_members.rs b/src/reportal_commands/workspace_members.rs index 92fdef0..b27f1d2 100644 --- a/src/reportal_commands/workspace_members.rs +++ b/src/reportal_commands/workspace_members.rs @@ -28,7 +28,7 @@ use owo_colors::OwoColorize; pub fn run_workspace_add_repo( member_edit: &WorkspaceArgsMemberEditParts, ) -> Result<(), ReportalError> { - let mut loaded_config = ReportalConfig::load_from_disk()?; + let mut loaded_config = ReportalConfig::load_or_initialize()?; loaded_config.get_repo(member_edit.repo_alias())?; let canonical_workspace_name = loaded_config.resolve_workspace_canonical_name(member_edit.workspace_name())?; @@ -82,7 +82,7 @@ pub fn run_workspace_add_repo( pub fn run_workspace_remove_repo( member_edit: &WorkspaceArgsMemberEditParts, ) -> Result<(), ReportalError> { - let mut loaded_config = ReportalConfig::load_from_disk()?; + 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)?; diff --git a/src/reportal_commands/workspace_open.rs b/src/reportal_commands/workspace_open.rs index c3b3f24..63f0db0 100644 --- a/src/reportal_commands/workspace_open.rs +++ b/src/reportal_commands/workspace_open.rs @@ -29,7 +29,7 @@ use std::process::Command; /// 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_from_disk()?; + let loaded_config = ReportalConfig::load_or_initialize()?; let canonical_name = if alias_or_canonical.is_empty() { workspace_selection::select_workspace(&loaded_config)? } else { diff --git a/src/reportal_commands/workspace_show.rs b/src/reportal_commands/workspace_show.rs index eebc572..a48ea08 100644 --- a/src/reportal_commands/workspace_show.rs +++ b/src/reportal_commands/workspace_show.rs @@ -26,7 +26,7 @@ use owo_colors::OwoColorize; /// member alias does not resolve, or the config / file I/O errors /// that the load and regeneration paths surface. pub fn run_workspace_show(alias_or_canonical: &str) -> Result<(), ReportalError> { - let loaded_config = ReportalConfig::load_from_disk()?; + 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)?; diff --git a/src/reportal_config/reportal_config_root.rs b/src/reportal_config/reportal_config_root.rs index 9598f88..977b66a 100644 --- a/src/reportal_config/reportal_config_root.rs +++ b/src/reportal_config/reportal_config_root.rs @@ -58,7 +58,14 @@ impl ReportalConfig { Ok(Self::config_directory()?.join("config.toml")) } - /// Loads, parses, and validates the config from disk. + /// 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 @@ -66,18 +73,18 @@ impl ReportalConfig { /// /// # Errors /// - /// Returns `ConfigNotFound` if the file does not exist, - /// `ConfigIoFailure` if the file cannot be read, - /// `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_from_disk() -> Result { + /// 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() { - return Err(ReportalError::ConfigNotFound { - config_path: file_path.display().to_string(), - }); + 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 { From 3abe6077548af1599fe30bcc997d5987e6cbb8a4 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Sat, 18 Apr 2026 20:18:17 -0400 Subject: [PATCH 54/67] Ship custom install.sh and install.ps1 (v0.15.0) Replace cargo-dist's templated installers with our own. The custom installers drop the binary, then auto-append the `eval "$(rep init )"` line to the user's shell rc file with a fenced idempotency marker. Matches the starship/mise/rustup/zoxide distribution pattern. cargo-dist continues to build cross-platform archives and checksums; only the installer layer is ours. Release workflow stages our two scripts into artifacts/ under the historical asset names so existing curl-piped install URLs keep working. Co-Authored-By: AI Opus 4.7 (1M context) --- .github/workflows/release.yml | 9 ++ README.md | 2 + dist-workspace.toml | 8 +- installer/install.ps1 | 188 +++++++++++++++++++++++ installer/install.sh | 277 ++++++++++++++++++++++++++++++++++ 5 files changed, 482 insertions(+), 2 deletions(-) create mode 100644 installer/install.ps1 create mode 100644 installer/install.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d9aa406..38955b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -266,6 +266,15 @@ jobs: run: | # Remove the granular manifests rm -f artifacts/*-dist-manifest.json + - name: Stage custom installers + run: | + # Ship our own install.sh / install.ps1 (cargo-dist's templated + # installers have no post-install hook, so they can't wire up the + # shell integration). Reuse the historical asset names so existing + # curl/irm URLs in README/docs keep working. + cp installer/install.sh artifacts/reportal-installer.sh + cp installer/install.ps1 artifacts/reportal-installer.ps1 + chmod +x artifacts/reportal-installer.sh - name: Create GitHub Release env: PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" diff --git a/README.md b/README.md index 50c95c2..a25bada 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,8 @@ curl --proto '=https' --tlsv1.2 -LsSf https://github.com/CheckPickerUpper/report powershell -ExecutionPolicy Bypass -c "irm https://github.com/CheckPickerUpper/reportal/releases/latest/download/reportal-installer.ps1 | iex" ``` +Both installers drop the binaries into a user-local directory and auto-append an idempotent `eval "$(rep init )"` block (or the PowerShell equivalent) to your shell rc file, so integration is wired up on the next shell start — no manual rc editing required. + ### From source ```bash diff --git a/dist-workspace.toml b/dist-workspace.toml index c0bef3c..6002e48 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -7,8 +7,12 @@ members = ["cargo:."] cargo-dist-version = "0.31.0" # CI backends to support ci = "github" -# The installers to generate for each app -installers = ["shell", "powershell", "homebrew"] +# The installers to generate for each app. +# Note: we ship our own shell and PowerShell installers from installer/ +# so that we can auto-configure shell integration (cargo-dist's templated +# installers have no post-install hook). cargo-dist still builds the +# platform archives, checksums, homebrew tap, and the GitHub release. +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 diff --git a/installer/install.ps1 b/installer/install.ps1 new file mode 100644 index 0000000..669393e --- /dev/null +++ b/installer/install.ps1 @@ -0,0 +1,188 @@ +#!/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 +# `Invoke-Expression (& rep init powershell | Out-String)` 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.15.0' 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 <<<' +$IntegrationLine = 'Invoke-Expression (& rep init powershell | Out-String)' + +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 += $IntegrationLine + $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..3a6a77d --- /dev/null +++ b/installer/install.sh @@ -0,0 +1,277 @@ +#!/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 `eval "$(rep init )"` 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.15.0 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 <<<" + +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 'eval "$(rep init %s)"\n' "$shell_name" + 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) + trap 'rm -rf "$tmpdir"' 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 "$@" From afffa02c28c56074e5e47235655e13a27ba1fb02 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Sat, 18 Apr 2026 21:09:40 -0400 Subject: [PATCH 55/67] Binary auto-wires shell integration on first run (v0.15.1) When a user cargo-installs reportal (or otherwise skips the shell installer), the first invocation of any rep subcommand now detects an unsourced integration and silently appends the fenced eval block to the user's shell rc file. Idempotent via the existing marker, silent when already loaded, skipped for `rep init` itself. Closes the cargo-install zero-touch gap. Co-Authored-By: AI Opus 4.7 (1M context) --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 4 +- src/main.rs | 13 +- src/reportal_commands/mod.rs | 1 + src/reportal_commands/shell_integration.rs | 231 +++++++++++++++++++++ 6 files changed, 248 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 99a6940..3e5a281 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -556,7 +556,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.15.0" +version = "0.15.1" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index d6ad9a0..f68decb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.15.0" +version = "0.15.1" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/README.md b/README.md index a25bada..37a88f9 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ cargo install --path . ## Quick start -Wire the shell integration into your rc file — same pattern as `starship`, `zoxide`, `direnv`, and `mise`: +Shell integration is auto-wired the first time you run any `rep` subcommand: the binary detects that the integration isn't sourced yet, appends an idempotent fenced `eval` block to your rc file (`~/.zshrc`, `~/.bashrc`, or `$PROFILE`), and prints a single `source your-rc` hint. Subsequent runs are a no-op. If you prefer to wire it yourself — same pattern as `starship`, `zoxide`, `direnv`, and `mise` — add one of these lines manually: **Zsh** (`~/.zshrc`): @@ -96,7 +96,7 @@ eval "$(rep init bash)" Invoke-Expression (& rep init powershell | Out-String) ``` -That's it — no disk writes, no profile editing, no prompts. The integration code is regenerated every shell session, so a `cargo install reportal` upgrade takes effect on the next terminal you open. The first time you run any `rep` subcommand, a default `~/.reportal/config.toml` is created for you. +That's it — no disk writes beyond the one-shot rc append, no profile editing after setup, no prompts. The integration code is regenerated every shell session, so a `cargo install reportal` upgrade takes effect on the next terminal you open. The first time you run any `rep` subcommand, a default `~/.reportal/config.toml` is created for you. ```bash # Register a local repo diff --git a/src/main.rs b/src/main.rs index e5d6cd1..7019f8c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,8 +22,19 @@ use reportal_commands::{ fn main() { let parsed_cli = ReportalCli::parse(); + let subcommand = parsed_cli.into_subcommand(); - let command_result = match 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 subcommand { ReportalCliSubcommand::Init(init_args) => { reportal_commands::run_init(init_args.shell()); Ok(()) diff --git a/src/reportal_commands/mod.rs b/src/reportal_commands/mod.rs index 4fb995d..a2184d3 100644 --- a/src/reportal_commands/mod.rs +++ b/src/reportal_commands/mod.rs @@ -40,6 +40,7 @@ mod workspace_show; pub use color_command_mode::ColorCommandMode; pub use doctor::run_doctor; pub use initialization::run_init; +pub use shell_integration::ensure_shell_integration_installed; pub use repo_add::run_add; pub use repo_ai::{run_ai, AiCommandParams}; pub use repo_edit::{run_edit, EditCommandParams}; diff --git a/src/reportal_commands/shell_integration.rs b/src/reportal_commands/shell_integration.rs index c6d4c12..62c74dd 100644 --- a/src/reportal_commands/shell_integration.rs +++ b/src/reportal_commands/shell_integration.rs @@ -6,6 +6,33 @@ //! 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. +//! +//! This module also owns the first-run auto-wire path: when the user +//! installs via `cargo install reportal` (or any route that bypasses +//! the shell installer in `installer/`), the binary detects on the +//! very first invocation that `REPORTAL_LOADED` is unset and appends +//! the fenced `eval "$(rep init )"` block to the user's rc +//! file. The same fenced marker the shell installer uses guarantees +//! idempotence — subsequent runs (and subsequent shell-installer +//! runs) are no-ops. + +use std::env; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use crate::cli_args::InitShell; + +/// Fenced marker opening the auto-managed integration block. This +/// string is 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 <<<"; /// Returns the `PowerShell` integration script content, stamped with /// the running binary's version. @@ -32,6 +59,210 @@ function global:prompt {{ $p = & $script:_reportal_original_prompt; $t = rep col ) } +/// Best-effort first-run hook that ensures the user's shell rc file +/// sources the `RePortal` integration. +/// +/// Called from `main()` before subcommand dispatch (but skipped for +/// `rep init`, which must not produce stderr noise inside an +/// `eval "$(rep init zsh)"` invocation). When the integration is +/// already loaded in the current shell — detected via the +/// `REPORTAL_LOADED` env var the integration itself sets — this is +/// silent. Otherwise it resolves the user's login shell from `$SHELL` +/// (or `$PROFILE` on `PowerShell`), locates the appropriate rc file, +/// and, if the fenced marker is absent, appends an idempotent eval +/// block that mirrors exactly what the shell installer writes. +/// +/// The function never returns an error to its caller: auto-setup is a +/// convenience for the `cargo install reportal` path and must never +/// break the user's actual command. Any failure (unsupported shell, +/// missing `$HOME`, unwritable rc file, unknown OS) is swallowed with +/// at most a single stderr warning. +pub fn ensure_shell_integration_installed() { + if env::var_os("REPORTAL_LOADED").is_some() { + return; + } + let Some(shell) = shell_from_env() else { + return; + }; + let Some(rc_file) = rc_file_for(shell) else { + return; + }; + match append_integration_if_missing(&rc_file, shell) { + Ok(AutoSetupOutcome::AlreadyPresent) => {} + Ok(AutoSetupOutcome::Appended) => { + eprintln!( + "RePortal: added shell integration to {} — run 'source {}' or open a new shell to activate.", + rc_file.display(), + rc_file.display(), + ); + } + Err(reason) => { + eprintln!( + "RePortal: note: could not auto-wire shell integration ({reason}); add `{}` to your rc file manually.", + eval_line_for(shell), + ); + } + } +} + +/// Result of a single auto-setup attempt. Used to decide whether the +/// caller should emit the stderr activation hint. +enum AutoSetupOutcome { + /// Marker already present — no change made. + AlreadyPresent, + /// Fenced block appended to the rc file. + Appended, +} + +/// Parses `$SHELL`'s basename (or picks `PowerShell` on Windows when +/// `$SHELL` is absent) and returns the matching `InitShell` variant. +/// Returns `None` for fish, tcsh, csh, or anything else we don't +/// generate integration code for — those users can run +/// `rep init ` themselves and will get a clear rejection. +fn shell_from_env() -> Option { + if let Some(shell_path) = env::var_os("SHELL") { + let basename = Path::new(&shell_path) + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_ascii_lowercase); + if let Some(name) = basename { + return match name.as_str() { + "zsh" => Some(InitShell::Zsh), + "bash" => Some(InitShell::Bash), + "pwsh" | "powershell" => Some(InitShell::Powershell), + _ => None, + }; + } + } + // Fallback: on Windows with no `$SHELL`, assume PowerShell. This + // matches the installer's behavior and covers users who launched + // from `pwsh`/`powershell.exe` directly. + if cfg!(windows) { + Some(InitShell::Powershell) + } else { + None + } +} + +/// Resolves the rc file we should append to for the given shell. +/// +/// - Zsh → `~/.zshrc` +/// - Bash → `~/.bashrc` +/// - `PowerShell` → `$PROFILE` if set by the host, otherwise the +/// conventional `Documents\PowerShell\Microsoft.PowerShell_profile.ps1` +/// under `$USERPROFILE` so the append still lands in the right place +/// when the binary is invoked outside an interactive PowerShell host +/// (e.g. from `cmd.exe`). +/// +/// Returns `None` if the home directory cannot be determined and no +/// `$PROFILE` is set — in which case auto-setup quietly bails. +fn rc_file_for(shell: InitShell) -> Option { + match shell { + InitShell::Zsh => dirs::home_dir().map(|home| home.join(".zshrc")), + InitShell::Bash => dirs::home_dir().map(|home| home.join(".bashrc")), + InitShell::Powershell => powershell_profile_path(), + } +} + +/// Resolves the `PowerShell` `$PROFILE` path. Prefers the host- +/// provided `PROFILE` env var (which `pwsh` exports), then falls back +/// to the conventional `Documents\PowerShell\…` layout rooted at the +/// user's home directory. +fn powershell_profile_path() -> Option { + if let Some(profile) = env::var_os("PROFILE") { + let path = PathBuf::from(profile); + if !path.as_os_str().is_empty() { + return Some(path); + } + } + dirs::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 the +/// given shell. Byte-for-byte identical to what the shell installer +/// writes, so either code path producing the block results in the +/// same file contents. +fn eval_line_for(shell: InitShell) -> &'static str { + match shell { + InitShell::Zsh => "eval \"$(rep init zsh)\"", + InitShell::Bash => "eval \"$(rep init bash)\"", + InitShell::Powershell => "Invoke-Expression (& rep init powershell | Out-String)", + } +} + +/// Appends the fenced integration block to `rc_file` iff the marker +/// is absent. Creates parent directories and the file itself if they +/// don't exist yet (matching the installer scripts' behavior). All +/// I/O failures are surfaced as a short human-readable reason string +/// for the caller's stderr hint. +fn append_integration_if_missing( + rc_file: &Path, + shell: InitShell, +) -> Result { + if rc_file.exists() { + let existing = std::fs::read_to_string(rc_file) + .map_err(|read_err| format!("read {}: {read_err}", rc_file.display()))?; + if existing.contains(MARKER_START) { + return Ok(AutoSetupOutcome::AlreadyPresent); + } + let needs_leading_newline = !existing.is_empty() && !existing.ends_with('\n'); + write_fenced_block(rc_file, shell, needs_leading_newline)?; + return Ok(AutoSetupOutcome::Appended); + } + + // File does not exist — create parent dirs first, then write. + if let Some(parent) = rc_file.parent() { + if !parent.as_os_str().is_empty() && !parent.exists() { + std::fs::create_dir_all(parent) + .map_err(|mkdir_err| format!("create {}: {mkdir_err}", parent.display()))?; + } + } + write_fenced_block(rc_file, shell, false)?; + Ok(AutoSetupOutcome::Appended) +} + +/// Opens `rc_file` in append mode and writes the fenced block. The +/// `needs_leading_newline` flag inserts a separator newline when the +/// existing file does not already end in one, so the marker always +/// starts on its own line. +fn write_fenced_block( + rc_file: &Path, + shell: InitShell, + needs_leading_newline: bool, +) -> Result<(), String> { + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(rc_file) + .map_err(|open_err| format!("open {}: {open_err}", rc_file.display()))?; + + let eol = if matches!(shell, InitShell::Powershell) && cfg!(windows) { + "\r\n" + } else { + "\n" + }; + + let mut block = String::new(); + if needs_leading_newline { + block.push_str(eol); + } + block.push_str(eol); + block.push_str(MARKER_START); + block.push_str(eol); + block.push_str(eval_line_for(shell)); + block.push_str(eol); + block.push_str(MARKER_END); + block.push_str(eol); + + file.write_all(block.as_bytes()) + .map_err(|write_err| format!("write {}: {write_err}", rc_file.display()))?; + Ok(()) +} + /// Returns the Bash and Zsh integration script content, stamped with /// the running binary's version. pub fn bash_integration_content() -> String { From 27e45b7805df7700a733e319d071d48c830021e2 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Sat, 18 Apr 2026 21:48:26 -0400 Subject: [PATCH 56/67] Workspaces become symlink-organized directories (v0.15.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: a workspace was metadata + a loose .code-workspace JSON file in ~/.reportal/workspaces/. `rjw` cd'd to that metadata directory — useless for terminal workflows. After: `rep workspace create ` materializes a real directory at ~/dev/workspaces// containing the .code-workspace file plus one symlink (Unix) or directory junction (Windows) per member repo. `rjw` now cds there, so `ls` and `ai` both see every member as a subdir, and git/cargo/IDE operations transparently reach the real repo through the link. - New `default_workspace_root` setting (default ~/dev/workspaces) - New `rep workspace rebuild ` to regenerate links - `rep workspace delete --purge` removes the dir too - Platform-split link creation via the `junction` crate on Windows - Transparent auto-migration of pre-v0.15.2 workspaces on first jump --- Cargo.lock | 13 +- Cargo.toml | 5 +- README.md | 52 +- src/cli_args/mod.rs | 3 +- src/cli_args/workspace_args.rs | 100 +++- src/reportal_commands/direct_alias_router.rs | 40 +- src/reportal_commands/mod.rs | 3 + src/reportal_commands/workspace_create.rs | 64 ++- src/reportal_commands/workspace_delete.rs | 60 +- src/reportal_commands/workspace_dispatch.rs | 10 +- src/reportal_commands/workspace_layout.rs | 540 ++++++++++++++++++ src/reportal_commands/workspace_listing.rs | 10 + src/reportal_commands/workspace_operations.rs | 224 ++++---- src/reportal_commands/workspace_rebuild.rs | 69 +++ src/reportal_commands/workspace_show.rs | 66 ++- src/reportal_config/global_settings.rs | 22 + src/reportal_config/reportal_config_root.rs | 26 + src/reportal_config/workspace_entry.rs | 54 +- 18 files changed, 1160 insertions(+), 201 deletions(-) create mode 100644 src/reportal_commands/workspace_layout.rs create mode 100644 src/reportal_commands/workspace_rebuild.rs diff --git a/Cargo.lock b/Cargo.lock index 3e5a281..8f3e958 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -371,6 +371,16 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" 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" version = "0.1.0" @@ -556,7 +566,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.15.1" +version = "0.15.2" dependencies = [ "anyhow", "clap", @@ -565,6 +575,7 @@ dependencies = [ "dialoguer", "dirs 5.0.1", "jsonc-parser", + "junction", "open", "owo-colors", "pathdiff", diff --git a/Cargo.toml b/Cargo.toml index f68decb..439f147 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.15.1" +version = "0.15.2" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" @@ -26,6 +26,9 @@ shellexpand = "3" pathdiff = "0.2" open = "5" +[target.'cfg(windows)'.dependencies] +junction = "1" + [lints.clippy] allow_attributes_without_reason = "warn" diff --git a/README.md b/README.md index 37a88f9..a04d828 100644 --- a/README.md +++ b/README.md @@ -148,12 +148,14 @@ ro | `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 member repos | -| `rep workspace show ` | | Show a workspace's description, file path, and resolved member paths | -| `rep workspace create --repos a,b,c` | | Register a new workspace and write its `.code-workspace` file | -| `rep workspace delete ` | `rep ws rm` | Unregister a workspace (leaves the `.code-workspace` file on disk) | -| `rep workspace add-repo ` | | Add a repo to a workspace and regenerate its file | -| `rep workspace remove-repo ` | | Remove a repo from a workspace and regenerate its file | +| `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 ` | | Open a workspace in your default editor | | `rep doctor` | | Diagnose config, shell integration, and repo path issues | @@ -164,11 +166,11 @@ ro | Shortcut | What it does | |----------|-------------| | `rj` | Fuzzy-select a repo and `cd` into it | -| `rj my-api` | Jump directly to a repo by alias; falls through to a workspace alias if no repo matches (cd's to the workspace's common-ancestor folder) | +| `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; 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 to the workspace file's parent dir | -| `row my-ws` | Workspace-only open — launches the editor on 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 | @@ -184,6 +186,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" @@ -205,6 +208,7 @@ 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 | @@ -240,9 +244,22 @@ The default config created on first `rep` invocation ships with aider pre-regist ### Workspaces -A workspace is a named group of repos that open together as one VSCode/Cursor window. RePortal owns the `[workspaces.]` table as the single source of truth, and the `.code-workspace` file on disk is a derived artifact generated from the member repos' current paths. +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 @@ -250,18 +267,24 @@ repos = [ { path = "~/dev/db-migrations" }, # inline filesystem path (no repo registry entry needed) ] description = "Jakuta backend services" -path = "" # empty = ~/.reportal/workspaces/backend.code-workspace +path = "" # empty = /backend/ aliases = ["be", "back"] # short names accepted by rep workspace ``` | 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. Bare-string members must resolve to a registered repo; inline-path members are not validated against the repo registry | +| `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 `.code-workspace` file. Empty falls back to `~/.reportal/workspaces/.code-workspace` | +| `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 | -**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 regenerates its `.code-workspace` file. 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 file (`settings`, `extensions`, `launch`, `tasks`, plus any JSONC comments) round-trip byte-stable across regeneration — RePortal only touches the `folders` array. +**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. + +**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. @@ -341,6 +364,7 @@ PROMPT_COMMAND='rep color 2>/dev/null' - [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) diff --git a/src/cli_args/mod.rs b/src/cli_args/mod.rs index 034ebd8..3af0efb 100644 --- a/src/cli_args/mod.rs +++ b/src/cli_args/mod.rs @@ -34,5 +34,6 @@ pub use status_args::StatusArgs; pub use sync_args::SyncArgs; pub use web_args::WebArgs; pub use workspace_args::{ - WorkspaceArgs, WorkspaceArgsCreateParts, WorkspaceArgsMemberEditParts, WorkspaceArgsSubcommand, + WorkspaceArgs, WorkspaceArgsCreateParts, WorkspaceArgsDeleteParts, WorkspaceArgsMemberEditParts, + WorkspaceArgsSubcommand, }; diff --git a/src/cli_args/workspace_args.rs b/src/cli_args/workspace_args.rs index 9a3252d..e3a06cf 100644 --- a/src/cli_args/workspace_args.rs +++ b/src/cli_args/workspace_args.rs @@ -66,7 +66,7 @@ impl WorkspaceArgsRepoAlias { } /// Payload for actions that target one workspace by name and -/// carry no other arguments: `show`, `delete`, `open`. +/// carry no other arguments: `show`, `open`, `rebuild`. #[derive(Args)] pub struct WorkspaceArgsNameOnly { /// The workspace this action targets. @@ -83,6 +83,57 @@ impl WorkspaceArgsNameOnly { } } +/// 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) -> WorkspaceArgsDeleteParts { + WorkspaceArgsDeleteParts { + workspace_name: self.target.into_name(), + purge: self.purge, + } + } +} + +/// Owned named-field result of `WorkspaceArgsDelete::into_parts`. +pub struct WorkspaceArgsDeleteParts { + /// 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 WorkspaceArgsDeleteParts { + /// 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 { @@ -148,9 +199,13 @@ pub struct WorkspaceArgsCreate { /// Human-readable description shown in `rep workspace list`. #[arg(long, default_value = "")] description: String, - /// Explicit filesystem path for the generated - /// `.code-workspace` file. Defaults to - /// `~/.reportal/workspaces/.code-workspace`. + /// 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 @@ -238,33 +293,40 @@ pub enum WorkspaceArgsSubcommand { #[command(alias = "ls")] List, /// Show a single workspace's details, including the resolved - /// absolute paths of its member repos and the location of its - /// `.code-workspace` file on disk. + /// 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 generate its `.code-workspace` - /// file under `~/.reportal/workspaces/` unless a custom path - /// is given. + /// 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. Does NOT delete the - /// `.code-workspace` file on disk — that is the user's to - /// manage so an accidental `rep workspace delete` cannot - /// destroy an open editor session. + /// 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(WorkspaceArgsNameOnly), + Delete(WorkspaceArgsDelete), /// Add a repo alias to an existing workspace and regenerate - /// its `.code-workspace` file. + /// its workspace directory (links + `.code-workspace` file). AddRepo(WorkspaceArgsMemberEdit), /// Remove a repo alias from an existing workspace and - /// regenerate its `.code-workspace` file. + /// 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 `.code-workspace` file parent - /// directory so the `rjw` shell wrapper can cd there. - /// Without a workspace name, presents a fuzzy finder. + /// 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 diff --git a/src/reportal_commands/direct_alias_router.rs b/src/reportal_commands/direct_alias_router.rs index 475115e..beaa174 100644 --- a/src/reportal_commands/direct_alias_router.rs +++ b/src/reportal_commands/direct_alias_router.rs @@ -103,41 +103,43 @@ impl<'config> DirectAliasRouter<'config> { } } - /// Prints the workspace's `.code-workspace` file parent - /// directory to stdout so the `rj` shell wrapper cd's there. + /// Prints the workspace's materialized directory path to + /// stdout so the `rj` / `rjw` shell wrappers cd there. /// - /// The parent directory is the common ancestor the user - /// declared when they chose the workspace file location, which - /// is the predictable cd target for a multi-folder workspace — - /// no single member directory is correct when the workspace - /// contains three sibling repos. + /// 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 file-path resolution, - /// or [`ReportalError::ConfigIoFailure`] if the default - /// `~/.reportal/workspaces/` location needs the home directory - /// and it cannot be resolved. + /// 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_file_path = - regenerator.resolve_workspace_file_path(canonical_workspace_name)?; - let target_directory = match workspace_file_path.parent() { - Some(parent_directory) => parent_directory.to_path_buf(), - None => std::path::PathBuf::from("."), - }; + 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(&target_directory); + .format_path(&workspace_directory); terminal_style::write_stdout(&formatted_path); path_display::print_selected_path_if_visible(&SelectedPathDisplayParams { loaded_config: self.loaded_config, - resolved_path: &target_directory, + resolved_path: &workspace_directory, }); Ok(()) } diff --git a/src/reportal_commands/mod.rs b/src/reportal_commands/mod.rs index a2184d3..dd61748 100644 --- a/src/reportal_commands/mod.rs +++ b/src/reportal_commands/mod.rs @@ -31,10 +31,12 @@ 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_show; pub use color_command_mode::ColorCommandMode; @@ -60,4 +62,5 @@ 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/workspace_create.rs b/src/reportal_commands/workspace_create.rs index 5a5db5e..8d90dec 100644 --- a/src/reportal_commands/workspace_create.rs +++ b/src/reportal_commands/workspace_create.rs @@ -1,4 +1,5 @@ -//! Creates a new workspace and writes its `.code-workspace` file. +//! Creates a new workspace and materializes its on-disk directory +//! (symlinks + `.code-workspace` file). use crate::cli_args::WorkspaceArgsCreateParts; use crate::error::ReportalError; @@ -6,14 +7,21 @@ 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 writes the -/// initial `.code-workspace` file from the current member paths. +/// 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` and -/// the file would never be produced. +/// 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 /// @@ -22,19 +30,22 @@ use owo_colors::OwoColorize; /// [`ReportalError::WorkspaceAlreadyExists`] if a workspace with /// that name is already registered, /// [`ReportalError::WorkspaceHasDanglingRepo`] if any member alias -/// is not registered, or the file I/O errors the regeneration path -/// surfaces. +/// is not registered, or the file/link I/O errors the regeneration +/// path surfaces. pub fn run_workspace_create( create_parts: &WorkspaceArgsCreateParts, ) -> 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(create_parts.custom_file_path().to_owned()) + .workspace_file_path(reinterpreted_directory_path) .workspace_aliases(create_parts.workspace_aliases().to_vec()) .build()?; @@ -42,12 +53,45 @@ pub fn run_workspace_create( loaded_config.save_to_disk()?; let regenerator = WorkspaceRegenerator::for_config(&loaded_config); - let written_file_path = regenerator.regenerate_workspace_file(create_parts.workspace_name())?; + 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), - written_file_path.display().to_string().style(terminal_style::PATH_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 index 0fa596b..4109ece 100644 --- a/src/reportal_commands/workspace_delete.rs +++ b/src/reportal_commands/workspace_delete.rs @@ -1,31 +1,65 @@ -//! Removes a workspace from config without touching the on-disk file. +//! Removes a workspace from config, optionally purging its on-disk +//! directory. +use crate::cli_args::WorkspaceArgsDeleteParts; 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. +/// Unregisters the named workspace from config, optionally purging +/// its on-disk directory. /// /// Accepts either the canonical workspace key or any declared -/// alias. Does NOT delete the on-disk `.code-workspace` file — -/// that is a destructive action the user should perform through -/// the filesystem, not as a side effect of a config mutation. +/// 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 I/O errors that the -/// load and save paths surface. -pub fn run_workspace_delete(alias_or_canonical: &str) -> Result<(), ReportalError> { +/// 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: &WorkspaceArgsDeleteParts, +) -> Result<(), ReportalError> { let mut loaded_config = ReportalConfig::load_or_initialize()?; - let canonical_name = loaded_config.resolve_workspace_canonical_name(alias_or_canonical)?; + 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()?; - terminal_style::print_success(&format!( - "Removed workspace {} from config (the `.code-workspace` file on disk was not deleted)", - canonical_name.style(terminal_style::ALIAS_STYLE), - )); + 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 index a033095..186d82b 100644 --- a/src/reportal_commands/workspace_dispatch.rs +++ b/src/reportal_commands/workspace_dispatch.rs @@ -9,7 +9,8 @@ use crate::cli_args::{WorkspaceArgs, WorkspaceArgsSubcommand}; 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_remove_repo, run_workspace_show, + 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 @@ -37,8 +38,8 @@ pub fn dispatch_workspace_subcommand( WorkspaceArgsSubcommand::Create(create_args) => { run_workspace_create(&create_args.into_parts()) } - WorkspaceArgsSubcommand::Delete(name_only) => { - run_workspace_delete(&name_only.into_workspace_name()) + WorkspaceArgsSubcommand::Delete(delete_args) => { + run_workspace_delete(&delete_args.into_parts()) } WorkspaceArgsSubcommand::AddRepo(member_edit) => { run_workspace_add_repo(&member_edit.into_parts()) @@ -52,5 +53,8 @@ pub fn dispatch_workspace_subcommand( WorkspaceArgsSubcommand::Jump(optional_name) => { run_workspace_jump(&optional_name.into_optional_workspace_name()) } + WorkspaceArgsSubcommand::Rebuild(name_only) => { + run_workspace_rebuild(&name_only.into_workspace_name()) + } } } diff --git a/src/reportal_commands/workspace_layout.rs b/src/reportal_commands/workspace_layout.rs new file mode 100644 index 0000000..e1a3d4b --- /dev/null +++ b/src/reportal_commands/workspace_layout.rs @@ -0,0 +1,540 @@ +//! 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 WorkspaceLayoutParams<'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], +} + +/// 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: &WorkspaceLayoutParams<'_>, +) -> 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); + 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(&WorkspaceLayoutParams { + workspace_directory: &workspace_dir, + workspace_name: "nro", + member_links: &member_links, + }) + .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(&WorkspaceLayoutParams { + workspace_directory: &workspace_dir, + workspace_name: "nro", + member_links: &member_links, + }) + .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(&WorkspaceLayoutParams { + workspace_directory: &workspace_dir, + workspace_name: "nro", + member_links: &member_links, + }) + .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(&WorkspaceLayoutParams { + workspace_directory: &workspace_dir, + workspace_name: "nro", + member_links: &member_links, + }); + + 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 index 5619bb9..81057be 100644 --- a/src/reportal_commands/workspace_listing.rs +++ b/src/reportal_commands/workspace_listing.rs @@ -5,6 +5,7 @@ //! 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; @@ -41,6 +42,7 @@ pub fn run_workspace_list() -> Result<(), ReportalError> { )); 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!( @@ -64,6 +66,14 @@ pub fn run_workspace_list() -> Result<(), ReportalError> { )); } + 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", diff --git a/src/reportal_commands/workspace_operations.rs b/src/reportal_commands/workspace_operations.rs index b67d250..67c5985 100644 --- a/src/reportal_commands/workspace_operations.rs +++ b/src/reportal_commands/workspace_operations.rs @@ -1,22 +1,29 @@ -//! Regeneration of `.code-workspace` files from the current config. +//! Regeneration of workspace directories + `.code-workspace` files +//! from the current config. //! //! `WorkspaceRegenerator` is the single chokepoint through which -//! every `rep workspace` subcommand writes a `.code-workspace` file, -//! so the resolution of member repo aliases to absolute paths and -//! the decision of where the file lives on disk cannot drift between -//! callers. The struct holds a borrowed reference to -//! `ReportalConfig` so each helper method carries the registry -//! context via `&self` and takes at most one additional argument, -//! which is the shape required by the project's param-count rules. +//! 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::code_workspace::CodeWorkspaceFile; use crate::error::ReportalError; -use crate::reportal_config::{ - ReportalConfig, WorkspaceEntry, WorkspaceMember, +use crate::reportal_commands::workspace_layout::{ + materialize_workspace_layout, workspace_file_path_inside_dir, WorkspaceLayoutParams, + WorkspaceLinkSpec, }; +use crate::reportal_config::{ReportalConfig, WorkspaceEntry, WorkspaceMember}; use std::path::PathBuf; -/// Regenerates `.code-workspace` files from a borrowed config. +/// 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 @@ -29,8 +36,8 @@ pub struct WorkspaceRegenerator<'config_lifetime> { config_registry: &'config_lifetime ReportalConfig, } -/// Regeneration methods that walk workspace membership and write -/// the resulting `.code-workspace` file to disk. +/// 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. @@ -39,126 +46,143 @@ impl<'config_lifetime> WorkspaceRegenerator<'config_lifetime> { Self { config_registry } } - /// Regenerates the on-disk `.code-workspace` file for the named - /// workspace and returns the resolved file path. + /// Rebuilds the workspace directory, its member symlinks / + /// junctions, and its `.code-workspace` file, and returns the + /// path of the workspace file. /// - /// Loads the existing file if present so the parse-merge-write - /// path preserves user-authored top-level fields (settings, - /// extensions, launch, tasks, comments) byte-for-byte, replaces - /// only the `folders` array with entries built from the current - /// absolute paths of the member repos in declared order, and - /// writes the mutated document back. Creates the parent - /// directory if the default location under - /// `~/.reportal/workspaces/` does not yet exist. + /// 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 to a registered repo, - /// [`ReportalError::ConfigIoFailure`] if the home directory - /// cannot be resolved for the default path, or + /// name is not registered, [`ReportalError::RepoNotFound`] if + /// any member alias does not resolve, or /// [`ReportalError::CodeWorkspaceIoFailure`] / - /// [`ReportalError::CodeWorkspaceParseFailure`] if reading, - /// parsing, or writing the file fails. + /// [`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_absolute_paths = self.resolve_member_repo_paths(target_workspace)?; - let workspace_file_path = self.resolve_workspace_file_path(workspace_name)?; - let mut code_workspace_document = - CodeWorkspaceFile::load_or_empty(&workspace_file_path)?; - code_workspace_document.set_folder_paths(&member_absolute_paths); - code_workspace_document.write_to_disk(&workspace_file_path)?; - Ok(workspace_file_path) + let member_links = self.build_member_link_specs(target_workspace)?; + let workspace_directory = self.resolve_workspace_directory(workspace_name)?; + materialize_workspace_layout(&WorkspaceLayoutParams { + workspace_directory: &workspace_directory, + workspace_name, + member_links: &member_links, + }) + } + + /// 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 member repo alias in the given workspace to - /// its absolute filesystem path, preserving the declared order. + /// 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. /// - /// The ordering is load-bearing because it determines the - /// folder order in the editor sidebar, so callers that later - /// write these paths into the `folders` array get the same - /// visual ordering the user declared in config. + /// 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 resolve_member_repo_paths( + fn build_member_link_specs( &self, target_workspace: &WorkspaceEntry, - ) -> Result, ReportalError> { + ) -> Result, ReportalError> { let declared_members = target_workspace.members(); - let mut resolved_paths = Vec::with_capacity(declared_members.len()); + let mut resolved_specs = Vec::with_capacity(declared_members.len()); for member in declared_members { match member { WorkspaceMember::RegisteredRepo(repo_alias) => { let member_repo = self.config_registry.get_repo(repo_alias)?; - resolved_paths.push(member_repo.resolved_path()); + resolved_specs.push(WorkspaceLinkSpec { + link_name: repo_alias.clone(), + target_absolute_path: member_repo.resolved_path(), + }); } WorkspaceMember::InlinePath { path } => { let expanded = shellexpand::tilde(path); - resolved_paths.push(PathBuf::from(expanded.as_ref())); + 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_paths) - } - - /// Resolves the on-disk location of the `.code-workspace` file - /// for the named workspace. - /// - /// Honors an explicit `path` field if the workspace entry sets - /// one, expanding a leading `~` against the user home directory. - /// Falls back to the default location - /// `~/.reportal/workspaces/.code-workspace` when the field - /// is empty so workspaces created without a custom path land in - /// a predictable directory reportal fully owns. - /// - /// # Errors - /// - /// Returns [`ReportalError::WorkspaceNotFound`] if the name does - /// not match a registered workspace, or - /// [`ReportalError::ConfigIoFailure`] if the home directory is - /// needed for the default location and cannot be resolved. - pub fn resolve_workspace_file_path( - &self, - workspace_name: &str, - ) -> Result { - let target_workspace = self.config_registry.get_workspace(workspace_name)?; - let raw_path = target_workspace.raw_workspace_file_path(); - if raw_path.is_empty() { - return default_workspace_file_location(workspace_name); - } - let expanded_path = shellexpand::tilde(raw_path); - Ok(PathBuf::from(expanded_path.as_ref())) + Ok(resolved_specs) } } -/// Computes the default on-disk location for a workspace file when -/// the workspace entry does not declare a custom path. -/// -/// The path is `~/.reportal/workspaces/.code-workspace`, which -/// places every default-located workspace in a single directory -/// under reportal's config root so discovery, cleanup, and manual -/// inspection are all straightforward. -/// -/// # Errors +/// Derives a short, stable link name for an inline-path workspace +/// member from the path's final component. /// -/// Returns [`ReportalError::ConfigIoFailure`] if the home directory -/// cannot be resolved, because without a home directory there is no -/// well-defined default location and silently falling back to a -/// relative path would produce an unpredictable file on disk. -fn default_workspace_file_location(workspace_name: &str) -> Result { - let home_directory = - dirs::home_dir().ok_or_else(|| ReportalError::ConfigIoFailure { - reason: "Could not determine home directory".to_owned(), - })?; - Ok(home_directory - .join(".reportal") - .join("workspaces") - .join(format!("{workspace_name}.code-workspace"))) +/// 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..9d63ca6 --- /dev/null +++ b/src/reportal_commands/workspace_rebuild.rs @@ -0,0 +1,69 @@ +//! 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_show.rs b/src/reportal_commands/workspace_show.rs index a48ea08..ce8283c 100644 --- a/src/reportal_commands/workspace_show.rs +++ b/src/reportal_commands/workspace_show.rs @@ -1,5 +1,5 @@ //! Shows a single workspace's details, resolved member paths, and -//! the on-disk location of its `.code-workspace` file. +//! the on-disk location of its workspace directory. use crate::error::ReportalError; use crate::reportal_commands::workspace_operations::WorkspaceRegenerator; @@ -8,30 +8,29 @@ use crate::terminal_style; use owo_colors::OwoColorize; /// Prints the named workspace's description, member aliases with -/// their resolved absolute paths, and the `.code-workspace` file -/// location that `rep workspace open` would launch. +/// their resolved absolute paths, the materialized workspace +/// directory path, and the `.code-workspace` file path inside it. /// -/// Accepts either the canonical workspace key or any declared -/// alias: the first step resolves to the canonical name so every -/// downstream call (regenerator, header display) uses the same key -/// and derived values like the default file path stay consistent. -/// -/// Regenerates the `.code-workspace` file before printing so the -/// shown file is guaranteed to match the current config state. +/// 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 and regeneration paths surface. +/// 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_file_path = regenerator.regenerate_workspace_file(&canonical_name)?; + 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!( @@ -55,6 +54,24 @@ pub fn run_workspace_show(alias_or_canonical: &str) -> Result<(), ReportalError> )); } + 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), @@ -70,23 +87,33 @@ pub fn run_workspace_show(alias_or_canonical: &str) -> Result<(), ReportalError> 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", + " {} {} {} {}\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", + " {} {} {} {}\n", "-".style(terminal_style::LABEL_STYLE), "(inline)".style(terminal_style::LABEL_STYLE), expanded.as_ref().style(terminal_style::PATH_STYLE), + link_status_text, )); } } @@ -95,3 +122,14 @@ pub fn run_workspace_show(alias_or_canonical: &str) -> Result<(), ReportalError> 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/global_settings.rs b/src/reportal_config/global_settings.rs index 8d89784..f7055ce 100644 --- a/src/reportal_config/global_settings.rs +++ b/src/reportal_config/global_settings.rs @@ -70,6 +70,17 @@ 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 { @@ -79,6 +90,16 @@ pub struct ReportalSettings { /// 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, @@ -96,6 +117,7 @@ impl Default for ReportalSettings { 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/reportal_config_root.rs b/src/reportal_config/reportal_config_root.rs index 977b66a..67af475 100644 --- a/src/reportal_config/reportal_config_root.rs +++ b/src/reportal_config/reportal_config_root.rs @@ -284,6 +284,31 @@ impl ReportalConfig { &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 @@ -535,6 +560,7 @@ impl ReportalConfig { 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(), diff --git a/src/reportal_config/workspace_entry.rs b/src/reportal_config/workspace_entry.rs index 470d530..baf0457 100644 --- a/src/reportal_config/workspace_entry.rs +++ b/src/reportal_config/workspace_entry.rs @@ -30,10 +30,19 @@ pub struct WorkspaceEntry { /// Human-readable description of what this workspace is for. #[serde(default)] pub(super) description: String, - /// Filesystem path where the `.code-workspace` file is written. + /// Filesystem path of the workspace directory on disk. /// - /// May contain `~` for home. When empty, the default location is - /// `~/.reportal/workspaces/.code-workspace`. + /// 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 @@ -84,14 +93,47 @@ impl WorkspaceEntry { &self.description } - /// The raw `.code-workspace` file path as stored in config, before - /// tilde expansion. An empty string signals that the default - /// location under `~/.reportal/workspaces/` should be used. + /// 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. /// From 069545e002810c35599bb503de44abdeef74796d Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Fri, 24 Apr 2026 17:19:39 -0400 Subject: [PATCH 57/67] Inline workspace/repo badge in shell prompt (v0.16.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New `rep prompt --shell ` subcommand emits a colored identity badge (wrapped with the shell's non-printing-escape markers) for inline use inside PS1 / PROMPT / prompt function. - `rep init` auto-wires the badge into the prompt and coexists with any framework (p10k, starship, oh-my-zsh) via strip-and-prepend plus end-of-chain self-registration — the hook never touches framework content, only bytes it wrote itself. - `.code-workspace` files generated by `rep workspace rebuild` now stamp `window.title` and `workbench.colorCustomizations` from the workspace's `title` + `color` config, giving editor and terminal a single identity signal. - `rep color` is now workspace-aware: CWD under `~/dev/workspaces//` resolves to the workspace's identity before falling back to repo longest-prefix match. - New config fields (backward-compatible via serde defaults): `[workspaces.]` now accepts `title` and `color`. Rename sweep (enforced by naming-clarity hooks): - Args → Arguments (cli_args module + all subcommand arg structs) - Params → Parameters (all CommandParams / LayoutParams types) - repo_color.rs → repository_color.rs (both config and commands) - InitShell / run_init → InitializeShell / run_initialize - ColorCommandParams.repo_alias → ColorCommandParameters.repository_alias All existing tests pass (54/54). No config-file breaking changes. Co-Authored-By: AI Opus 4.7 (1M context) --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/cli_args/{ai_args.rs => ai_arguments.rs} | 8 +- .../{color_args.rs => color_arguments.rs} | 6 +- .../{edit_args.rs => edit_arguments.rs} | 8 +- .../{init_args.rs => initialize_arguments.rs} | 10 +- .../{jump_args.rs => jump_arguments.rs} | 8 +- .../{list_args.rs => list_arguments.rs} | 24 +-- src/cli_args/mod.rs | 64 ++++--- .../{open_args.rs => open_arguments.rs} | 8 +- src/cli_args/prompt_arguments.rs | 50 +++++ .../{remove_args.rs => remove_arguments.rs} | 4 +- src/cli_args/reportal_cli.rs | 40 ++-- ...s.rs => repository_selection_arguments.rs} | 8 +- .../{run_args.rs => run_arguments.rs} | 8 +- .../{status_args.rs => status_arguments.rs} | 8 +- .../{sync_args.rs => sync_arguments.rs} | 8 +- ...filter_args.rs => tag_filter_arguments.rs} | 4 +- .../{web_args.rs => web_arguments.rs} | 8 +- ...rkspace_args.rs => workspace_arguments.rs} | 50 ++--- ..._args.rs => workspace_filter_arguments.rs} | 4 +- src/code_workspace/code_workspace_file.rs | 108 ++++++++++- src/main.rs | 75 ++++---- src/reportal_commands/direct_alias_router.rs | 4 +- src/reportal_commands/git_commands.rs | 4 +- src/reportal_commands/initialization.rs | 23 ++- src/reportal_commands/mod.rs | 22 ++- src/reportal_commands/path_display.rs | 4 +- src/reportal_commands/prompt_badge_emit.rs | 120 ++++++++++++ src/reportal_commands/prompt_identity.rs | 181 ++++++++++++++++++ .../prompts/color_edit_prompt.rs | 4 +- src/reportal_commands/prompts/mod.rs | 4 +- src/reportal_commands/prompts/text_prompt.rs | 4 +- .../repo_add/registration_context.rs | 18 +- src/reportal_commands/repo_ai.rs | 14 +- src/reportal_commands/repo_color.rs | 119 ------------ src/reportal_commands/repo_edit.rs | 10 +- src/reportal_commands/repo_edit_mutator.rs | 12 +- src/reportal_commands/repo_jump.rs | 22 +-- src/reportal_commands/repo_listing.rs | 8 +- .../repo_listing_renderer.rs | 10 +- src/reportal_commands/repo_open.rs | 18 +- src/reportal_commands/repo_remove.rs | 10 +- src/reportal_commands/repo_run.rs | 20 +- src/reportal_commands/repo_selection.rs | 14 +- src/reportal_commands/repo_status.rs | 16 +- src/reportal_commands/repo_sync.rs | 4 +- src/reportal_commands/repo_tree_grouping.rs | 22 +-- src/reportal_commands/repo_web.rs | 16 +- src/reportal_commands/repository_color.rs | 152 +++++++++++++++ src/reportal_commands/shell_integration.rs | 36 ++-- src/reportal_commands/shell_prompt_badge.rs | 111 +++++++++++ src/reportal_commands/target_selection.rs | 12 +- .../terminal_identity_emit.rs | 8 +- src/reportal_commands/workspace_create.rs | 4 +- src/reportal_commands/workspace_delete.rs | 4 +- src/reportal_commands/workspace_dispatch.rs | 26 +-- src/reportal_commands/workspace_layout.rs | 35 +++- src/reportal_commands/workspace_members.rs | 26 +-- src/reportal_commands/workspace_operations.rs | 20 +- src/reportal_commands/workspace_selection.rs | 2 +- src/reportal_config/mod.rs | 4 +- src/reportal_config/repo_entry.rs | 2 +- .../repo_registration_builder.rs | 2 +- src/reportal_config/reportal_config_root.rs | 14 +- .../{repo_color.rs => repository_color.rs} | 17 ++ src/reportal_config/workspace_entry.rs | 34 +++- .../workspace_registration_builder.rs | 2 + src/terminal_style.rs | 4 +- 69 files changed, 1213 insertions(+), 520 deletions(-) rename src/cli_args/{ai_args.rs => ai_arguments.rs} (82%) rename src/cli_args/{color_args.rs => color_arguments.rs} (89%) rename src/cli_args/{edit_args.rs => edit_arguments.rs} (75%) rename src/cli_args/{init_args.rs => initialize_arguments.rs} (85%) rename src/cli_args/{jump_args.rs => jump_arguments.rs} (81%) rename src/cli_args/{list_args.rs => list_arguments.rs} (76%) rename src/cli_args/{open_args.rs => open_arguments.rs} (84%) create mode 100644 src/cli_args/prompt_arguments.rs rename src/cli_args/{remove_args.rs => remove_arguments.rs} (89%) rename src/cli_args/{repo_selection_args.rs => repository_selection_arguments.rs} (85%) rename src/cli_args/{run_args.rs => run_arguments.rs} (82%) rename src/cli_args/{status_args.rs => status_arguments.rs} (77%) rename src/cli_args/{sync_args.rs => sync_arguments.rs} (77%) rename src/cli_args/{tag_filter_args.rs => tag_filter_arguments.rs} (93%) rename src/cli_args/{web_args.rs => web_arguments.rs} (75%) rename src/cli_args/{workspace_args.rs => workspace_arguments.rs} (90%) rename src/cli_args/{workspace_filter_args.rs => workspace_filter_arguments.rs} (94%) create mode 100644 src/reportal_commands/prompt_badge_emit.rs create mode 100644 src/reportal_commands/prompt_identity.rs delete mode 100644 src/reportal_commands/repo_color.rs create mode 100644 src/reportal_commands/repository_color.rs create mode 100644 src/reportal_commands/shell_prompt_badge.rs rename src/reportal_config/{repo_color.rs => repository_color.rs} (70%) diff --git a/Cargo.lock b/Cargo.lock index 8f3e958..308f490 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -566,7 +566,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.15.2" +version = "0.16.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 439f147..6fbebd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.15.2" +version = "0.16.0" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/src/cli_args/ai_args.rs b/src/cli_args/ai_arguments.rs similarity index 82% rename from src/cli_args/ai_args.rs rename to src/cli_args/ai_arguments.rs index 43c2e27..0d253a9 100644 --- a/src/cli_args/ai_args.rs +++ b/src/cli_args/ai_arguments.rs @@ -2,23 +2,23 @@ use clap::Args; use crate::reportal_config::TagFilter; -use super::repo_selection_args::RepoSelectionArgs; +use super::repository_selection_arguments::RepositorySelectionArguments; /// 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 AiArgs { +pub struct AiArguments { #[command(flatten)] - selection: RepoSelectionArgs, + 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 AiArgs { +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(); diff --git a/src/cli_args/color_args.rs b/src/cli_args/color_arguments.rs similarity index 89% rename from src/cli_args/color_args.rs rename to src/cli_args/color_arguments.rs index e80c64c..f583b02 100644 --- a/src/cli_args/color_args.rs +++ b/src/cli_args/color_arguments.rs @@ -10,7 +10,7 @@ use crate::reportal_commands::ColorCommandMode; /// behavior (prints title to stdout, preserves state on no-match) and /// explicit behavior (silent stdout, resets color on no-match). #[derive(Args)] -pub struct ColorArgs { +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, @@ -20,8 +20,8 @@ pub struct ColorArgs { } /// Consuming conversion that splits into domain-layer parts. -impl ColorArgs { - /// Returns (`repo_alias`, mode), consuming self. +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_args.rs b/src/cli_args/edit_arguments.rs similarity index 75% rename from src/cli_args/edit_args.rs rename to src/cli_args/edit_arguments.rs index 04cc72d..c05df93 100644 --- a/src/cli_args/edit_args.rs +++ b/src/cli_args/edit_arguments.rs @@ -2,20 +2,20 @@ use clap::Args; use crate::reportal_config::TagFilter; -use super::repo_selection_args::RepoSelectionArgs; +use super::repository_selection_arguments::RepositorySelectionArguments; /// 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 EditArgs { +pub struct EditArguments { #[command(flatten)] - selection: RepoSelectionArgs, + selection: RepositorySelectionArguments, } /// Consuming conversion that splits into domain-layer parts. -impl EditArgs { +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/init_args.rs b/src/cli_args/initialize_arguments.rs similarity index 85% rename from src/cli_args/init_args.rs rename to src/cli_args/initialize_arguments.rs index 44174b7..ed9db6e 100644 --- a/src/cli_args/init_args.rs +++ b/src/cli_args/initialize_arguments.rs @@ -10,14 +10,14 @@ use clap::{Args, ValueEnum}; /// starship, zoxide, direnv, and mise. The binary never writes /// integration files to disk and never prompts the user. #[derive(Args)] -pub struct InitArgs { +pub struct InitializeArguments { /// Shell to generate integration code for - shell: InitShell, + shell: InitializeShell, } /// Shells that `rep init` can generate integration code for. #[derive(Clone, Copy, Debug, ValueEnum)] -pub enum InitShell { +pub enum InitializeShell { /// Zsh (prints POSIX-compatible shell functions). Zsh, /// Bash (prints POSIX-compatible shell functions). @@ -27,9 +27,9 @@ pub enum InitShell { } /// Accessor for the parsed shell choice. -impl InitArgs { +impl InitializeArguments { /// The shell whose integration code should be emitted. - pub fn shell(&self) -> InitShell { + pub fn shell(&self) -> InitializeShell { self.shell } } diff --git a/src/cli_args/jump_args.rs b/src/cli_args/jump_arguments.rs similarity index 81% rename from src/cli_args/jump_args.rs rename to src/cli_args/jump_arguments.rs index 062e4fc..7398aaa 100644 --- a/src/cli_args/jump_args.rs +++ b/src/cli_args/jump_arguments.rs @@ -2,23 +2,23 @@ use clap::Args; use crate::reportal_config::TagFilter; -use super::repo_selection_args::RepoSelectionArgs; +use super::repository_selection_arguments::RepositorySelectionArguments; /// 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 JumpArgs { +pub struct JumpArguments { #[command(flatten)] - selection: RepoSelectionArgs, + 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 JumpArgs { +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(); diff --git a/src/cli_args/list_args.rs b/src/cli_args/list_arguments.rs similarity index 76% rename from src/cli_args/list_args.rs rename to src/cli_args/list_arguments.rs index 2023ee0..c6d5433 100644 --- a/src/cli_args/list_args.rs +++ b/src/cli_args/list_arguments.rs @@ -1,7 +1,7 @@ //! CLI args for `rep list`. -use super::tag_filter_args::TagFilterArgs; -use super::workspace_filter_args::WorkspaceFilterArgs; +use super::tag_filter_arguments::TagFilterArguments; +use super::workspace_filter_arguments::WorkspaceFilterArguments; use crate::reportal_config::{TagFilter, WorkspaceFilter}; use clap::Args; @@ -12,35 +12,35 @@ use clap::Args; /// specified together — the rendered output includes only repos /// that match both filters. #[derive(Args)] -pub struct ListArgs { +pub struct ListArguments { /// Optional `--tag` flag for tag-based filtering. #[command(flatten)] - tag_filter: TagFilterArgs, + tag_filter: TagFilterArguments, /// Optional `--workspace` flag for workspace-membership /// filtering, orthogonal to `--tag`. #[command(flatten)] - workspace_filter: WorkspaceFilterArgs, + workspace_filter: WorkspaceFilterArguments, } -/// Consuming accessors for `ListArgs`. -impl ListArgs { +/// 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) -> ListArgsFilterParts { - ListArgsFilterParts { + 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 `ListArgs::into_filter_parts`. +/// 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 ListArgsFilterParts { +pub struct ListArgumentsFilterParts { /// Resolved tag filter for the listing. pub(super) tag_filter: TagFilter, /// Resolved workspace-membership filter for the listing. @@ -48,7 +48,7 @@ pub struct ListArgsFilterParts { } /// Accessors for the destructured filter parts. -impl ListArgsFilterParts { +impl ListArgumentsFilterParts { /// The resolved tag filter for this listing invocation. #[must_use] pub fn tag_filter(&self) -> &TagFilter { diff --git a/src/cli_args/mod.rs b/src/cli_args/mod.rs index 3af0efb..94a8fc7 100644 --- a/src/cli_args/mod.rs +++ b/src/cli_args/mod.rs @@ -2,38 +2,40 @@ //! //! Each subcommand's args live in their own file. Shared building //! blocks (tag filter, repo selection) are composed via `#[command(flatten)]`. -mod ai_args; -mod color_args; -mod edit_args; -mod init_args; -mod jump_args; -mod list_args; -mod open_args; -mod remove_args; -mod repo_selection_args; +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 repository_selection_arguments; mod reportal_cli; -mod run_args; -mod status_args; -mod sync_args; -mod tag_filter_args; -mod web_args; -mod workspace_args; -mod workspace_filter_args; +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_args::AiArgs; -pub use color_args::ColorArgs; -pub use edit_args::EditArgs; -pub use init_args::{InitArgs, InitShell}; -pub use jump_args::JumpArgs; -pub use list_args::{ListArgs, ListArgsFilterParts}; -pub use open_args::OpenArgs; -pub use remove_args::RemoveArgs; +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_args::RunArgs; -pub use status_args::StatusArgs; -pub use sync_args::SyncArgs; -pub use web_args::WebArgs; -pub use workspace_args::{ - WorkspaceArgs, WorkspaceArgsCreateParts, WorkspaceArgsDeleteParts, WorkspaceArgsMemberEditParts, - WorkspaceArgsSubcommand, +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_args.rs b/src/cli_args/open_arguments.rs similarity index 84% rename from src/cli_args/open_args.rs rename to src/cli_args/open_arguments.rs index 105011e..a2f153a 100644 --- a/src/cli_args/open_args.rs +++ b/src/cli_args/open_arguments.rs @@ -2,16 +2,16 @@ use clap::Args; use crate::reportal_config::TagFilter; -use super::repo_selection_args::RepoSelectionArgs; +use super::repository_selection_arguments::RepositorySelectionArguments; /// Arguments for the `rep open` subcommand. /// /// Supports repo selection, an optional editor override, and an /// optional tab title override. #[derive(Args)] -pub struct OpenArgs { +pub struct OpenArguments { #[command(flatten)] - selection: RepoSelectionArgs, + selection: RepositorySelectionArguments, /// Override the default editor command #[arg(long, default_value = "", hide_default_value = true)] editor: String, @@ -21,7 +21,7 @@ pub struct OpenArgs { } /// Consuming conversion that splits into domain-layer parts. -impl OpenArgs { +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(); 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_args.rs b/src/cli_args/remove_arguments.rs similarity index 89% rename from src/cli_args/remove_args.rs rename to src/cli_args/remove_arguments.rs index a8ca2d7..5b7f017 100644 --- a/src/cli_args/remove_args.rs +++ b/src/cli_args/remove_arguments.rs @@ -7,13 +7,13 @@ use clap::Args; /// 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 RemoveArgs { +pub struct RemoveArguments { /// Alias of the repo to remove alias: String, } /// Accessor for the parsed alias. -impl RemoveArgs { +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 index 61738c3..c375c12 100644 --- a/src/cli_args/reportal_cli.rs +++ b/src/cli_args/reportal_cli.rs @@ -2,8 +2,9 @@ use clap::{Parser, Subcommand}; use super::{ - AiArgs, ColorArgs, EditArgs, InitArgs, JumpArgs, ListArgs, - OpenArgs, RemoveArgs, RunArgs, StatusArgs, SyncArgs, WebArgs, WorkspaceArgs, + AiArguments, ColorArguments, EditArguments, InitializeArguments, JumpArguments, ListArguments, + OpenArguments, PromptArguments, RemoveArguments, RunArguments, StatusArguments, SyncArguments, + WebArguments, WorkspaceArguments, }; /// A fast CLI tool for jumping between and managing your dev repos. @@ -41,16 +42,16 @@ pub enum ReportalCliSubcommand { /// /// Invoke-Expression (& rep init powershell | Out-String) #[command(verbatim_doc_comment)] - Init(InitArgs), + Init(InitializeArguments), /// List all registered repos with status and metadata #[command(alias = "l")] - List(ListArgs), + List(ListArguments), /// Fuzzy-select a repo and print its path (for shell cd integration) #[command(alias = "j")] - Jump(JumpArgs), + Jump(JumpArguments), /// Fuzzy-select a repo and open it in your editor #[command(alias = "o")] - Open(OpenArgs), + Open(OpenArguments), /// Register a local repo in the config #[command(alias = "a")] Add { @@ -59,28 +60,37 @@ pub enum ReportalCliSubcommand { }, /// Edit a repo's description, tags, title, and color #[command(alias = "e")] - Edit(EditArgs), + Edit(EditArguments), /// Unregister a repo from the config (does not delete files) #[command(alias = "rm")] - Remove(RemoveArgs), + Remove(RemoveArguments), /// Emit terminal tab title and background color for a repo (for shell hooks) - Color(ColorArgs), + 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(StatusArgs), + Status(StatusArguments), /// Pull latest changes across all registered repos - Sync(SyncArgs), + Sync(SyncArguments), /// Diagnose config, shell integration, and repo path issues Doctor, /// Launch an AI coding CLI in a repo - Ai(AiArgs), + Ai(AiArguments), /// Open a repo's remote URL in the browser #[command(alias = "w")] - Web(WebArgs), + Web(WebArguments), /// Run a configured command in a repo #[command(alias = "r")] - Run(RunArgs), + Run(RunArguments), /// Manage VSCode/Cursor `.code-workspace` definitions #[command(alias = "ws")] - Workspace(WorkspaceArgs), + Workspace(WorkspaceArguments), } diff --git a/src/cli_args/repo_selection_args.rs b/src/cli_args/repository_selection_arguments.rs similarity index 85% rename from src/cli_args/repo_selection_args.rs rename to src/cli_args/repository_selection_arguments.rs index 6017cbb..ad0b195 100644 --- a/src/cli_args/repo_selection_args.rs +++ b/src/cli_args/repository_selection_arguments.rs @@ -2,7 +2,7 @@ use clap::Args; use crate::reportal_config::TagFilter; -use super::tag_filter_args::TagFilterArgs; +use super::tag_filter_arguments::TagFilterArguments; /// Optional repo alias (positional) combined with a `--tag` filter flag. /// @@ -10,16 +10,16 @@ use super::tag_filter_args::TagFilterArgs; /// 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 RepoSelectionArgs { +pub struct RepositorySelectionArguments { /// Jump directly to this alias (skip fuzzy finder) #[arg(default_value = "", hide_default_value = true)] alias: String, #[command(flatten)] - tag_filter: TagFilterArgs, + tag_filter: TagFilterArguments, } /// Consuming conversion that splits the args into their domain-layer parts. -impl RepoSelectionArgs { +impl RepositorySelectionArguments { /// Returns the alias string and a `TagFilter`, consuming self. /// /// The alias may be empty (meaning no direct selection was provided). diff --git a/src/cli_args/run_args.rs b/src/cli_args/run_arguments.rs similarity index 82% rename from src/cli_args/run_args.rs rename to src/cli_args/run_arguments.rs index 32c280c..db653c9 100644 --- a/src/cli_args/run_args.rs +++ b/src/cli_args/run_arguments.rs @@ -2,23 +2,23 @@ use clap::Args; use crate::reportal_config::TagFilter; -use super::repo_selection_args::RepoSelectionArgs; +use super::repository_selection_arguments::RepositorySelectionArguments; /// 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 RunArgs { +pub struct RunArguments { #[command(flatten)] - selection: RepoSelectionArgs, + 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 RunArgs { +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(); diff --git a/src/cli_args/status_args.rs b/src/cli_args/status_arguments.rs similarity index 77% rename from src/cli_args/status_args.rs rename to src/cli_args/status_arguments.rs index 96445a3..3f954d4 100644 --- a/src/cli_args/status_args.rs +++ b/src/cli_args/status_arguments.rs @@ -2,19 +2,19 @@ use clap::Args; use crate::reportal_config::TagFilter; -use super::tag_filter_args::TagFilterArgs; +use super::tag_filter_arguments::TagFilterArguments; /// Arguments for the `rep status` subcommand. /// /// Accepts an optional `--tag` flag to filter which repos are checked. #[derive(Args)] -pub struct StatusArgs { +pub struct StatusArguments { #[command(flatten)] - filter: TagFilterArgs, + filter: TagFilterArguments, } /// Consuming conversion to the domain tag filter. -impl StatusArgs { +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_args.rs b/src/cli_args/sync_arguments.rs similarity index 77% rename from src/cli_args/sync_args.rs rename to src/cli_args/sync_arguments.rs index ba75d89..a22573e 100644 --- a/src/cli_args/sync_args.rs +++ b/src/cli_args/sync_arguments.rs @@ -2,19 +2,19 @@ use clap::Args; use crate::reportal_config::TagFilter; -use super::tag_filter_args::TagFilterArgs; +use super::tag_filter_arguments::TagFilterArguments; /// Arguments for the `rep sync` subcommand. /// /// Accepts an optional `--tag` flag to filter which repos are synced. #[derive(Args)] -pub struct SyncArgs { +pub struct SyncArguments { #[command(flatten)] - filter: TagFilterArgs, + filter: TagFilterArguments, } /// Consuming conversion to the domain tag filter. -impl SyncArgs { +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_args.rs b/src/cli_args/tag_filter_arguments.rs similarity index 93% rename from src/cli_args/tag_filter_args.rs rename to src/cli_args/tag_filter_arguments.rs index 05e2c97..b295797 100644 --- a/src/cli_args/tag_filter_args.rs +++ b/src/cli_args/tag_filter_arguments.rs @@ -8,14 +8,14 @@ use crate::reportal_config::TagFilter; /// 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 TagFilterArgs { +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 TagFilterArgs { +impl TagFilterArguments { /// Converts the CLI flag value into a `TagFilter`. /// /// Empty string (absent flag) becomes `All`; any non-empty value diff --git a/src/cli_args/web_args.rs b/src/cli_args/web_arguments.rs similarity index 75% rename from src/cli_args/web_args.rs rename to src/cli_args/web_arguments.rs index c9749da..784483b 100644 --- a/src/cli_args/web_args.rs +++ b/src/cli_args/web_arguments.rs @@ -2,20 +2,20 @@ use clap::Args; use crate::reportal_config::TagFilter; -use super::repo_selection_args::RepoSelectionArgs; +use super::repository_selection_arguments::RepositorySelectionArguments; /// 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 WebArgs { +pub struct WebArguments { #[command(flatten)] - selection: RepoSelectionArgs, + selection: RepositorySelectionArguments, } /// Consuming conversion that splits into domain-layer parts. -impl WebArgs { +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_args.rs b/src/cli_args/workspace_arguments.rs similarity index 90% rename from src/cli_args/workspace_args.rs rename to src/cli_args/workspace_arguments.rs index e3a06cf..7a93c69 100644 --- a/src/cli_args/workspace_args.rs +++ b/src/cli_args/workspace_arguments.rs @@ -4,7 +4,7 @@ //! registered VSCode/Cursor `.code-workspace` definitions: //! listing, showing, creating, deleting, member editing, and //! opening. Shared fields that would otherwise repeat across -//! variants (`workspace_name`, `repo_alias`) are extracted into +//! 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. @@ -12,17 +12,17 @@ use clap::{Args, Subcommand}; /// Arguments for the top-level `rep workspace` command. #[derive(Args)] -pub struct WorkspaceArgs { +pub struct WorkspaceArguments { /// The workspace action the user invoked. #[command(subcommand)] - action: WorkspaceArgsSubcommand, + action: WorkspaceArgumentsSubcommand, } /// Consuming accessor for the workspace subcommand. -impl WorkspaceArgs { +impl WorkspaceArguments { /// Extracts the action variant, consuming the parsed args. #[must_use] - pub fn into_action(self) -> WorkspaceArgsSubcommand { + pub fn into_action(self) -> WorkspaceArgumentsSubcommand { self.action } } @@ -48,12 +48,12 @@ impl WorkspaceArgsName { } } -/// Positional `repo_alias` argument shared by member-editing +/// 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. - repo_alias: String, + repository_alias: String, } /// Consuming accessor for the repo alias. @@ -61,7 +61,7 @@ impl WorkspaceArgsRepoAlias { /// Extracts the repo alias, consuming the parsed arg. #[must_use] pub fn into_alias(self) -> String { - self.repo_alias + self.repository_alias } } @@ -103,8 +103,8 @@ impl WorkspaceArgsDelete { /// Extracts the delete-action fields into a named parts struct, /// consuming the parsed args. #[must_use] - pub fn into_parts(self) -> WorkspaceArgsDeleteParts { - WorkspaceArgsDeleteParts { + pub fn into_parts(self) -> WorkspaceArgumentsDeleteParts { + WorkspaceArgumentsDeleteParts { workspace_name: self.target.into_name(), purge: self.purge, } @@ -112,7 +112,7 @@ impl WorkspaceArgsDelete { } /// Owned named-field result of `WorkspaceArgsDelete::into_parts`. -pub struct WorkspaceArgsDeleteParts { +pub struct WorkspaceArgumentsDeleteParts { /// The workspace to unregister. workspace_name: String, /// Whether to also delete the on-disk workspace directory. @@ -120,7 +120,7 @@ pub struct WorkspaceArgsDeleteParts { } /// Accessors for the delete-action parts. -impl WorkspaceArgsDeleteParts { +impl WorkspaceArgumentsDeleteParts { /// The workspace name to unregister. #[must_use] pub fn workspace_name(&self) -> &str { @@ -150,10 +150,10 @@ impl WorkspaceArgsMemberEdit { /// Extracts the targeted workspace name and the repo alias, /// consuming the parsed args. #[must_use] - pub fn into_parts(self) -> WorkspaceArgsMemberEditParts { - WorkspaceArgsMemberEditParts { + pub fn into_parts(self) -> WorkspaceArgumentsMemberEditParts { + WorkspaceArgumentsMemberEditParts { workspace_name: self.target.into_name(), - repo_alias: self.member.into_alias(), + repository_alias: self.member.into_alias(), } } } @@ -161,15 +161,15 @@ impl WorkspaceArgsMemberEdit { /// Owned tuple-equivalent returned from /// `WorkspaceArgsMemberEdit::into_parts`, with named fields so /// call sites never confuse the two strings. -pub struct WorkspaceArgsMemberEditParts { +pub struct WorkspaceArgumentsMemberEditParts { /// The workspace whose membership is being edited. workspace_name: String, /// The repo being added to or removed from the workspace. - repo_alias: String, + repository_alias: String, } /// Destructuring accessors for the member-edit parts. -impl WorkspaceArgsMemberEditParts { +impl WorkspaceArgumentsMemberEditParts { /// The workspace name this edit targets. #[must_use] pub fn workspace_name(&self) -> &str { @@ -178,8 +178,8 @@ impl WorkspaceArgsMemberEditParts { /// The repo alias being added to or removed from the workspace. #[must_use] - pub fn repo_alias(&self) -> &str { - &self.repo_alias + pub fn repository_alias(&self) -> &str { + &self.repository_alias } } @@ -219,8 +219,8 @@ impl WorkspaceArgsCreate { /// Extracts all create-action fields into a named parts /// struct, consuming the parsed args. #[must_use] - pub fn into_parts(self) -> WorkspaceArgsCreateParts { - WorkspaceArgsCreateParts { + pub fn into_parts(self) -> WorkspaceArgumentsCreateParts { + WorkspaceArgumentsCreateParts { workspace_name: self.target.into_name(), repo_aliases: self.repos, description: self.description, @@ -234,7 +234,7 @@ impl WorkspaceArgsCreate { /// /// Returned instead of a bare tuple so call sites never confuse /// the string-typed fields. -pub struct WorkspaceArgsCreateParts { +pub struct WorkspaceArgumentsCreateParts { /// Name for the new workspace. workspace_name: String, /// Ordered list of repo aliases that belong to the workspace. @@ -250,7 +250,7 @@ pub struct WorkspaceArgsCreateParts { } /// Accessors for the create-action parts. -impl WorkspaceArgsCreateParts { +impl WorkspaceArgumentsCreateParts { /// The name for the new workspace. #[must_use] pub fn workspace_name(&self) -> &str { @@ -288,7 +288,7 @@ impl WorkspaceArgsCreateParts { /// Subcommands under `rep workspace`. #[derive(Subcommand)] -pub enum WorkspaceArgsSubcommand { +pub enum WorkspaceArgumentsSubcommand { /// List all registered workspaces with their member repos. #[command(alias = "ls")] List, diff --git a/src/cli_args/workspace_filter_args.rs b/src/cli_args/workspace_filter_arguments.rs similarity index 94% rename from src/cli_args/workspace_filter_args.rs rename to src/cli_args/workspace_filter_arguments.rs index f60f29b..b7cf7f2 100644 --- a/src/cli_args/workspace_filter_args.rs +++ b/src/cli_args/workspace_filter_arguments.rs @@ -14,14 +14,14 @@ use clap::Args; /// because the project's rules forbid `Option` for domain state /// where both branches have meaning. #[derive(Args)] -pub struct WorkspaceFilterArgs { +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 WorkspaceFilterArgs { +impl WorkspaceFilterArguments { /// Converts the CLI flag value into a `WorkspaceFilter`. /// /// Empty string (absent flag) becomes `All`; any non-empty diff --git a/src/code_workspace/code_workspace_file.rs b/src/code_workspace/code_workspace_file.rs index 11a6fd1..0ad860c 100644 --- a/src/code_workspace/code_workspace_file.rs +++ b/src/code_workspace/code_workspace_file.rs @@ -38,6 +38,23 @@ pub struct CodeWorkspaceFile { /// 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. @@ -54,6 +71,8 @@ impl CodeWorkspaceFile { Self { original_file_text: String::new(), folders: Vec::new(), + window_title_override: None, + title_bar_color_hex: None, } } @@ -87,6 +106,8 @@ impl CodeWorkspaceFile { Ok(Self { original_file_text: file_contents, folders: extracted_folders, + window_title_override: None, + title_bar_color_hex: None, }) } @@ -102,14 +123,11 @@ impl CodeWorkspaceFile { &self.folders } - /// Replaces the folder list with entries built from the given - /// absolute paths, in order. - /// - /// The original file text and every top-level field outside - /// `folders` are untouched so user-authored settings, - /// extensions, launch configs, and JSONC comments round-trip - /// across the regeneration. This is the single mutation the - /// parse-merge-write path performs. + /// @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() @@ -117,6 +135,21 @@ impl CodeWorkspaceFile { .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 @@ -157,6 +190,15 @@ impl CodeWorkspaceFile { 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 { @@ -166,6 +208,56 @@ impl CodeWorkspaceFile { })?; 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 diff --git a/src/main.rs b/src/main.rs index 7019f8c..db1134d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,8 +16,8 @@ mod terminal_style; use clap::Parser; use cli_args::{ReportalCli, ReportalCliSubcommand}; use reportal_commands::{ - AiCommandParams, ColorCommandParams, EditCommandParams, JumpCommandParams, - OpenCommandParams, RunCommandParams, WebCommandParams, + AiCommandParameters, ColorCommandParameters, EditCommandParameters, JumpCommandParameters, + OpenCommandParameters, RunCommandParameters, WebCommandParameters, }; fn main() { @@ -35,24 +35,24 @@ fn main() { } let command_result = match subcommand { - ReportalCliSubcommand::Init(init_args) => { - reportal_commands::run_init(init_args.shell()); + ReportalCliSubcommand::Init(initialize_arguments) => { + reportal_commands::run_initialize(initialize_arguments.shell()); Ok(()) } - ReportalCliSubcommand::List(list_args) => { - reportal_commands::run_list(&list_args.into_filter_parts()) + ReportalCliSubcommand::List(list_arguments) => { + reportal_commands::run_list(&list_arguments.into_filter_parts()) } - ReportalCliSubcommand::Jump(jump_args) => { - let (direct_alias, tag_filter, title_override) = jump_args.into_parts(); - reportal_commands::run_jump(&JumpCommandParams { + ReportalCliSubcommand::Jump(jump_arguments) => { + let (direct_alias, tag_filter, title_override) = jump_arguments.into_parts(); + reportal_commands::run_jump(&JumpCommandParameters { tag_filter, direct_alias: &direct_alias, title_override: &title_override, }) } - ReportalCliSubcommand::Open(open_args) => { - let (direct_alias, tag_filter, editor_override, title_override) = open_args.into_parts(); - 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: &direct_alias, editor_override: &editor_override, @@ -62,58 +62,61 @@ fn main() { ReportalCliSubcommand::Add { repo_path } => { reportal_commands::run_add(&repo_path) } - ReportalCliSubcommand::Edit(edit_args) => { - let (direct_alias, tag_filter) = edit_args.into_parts(); - reportal_commands::run_edit(&EditCommandParams { + ReportalCliSubcommand::Edit(edit_arguments) => { + let (direct_alias, tag_filter) = edit_arguments.into_parts(); + reportal_commands::run_edit(&EditCommandParameters { tag_filter, direct_alias: &direct_alias, }) } - ReportalCliSubcommand::Remove(remove_args) => { - reportal_commands::run_remove(remove_args.alias()) + ReportalCliSubcommand::Remove(remove_arguments) => { + reportal_commands::run_remove(remove_arguments.alias()) } - ReportalCliSubcommand::Color(color_args) => { - let (repo_alias, mode) = color_args.into_parts(); - reportal_commands::run_color(&ColorCommandParams { - repo_alias: &repo_alias, + ReportalCliSubcommand::Color(color_arguments) => { + let (repository_alias, mode) = color_arguments.into_parts(); + reportal_commands::run_color(&ColorCommandParameters { + repository_alias: &repository_alias, mode, }) } - ReportalCliSubcommand::Status(status_args) => { - reportal_commands::run_status(&status_args.into_tag_filter()) + ReportalCliSubcommand::Prompt(prompt_arguments) => { + reportal_commands::run_prompt(prompt_arguments.into_shell()) } - ReportalCliSubcommand::Sync(sync_args) => { - reportal_commands::run_sync(&sync_args.into_tag_filter()) + 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_args) => { - let (direct_alias, tag_filter) = web_args.into_parts(); - reportal_commands::run_web(&WebCommandParams { + 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_args) => { - let (direct_alias, tag_filter, direct_command) = run_args.into_parts(); - reportal_commands::run_run(&RunCommandParams { + 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_args) => { - let (direct_alias, tag_filter, tool_override) = ai_args.into_parts(); - reportal_commands::run_ai(&AiCommandParams { + 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, }) } - ReportalCliSubcommand::Workspace(workspace_args) => { - reportal_commands::dispatch_workspace_subcommand(workspace_args) + ReportalCliSubcommand::Workspace(workspace_arguments) => { + reportal_commands::dispatch_workspace_subcommand(workspace_arguments) } }; diff --git a/src/reportal_commands/direct_alias_router.rs b/src/reportal_commands/direct_alias_router.rs index beaa174..22b7db3 100644 --- a/src/reportal_commands/direct_alias_router.rs +++ b/src/reportal_commands/direct_alias_router.rs @@ -8,7 +8,7 @@ //! member's identity propagate into tab-title emission". use crate::error::ReportalError; -use crate::reportal_commands::path_display::{self, SelectedPathDisplayParams}; +use crate::reportal_commands::path_display::{self, SelectedPathDisplayParameters}; use crate::reportal_commands::workspace_operations::WorkspaceRegenerator; use crate::reportal_config::ReportalConfig; use crate::terminal_style; @@ -137,7 +137,7 @@ impl<'config> DirectAliasRouter<'config> { .path_display_format() .format_path(&workspace_directory); terminal_style::write_stdout(&formatted_path); - path_display::print_selected_path_if_visible(&SelectedPathDisplayParams { + path_display::print_selected_path_if_visible(&SelectedPathDisplayParameters { loaded_config: self.loaded_config, resolved_path: &workspace_directory, }); diff --git a/src/reportal_commands/git_commands.rs b/src/reportal_commands/git_commands.rs index 7565334..d81a29d 100644 --- a/src/reportal_commands/git_commands.rs +++ b/src/reportal_commands/git_commands.rs @@ -15,7 +15,7 @@ pub enum GitCommandOutcome { /// Parameters for running a git subcommand inside a specific repo directory. /// Passed to `run_git_command()` as its single argument. -pub struct GitCommandParams<'a> { +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"]`). @@ -27,7 +27,7 @@ pub struct GitCommandParams<'a> { /// 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: &GitCommandParams<'_>) -> GitCommandOutcome { +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) diff --git a/src/reportal_commands/initialization.rs b/src/reportal_commands/initialization.rs index 14a6e13..4bb17db 100644 --- a/src/reportal_commands/initialization.rs +++ b/src/reportal_commands/initialization.rs @@ -6,18 +6,21 @@ //! No disk writes, no profile editing, no prompts — the shell code //! is regenerated every session and therefore never goes stale. -use crate::cli_args::InitShell; +use crate::cli_args::InitializeShell; use super::shell_integration::{bash_integration_content, powershell_integration_content}; +use super::shell_prompt_badge::prompt_badge_integration_snippet; -/// Emits the shell integration script for the requested shell to -/// stdout and returns. All integration content is generated in-memory -/// from the running binary's version, which guarantees that a binary -/// update takes effect in the next shell session with no manual step. -pub fn run_init(shell: InitShell) { - let integration_script = match shell { - InitShell::Zsh | InitShell::Bash => bash_integration_content(), - InitShell::Powershell => powershell_integration_content(), +/// @why Emits the shell integration script — chrome hooks plus +/// the prompt-badge snippet — for the requested shell to stdout +/// so the user wires a single `eval "$(rep init )"` line +/// into their rc file and gets both the tab / window title +/// chrome and the inline PS1 badge 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(), }; - print!("{integration_script}"); + let badge_snippet = prompt_badge_integration_snippet(shell); + print!("{base_integration_script}{badge_snippet}"); } diff --git a/src/reportal_commands/mod.rs b/src/reportal_commands/mod.rs index dd61748..75dd832 100644 --- a/src/reportal_commands/mod.rs +++ b/src/reportal_commands/mod.rs @@ -5,11 +5,14 @@ mod doctor; mod git_commands; mod initialization; mod path_display; +mod prompt_badge_emit; +mod prompt_identity; mod prompts; +mod shell_prompt_badge; mod shell_integration; mod repo_add; mod repo_ai; -mod repo_color; +mod repository_color; mod repo_edit; mod repo_edit_mutator; mod repo_jump; @@ -41,20 +44,21 @@ mod workspace_show; pub use color_command_mode::ColorCommandMode; pub use doctor::run_doctor; -pub use initialization::run_init; +pub use initialization::run_initialize; pub use shell_integration::ensure_shell_integration_installed; pub use repo_add::run_add; -pub use repo_ai::{run_ai, AiCommandParams}; -pub use repo_edit::{run_edit, EditCommandParams}; -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 prompt_badge_emit::run_prompt; +pub use repository_color::{run_color, ColorCommandParameters}; +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, RunCommandParams}; +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, WebCommandParams}; +pub use repo_web::{run_web, WebCommandParameters}; pub use workspace_create::run_workspace_create; pub use workspace_delete::run_workspace_delete; pub use workspace_dispatch::dispatch_workspace_subcommand; diff --git a/src/reportal_commands/path_display.rs b/src/reportal_commands/path_display.rs index 6b5d238..d44e0e9 100644 --- a/src/reportal_commands/path_display.rs +++ b/src/reportal_commands/path_display.rs @@ -6,7 +6,7 @@ use owo_colors::OwoColorize; use std::path::PathBuf; /// Parameters for conditionally printing the selected repo's path. -pub struct SelectedPathDisplayParams<'a> { +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. @@ -18,7 +18,7 @@ pub struct SelectedPathDisplayParams<'a> { /// 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: &SelectedPathDisplayParams<'_>) { +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 diff --git a/src/reportal_commands/prompt_badge_emit.rs b/src/reportal_commands/prompt_badge_emit.rs new file mode 100644 index 0000000..f6c6530 --- /dev/null +++ b/src/reportal_commands/prompt_badge_emit.rs @@ -0,0 +1,120 @@ +//! 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..c0af29e --- /dev/null +++ b/src/reportal_commands/prompt_identity.rs @@ -0,0 +1,181 @@ +//! 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 index 5f07800..bf17902 100644 --- a/src/reportal_commands/prompts/color_edit_prompt.rs +++ b/src/reportal_commands/prompts/color_edit_prompt.rs @@ -8,7 +8,7 @@ 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 ColorEditPromptParams<'a> { +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). @@ -18,7 +18,7 @@ pub struct ColorEditPromptParams<'a> { /// 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: &ColorEditPromptParams<'_>) -> Result { +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)") diff --git a/src/reportal_commands/prompts/mod.rs b/src/reportal_commands/prompts/mod.rs index cd36002..f606aa0 100644 --- a/src/reportal_commands/prompts/mod.rs +++ b/src/reportal_commands/prompts/mod.rs @@ -4,7 +4,7 @@ mod color_edit_result; mod color_prompt; mod text_prompt; -pub use color_edit_prompt::{prompt_for_color_edit, ColorEditPromptParams}; +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, TextPromptParams}; +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 index 35a0a0d..837bf58 100644 --- a/src/reportal_commands/prompts/text_prompt.rs +++ b/src/reportal_commands/prompts/text_prompt.rs @@ -4,7 +4,7 @@ use crate::error::ReportalError; use dialoguer::{theme::ColorfulTheme, Input}; /// Parameters for a text input prompt with a label and default value. -pub struct TextPromptParams<'a> { +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"). @@ -17,7 +17,7 @@ pub struct TextPromptParams<'a> { /// /// 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: &TextPromptParams<'_>) -> Result { +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()) diff --git a/src/reportal_commands/repo_add/registration_context.rs b/src/reportal_commands/repo_add/registration_context.rs index 0e71343..9671fb3 100644 --- a/src/reportal_commands/repo_add/registration_context.rs +++ b/src/reportal_commands/repo_add/registration_context.rs @@ -2,7 +2,7 @@ use crate::error::ReportalError; use crate::reportal_commands::prompts::{ - self, ColorPromptResult, TextPromptParams, + self, ColorPromptResult, TextPromptParameters, }; use crate::reportal_config::{RepoRegistrationBuilder, ReportalConfig}; use crate::terminal_style; @@ -28,19 +28,19 @@ impl RegistrationContext<'_> { pub fn collect_metadata_and_register(self) -> Result<(), ReportalError> { let prompt_theme = terminal_style::reportal_prompt_theme(); - let repo_alias = prompts::prompt_for_text(&TextPromptParams { + 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(&TextPromptParams { + let repo_description = prompts::prompt_for_text(&TextPromptParameters { prompt_theme: &prompt_theme, label: "Description", default_value: "", })?; - let tags_input = prompts::prompt_for_text(&TextPromptParams { + let tags_input = prompts::prompt_for_text(&TextPromptParameters { prompt_theme: &prompt_theme, label: "Tags (comma-separated)", default_value: "", @@ -48,13 +48,13 @@ impl RegistrationContext<'_> { let parsed_tags = prompts::parse_comma_separated_tags(&tags_input); - let repo_remote = prompts::prompt_for_text(&TextPromptParams { + 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(&TextPromptParams { + let tab_title = prompts::prompt_for_text(&TextPromptParameters { prompt_theme: &prompt_theme, label: "Tab title (empty = use alias)", default_value: "", @@ -63,7 +63,7 @@ impl RegistrationContext<'_> { 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), repo_alias.style(terminal_style::ALIAS_STYLE))); + 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)); @@ -85,9 +85,9 @@ impl RegistrationContext<'_> { } terminal_style::write_stdout("\n"); - let display_alias = repo_alias.as_str().to_owned(); + let display_alias = repository_alias.as_str().to_owned(); - let mut builder = RepoRegistrationBuilder::start(repo_alias) + let mut builder = RepoRegistrationBuilder::start(repository_alias) .repo_path(self.filesystem_path.to_owned()) .repo_description(repo_description) .repo_tags(parsed_tags) diff --git a/src/reportal_commands/repo_ai.rs b/src/reportal_commands/repo_ai.rs index 7309b54..c5deb87 100644 --- a/src/reportal_commands/repo_ai.rs +++ b/src/reportal_commands/repo_ai.rs @@ -3,15 +3,15 @@ use crate::error::ReportalError; use crate::reportal_config::{ReportalConfig, TagFilter}; use crate::terminal_style; -use crate::reportal_commands::repo_selection::{self, SelectedRepoParams}; +use crate::reportal_commands::repo_selection::{self, SelectedRepoParameters}; use crate::reportal_commands::terminal_identity_emit::{ - self, TerminalIdentityEmitParams, + self, TerminalIdentityEmitParameters, }; use owo_colors::OwoColorize; use std::process::Command; /// All parameters needed to run the ai command. -pub struct AiCommandParams<'a> { +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. @@ -25,7 +25,7 @@ pub struct AiCommandParams<'a> { /// 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: &AiCommandParams<'_>) -> Result<(), ReportalError> { +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(); @@ -38,7 +38,7 @@ pub fn run_ai(ai_params: &AiCommandParams<'_>) -> Result<(), ReportalError> { let ai_tool = loaded_config.get_ai_tool(tool_name)?; let prompt_label = format!("Launch {tool_name} in"); - let selection_params = SelectedRepoParams { + let selection_params = SelectedRepoParameters { loaded_config: &loaded_config, direct_alias: ai_params.direct_alias, tag_filter: &ai_params.tag_filter, @@ -46,8 +46,8 @@ pub fn run_ai(ai_params: &AiCommandParams<'_>) -> Result<(), ReportalError> { }; let selected = repo_selection::select_repo(&selection_params)?; - terminal_identity_emit::emit_repo_terminal_identity(&TerminalIdentityEmitParams { - selected_alias: selected.repo_alias(), + terminal_identity_emit::emit_repo_terminal_identity(&TerminalIdentityEmitParameters { + selected_alias: selected.repository_alias(), selected_repo: selected.repo_config(), title_override: "", }); diff --git a/src/reportal_commands/repo_color.rs b/src/reportal_commands/repo_color.rs deleted file mode 100644 index d472ded..0000000 --- a/src/reportal_commands/repo_color.rs +++ /dev/null @@ -1,119 +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, TabColorAction, TerminalIdentity, TerminalIdentityParams}; - -use super::color_command_mode::ColorCommandMode; - -/// 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, - /// How the command was invoked, controlling stdout and no-match behavior. - pub mode: ColorCommandMode, -} - -/// Resolves the tab title from a repo's config, falling back to the alias. -fn resolve_tab_title(tab_title: &TabTitle, fallback_alias: &str) -> String { - match tab_title { - TabTitle::Custom(custom_title) => custom_title.to_owned(), - TabTitle::UseAlias => fallback_alias.to_owned(), - } -} - -/// Converts a repo's color config into the appropriate tab color action. -fn resolve_tab_color(repo_color: &RepoColor) -> TabColorAction { - match repo_color { - RepoColor::Themed(hex_color) => TabColorAction::SetColor(hex_color.as_osc_tab_color_sequence()), - RepoColor::ResetToDefault => TabColorAction::Reset, - } -} - -/// Finds the best-matching repo for the current working directory using -/// longest-prefix-match against all registered repo paths. -fn resolve_from_working_directory(loaded_config: &ReportalConfig) -> Result, ReportalError> { - 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: Option<&crate::reportal_config::RepoEntry> = None; - - for (alias, repo) in &all_repos { - let repo_path = repo.resolved_path(); - if !current_directory.starts_with(&repo_path) { - continue; - } - let path_length = repo_path.as_os_str().len(); - if path_length > best_match_length { - best_match_length = path_length; - best_alias = alias.as_str(); - best_repo = Some(repo); - } - } - - Ok(best_repo.map(|matched_repo| { - let title = resolve_tab_title(matched_repo.tab_title(), best_alias); - let tab_color_action = resolve_tab_color(matched_repo.repo_color()); - (title, tab_color_action) - })) -} - -/// 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. -/// -/// When no repo matches, behavior depends on `mode`: -/// - `Explicit` (direct call): resets the tab color to terminal default. -/// - `PromptHook` (shell prompt): does nothing, preserving the -/// color and title set by the last `rj` or `ro` invocation. -/// -/// When `mode` is `PromptHook`, also writes the resolved title text -/// to stdout so shell integrations can set it via the native API -/// (e.g. `$Host.UI.RawUI.WindowTitle` in `PowerShell`). -pub fn run_color(color_params: &ColorCommandParams<'_>) -> Result<(), ReportalError> { - let loaded_config = ReportalConfig::load_or_initialize()?; - - let resolved = if color_params.repo_alias.is_empty() { - resolve_from_working_directory(&loaded_config)? - } else { - let found_repo = loaded_config.get_repo(color_params.repo_alias)?; - let title = resolve_tab_title(found_repo.tab_title(), color_params.repo_alias); - let tab_color_action = resolve_tab_color(found_repo.repo_color()); - Some((title, tab_color_action)) - }; - - match resolved { - Some((title, tab_color_action)) => { - match color_params.mode { - ColorCommandMode::PromptHook => terminal_style::write_stdout(&title.clone()), - ColorCommandMode::Explicit => {} - } - - let terminal_identity = TerminalIdentity::new(TerminalIdentityParams { - resolved_title: title, - tab_color_action, - }); - terminal_style::emit_terminal_identity_to_console(&terminal_identity); - } - 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/repo_edit.rs b/src/reportal_commands/repo_edit.rs index d2c48ec..cbeee80 100644 --- a/src/reportal_commands/repo_edit.rs +++ b/src/reportal_commands/repo_edit.rs @@ -2,14 +2,14 @@ use crate::error::ReportalError; use crate::reportal_commands::repo_edit_mutator::RepoEditFieldMutator; -use crate::reportal_commands::repo_selection::{self, SelectedRepoParams}; +use crate::reportal_commands::repo_selection::{self, SelectedRepoParameters}; use crate::reportal_config::{RepoColor, ReportalConfig, TabTitle, TagFilter}; use crate::terminal_style; use dialoguer::Select; use owo_colors::OwoColorize; /// All parameters needed to run the edit command. -pub struct EditCommandParams<'a> { +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. @@ -30,18 +30,18 @@ pub struct EditCommandParams<'a> { /// /// Returns whatever the underlying selection, prompt, config I/O, /// validation, or workspace regeneration call surfaces. -pub fn run_edit(command_params: &EditCommandParams<'_>) -> Result<(), ReportalError> { +pub fn run_edit(command_params: &EditCommandParameters<'_>) -> Result<(), ReportalError> { let mut loaded_config = ReportalConfig::load_or_initialize()?; let selected_alias = { - let selection_params = SelectedRepoParams { + 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.repo_alias().to_owned() + selection.repository_alias().to_owned() }; let initial_repo = loaded_config.get_repo(&selected_alias)?; diff --git a/src/reportal_commands/repo_edit_mutator.rs b/src/reportal_commands/repo_edit_mutator.rs index 0054979..a100cdf 100644 --- a/src/reportal_commands/repo_edit_mutator.rs +++ b/src/reportal_commands/repo_edit_mutator.rs @@ -5,7 +5,7 @@ use crate::error::ReportalError; use crate::reportal_commands::prompts::{ - self, ColorEditPromptParams, ColorEditResult, TextPromptParams, + self, ColorEditPromptParameters, ColorEditResult, TextPromptParameters, }; use crate::reportal_commands::workspace_operations::WorkspaceRegenerator; use crate::reportal_config::{RepoColor, ReportalConfig, TabTitle}; @@ -94,7 +94,7 @@ impl RepoEditFieldMutator<'_, '_, '_> { /// 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(&TextPromptParams { + let new_raw_path = prompts::prompt_for_text(&TextPromptParameters { prompt_theme: self.prompt_theme, label: "Path", default_value: &self.current_raw_path, @@ -131,7 +131,7 @@ impl RepoEditFieldMutator<'_, '_, '_> { /// 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(&TextPromptParams { + let new_description = prompts::prompt_for_text(&TextPromptParameters { prompt_theme: self.prompt_theme, label: "Description", default_value: &self.current_description, @@ -155,7 +155,7 @@ impl RepoEditFieldMutator<'_, '_, '_> { /// 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(&TextPromptParams { + let tags_input = prompts::prompt_for_text(&TextPromptParameters { prompt_theme: self.prompt_theme, label: "Tags (comma-separated)", default_value: &self.current_tags_csv, @@ -178,7 +178,7 @@ impl RepoEditFieldMutator<'_, '_, '_> { /// 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(&TextPromptParams { + let new_title = prompts::prompt_for_text(&TextPromptParameters { prompt_theme: self.prompt_theme, label: "Tab title (empty = use alias)", default_value: &self.current_title, @@ -208,7 +208,7 @@ impl RepoEditFieldMutator<'_, '_, '_> { /// 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(&ColorEditPromptParams { + let color_edit_result = prompts::prompt_for_color_edit(&ColorEditPromptParameters { prompt_theme: self.prompt_theme, current_default: &self.current_color_hex, })?; diff --git a/src/reportal_commands/repo_jump.rs b/src/reportal_commands/repo_jump.rs index edc27b8..d62d6b9 100644 --- a/src/reportal_commands/repo_jump.rs +++ b/src/reportal_commands/repo_jump.rs @@ -4,19 +4,19 @@ use crate::error::ReportalError; use crate::reportal_commands::direct_alias_router::{ DirectAliasRouter, DirectAliasRouterOutcome, }; -use crate::reportal_commands::path_display::{self, SelectedPathDisplayParams}; -use crate::reportal_commands::repo_selection::{self, SelectedRepoParams}; +use crate::reportal_commands::path_display::{self, SelectedPathDisplayParameters}; +use crate::reportal_commands::repo_selection::{self, SelectedRepoParameters}; use crate::reportal_commands::target_selection::{ - self, SelectedTarget, SelectedTargetParams, + self, SelectedTarget, SelectedTargetParameters, }; use crate::reportal_commands::terminal_identity_emit::{ - self, TerminalIdentityEmitParams, + 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. @@ -47,11 +47,11 @@ pub struct JumpCommandParams<'a> { /// 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: &JumpCommandParams<'_>) -> Result<(), ReportalError> { +pub fn run_jump(jump_params: &JumpCommandParameters<'_>) -> Result<(), ReportalError> { let loaded_config = ReportalConfig::load_or_initialize()?; let resolved_repo_alias: String = if jump_params.direct_alias.is_empty() { - let target_params = SelectedTargetParams { + let target_params = SelectedTargetParameters { loaded_config: &loaded_config, tag_filter: &jump_params.tag_filter, prompt_label: "Jump to repo or workspace", @@ -80,7 +80,7 @@ pub fn run_jump(jump_params: &JumpCommandParams<'_>) -> Result<(), ReportalError } }; - let selection_params = SelectedRepoParams { + let selection_params = SelectedRepoParameters { loaded_config: &loaded_config, direct_alias: &resolved_repo_alias, tag_filter: &jump_params.tag_filter, @@ -88,8 +88,8 @@ pub fn run_jump(jump_params: &JumpCommandParams<'_>) -> Result<(), ReportalError }; let selected = repo_selection::select_repo(&selection_params)?; - terminal_identity_emit::emit_repo_terminal_identity(&TerminalIdentityEmitParams { - selected_alias: selected.repo_alias(), + terminal_identity_emit::emit_repo_terminal_identity(&TerminalIdentityEmitParameters { + selected_alias: selected.repository_alias(), selected_repo: selected.repo_config(), title_override: jump_params.title_override, }); @@ -99,7 +99,7 @@ pub fn run_jump(jump_params: &JumpCommandParams<'_>) -> Result<(), ReportalError terminal_style::write_stdout(&formatted_path.clone()); - path_display::print_selected_path_if_visible(&SelectedPathDisplayParams { + path_display::print_selected_path_if_visible(&SelectedPathDisplayParameters { loaded_config: &loaded_config, resolved_path: &resolved_path, }); diff --git a/src/reportal_commands/repo_listing.rs b/src/reportal_commands/repo_listing.rs index ca893b8..8feb5c7 100644 --- a/src/reportal_commands/repo_listing.rs +++ b/src/reportal_commands/repo_listing.rs @@ -1,9 +1,9 @@ //! Lists registered repos grouped by workspace membership. -use crate::cli_args::ListArgsFilterParts; +use crate::cli_args::ListArgumentsFilterParts; use crate::error::ReportalError; use crate::reportal_commands::repo_listing_renderer::RepoListingRenderer; -use crate::reportal_commands::repo_tree_grouping::{RepoTreeGrouping, RepoTreeGroupingParams}; +use crate::reportal_commands::repo_tree_grouping::{RepoTreeGrouping, RepoTreeGroupingParameters}; use crate::reportal_config::ReportalConfig; /// Prints a workspace-grouped listing of registered repos. @@ -26,9 +26,9 @@ use crate::reportal_config::ReportalConfig; /// 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: &ListArgsFilterParts) -> Result<(), ReportalError> { +pub fn run_list(filter_parts: &ListArgumentsFilterParts) -> Result<(), ReportalError> { let loaded_config = ReportalConfig::load_or_initialize()?; - let tree_grouping = RepoTreeGrouping::build(&RepoTreeGroupingParams { + let tree_grouping = RepoTreeGrouping::build(&RepoTreeGroupingParameters { loaded_config: &loaded_config, tag_filter: filter_parts.tag_filter(), workspace_filter: filter_parts.workspace_filter(), diff --git a/src/reportal_commands/repo_listing_renderer.rs b/src/reportal_commands/repo_listing_renderer.rs index adb092a..aafb847 100644 --- a/src/reportal_commands/repo_listing_renderer.rs +++ b/src/reportal_commands/repo_listing_renderer.rs @@ -6,7 +6,7 @@ //! without a terminal, and keeps the rendering free to change //! its output format without touching the tree-building rules. -use crate::cli_args::ListArgsFilterParts; +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; @@ -48,7 +48,7 @@ impl<'tree> RepoListingRenderer<'tree> { /// /// Returns any error surfaced by the per-repo color swatch /// resolution path. - pub fn render(&self, filter_parts: &ListArgsFilterParts) -> Result<(), ReportalError> { + pub fn render(&self, filter_parts: &ListArgumentsFilterParts) -> Result<(), ReportalError> { if self.tree_grouping.is_empty() { Self::render_empty_state(filter_parts); return Ok(()); @@ -89,7 +89,7 @@ impl<'tree> RepoListingRenderer<'tree> { /// 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: &ListArgsFilterParts) { + fn render_empty_state(filter_parts: &ListArgumentsFilterParts) { let empty_state_message = match ( filter_parts.tag_filter(), filter_parts.workspace_filter(), @@ -145,10 +145,10 @@ impl<'tree> RepoListingRenderer<'tree> { /// project's argument rules cap non-`self` parameters at one /// per method. fn render_repo_leaf(repo_pair: &(&str, &RepoEntry)) -> Result<(), ReportalError> { - let (repo_alias, repo_entry) = *repo_pair; + 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 = repo_alias.to_uppercase(); + let uppercase_alias = repository_alias.to_uppercase(); terminal_style::write_stdout(&format!( " {} {}\n", "██".style(swatch_style), diff --git a/src/reportal_commands/repo_open.rs b/src/reportal_commands/repo_open.rs index fa4f64f..bddbb32 100644 --- a/src/reportal_commands/repo_open.rs +++ b/src/reportal_commands/repo_open.rs @@ -4,13 +4,13 @@ use crate::error::ReportalError; use crate::reportal_commands::direct_alias_router::{ DirectAliasRouter, DirectAliasRouterOutcome, }; -use crate::reportal_commands::repo_selection::{self, SelectedRepoParams}; +use crate::reportal_commands::repo_selection::{self, SelectedRepoParameters}; use crate::reportal_commands::run_workspace_open; use crate::reportal_commands::target_selection::{ - self, SelectedTarget, SelectedTargetParams, + self, SelectedTarget, SelectedTargetParameters, }; use crate::reportal_commands::terminal_identity_emit::{ - self, TerminalIdentityEmitParams, + self, TerminalIdentityEmitParameters, }; use crate::reportal_config::{PathVisibility, ReportalConfig, TagFilter}; use crate::terminal_style; @@ -18,7 +18,7 @@ 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. @@ -48,11 +48,11 @@ pub struct OpenCommandParams<'a> { /// [`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: &OpenCommandParams<'_>) -> Result<(), ReportalError> { +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 = SelectedTargetParams { + let target_params = SelectedTargetParameters { loaded_config: &loaded_config, tag_filter: &open_params.tag_filter, prompt_label: "Open repo or workspace", @@ -80,7 +80,7 @@ pub fn run_open(open_params: &OpenCommandParams<'_>) -> Result<(), ReportalError } }; - let selection_params = SelectedRepoParams { + let selection_params = SelectedRepoParameters { loaded_config: &loaded_config, direct_alias: &resolved_repo_alias, tag_filter: &open_params.tag_filter, @@ -88,8 +88,8 @@ pub fn run_open(open_params: &OpenCommandParams<'_>) -> Result<(), ReportalError }; let selected = repo_selection::select_repo(&selection_params)?; - terminal_identity_emit::emit_repo_terminal_identity(&TerminalIdentityEmitParams { - selected_alias: selected.repo_alias(), + terminal_identity_emit::emit_repo_terminal_identity(&TerminalIdentityEmitParameters { + selected_alias: selected.repository_alias(), selected_repo: selected.repo_config(), title_override: open_params.title_override, }); diff --git a/src/reportal_commands/repo_remove.rs b/src/reportal_commands/repo_remove.rs index 7450fb5..ff04113 100644 --- a/src/reportal_commands/repo_remove.rs +++ b/src/reportal_commands/repo_remove.rs @@ -22,10 +22,10 @@ use crate::terminal_style; /// [`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(repo_alias: &str) -> Result<(), ReportalError> { +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(repo_alias); + let containing_workspaces = loaded_config.workspaces_containing_repo(repository_alias); if !containing_workspaces.is_empty() { let joined_workspace_names = containing_workspaces .iter() @@ -33,13 +33,13 @@ pub fn run_remove(repo_alias: &str) -> Result<(), ReportalError> { .collect::>() .join(", "); return Err(ReportalError::RepoIsWorkspaceMember { - alias: repo_alias.to_owned(), + alias: repository_alias.to_owned(), affected_workspaces: joined_workspace_names, }); } - let removed_entry = loaded_config.remove_repo(repo_alias)?; + 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 index 19abfef..60f6ad2 100644 --- a/src/reportal_commands/repo_run.rs +++ b/src/reportal_commands/repo_run.rs @@ -3,9 +3,9 @@ use crate::error::ReportalError; use crate::reportal_config::{CommandEntry, ReportalConfig, TagFilter}; use crate::terminal_style; -use crate::reportal_commands::repo_selection::{self, SelectedRepoParams}; +use crate::reportal_commands::repo_selection::{self, SelectedRepoParameters}; use crate::reportal_commands::terminal_identity_emit::{ - self, TerminalIdentityEmitParams, + self, TerminalIdentityEmitParameters, }; use dialoguer::FuzzySelect; use owo_colors::OwoColorize; @@ -13,7 +13,7 @@ use std::collections::BTreeMap; use std::process::Command; /// All parameters needed to run the run command. -pub struct RunCommandParams<'a> { +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. @@ -36,7 +36,7 @@ struct MergedCommandEntry<'a> { } /// Parameters for resolving which command to run. -struct ResolveCommandParams<'a> { +struct ResolveCommandParameters<'a> { /// Global commands from config. global_commands: &'a BTreeMap, /// Per-repo commands (override globals with same name, or add repo-specific ones). @@ -49,7 +49,7 @@ struct ResolveCommandParams<'a> { /// which command to run (direct or fuzzy-selected). /// /// Repo-level commands override global commands with the same name. -fn resolve_command(resolve_params: &ResolveCommandParams<'_>) -> Result { +fn resolve_command(resolve_params: &ResolveCommandParameters<'_>) -> Result { let mut merged: Vec> = Vec::new(); for (name, entry) in resolve_params.global_commands { @@ -136,10 +136,10 @@ fn resolve_command(resolve_params: &ResolveCommandParams<'_>) -> Result.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: &RunCommandParams<'_>) -> Result<(), ReportalError> { +pub fn run_run(run_params: &RunCommandParameters<'_>) -> Result<(), ReportalError> { let loaded_config = ReportalConfig::load_or_initialize()?; - let selection_params = SelectedRepoParams { + let selection_params = SelectedRepoParameters { loaded_config: &loaded_config, direct_alias: run_params.direct_alias, tag_filter: &run_params.tag_filter, @@ -147,13 +147,13 @@ pub fn run_run(run_params: &RunCommandParams<'_>) -> Result<(), ReportalError> { }; let selected = repo_selection::select_repo(&selection_params)?; - terminal_identity_emit::emit_repo_terminal_identity(&TerminalIdentityEmitParams { - selected_alias: selected.repo_alias(), + 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(&ResolveCommandParams { + 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, diff --git a/src/reportal_commands/repo_selection.rs b/src/reportal_commands/repo_selection.rs index 198aa6a..3236021 100644 --- a/src/reportal_commands/repo_selection.rs +++ b/src/reportal_commands/repo_selection.rs @@ -11,7 +11,7 @@ use std::fmt::Write; /// Holds borrowed references into the loaded config so callers can read /// the alias and entry without cloning. pub struct SelectedRepo<'a> { - repo_alias: &'a str, + repository_alias: &'a str, repo_config: &'a RepoEntry, } @@ -19,8 +19,8 @@ pub struct SelectedRepo<'a> { 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 repo_alias(&self) -> &'a str { - self.repo_alias + pub fn repository_alias(&self) -> &'a str { + self.repository_alias } /// The full config entry for this repo, including path, tags, @@ -32,7 +32,7 @@ impl<'a> SelectedRepo<'a> { /// 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 SelectedRepoParams<'a> { +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. @@ -56,11 +56,11 @@ fn push_formatted(target: &mut String, format_payload: std::fmt::Arguments<'_>) /// 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 SelectedRepoParams<'a>) -> Result, ReportalError> { +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 { - repo_alias: selection_params.direct_alias, + repository_alias: selection_params.direct_alias, repo_config: found_repo, }); } @@ -120,7 +120,7 @@ pub fn select_repo<'a>(selection_params: &'a SelectedRepoParams<'a>) -> Result { +struct StatusCollectionParameters<'a> { /// The alias of the repo being checked. alias: &'a str, /// The resolved filesystem path to the repo. @@ -94,7 +94,7 @@ fn parse_usize(raw_text: &str) -> UsizeParse { /// Reads the current branch name from a repo directory. fn read_branch_name(repo_path: &PathBuf) -> String { - match git_commands::run_git_command(&GitCommandParams { + match git_commands::run_git_command(&GitCommandParameters { repo_path, git_subcommand_args: &["rev-parse", "--abbrev-ref", "HEAD"], }) { @@ -106,7 +106,7 @@ fn read_branch_name(repo_path: &PathBuf) -> String { /// Checks whether the working tree has uncommitted changes. fn read_working_tree_state(repo_path: &PathBuf) -> WorkingTreeState { - match git_commands::run_git_command(&GitCommandParams { + match git_commands::run_git_command(&GitCommandParameters { repo_path, git_subcommand_args: &["status", "--porcelain"], }) { @@ -133,7 +133,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 git_commands::run_git_command(&GitCommandParams { + match git_commands::run_git_command(&GitCommandParameters { repo_path, git_subcommand_args: &["rev-list", "--left-right", "--count", "HEAD...@{upstream}"], }) { @@ -147,7 +147,7 @@ fn read_upstream_delta(repo_path: &PathBuf) -> UpstreamDelta { /// Reads the relative age of the last commit (e.g. "3 days ago"). fn read_last_commit_age(repo_path: &PathBuf) -> String { - match git_commands::run_git_command(&GitCommandParams { + match git_commands::run_git_command(&GitCommandParameters { repo_path, git_subcommand_args: &["log", "-1", "--format=%cr"], }) { @@ -158,7 +158,7 @@ fn read_last_commit_age(repo_path: &PathBuf) -> String { } /// Collects git status for a single repo by running git commands in its directory. -fn collect_repo_status(status_collection_params: &StatusCollectionParams<'_>) -> RepoGitStatus { +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); @@ -214,7 +214,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, }) diff --git a/src/reportal_commands/repo_sync.rs b/src/reportal_commands/repo_sync.rs index b6ef78a..2488ccb 100644 --- a/src/reportal_commands/repo_sync.rs +++ b/src/reportal_commands/repo_sync.rs @@ -1,7 +1,7 @@ //! Pulls latest changes across all registered repos. use crate::error::ReportalError; -use crate::reportal_commands::git_commands::{self, GitCommandOutcome, GitCommandParams}; +use crate::reportal_commands::git_commands::{self, GitCommandOutcome, GitCommandParameters}; use crate::reportal_config::{ReportalConfig, TagFilter}; use crate::terminal_style; use owo_colors::OwoColorize; @@ -40,7 +40,7 @@ struct RepoSyncResult { /// Checks whether the working tree is clean enough to safely pull. fn check_pull_readiness(repo_path: &PathBuf) -> PullReadiness { - match git_commands::run_git_command(&GitCommandParams { + match git_commands::run_git_command(&GitCommandParameters { repo_path, git_subcommand_args: &["status", "--porcelain"], }) { diff --git a/src/reportal_commands/repo_tree_grouping.rs b/src/reportal_commands/repo_tree_grouping.rs index 561795c..a10dea9 100644 --- a/src/reportal_commands/repo_tree_grouping.rs +++ b/src/reportal_commands/repo_tree_grouping.rs @@ -63,7 +63,7 @@ impl<'config> RepoTreeGrouping<'config> { /// alias fails post-load resolution (which should not happen /// if `validate_workspace_references` succeeded). pub fn build( - build_params: &RepoTreeGroupingParams<'config>, + build_params: &RepoTreeGroupingParameters<'config>, ) -> Result { build_params.validate_workspace_target()?; let workspace_sections = build_params.collect_workspace_sections()?; @@ -121,7 +121,7 @@ impl<'config> RepoTreeGrouping<'config> { /// 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 RepoTreeGroupingParams<'config> { +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. @@ -130,10 +130,10 @@ pub struct RepoTreeGroupingParams<'config> { pub(super) workspace_filter: &'config WorkspaceFilter, } -/// Collection methods for `RepoTreeGroupingParams`, each written +/// 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> RepoTreeGroupingParams<'config> { +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 @@ -238,7 +238,7 @@ impl<'config> RepoTreeGroupingParams<'config> { if workspace_restriction_active { return unassigned_repos; } - for (repo_alias, repo_entry) in self.loaded_config.repos_with_aliases() { + for (repository_alias, repo_entry) in self.loaded_config.repos_with_aliases() { let repo_survives_tag = !tag_restriction_active || repo_entry .tags() @@ -248,9 +248,9 @@ impl<'config> RepoTreeGroupingParams<'config> { continue; } let containing_workspaces = - self.loaded_config.workspaces_containing_repo(repo_alias); + self.loaded_config.workspaces_containing_repo(repository_alias); if containing_workspaces.is_empty() { - unassigned_repos.push((repo_alias.as_str(), repo_entry)); + unassigned_repos.push((repository_alias.as_str(), repo_entry)); } } unassigned_repos @@ -259,7 +259,7 @@ impl<'config> RepoTreeGroupingParams<'config> { /// 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 - /// `WorkspaceFilterArgs::into_workspace_filter` rejects + /// `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 { @@ -271,7 +271,7 @@ impl<'config> RepoTreeGroupingParams<'config> { /// 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 `TagFilterArgs::into_tag_filter` + /// 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 { @@ -294,7 +294,7 @@ mod tests { tag_filter: &'config TagFilter, workspace_filter: &'config WorkspaceFilter, ) -> Result, ReportalError> { - RepoTreeGrouping::build(&RepoTreeGroupingParams { + RepoTreeGrouping::build(&RepoTreeGroupingParameters { loaded_config: test_config, tag_filter, workspace_filter, @@ -478,7 +478,7 @@ mod tests { let observed_member_order: Vec<&str> = tree_grouping.workspace_sections()[0] .member_repos() .iter() - .map(|(repo_alias, _repo_entry)| *repo_alias) + .map(|(repository_alias, _repo_entry)| *repository_alias) .collect(); assert_eq!( observed_member_order, diff --git a/src/reportal_commands/repo_web.rs b/src/reportal_commands/repo_web.rs index 04960b5..868d97d 100644 --- a/src/reportal_commands/repo_web.rs +++ b/src/reportal_commands/repo_web.rs @@ -3,15 +3,15 @@ use crate::error::ReportalError; use crate::reportal_config::{ReportalConfig, TagFilter}; use crate::terminal_style; -use crate::reportal_commands::repo_selection::{self, SelectedRepoParams}; +use crate::reportal_commands::repo_selection::{self, SelectedRepoParameters}; use crate::reportal_commands::terminal_identity_emit::{ - self, TerminalIdentityEmitParams, + self, TerminalIdentityEmitParameters, }; use owo_colors::OwoColorize; use std::process::Command; /// All parameters needed to run the web command. -pub struct WebCommandParams<'a> { +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. @@ -82,10 +82,10 @@ fn detect_git_remote(repo_path: &std::path::Path) -> RemoteResolution { /// 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: &WebCommandParams<'_>) -> Result<(), ReportalError> { +pub fn run_web(web_params: &WebCommandParameters<'_>) -> Result<(), ReportalError> { let loaded_config = ReportalConfig::load_or_initialize()?; - let selection_params = SelectedRepoParams { + let selection_params = SelectedRepoParameters { loaded_config: &loaded_config, direct_alias: web_params.direct_alias, tag_filter: &web_params.tag_filter, @@ -93,8 +93,8 @@ pub fn run_web(web_params: &WebCommandParams<'_>) -> Result<(), ReportalError> { }; let selected = repo_selection::select_repo(&selection_params)?; - terminal_identity_emit::emit_repo_terminal_identity(&TerminalIdentityEmitParams { - selected_alias: selected.repo_alias(), + terminal_identity_emit::emit_repo_terminal_identity(&TerminalIdentityEmitParameters { + selected_alias: selected.repository_alias(), selected_repo: selected.repo_config(), title_override: "", }); @@ -103,7 +103,7 @@ pub fn run_web(web_params: &WebCommandParams<'_>) -> Result<(), ReportalError> { RemoteResolution::Resolved(detected_url) => detected_url, RemoteResolution::NotFound => { return Err(ReportalError::NoRemoteUrl { - alias: selected.repo_alias().to_owned(), + alias: selected.repository_alias().to_owned(), }); } } } else { selected.repo_config().remote().to_owned() }; diff --git a/src/reportal_commands/repository_color.rs b/src/reportal_commands/repository_color.rs new file mode 100644 index 0000000..c4f08d6 --- /dev/null +++ b/src/reportal_commands/repository_color.rs @@ -0,0 +1,152 @@ +//! 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_integration.rs b/src/reportal_commands/shell_integration.rs index 62c74dd..df43cd2 100644 --- a/src/reportal_commands/shell_integration.rs +++ b/src/reportal_commands/shell_integration.rs @@ -21,7 +21,7 @@ use std::fs::OpenOptions; use std::io::Write; use std::path::{Path, PathBuf}; -use crate::cli_args::InitShell; +use crate::cli_args::InitializeShell; /// Fenced marker opening the auto-managed integration block. This /// string is byte-for-byte identical to the markers used by the shell @@ -115,11 +115,11 @@ enum AutoSetupOutcome { } /// Parses `$SHELL`'s basename (or picks `PowerShell` on Windows when -/// `$SHELL` is absent) and returns the matching `InitShell` variant. +/// `$SHELL` is absent) and returns the matching `InitializeShell` variant. /// Returns `None` for fish, tcsh, csh, or anything else we don't /// generate integration code for — those users can run /// `rep init ` themselves and will get a clear rejection. -fn shell_from_env() -> Option { +fn shell_from_env() -> Option { if let Some(shell_path) = env::var_os("SHELL") { let basename = Path::new(&shell_path) .file_name() @@ -127,9 +127,9 @@ fn shell_from_env() -> Option { .map(str::to_ascii_lowercase); if let Some(name) = basename { return match name.as_str() { - "zsh" => Some(InitShell::Zsh), - "bash" => Some(InitShell::Bash), - "pwsh" | "powershell" => Some(InitShell::Powershell), + "zsh" => Some(InitializeShell::Zsh), + "bash" => Some(InitializeShell::Bash), + "pwsh" | "powershell" => Some(InitializeShell::Powershell), _ => None, }; } @@ -138,7 +138,7 @@ fn shell_from_env() -> Option { // matches the installer's behavior and covers users who launched // from `pwsh`/`powershell.exe` directly. if cfg!(windows) { - Some(InitShell::Powershell) + Some(InitializeShell::Powershell) } else { None } @@ -156,11 +156,11 @@ fn shell_from_env() -> Option { /// /// Returns `None` if the home directory cannot be determined and no /// `$PROFILE` is set — in which case auto-setup quietly bails. -fn rc_file_for(shell: InitShell) -> Option { +fn rc_file_for(shell: InitializeShell) -> Option { match shell { - InitShell::Zsh => dirs::home_dir().map(|home| home.join(".zshrc")), - InitShell::Bash => dirs::home_dir().map(|home| home.join(".bashrc")), - InitShell::Powershell => powershell_profile_path(), + InitializeShell::Zsh => dirs::home_dir().map(|home| home.join(".zshrc")), + InitializeShell::Bash => dirs::home_dir().map(|home| home.join(".bashrc")), + InitializeShell::Powershell => powershell_profile_path(), } } @@ -186,11 +186,11 @@ fn powershell_profile_path() -> Option { /// given shell. Byte-for-byte identical to what the shell installer /// writes, so either code path producing the block results in the /// same file contents. -fn eval_line_for(shell: InitShell) -> &'static str { +fn eval_line_for(shell: InitializeShell) -> &'static str { match shell { - InitShell::Zsh => "eval \"$(rep init zsh)\"", - InitShell::Bash => "eval \"$(rep init bash)\"", - InitShell::Powershell => "Invoke-Expression (& rep init powershell | Out-String)", + InitializeShell::Zsh => "eval \"$(rep init zsh)\"", + InitializeShell::Bash => "eval \"$(rep init bash)\"", + InitializeShell::Powershell => "Invoke-Expression (& rep init powershell | Out-String)", } } @@ -201,7 +201,7 @@ fn eval_line_for(shell: InitShell) -> &'static str { /// for the caller's stderr hint. fn append_integration_if_missing( rc_file: &Path, - shell: InitShell, + shell: InitializeShell, ) -> Result { if rc_file.exists() { let existing = std::fs::read_to_string(rc_file) @@ -231,7 +231,7 @@ fn append_integration_if_missing( /// starts on its own line. fn write_fenced_block( rc_file: &Path, - shell: InitShell, + shell: InitializeShell, needs_leading_newline: bool, ) -> Result<(), String> { let mut file = OpenOptions::new() @@ -240,7 +240,7 @@ fn write_fenced_block( .open(rc_file) .map_err(|open_err| format!("open {}: {open_err}", rc_file.display()))?; - let eol = if matches!(shell, InitShell::Powershell) && cfg!(windows) { + let eol = if matches!(shell, InitializeShell::Powershell) && cfg!(windows) { "\r\n" } else { "\n" diff --git a/src/reportal_commands/shell_prompt_badge.rs b/src/reportal_commands/shell_prompt_badge.rs new file mode 100644 index 0000000..d2907a7 --- /dev/null +++ b/src/reportal_commands/shell_prompt_badge.rs @@ -0,0 +1,111 @@ +//! 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 self-registration.** The hook moves itself to +//! the end of the shell's prompt-hook chain every time it +//! runs. For zsh that means re-appending to +//! `precmd_functions`; for bash it means rewriting +//! `PROMPT_COMMAND` so our entry is last. Whoever else +//! registered a prompt hook still runs, but rep always runs +//! *after* them, so the strip-and-prepend sees the +//! framework's final prompt content and lays the badge on +//! top. +//! +//! 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 + precmd_functions=(${{precmd_functions:#_reportal_prompt_badge_hook}} _reportal_prompt_badge_hook) +}} +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 index 31e79ca..dea20af 100644 --- a/src/reportal_commands/target_selection.rs +++ b/src/reportal_commands/target_selection.rs @@ -36,7 +36,7 @@ pub enum SelectedTarget { /// 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 SelectedTargetParams<'borrow> { +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 @@ -67,7 +67,7 @@ pub struct SelectedTargetParams<'borrow> { /// escapes the prompt; [`ReportalError::ConfigIoFailure`] for /// fuzzy-finder I/O errors. pub fn select_target( - params: &SelectedTargetParams<'_>, + params: &SelectedTargetParameters<'_>, ) -> Result { let matching_repos = params.loaded_config.repos_matching_tag_filter(params.tag_filter); let workspace_rows_included = match params.tag_filter { @@ -83,14 +83,14 @@ pub fn select_target( matching_repos.len() + workspace_rows_included.len(), ); - for (repo_alias, repo_entry) in &matching_repos { + 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} {repo_alias}"); + 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})"); @@ -137,7 +137,7 @@ pub fn select_target( if chosen_index < repo_count { match matching_repos.get(chosen_index) { Some((chosen_repo_alias, _chosen_repo_entry)) => { - Ok(SelectedTarget::Repo(chosen_repo_alias.to_string())) + Ok(SelectedTarget::Repo((*chosen_repo_alias).clone())) } None => Err(ReportalError::SelectionCancelled), } @@ -145,7 +145,7 @@ pub fn select_target( 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.to_string())) + 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 index 0c76a0d..62edb44 100644 --- a/src/reportal_commands/terminal_identity_emit.rs +++ b/src/reportal_commands/terminal_identity_emit.rs @@ -1,10 +1,10 @@ //! 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, TerminalIdentityParams}; +use crate::terminal_style::{self, TabColorAction, TerminalIdentity, TerminalIdentityParameters}; /// Parameters for emitting terminal identity (tab title + color). -pub struct TerminalIdentityEmitParams<'a> { +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). @@ -18,7 +18,7 @@ pub struct TerminalIdentityEmitParams<'a> { /// /// 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: &TerminalIdentityEmitParams<'_>) { +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(), @@ -35,7 +35,7 @@ pub fn emit_repo_terminal_identity(identity_params: &TerminalIdentityEmitParams< RepoColor::ResetToDefault => TabColorAction::Reset, }; - let identity = TerminalIdentity::new(TerminalIdentityParams { + let identity = TerminalIdentity::new(TerminalIdentityParameters { resolved_title, tab_color_action, }); diff --git a/src/reportal_commands/workspace_create.rs b/src/reportal_commands/workspace_create.rs index 8d90dec..aa88e20 100644 --- a/src/reportal_commands/workspace_create.rs +++ b/src/reportal_commands/workspace_create.rs @@ -1,7 +1,7 @@ //! Creates a new workspace and materializes its on-disk directory //! (symlinks + `.code-workspace` file). -use crate::cli_args::WorkspaceArgsCreateParts; +use crate::cli_args::WorkspaceArgumentsCreateParts; use crate::error::ReportalError; use crate::reportal_commands::workspace_operations::WorkspaceRegenerator; use crate::reportal_config::{ReportalConfig, WorkspaceRegistrationBuilder}; @@ -33,7 +33,7 @@ use std::path::PathBuf; /// is not registered, or the file/link I/O errors the regeneration /// path surfaces. pub fn run_workspace_create( - create_parts: &WorkspaceArgsCreateParts, + create_parts: &WorkspaceArgumentsCreateParts, ) -> Result<(), ReportalError> { let mut loaded_config = ReportalConfig::load_or_initialize()?; diff --git a/src/reportal_commands/workspace_delete.rs b/src/reportal_commands/workspace_delete.rs index 4109ece..c949ca5 100644 --- a/src/reportal_commands/workspace_delete.rs +++ b/src/reportal_commands/workspace_delete.rs @@ -1,7 +1,7 @@ //! Removes a workspace from config, optionally purging its on-disk //! directory. -use crate::cli_args::WorkspaceArgsDeleteParts; +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; @@ -26,7 +26,7 @@ use owo_colors::OwoColorize; /// 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: &WorkspaceArgsDeleteParts, + delete_parts: &WorkspaceArgumentsDeleteParts, ) -> Result<(), ReportalError> { let mut loaded_config = ReportalConfig::load_or_initialize()?; let canonical_name = diff --git a/src/reportal_commands/workspace_dispatch.rs b/src/reportal_commands/workspace_dispatch.rs index 186d82b..5f9df7c 100644 --- a/src/reportal_commands/workspace_dispatch.rs +++ b/src/reportal_commands/workspace_dispatch.rs @@ -1,11 +1,11 @@ //! Dispatch layer for `rep workspace` subcommands. //! -//! Routes a parsed `WorkspaceArgs` to the matching handler in one +//! 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::{WorkspaceArgs, WorkspaceArgsSubcommand}; +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, @@ -16,7 +16,7 @@ use crate::reportal_commands::{ /// Dispatches a parsed `rep workspace` invocation to the matching /// subcommand handler. /// -/// Taking ownership of `WorkspaceArgs` is required because every +/// 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 @@ -28,32 +28,32 @@ use crate::reportal_commands::{ /// individual `run_workspace_*` functions for the per-action error /// set. pub fn dispatch_workspace_subcommand( - parsed_workspace_args: WorkspaceArgs, + parsed_workspace_args: WorkspaceArguments, ) -> Result<(), ReportalError> { match parsed_workspace_args.into_action() { - WorkspaceArgsSubcommand::List => run_workspace_list(), - WorkspaceArgsSubcommand::Show(name_only) => { + WorkspaceArgumentsSubcommand::List => run_workspace_list(), + WorkspaceArgumentsSubcommand::Show(name_only) => { run_workspace_show(&name_only.into_workspace_name()) } - WorkspaceArgsSubcommand::Create(create_args) => { + WorkspaceArgumentsSubcommand::Create(create_args) => { run_workspace_create(&create_args.into_parts()) } - WorkspaceArgsSubcommand::Delete(delete_args) => { + WorkspaceArgumentsSubcommand::Delete(delete_args) => { run_workspace_delete(&delete_args.into_parts()) } - WorkspaceArgsSubcommand::AddRepo(member_edit) => { + WorkspaceArgumentsSubcommand::AddRepo(member_edit) => { run_workspace_add_repo(&member_edit.into_parts()) } - WorkspaceArgsSubcommand::RemoveRepo(member_edit) => { + WorkspaceArgumentsSubcommand::RemoveRepo(member_edit) => { run_workspace_remove_repo(&member_edit.into_parts()) } - WorkspaceArgsSubcommand::Open(optional_name) => { + WorkspaceArgumentsSubcommand::Open(optional_name) => { run_workspace_open(&optional_name.into_optional_workspace_name()) } - WorkspaceArgsSubcommand::Jump(optional_name) => { + WorkspaceArgumentsSubcommand::Jump(optional_name) => { run_workspace_jump(&optional_name.into_optional_workspace_name()) } - WorkspaceArgsSubcommand::Rebuild(name_only) => { + WorkspaceArgumentsSubcommand::Rebuild(name_only) => { run_workspace_rebuild(&name_only.into_workspace_name()) } } diff --git a/src/reportal_commands/workspace_layout.rs b/src/reportal_commands/workspace_layout.rs index e1a3d4b..51e1945 100644 --- a/src/reportal_commands/workspace_layout.rs +++ b/src/reportal_commands/workspace_layout.rs @@ -87,7 +87,7 @@ pub fn create_workspace_link(target: &Path, link_path: &Path) -> io::Result<()> /// 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 WorkspaceLayoutParams<'caller> { +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, @@ -98,6 +98,15 @@ pub struct WorkspaceLayoutParams<'caller> { /// 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 @@ -123,7 +132,7 @@ pub struct WorkspaceLayoutParams<'caller> { /// occupied by a real file / directory that is not a symlink /// pointing at the expected target. pub fn materialize_workspace_layout( - layout_params: &WorkspaceLayoutParams<'_>, + layout_params: &WorkspaceLayoutParameters<'_>, ) -> Result { ensure_directory_exists(layout_params.workspace_directory)?; for link_spec in layout_params.member_links { @@ -141,6 +150,12 @@ pub fn materialize_workspace_layout( 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) } @@ -375,10 +390,12 @@ mod tests { }, ]; - let workspace_file_path = materialize_workspace_layout(&WorkspaceLayoutParams { + 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"); @@ -457,10 +474,12 @@ mod tests { target_absolute_path: real_target.clone(), }]; - materialize_workspace_layout(&WorkspaceLayoutParams { + 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"); @@ -472,10 +491,12 @@ mod tests { ); // Second run is a no-op — must still succeed. - materialize_workspace_layout(&WorkspaceLayoutParams { + 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"); @@ -506,10 +527,12 @@ mod tests { target_absolute_path: real_target.clone(), }]; - let outcome = materialize_workspace_layout(&WorkspaceLayoutParams { + 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!( diff --git a/src/reportal_commands/workspace_members.rs b/src/reportal_commands/workspace_members.rs index b27f1d2..57f041c 100644 --- a/src/reportal_commands/workspace_members.rs +++ b/src/reportal_commands/workspace_members.rs @@ -1,6 +1,6 @@ //! Add-repo and remove-repo subcommands for workspace membership. -use crate::cli_args::WorkspaceArgsMemberEditParts; +use crate::cli_args::WorkspaceArgumentsMemberEditParts; use crate::error::ReportalError; use crate::reportal_commands::workspace_operations::WorkspaceRegenerator; use crate::reportal_config::{ @@ -26,26 +26,26 @@ use owo_colors::OwoColorize; /// 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: &WorkspaceArgsMemberEditParts, + member_edit: &WorkspaceArgumentsMemberEditParts, ) -> Result<(), ReportalError> { let mut loaded_config = ReportalConfig::load_or_initialize()?; - loaded_config.get_repo(member_edit.repo_alias())?; + 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.repo_alias()) { + 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.repo_alias(), + member_edit.repository_alias(), ), }); } let mut updated_members = target_workspace.members().to_vec(); updated_members.push(WorkspaceMember::RegisteredRepo( - member_edit.repo_alias().to_owned(), + member_edit.repository_alias().to_owned(), )); target_workspace.set_members(updated_members); loaded_config.save_to_disk()?; @@ -55,7 +55,7 @@ pub fn run_workspace_add_repo( terminal_style::print_success(&format!( "Added {} to workspace {}", - member_edit.repo_alias().style(terminal_style::ALIAS_STYLE), + member_edit.repository_alias().style(terminal_style::ALIAS_STYLE), canonical_workspace_name.style(terminal_style::ALIAS_STYLE), )); Ok(()) @@ -80,18 +80,18 @@ pub fn run_workspace_add_repo( /// zero members. Config I/O and file regeneration errors bubble up /// from the underlying calls. pub fn run_workspace_remove_repo( - member_edit: &WorkspaceArgsMemberEditParts, + 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.repo_alias()) { + 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.repo_alias(), + member_edit.repository_alias(), ), }); } @@ -100,7 +100,7 @@ pub fn run_workspace_remove_repo( .iter() .filter(|member| match member.registered_repo_alias() { WorkspaceMemberAliasLookup::Matches(existing_alias) => { - existing_alias != member_edit.repo_alias() + existing_alias != member_edit.repository_alias() } WorkspaceMemberAliasLookup::NotARepoReference => true, }) @@ -111,7 +111,7 @@ pub fn run_workspace_remove_repo( field: "workspace member".to_owned(), reason: format!( "removing '{}' would leave workspace '{canonical_workspace_name}' with zero members; delete the workspace instead", - member_edit.repo_alias(), + member_edit.repository_alias(), ), }); } @@ -123,7 +123,7 @@ pub fn run_workspace_remove_repo( terminal_style::print_success(&format!( "Removed {} from workspace {}", - member_edit.repo_alias().style(terminal_style::ALIAS_STYLE), + 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_operations.rs b/src/reportal_commands/workspace_operations.rs index 67c5985..1f090a4 100644 --- a/src/reportal_commands/workspace_operations.rs +++ b/src/reportal_commands/workspace_operations.rs @@ -16,7 +16,7 @@ use crate::error::ReportalError; use crate::reportal_commands::workspace_layout::{ - materialize_workspace_layout, workspace_file_path_inside_dir, WorkspaceLayoutParams, + materialize_workspace_layout, workspace_file_path_inside_dir, WorkspaceLayoutParameters, WorkspaceLinkSpec, }; use crate::reportal_config::{ReportalConfig, WorkspaceEntry, WorkspaceMember}; @@ -71,10 +71,20 @@ impl<'config_lifetime> WorkspaceRegenerator<'config_lifetime> { 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)?; - materialize_workspace_layout(&WorkspaceLayoutParams { + 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(), }) } @@ -150,10 +160,10 @@ impl<'config_lifetime> WorkspaceRegenerator<'config_lifetime> { let mut resolved_specs = Vec::with_capacity(declared_members.len()); for member in declared_members { match member { - WorkspaceMember::RegisteredRepo(repo_alias) => { - let member_repo = self.config_registry.get_repo(repo_alias)?; + WorkspaceMember::RegisteredRepo(repository_alias) => { + let member_repo = self.config_registry.get_repo(repository_alias)?; resolved_specs.push(WorkspaceLinkSpec { - link_name: repo_alias.clone(), + link_name: repository_alias.clone(), target_absolute_path: member_repo.resolved_path(), }); } diff --git a/src/reportal_commands/workspace_selection.rs b/src/reportal_commands/workspace_selection.rs index e198964..c1641be 100644 --- a/src/reportal_commands/workspace_selection.rs +++ b/src/reportal_commands/workspace_selection.rs @@ -64,7 +64,7 @@ pub fn select_workspace(loaded_config: &ReportalConfig) -> Result { - Ok(chosen_workspace_name.to_string()) + Ok((*chosen_workspace_name).clone()) } None => Err(ReportalError::SelectionCancelled), } diff --git a/src/reportal_config/mod.rs b/src/reportal_config/mod.rs index 7c85ded..e90c95c 100644 --- a/src/reportal_config/mod.rs +++ b/src/reportal_config/mod.rs @@ -9,7 +9,7 @@ mod command_entry; mod global_settings; mod has_aliases; mod hex_color; -mod repo_color; +mod repository_color; mod repo_entry; mod repo_registration_builder; mod reportal_config_root; @@ -24,7 +24,7 @@ pub use command_entry::CommandEntry; pub use global_settings::PathVisibility; pub use has_aliases::HasAliases; pub use hex_color::HexColor; -pub use repo_color::RepoColor; +pub use repository_color::RepoColor; pub use repo_entry::RepoEntry; pub use repo_registration_builder::RepoRegistrationBuilder; pub use reportal_config_root::ReportalConfig; diff --git a/src/reportal_config/repo_entry.rs b/src/reportal_config/repo_entry.rs index 902461a..8bacba2 100644 --- a/src/reportal_config/repo_entry.rs +++ b/src/reportal_config/repo_entry.rs @@ -2,7 +2,7 @@ use crate::reportal_config::command_entry::CommandEntry; use crate::reportal_config::has_aliases::HasAliases; -use crate::reportal_config::repo_color::RepoColor; +use crate::reportal_config::repository_color::RepoColor; use crate::reportal_config::tab_title::TabTitle; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; diff --git a/src/reportal_config/repo_registration_builder.rs b/src/reportal_config/repo_registration_builder.rs index 27c80ee..b4b7cc4 100644 --- a/src/reportal_config/repo_registration_builder.rs +++ b/src/reportal_config/repo_registration_builder.rs @@ -2,7 +2,7 @@ use crate::error::ReportalError; use crate::reportal_config::hex_color::HexColor; -use crate::reportal_config::repo_color::RepoColor; +use crate::reportal_config::repository_color::RepoColor; use crate::reportal_config::repo_entry::RepoEntry; use crate::reportal_config::tab_title::TabTitle; use std::collections::BTreeMap; diff --git a/src/reportal_config/reportal_config_root.rs b/src/reportal_config/reportal_config_root.rs index 67af475..c7cd84f 100644 --- a/src/reportal_config/reportal_config_root.rs +++ b/src/reportal_config/reportal_config_root.rs @@ -371,13 +371,13 @@ impl ReportalConfig { /// /// 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) { + let (repository_alias, repo_entry) = validated_registration; + if self.repos.contains_key(&repository_alias) { return Err(ReportalError::RepoAlreadyExists { - alias: repo_alias, + alias: repository_alias, }); } - self.repos.insert(repo_alias, repo_entry); + self.repos.insert(repository_alias, repo_entry); Ok(()) } @@ -523,11 +523,11 @@ impl ReportalConfig { #[must_use] pub fn workspaces_containing_repo( &self, - repo_alias: &str, + repository_alias: &str, ) -> Vec<(&String, &WorkspaceEntry)> { self.workspaces .iter() - .filter(|(_, workspace)| workspace.contains_repo(repo_alias)) + .filter(|(_, workspace)| workspace.contains_repo(repository_alias)) .collect() } @@ -759,6 +759,8 @@ mod tests { description: String::new(), path: String::new(), aliases: Vec::new(), + title: Default::default(), + color: Default::default(), } } diff --git a/src/reportal_config/repo_color.rs b/src/reportal_config/repository_color.rs similarity index 70% rename from src/reportal_config/repo_color.rs rename to src/reportal_config/repository_color.rs index 8996018..518169d 100644 --- a/src/reportal_config/repo_color.rs +++ b/src/reportal_config/repository_color.rs @@ -20,6 +20,23 @@ pub enum RepoColor { 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>( diff --git a/src/reportal_config/workspace_entry.rs b/src/reportal_config/workspace_entry.rs index baf0457..6c621fa 100644 --- a/src/reportal_config/workspace_entry.rs +++ b/src/reportal_config/workspace_entry.rs @@ -2,6 +2,8 @@ //! VSCode/Cursor `.code-workspace` file. use crate::reportal_config::has_aliases::HasAliases; +use crate::reportal_config::repository_color::RepoColor; +use crate::reportal_config::tab_title::TabTitle; use crate::reportal_config::workspace_member::{ WorkspaceMember, WorkspaceMemberAliasLookup, }; @@ -55,6 +57,18 @@ pub struct WorkspaceEntry { /// 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, } /// Accessors and mutators for a workspace entry. @@ -141,15 +155,31 @@ impl WorkspaceEntry { /// 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, repo_alias: &str) -> bool { + pub fn contains_repo(&self, repository_alias: &str) -> bool { self.repos.iter().any(|member| { matches!( member.registered_repo_alias(), - WorkspaceMemberAliasLookup::Matches(alias) if alias == 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 diff --git a/src/reportal_config/workspace_registration_builder.rs b/src/reportal_config/workspace_registration_builder.rs index 7f8e7b9..fbe19ac 100644 --- a/src/reportal_config/workspace_registration_builder.rs +++ b/src/reportal_config/workspace_registration_builder.rs @@ -126,6 +126,8 @@ impl WorkspaceRegistrationBuilder { 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(), }; Ok((self.workspace_name, validated_entry)) } diff --git a/src/terminal_style.rs b/src/terminal_style.rs index 8b9cd0f..a8618fa 100644 --- a/src/terminal_style.rs +++ b/src/terminal_style.rs @@ -114,7 +114,7 @@ impl TerminalIdentity { /// Builds a terminal identity from the resolved title string and /// tab color action. Called after the title fallback chain /// (flag > config > alias) has been resolved. - pub fn new(identity_params: TerminalIdentityParams) -> Self { + pub fn new(identity_params: TerminalIdentityParameters) -> Self { Self { resolved_title: identity_params.resolved_title, tab_color_action: identity_params.tab_color_action, @@ -133,7 +133,7 @@ impl TerminalIdentity { } /// 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 tab color or reset to default. From b631b3cc18183b8b95d73812367774ea4fc8d952 Mon Sep 17 00:00:00 2001 From: ozzy Date: Fri, 24 Apr 2026 21:37:37 -0400 Subject: [PATCH 58/67] Opt-in shell aliases for repos, workspaces, commands (v0.18.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `shell_alias = true` field to repo / workspace / command config entries. When set, `rep init ` emits a top-level shell function for the entry's canonical key (and each declared alias for repos / workspaces) so users can invoke `vna` directly instead of `rj vna`, or `uc` directly instead of `rep run --cmd uc`. Names are emitted with the `function name() { ... }` form and preceded by `unalias name 2>/dev/null` so a pre-existing zsh / bash alias does not block the function definition. Built-in collisions (`rj`, `ro`, `rjw`, `row`, `rw`, `rr`) and names outside `[A-Za-z0-9_-]` are silently skipped. `RepoRegistrationBuilder` and `WorkspaceRegistrationBuilder` now walk `$PATH` and reject any alias / canonical name that resolves to an existing executable, so `rep add` refuses names like `mc`, `git`, `ls` that would shadow real commands once shell-alias export is opted in. Existing config entries are not re-validated on load — legacy hand-edited shadows continue to work as `rj ` targets. `rep doctor` adds a "Shell Alias Shadows" section that probes every opted-in name and reports any that shadow a system command on the running machine, with a hint to either rename or drop the opt-in. Co-Authored-By: AI Opus 4.7 (1M context) --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/main.rs | 1 + src/reportal_commands/doctor.rs | 185 +++++++- src/reportal_commands/initialization.rs | 36 +- src/reportal_commands/mod.rs | 1 + src/reportal_commands/shell_alias_emit.rs | 415 ++++++++++++++++++ src/reportal_config/command_entry.rs | 28 +- src/reportal_config/mod.rs | 2 + src/reportal_config/repo_entry.rs | 25 ++ .../repo_registration_builder.rs | 16 + src/reportal_config/reportal_config_root.rs | 2 + src/reportal_config/shell_alias_export.rs | 52 +++ src/reportal_config/workspace_entry.rs | 25 ++ .../workspace_registration_builder.rs | 30 ++ src/system_executable_lookup.rs | 111 +++++ 16 files changed, 916 insertions(+), 17 deletions(-) create mode 100644 src/reportal_commands/shell_alias_emit.rs create mode 100644 src/reportal_config/shell_alias_export.rs create mode 100644 src/system_executable_lookup.rs diff --git a/Cargo.lock b/Cargo.lock index 308f490..07d1618 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -566,7 +566,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.16.0" +version = "0.18.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 6fbebd6..ca35c93 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.16.0" +version = "0.18.1" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/src/main.rs b/src/main.rs index db1134d..7e905e0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ mod cli_args; mod code_workspace; mod error; +mod system_executable_lookup; mod reportal_commands; mod reportal_config; mod terminal_style; diff --git a/src/reportal_commands/doctor.rs b/src/reportal_commands/doctor.rs index fa18366..9f0b9e0 100644 --- a/src/reportal_commands/doctor.rs +++ b/src/reportal_commands/doctor.rs @@ -8,7 +8,8 @@ //! Each failed check prints an actionable hint so the user knows //! exactly what to run to fix the problem. -use crate::reportal_config::ReportalConfig; +use crate::reportal_config::{HasAliases, ReportalConfig, ShellAliasExport}; +use crate::system_executable_lookup::SystemExecutableLookupOutcome; use crate::terminal_style; use owo_colors::OwoColorize; @@ -125,16 +126,25 @@ impl DiagnosticSummary { return; } - for (alias, entry) in &all_repos { - self.check_single_repo(alias, entry); + for (registered_alias, registered_repository_entry) in &all_repos { + self.check_single_repository(&DiagnosticSummaryRepositoryEntryParameters { + registered_alias, + registered_repository_entry, + }); } } - /// Validates that a single repo path exists and records the diagnostic. - fn check_single_repo(&mut self, alias: &str, entry: &crate::reportal_config::RepoEntry) { - let resolved_path = entry.resolved_path(); + /// 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 = alias.style(terminal_style::ALIAS_STYLE); + 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() { @@ -145,6 +155,162 @@ impl DiagnosticSummary { } } +/// 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. @@ -159,7 +325,10 @@ pub fn run_doctor() { let loaded_config = summary.check_config(); summary.check_shell_integration(); - if let Some(ref config) = loaded_config { summary.check_repo_paths(config) } + 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 { diff --git a/src/reportal_commands/initialization.rs b/src/reportal_commands/initialization.rs index 4bb17db..b66d8f3 100644 --- a/src/reportal_commands/initialization.rs +++ b/src/reportal_commands/initialization.rs @@ -7,20 +7,46 @@ //! is regenerated every session and therefore never goes stale. use crate::cli_args::InitializeShell; +use crate::reportal_config::ReportalConfig; +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 — for the requested shell to stdout -/// so the user wires a single `eval "$(rep init )"` line -/// into their rc file and gets both the tab / window title -/// chrome and the inline PS1 badge without further manual setup. +/// 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); - print!("{base_integration_script}{badge_snippet}"); + 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(), + } } diff --git a/src/reportal_commands/mod.rs b/src/reportal_commands/mod.rs index 75dd832..f3b7a42 100644 --- a/src/reportal_commands/mod.rs +++ b/src/reportal_commands/mod.rs @@ -8,6 +8,7 @@ mod path_display; mod prompt_badge_emit; mod prompt_identity; mod prompts; +mod shell_alias_emit; mod shell_prompt_badge; mod shell_integration; mod repo_add; diff --git a/src/reportal_commands/shell_alias_emit.rs b/src/reportal_commands/shell_alias_emit.rs new file mode 100644 index 0000000..6de80ee --- /dev/null +++ b/src/reportal_commands/shell_alias_emit.rs @@ -0,0 +1,415 @@ +//! 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"]; + +/// @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( + parameters: &ShellAliasEmissionParameters<'_>, +) -> String { + let exported_dispatch_entries = + collect_exported_dispatch_entries(parameters.configuration); + if exported_dispatch_entries.is_empty() { + return String::new(); + } + match 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( + configuration: &ReportalConfig, +) -> Vec { + let mut collected: Vec = Vec::new(); + for (command_key, command_entry) in configuration.global_commands() { + append_command_dispatch_entry(&mut collected, command_key, command_entry); + } + for (repository_canonical_key, repository_entry) in + configuration.repos_with_aliases() + { + append_repository_dispatch_entries( + &mut collected, + repository_canonical_key, + repository_entry, + ); + } + for (workspace_canonical_name, workspace_entry) in + 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}() {{ cd \"$(rep jump {arg})\"; rep color; }}\n", + name = exported_entry.function_name, + arg = exported_entry.dispatch_argument, + )); + } + ExportedDispatchKind::WorkspaceJump => { + rendered_block.push_str(&format!( + "function {name}() {{ cd \"$(rep workspace jump {arg})\"; 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} {{ Set-Location (rep jump {arg}); 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} {{ Set-Location (rep workspace jump {arg}); 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_config/command_entry.rs b/src/reportal_config/command_entry.rs index f3d9cde..335f6a9 100644 --- a/src/reportal_config/command_entry.rs +++ b/src/reportal_config/command_entry.rs @@ -1,6 +1,7 @@ //! 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. @@ -11,17 +12,40 @@ pub struct CommandEntry { /// 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 { - /// The shell command string to execute. + /// @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 } - /// Human-readable description of what this command does. + /// @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/mod.rs b/src/reportal_config/mod.rs index e90c95c..4031141 100644 --- a/src/reportal_config/mod.rs +++ b/src/reportal_config/mod.rs @@ -9,6 +9,7 @@ mod command_entry; mod global_settings; mod has_aliases; mod hex_color; +mod shell_alias_export; mod repository_color; mod repo_entry; mod repo_registration_builder; @@ -28,6 +29,7 @@ pub use repository_color::RepoColor; pub use repo_entry::RepoEntry; pub use repo_registration_builder::RepoRegistrationBuilder; pub use reportal_config_root::ReportalConfig; +pub use shell_alias_export::ShellAliasExport; pub use tab_title::TabTitle; pub use tag_filter::TagFilter; pub use workspace_entry::WorkspaceEntry; diff --git a/src/reportal_config/repo_entry.rs b/src/reportal_config/repo_entry.rs index 8bacba2..3620163 100644 --- a/src/reportal_config/repo_entry.rs +++ b/src/reportal_config/repo_entry.rs @@ -3,6 +3,7 @@ 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; @@ -42,6 +43,14 @@ pub struct RepoEntry { /// 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. @@ -127,3 +136,19 @@ impl HasAliases for RepoEntry { &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 index b4b7cc4..967de97 100644 --- a/src/reportal_config/repo_registration_builder.rs +++ b/src/reportal_config/repo_registration_builder.rs @@ -4,7 +4,9 @@ use crate::error::ReportalError; use crate::reportal_config::hex_color::HexColor; use crate::reportal_config::repository_color::RepoColor; use crate::reportal_config::repo_entry::RepoEntry; +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; @@ -111,6 +113,19 @@ impl RepoRegistrationBuilder { 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(), @@ -134,6 +149,7 @@ impl RepoRegistrationBuilder { 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 index c7cd84f..1e541d1 100644 --- a/src/reportal_config/reportal_config_root.rs +++ b/src/reportal_config/reportal_config_root.rs @@ -593,6 +593,7 @@ mod tests { title: Default::default(), color: Default::default(), commands: BTreeMap::new(), + shell_alias: Default::default(), } } @@ -761,6 +762,7 @@ mod tests { aliases: Vec::new(), title: Default::default(), color: Default::default(), + shell_alias: Default::default(), } } 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/workspace_entry.rs b/src/reportal_config/workspace_entry.rs index 6c621fa..19da66d 100644 --- a/src/reportal_config/workspace_entry.rs +++ b/src/reportal_config/workspace_entry.rs @@ -3,6 +3,7 @@ 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, @@ -69,6 +70,14 @@ pub struct WorkspaceEntry { /// 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. @@ -198,3 +207,19 @@ impl HasAliases for WorkspaceEntry { &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_registration_builder.rs b/src/reportal_config/workspace_registration_builder.rs index fbe19ac..78e95a2 100644 --- a/src/reportal_config/workspace_registration_builder.rs +++ b/src/reportal_config/workspace_registration_builder.rs @@ -1,8 +1,10 @@ //! 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()`. @@ -117,6 +119,10 @@ impl WorkspaceRegistrationBuilder { }); } 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 @@ -128,6 +134,7 @@ impl WorkspaceRegistrationBuilder { 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)) } @@ -190,6 +197,29 @@ fn validate_alias_list_shape( 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::*; diff --git a/src/system_executable_lookup.rs b/src/system_executable_lookup.rs new file mode 100644 index 0000000..5f84eb2 --- /dev/null +++ b/src/system_executable_lookup.rs @@ -0,0 +1,111 @@ +//! 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:?}", + ); + } +} From 1724c81d92e4c5871852d3b3cc955b909b7c033b Mon Sep 17 00:00:00 2001 From: ozzy Date: Sat, 25 Apr 2026 18:00:48 -0400 Subject: [PATCH 59/67] Stamp REPORTAL_LOADED with shell PID, detect inherited markers (v0.18.3) Fixes a class of bug where REPORTAL_LOADED=1 was exported and inherited by child processes that never sourced the integration. Affected child shells (non-interactive zsh -c, scripts, certain terminal-host setups) showed up as 'loaded' even though rj / ro / etc. weren't defined, so 'rep doctor' lied and the user got 'command not found' with no diagnostic. The integration scripts now stamp REPORTAL_LOADED with the shell's own PID ($\$ in bash/zsh, $PID on PowerShell). The rep binary compares the marker against its parent process id via rustix::process::getppid on Unix and sysinfo::System on Windows. Match means real loaded session; mismatch means inherited from a grandparent. Legacy '=1' values are still treated as loaded so users mid-upgrade don't see false negatives. Auto-wire and 'rep doctor' both consume the new typed AutoWireOperationLoadedState. The auto-wire helpers moved from shell_integration.rs into a new shell_integration_autowire.rs to satisfy the project's free-fn ceiling and pub-type-companion guards. Co-Authored-By: AI Opus 4.7 (1M context) --- Cargo.lock | 110 +++- Cargo.toml | 6 +- src/reportal_commands/doctor.rs | 11 +- src/reportal_commands/mod.rs | 6 +- src/reportal_commands/shell_integration.rs | 295 ++-------- .../shell_integration_autowire.rs | 546 ++++++++++++++++++ 6 files changed, 705 insertions(+), 269 deletions(-) create mode 100644 src/reportal_commands/shell_integration_autowire.rs diff --git a/Cargo.lock b/Cargo.lock index 07d1618..e2c12cc 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", ] @@ -402,6 +408,12 @@ 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" @@ -435,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" @@ -566,7 +587,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.18.0" +version = "0.18.3" dependencies = [ "anyhow", "clap", @@ -579,12 +600,27 @@ dependencies = [ "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" @@ -594,7 +630,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -700,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" @@ -709,7 +758,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -913,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 ca35c93..5eb8de1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.18.1" +version = "0.18.3" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" @@ -28,6 +28,10 @@ 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" diff --git a/src/reportal_commands/doctor.rs b/src/reportal_commands/doctor.rs index 9f0b9e0..5ade3e9 100644 --- a/src/reportal_commands/doctor.rs +++ b/src/reportal_commands/doctor.rs @@ -8,6 +8,9 @@ //! 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; @@ -97,14 +100,14 @@ impl DiagnosticSummary { "Shell Integration".style(terminal_style::EMPHASIS_STYLE), )); - match std::env::var("REPORTAL_LOADED") { - Ok(ref _marker_value) => { + match current_shell_integration_loaded_state() { + AutoWireOperationLoadedState::LoadedInCurrentShell => { self.record_pass("Shell integration loaded in current session"); } - Err(ref _env_error) => { + 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", + "Add eval \"$(rep init zsh)\" (or bash/powershell) to your shell rc file, then 'source' it or open a new terminal", ); } } diff --git a/src/reportal_commands/mod.rs b/src/reportal_commands/mod.rs index f3b7a42..6977b69 100644 --- a/src/reportal_commands/mod.rs +++ b/src/reportal_commands/mod.rs @@ -11,6 +11,7 @@ mod prompts; mod shell_alias_emit; mod shell_prompt_badge; mod shell_integration; +mod shell_integration_autowire; mod repo_add; mod repo_ai; mod repository_color; @@ -46,7 +47,10 @@ mod workspace_show; pub use color_command_mode::ColorCommandMode; pub use doctor::run_doctor; pub use initialization::run_initialize; -pub use shell_integration::ensure_shell_integration_installed; +pub use shell_integration_autowire::{ + current_shell_integration_loaded_state, ensure_shell_integration_installed, + AutoWireOperationLoadedState, +}; pub use repo_add::run_add; pub use repo_ai::{run_ai, AiCommandParameters}; pub use repo_edit::{run_edit, EditCommandParameters}; diff --git a/src/reportal_commands/shell_integration.rs b/src/reportal_commands/shell_integration.rs index df43cd2..a75cbd5 100644 --- a/src/reportal_commands/shell_integration.rs +++ b/src/reportal_commands/shell_integration.rs @@ -7,270 +7,23 @@ //! every shell session so a binary upgrade takes effect the next //! time the user opens a terminal, with zero manual steps. //! -//! This module also owns the first-run auto-wire path: when the user -//! installs via `cargo install reportal` (or any route that bypasses -//! the shell installer in `installer/`), the binary detects on the -//! very first invocation that `REPORTAL_LOADED` is unset and appends -//! the fenced `eval "$(rep init )"` block to the user's rc -//! file. The same fenced marker the shell installer uses guarantees -//! idempotence — subsequent runs (and subsequent shell-installer -//! runs) are no-ops. - -use std::env; -use std::fs::OpenOptions; -use std::io::Write; -use std::path::{Path, PathBuf}; - -use crate::cli_args::InitializeShell; - -/// Fenced marker opening the auto-managed integration block. This -/// string is 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 <<<"; - -/// Returns the `PowerShell` integration script content, stamped with -/// the running binary's version. -/// -/// Uses `global:` scope qualifiers on every function definition so -/// the script works identically whether the user pipes it into -/// `Invoke-Expression` from `$PROFILE`, dot-sources it into a module, -/// or feeds it through any other loading mechanism. -pub fn powershell_integration_content() -> String { - format!( - r#"# RePortal shell integration — v{} -# Generated by 'rep init powershell'. -$env:REPORTAL_LOADED = "1" -function global:rj {{ Set-Location (rep jump @args); rep color }} -function global:ro {{ rep open @args; rep color }} -function global:rjw {{ Set-Location (rep workspace jump @args); rep color }} -function global:row {{ rep workspace open @args; 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 }} -"#, - env!("CARGO_PKG_VERSION"), - ) -} - -/// Best-effort first-run hook that ensures the user's shell rc file -/// sources the `RePortal` integration. -/// -/// Called from `main()` before subcommand dispatch (but skipped for -/// `rep init`, which must not produce stderr noise inside an -/// `eval "$(rep init zsh)"` invocation). When the integration is -/// already loaded in the current shell — detected via the -/// `REPORTAL_LOADED` env var the integration itself sets — this is -/// silent. Otherwise it resolves the user's login shell from `$SHELL` -/// (or `$PROFILE` on `PowerShell`), locates the appropriate rc file, -/// and, if the fenced marker is absent, appends an idempotent eval -/// block that mirrors exactly what the shell installer writes. -/// -/// The function never returns an error to its caller: auto-setup is a -/// convenience for the `cargo install reportal` path and must never -/// break the user's actual command. Any failure (unsupported shell, -/// missing `$HOME`, unwritable rc file, unknown OS) is swallowed with -/// at most a single stderr warning. -pub fn ensure_shell_integration_installed() { - if env::var_os("REPORTAL_LOADED").is_some() { - return; - } - let Some(shell) = shell_from_env() else { - return; - }; - let Some(rc_file) = rc_file_for(shell) else { - return; - }; - match append_integration_if_missing(&rc_file, shell) { - Ok(AutoSetupOutcome::AlreadyPresent) => {} - Ok(AutoSetupOutcome::Appended) => { - eprintln!( - "RePortal: added shell integration to {} — run 'source {}' or open a new shell to activate.", - rc_file.display(), - rc_file.display(), - ); - } - Err(reason) => { - eprintln!( - "RePortal: note: could not auto-wire shell integration ({reason}); add `{}` to your rc file manually.", - eval_line_for(shell), - ); - } - } -} - -/// Result of a single auto-setup attempt. Used to decide whether the -/// caller should emit the stderr activation hint. -enum AutoSetupOutcome { - /// Marker already present — no change made. - AlreadyPresent, - /// Fenced block appended to the rc file. - Appended, -} - -/// Parses `$SHELL`'s basename (or picks `PowerShell` on Windows when -/// `$SHELL` is absent) and returns the matching `InitializeShell` variant. -/// Returns `None` for fish, tcsh, csh, or anything else we don't -/// generate integration code for — those users can run -/// `rep init ` themselves and will get a clear rejection. -fn shell_from_env() -> Option { - if let Some(shell_path) = env::var_os("SHELL") { - let basename = Path::new(&shell_path) - .file_name() - .and_then(|name| name.to_str()) - .map(str::to_ascii_lowercase); - if let Some(name) = basename { - return match name.as_str() { - "zsh" => Some(InitializeShell::Zsh), - "bash" => Some(InitializeShell::Bash), - "pwsh" | "powershell" => Some(InitializeShell::Powershell), - _ => None, - }; - } - } - // Fallback: on Windows with no `$SHELL`, assume PowerShell. This - // matches the installer's behavior and covers users who launched - // from `pwsh`/`powershell.exe` directly. - if cfg!(windows) { - Some(InitializeShell::Powershell) - } else { - None - } -} - -/// Resolves the rc file we should append to for the given shell. -/// -/// - Zsh → `~/.zshrc` -/// - Bash → `~/.bashrc` -/// - `PowerShell` → `$PROFILE` if set by the host, otherwise the -/// conventional `Documents\PowerShell\Microsoft.PowerShell_profile.ps1` -/// under `$USERPROFILE` so the append still lands in the right place -/// when the binary is invoked outside an interactive PowerShell host -/// (e.g. from `cmd.exe`). -/// -/// Returns `None` if the home directory cannot be determined and no -/// `$PROFILE` is set — in which case auto-setup quietly bails. -fn rc_file_for(shell: InitializeShell) -> Option { - match shell { - InitializeShell::Zsh => dirs::home_dir().map(|home| home.join(".zshrc")), - InitializeShell::Bash => dirs::home_dir().map(|home| home.join(".bashrc")), - InitializeShell::Powershell => powershell_profile_path(), - } -} - -/// Resolves the `PowerShell` `$PROFILE` path. Prefers the host- -/// provided `PROFILE` env var (which `pwsh` exports), then falls back -/// to the conventional `Documents\PowerShell\…` layout rooted at the -/// user's home directory. -fn powershell_profile_path() -> Option { - if let Some(profile) = env::var_os("PROFILE") { - let path = PathBuf::from(profile); - if !path.as_os_str().is_empty() { - return Some(path); - } - } - dirs::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 the -/// given shell. Byte-for-byte identical to what the shell installer -/// writes, so either code path producing the block results in the -/// same file contents. -fn eval_line_for(shell: InitializeShell) -> &'static str { - match 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 `rc_file` iff the marker -/// is absent. Creates parent directories and the file itself if they -/// don't exist yet (matching the installer scripts' behavior). All -/// I/O failures are surfaced as a short human-readable reason string -/// for the caller's stderr hint. -fn append_integration_if_missing( - rc_file: &Path, - shell: InitializeShell, -) -> Result { - if rc_file.exists() { - let existing = std::fs::read_to_string(rc_file) - .map_err(|read_err| format!("read {}: {read_err}", rc_file.display()))?; - if existing.contains(MARKER_START) { - return Ok(AutoSetupOutcome::AlreadyPresent); - } - let needs_leading_newline = !existing.is_empty() && !existing.ends_with('\n'); - write_fenced_block(rc_file, shell, needs_leading_newline)?; - return Ok(AutoSetupOutcome::Appended); - } - - // File does not exist — create parent dirs first, then write. - if let Some(parent) = rc_file.parent() { - if !parent.as_os_str().is_empty() && !parent.exists() { - std::fs::create_dir_all(parent) - .map_err(|mkdir_err| format!("create {}: {mkdir_err}", parent.display()))?; - } - } - write_fenced_block(rc_file, shell, false)?; - Ok(AutoSetupOutcome::Appended) -} - -/// Opens `rc_file` in append mode and writes the fenced block. The -/// `needs_leading_newline` flag inserts a separator newline when the -/// existing file does not already end in one, so the marker always -/// starts on its own line. -fn write_fenced_block( - rc_file: &Path, - shell: InitializeShell, - needs_leading_newline: bool, -) -> Result<(), String> { - let mut file = OpenOptions::new() - .create(true) - .append(true) - .open(rc_file) - .map_err(|open_err| format!("open {}: {open_err}", rc_file.display()))?; - - let eol = if matches!(shell, InitializeShell::Powershell) && cfg!(windows) { - "\r\n" - } else { - "\n" - }; - - let mut block = String::new(); - if needs_leading_newline { - block.push_str(eol); - } - block.push_str(eol); - block.push_str(MARKER_START); - block.push_str(eol); - block.push_str(eval_line_for(shell)); - block.push_str(eol); - block.push_str(MARKER_END); - block.push_str(eol); - - file.write_all(block.as_bytes()) - .map_err(|write_err| format!("write {}: {write_err}", rc_file.display()))?; - Ok(()) -} - -/// Returns the Bash and Zsh integration script content, stamped with -/// the running binary's version. +//! `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`. + +/// @why Renders the bash/zsh integration block for callers piping +/// it through `eval "$(rep init bash|zsh)"`. +#[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=1 +export REPORTAL_LOADED=$$ rj() {{ cd "$(rep jump "$@")"; rep color; }} ro() {{ rep open "$@"; rep color; }} rjw() {{ cd "$(rep workspace jump "$@")"; rep color; }} @@ -284,3 +37,27 @@ PROMPT_COMMAND="${{PROMPT_COMMAND:+$PROMPT_COMMAND;}}_reportal_hook" null_device = null_device.display(), ) } + +/// @why Renders the `PowerShell` integration block for callers +/// piping it through `Invoke-Expression (& rep init powershell | +/// Out-String)`. Uses `global:` scope qualifiers so the script +/// works the same whether sourced into `$PROFILE`, dot-sourced +/// into a module, or fed through any other loader. +#[must_use] +pub fn powershell_integration_content() -> String { + format!( + r#"# RePortal shell integration — v{} +# Generated by 'rep init powershell'. +$env:REPORTAL_LOADED = "$PID" +function global:rj {{ Set-Location (rep jump @args); rep color }} +function global:ro {{ rep open @args; rep color }} +function global:rjw {{ Set-Location (rep workspace jump @args); rep color }} +function global:row {{ rep workspace open @args; 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 }} +"#, + env!("CARGO_PKG_VERSION"), + ) +} diff --git a/src/reportal_commands/shell_integration_autowire.rs b/src/reportal_commands/shell_integration_autowire.rs new file mode 100644 index 0000000..021680a --- /dev/null +++ b/src/reportal_commands/shell_integration_autowire.rs @@ -0,0 +1,546 @@ +//! 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, + ) +} From 01b6a00fe0993049e5a9419e4d83dadcb148391d Mon Sep 17 00:00:00 2001 From: ChequePickerUpper Date: Tue, 5 May 2026 14:26:20 -0400 Subject: [PATCH 60/67] Ignore .serena/ MCP cache; mark stale workspaces agent state completed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `.serena/` to root .gitignore — local serena MCP cache, memories, and project config are user-local and should not be tracked. - Mark the 2026-04-15 workspaces VSCode integration agent state as completed (stale-auto-close, 2026-04-20). --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 9cf4f1e..ce6b649 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ Desktop.ini # MCP / Puppeteer logs logs/ +.serena/ From 9876e7e02d664e5231aca2778b39dd9554b78fc4 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Sun, 10 May 2026 14:03:00 -0400 Subject: [PATCH 61/67] chore: ignore Serena project metadata --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ce6b649..f5c6e9e 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ Desktop.ini # IDE .idea/ .vscode/ +.serena/ *.swp *.swo From 45e24ba96ff38003114493beff9166a6c4c7c95b Mon Sep 17 00:00:00 2001 From: ChequePickerUpper Date: Sun, 24 May 2026 13:51:09 -0400 Subject: [PATCH 62/67] fix: prevent zsh prompt hook recursion --- src/cli_args/ai_arguments.rs | 4 +- src/cli_args/color_arguments.rs | 2 +- src/cli_args/edit_arguments.rs | 4 +- src/cli_args/jump_arguments.rs | 4 +- src/cli_args/mod.rs | 6 +- src/cli_args/open_arguments.rs | 4 +- src/cli_args/reportal_cli.rs | 2 +- .../repository_selection_arguments.rs | 4 +- src/cli_args/run_arguments.rs | 4 +- src/cli_args/status_arguments.rs | 4 +- src/cli_args/sync_arguments.rs | 4 +- src/cli_args/tag_filter_arguments.rs | 8 +- src/cli_args/web_arguments.rs | 4 +- src/code_workspace/code_workspace_file.rs | 15 +- src/code_workspace/folder_parsing.rs | 30 ++-- src/code_workspace/mod.rs | 5 +- src/error.rs | 4 +- src/main.rs | 9 +- src/reportal_commands/direct_alias_router.rs | 23 +-- src/reportal_commands/doctor.rs | 63 +++---- src/reportal_commands/git_commands.rs | 10 +- src/reportal_commands/initialization.rs | 14 +- src/reportal_commands/mod.rs | 30 ++-- src/reportal_commands/prompt_badge_emit.rs | 8 +- src/reportal_commands/prompt_identity.rs | 15 +- .../prompts/color_edit_prompt.rs | 4 +- src/reportal_commands/prompts/color_prompt.rs | 15 +- src/reportal_commands/prompts/text_prompt.rs | 4 +- .../repo_add/alias_suggestion.rs | 13 +- .../repo_add/clone_destination.rs | 7 +- .../repo_add/clone_placement.rs | 10 +- .../repo_add/git_clone_operation.rs | 12 +- .../repo_add/git_remote_detection.rs | 20 ++- .../repo_add/registration_context.rs | 46 +++-- src/reportal_commands/repo_add/run.rs | 13 +- src/reportal_commands/repo_ai.rs | 31 ++-- src/reportal_commands/repo_edit_mutator.rs | 5 +- src/reportal_commands/repo_jump.rs | 20 +-- .../repo_listing_renderer.rs | 10 +- src/reportal_commands/repo_open.rs | 27 ++- src/reportal_commands/repo_remove.rs | 6 +- src/reportal_commands/repo_run.rs | 64 ++++--- src/reportal_commands/repo_selection.rs | 122 +++++++------ src/reportal_commands/repo_status.rs | 50 ++++-- src/reportal_commands/repo_sync.rs | 48 +++-- src/reportal_commands/repo_tree_grouping.rs | 43 ++--- src/reportal_commands/repo_web.rs | 61 ++++--- src/reportal_commands/repository_color.rs | 10 +- src/reportal_commands/shell_alias_emit.rs | 37 ++-- .../shell_integration_autowire.rs | 62 +++---- src/reportal_commands/shell_prompt_badge.rs | 16 +- src/reportal_commands/target_selection.rs | 9 +- src/reportal_commands/workspace_create.rs | 36 ++-- src/reportal_commands/workspace_delete.rs | 10 +- src/reportal_commands/workspace_layout.rs | 54 +++--- src/reportal_commands/workspace_listing.rs | 14 +- src/reportal_commands/workspace_members.rs | 12 +- src/reportal_commands/workspace_operations.rs | 10 +- src/reportal_commands/workspace_rebuild.rs | 13 +- src/reportal_commands/workspace_selection.rs | 4 +- src/reportal_commands/workspace_show.rs | 26 ++- src/reportal_config/ai_tool_entry.rs | 1 - src/reportal_config/global_settings.rs | 13 +- src/reportal_config/has_aliases.rs | 5 +- src/reportal_config/hex_color.rs | 15 +- src/reportal_config/mod.rs | 6 +- .../repo_registration_builder.rs | 6 +- src/reportal_config/reportal_config_root.rs | 167 +++++++++++------- src/reportal_config/workspace_entry.rs | 4 +- src/reportal_config/workspace_member.rs | 4 +- .../workspace_registration_builder.rs | 10 +- src/system_executable_lookup.rs | 3 +- src/terminal_style.rs | 36 ++-- 73 files changed, 835 insertions(+), 659 deletions(-) diff --git a/src/cli_args/ai_arguments.rs b/src/cli_args/ai_arguments.rs index 0d253a9..f64a776 100644 --- a/src/cli_args/ai_arguments.rs +++ b/src/cli_args/ai_arguments.rs @@ -1,8 +1,8 @@ //! CLI args for `rep ai`. -use clap::Args; -use crate::reportal_config::TagFilter; use super::repository_selection_arguments::RepositorySelectionArguments; +use crate::reportal_config::TagFilter; +use clap::Args; /// Arguments for the `rep ai` subcommand. /// diff --git a/src/cli_args/color_arguments.rs b/src/cli_args/color_arguments.rs index f583b02..a92eef6 100644 --- a/src/cli_args/color_arguments.rs +++ b/src/cli_args/color_arguments.rs @@ -1,7 +1,7 @@ //! CLI args for `rep color`. -use clap::Args; use crate::reportal_commands::ColorCommandMode; +use clap::Args; /// Arguments for the `rep color` subcommand. /// diff --git a/src/cli_args/edit_arguments.rs b/src/cli_args/edit_arguments.rs index c05df93..f9ef171 100644 --- a/src/cli_args/edit_arguments.rs +++ b/src/cli_args/edit_arguments.rs @@ -1,8 +1,8 @@ //! CLI args for `rep edit`. -use clap::Args; -use crate::reportal_config::TagFilter; use super::repository_selection_arguments::RepositorySelectionArguments; +use crate::reportal_config::TagFilter; +use clap::Args; /// Arguments for the `rep edit` subcommand. /// diff --git a/src/cli_args/jump_arguments.rs b/src/cli_args/jump_arguments.rs index 7398aaa..cd3c557 100644 --- a/src/cli_args/jump_arguments.rs +++ b/src/cli_args/jump_arguments.rs @@ -1,8 +1,8 @@ //! CLI args for `rep jump`. -use clap::Args; -use crate::reportal_config::TagFilter; use super::repository_selection_arguments::RepositorySelectionArguments; +use crate::reportal_config::TagFilter; +use clap::Args; /// Arguments for the `rep jump` subcommand. /// diff --git a/src/cli_args/mod.rs b/src/cli_args/mod.rs index 94a8fc7..2f6c13f 100644 --- a/src/cli_args/mod.rs +++ b/src/cli_args/mod.rs @@ -11,8 +11,8 @@ mod list_arguments; mod open_arguments; mod prompt_arguments; mod remove_arguments; -mod repository_selection_arguments; mod reportal_cli; +mod repository_selection_arguments; mod run_arguments; mod status_arguments; mod sync_arguments; @@ -36,6 +36,6 @@ pub use status_arguments::StatusArguments; pub use sync_arguments::SyncArguments; pub use web_arguments::WebArguments; pub use workspace_arguments::{ - WorkspaceArguments, WorkspaceArgumentsCreateParts, WorkspaceArgumentsDeleteParts, WorkspaceArgumentsMemberEditParts, - WorkspaceArgumentsSubcommand, + WorkspaceArguments, WorkspaceArgumentsCreateParts, WorkspaceArgumentsDeleteParts, + WorkspaceArgumentsMemberEditParts, WorkspaceArgumentsSubcommand, }; diff --git a/src/cli_args/open_arguments.rs b/src/cli_args/open_arguments.rs index a2f153a..732593c 100644 --- a/src/cli_args/open_arguments.rs +++ b/src/cli_args/open_arguments.rs @@ -1,8 +1,8 @@ //! CLI args for `rep open`. -use clap::Args; -use crate::reportal_config::TagFilter; use super::repository_selection_arguments::RepositorySelectionArguments; +use crate::reportal_config::TagFilter; +use clap::Args; /// Arguments for the `rep open` subcommand. /// diff --git a/src/cli_args/reportal_cli.rs b/src/cli_args/reportal_cli.rs index c375c12..5306c83 100644 --- a/src/cli_args/reportal_cli.rs +++ b/src/cli_args/reportal_cli.rs @@ -1,11 +1,11 @@ //! Top-level CLI parser and subcommand dispatch enum. -use clap::{Parser, Subcommand}; 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)] diff --git a/src/cli_args/repository_selection_arguments.rs b/src/cli_args/repository_selection_arguments.rs index ad0b195..0b8fab4 100644 --- a/src/cli_args/repository_selection_arguments.rs +++ b/src/cli_args/repository_selection_arguments.rs @@ -1,8 +1,8 @@ //! Shared optional alias positional arg + `--tag` flag for repo selection. -use clap::Args; -use crate::reportal_config::TagFilter; use super::tag_filter_arguments::TagFilterArguments; +use crate::reportal_config::TagFilter; +use clap::Args; /// Optional repo alias (positional) combined with a `--tag` filter flag. /// diff --git a/src/cli_args/run_arguments.rs b/src/cli_args/run_arguments.rs index db653c9..8f2a2ec 100644 --- a/src/cli_args/run_arguments.rs +++ b/src/cli_args/run_arguments.rs @@ -1,8 +1,8 @@ //! CLI args for `rep run`. -use clap::Args; -use crate::reportal_config::TagFilter; use super::repository_selection_arguments::RepositorySelectionArguments; +use crate::reportal_config::TagFilter; +use clap::Args; /// Arguments for the `rep run` subcommand. /// diff --git a/src/cli_args/status_arguments.rs b/src/cli_args/status_arguments.rs index 3f954d4..98c7494 100644 --- a/src/cli_args/status_arguments.rs +++ b/src/cli_args/status_arguments.rs @@ -1,8 +1,8 @@ //! CLI args for `rep status`. -use clap::Args; -use crate::reportal_config::TagFilter; use super::tag_filter_arguments::TagFilterArguments; +use crate::reportal_config::TagFilter; +use clap::Args; /// Arguments for the `rep status` subcommand. /// diff --git a/src/cli_args/sync_arguments.rs b/src/cli_args/sync_arguments.rs index a22573e..5d6b777 100644 --- a/src/cli_args/sync_arguments.rs +++ b/src/cli_args/sync_arguments.rs @@ -1,8 +1,8 @@ //! CLI args for `rep sync`. -use clap::Args; -use crate::reportal_config::TagFilter; use super::tag_filter_arguments::TagFilterArguments; +use crate::reportal_config::TagFilter; +use clap::Args; /// Arguments for the `rep sync` subcommand. /// diff --git a/src/cli_args/tag_filter_arguments.rs b/src/cli_args/tag_filter_arguments.rs index b295797..9ce89aa 100644 --- a/src/cli_args/tag_filter_arguments.rs +++ b/src/cli_args/tag_filter_arguments.rs @@ -1,7 +1,7 @@ //! Shared `--tag` flag used by commands that filter repos. -use clap::Args; use crate::reportal_config::TagFilter; +use clap::Args; /// Shared `--tag` flag that converts to a `TagFilter` enum. /// @@ -21,6 +21,10 @@ impl TagFilterArguments { /// 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) } + 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 index 784483b..e4aa896 100644 --- a/src/cli_args/web_arguments.rs +++ b/src/cli_args/web_arguments.rs @@ -1,8 +1,8 @@ //! CLI args for `rep web`. -use clap::Args; -use crate::reportal_config::TagFilter; use super::repository_selection_arguments::RepositorySelectionArguments; +use crate::reportal_config::TagFilter; +use clap::Args; /// Arguments for the `rep web` subcommand. /// diff --git a/src/code_workspace/code_workspace_file.rs b/src/code_workspace/code_workspace_file.rs index 0ad860c..f9d4ddb 100644 --- a/src/code_workspace/code_workspace_file.rs +++ b/src/code_workspace/code_workspace_file.rs @@ -3,8 +3,8 @@ use crate::code_workspace::code_workspace_folder_entry::CodeWorkspaceFolderEntry; use crate::code_workspace::folder_parsing::FolderParsingContext; use crate::error::ReportalError; -use jsonc_parser::ParseOptions; use jsonc_parser::cst::{CstInputValue, CstRootNode}; +use jsonc_parser::ParseOptions; use std::path::{Path, PathBuf}; /// Parsed, mutable view of a `.code-workspace` file. @@ -176,12 +176,13 @@ impl CodeWorkspaceFile { } 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 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") { diff --git a/src/code_workspace/folder_parsing.rs b/src/code_workspace/folder_parsing.rs index 0cbc4bd..9f85ae2 100644 --- a/src/code_workspace/folder_parsing.rs +++ b/src/code_workspace/folder_parsing.rs @@ -8,8 +8,8 @@ use crate::code_workspace::code_workspace_folder_entry::CodeWorkspaceFolderEntry; use crate::error::ReportalError; -use jsonc_parser::ParseOptions; use jsonc_parser::cst::{CstNode, CstRootNode}; +use jsonc_parser::ParseOptions; use std::path::Path; /// Per-file context for parsing a `.code-workspace` document. @@ -52,12 +52,13 @@ impl<'path_lifetime> FolderParsingContext<'path_lifetime> { &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 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(), @@ -169,7 +170,10 @@ mod tests { let input_text = "[]"; let parse_outcome = parse(input_text); assert!( - matches!(parse_outcome, Err(ReportalError::CodeWorkspaceParseFailure { .. })), + matches!( + parse_outcome, + Err(ReportalError::CodeWorkspaceParseFailure { .. }) + ), "array root must be rejected, got {parse_outcome:?}", ); } @@ -183,7 +187,10 @@ mod tests { }"#; let parse_outcome = parse(input_text); assert!( - matches!(parse_outcome, Err(ReportalError::CodeWorkspaceParseFailure { .. })), + matches!( + parse_outcome, + Err(ReportalError::CodeWorkspaceParseFailure { .. }) + ), "missing path field must be rejected, got {parse_outcome:?}", ); } @@ -197,7 +204,10 @@ mod tests { }"#; let parse_outcome = parse(input_text); assert!( - matches!(parse_outcome, Err(ReportalError::CodeWorkspaceParseFailure { .. })), + matches!( + parse_outcome, + Err(ReportalError::CodeWorkspaceParseFailure { .. }) + ), "non-string path must be rejected, got {parse_outcome:?}", ); } diff --git a/src/code_workspace/mod.rs b/src/code_workspace/mod.rs index 8059a82..94f1f5d 100644 --- a/src/code_workspace/mod.rs +++ b/src/code_workspace/mod.rs @@ -18,5 +18,8 @@ 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")] +#[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 cf5ee37..a41517d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -87,7 +87,9 @@ pub enum ReportalError { 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.")] + #[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, diff --git a/src/main.rs b/src/main.rs index 7e905e0..440aa4f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,9 +9,9 @@ mod cli_args; mod code_workspace; mod error; -mod system_executable_lookup; mod reportal_commands; mod reportal_config; +mod system_executable_lookup; mod terminal_style; use clap::Parser; @@ -52,7 +52,8 @@ fn main() { }) } ReportalCliSubcommand::Open(open_arguments) => { - let (direct_alias, tag_filter, editor_override, title_override) = open_arguments.into_parts(); + let (direct_alias, tag_filter, editor_override, title_override) = + open_arguments.into_parts(); reportal_commands::run_open(&OpenCommandParameters { tag_filter, direct_alias: &direct_alias, @@ -60,9 +61,7 @@ fn main() { title_override: &title_override, }) } - ReportalCliSubcommand::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 { diff --git a/src/reportal_commands/direct_alias_router.rs b/src/reportal_commands/direct_alias_router.rs index 22b7db3..806843f 100644 --- a/src/reportal_commands/direct_alias_router.rs +++ b/src/reportal_commands/direct_alias_router.rs @@ -63,17 +63,10 @@ impl<'config> DirectAliasRouter<'config> { /// /// Returns any non-NotFound error surfaced by the underlying /// `get_repo` or `resolve_workspace_canonical_name` calls. - pub fn classify( - &self, - alias: &str, - ) -> Result { + 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) - } + Ok(_repo_found_for_classification) => Ok(DirectAliasRouterOutcome::RegisteredRepo), + Err(ReportalError::RepoNotFound { .. }) => self.classify_workspace_branch(alias), Err(other_repo_error) => Err(other_repo_error), } } @@ -93,12 +86,10 @@ impl<'config> DirectAliasRouter<'config> { 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) - } + Ok(canonical_workspace_name) => Ok(DirectAliasRouterOutcome::Workspace( + canonical_workspace_name, + )), + Err(ReportalError::WorkspaceNotFound { .. }) => Ok(DirectAliasRouterOutcome::Unknown), Err(other_workspace_error) => Err(other_workspace_error), } } diff --git a/src/reportal_commands/doctor.rs b/src/reportal_commands/doctor.rs index 5ade3e9..2f5bb46 100644 --- a/src/reportal_commands/doctor.rs +++ b/src/reportal_commands/doctor.rs @@ -142,8 +142,7 @@ impl DiagnosticSummary { &mut self, parameters: &DiagnosticSummaryRepositoryEntryParameters<'_>, ) { - let resolved_path = - parameters.registered_repository_entry.resolved_path(); + let resolved_path = parameters.registered_repository_entry.resolved_path(); let path_display = resolved_path.display().to_string(); let styled_alias = parameters .registered_alias @@ -198,10 +197,7 @@ impl DiagnosticSummary { /// 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, - ) { + fn check_shell_alias_emission_health(&mut self, loaded_configuration: &ReportalConfig) { terminal_style::write_stdout("\n"); terminal_style::write_stdout(&format!( " {}\n", @@ -216,19 +212,15 @@ impl DiagnosticSummary { 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", - }, - ); + 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", - }, - ); + self.probe_one_emitted_name(&DiagnosticSummaryShadowProbeParameters { + candidate_emitted_name: declared_alias, + emitted_kind_label: "repository alias", + }); } } } @@ -240,19 +232,15 @@ impl DiagnosticSummary { 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", - }, - ); + 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", - }, - ); + self.probe_one_emitted_name(&DiagnosticSummaryShadowProbeParameters { + candidate_emitted_name: declared_alias, + emitted_kind_label: "workspace alias", + }); } } } @@ -262,12 +250,10 @@ impl DiagnosticSummary { 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", - }, - ); + self.probe_one_emitted_name(&DiagnosticSummaryShadowProbeParameters { + candidate_emitted_name: command_key, + emitted_kind_label: "command", + }); } } } @@ -336,10 +322,7 @@ pub fn run_doctor() { terminal_style::write_stdout("\n"); match summary.failed { 0 => { - terminal_style::print_success(&format!( - "All {} checks passed", - summary.passed, - )); + terminal_style::print_success(&format!("All {} checks passed", summary.passed,)); } _failure_count => { terminal_style::write_stdout(&format!( diff --git a/src/reportal_commands/git_commands.rs b/src/reportal_commands/git_commands.rs index d81a29d..3f58931 100644 --- a/src/reportal_commands/git_commands.rs +++ b/src/reportal_commands/git_commands.rs @@ -34,9 +34,13 @@ pub fn run_git_command(git_command_params: &GitCommandParameters<'_>) -> GitComm .output(); match command_result { - Ok(output) => if output.status.success() { GitCommandOutcome::Output( - String::from_utf8_lossy(&output.stdout).trim().to_owned(), - ) } else { GitCommandOutcome::NonZeroExit }, + 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 b66d8f3..6b028a1 100644 --- a/src/reportal_commands/initialization.rs +++ b/src/reportal_commands/initialization.rs @@ -9,9 +9,7 @@ use crate::cli_args::InitializeShell; use crate::reportal_config::ReportalConfig; -use super::shell_alias_emit::{ - self, ShellAliasEmissionParameters, -}; +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; @@ -40,12 +38,10 @@ pub fn run_initialize(shell: InitializeShell) { 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, - }, - ) + 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(), } diff --git a/src/reportal_commands/mod.rs b/src/reportal_commands/mod.rs index 6977b69..f2be520 100644 --- a/src/reportal_commands/mod.rs +++ b/src/reportal_commands/mod.rs @@ -8,30 +8,29 @@ mod path_display; mod prompt_badge_emit; mod prompt_identity; mod prompts; -mod shell_alias_emit; -mod shell_prompt_badge; -mod shell_integration; -mod shell_integration_autowire; mod repo_add; mod repo_ai; -mod repository_color; mod repo_edit; mod repo_edit_mutator; mod repo_jump; mod repo_listing; mod repo_listing_renderer; -mod repo_tree_grouping; -mod repo_tree_workspace_section; mod repo_open; mod repo_remove; mod repo_run; mod repo_selection; mod repo_status; -mod terminal_identity_emit; 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 workspace_selection; +mod terminal_identity_emit; mod workspace_create; mod workspace_delete; mod workspace_dispatch; @@ -42,20 +41,16 @@ mod workspace_members; mod workspace_open; mod workspace_operations; mod workspace_rebuild; +mod workspace_selection; mod workspace_show; pub use color_command_mode::ColorCommandMode; pub use doctor::run_doctor; pub use initialization::run_initialize; -pub use shell_integration_autowire::{ - current_shell_integration_loaded_state, ensure_shell_integration_installed, - AutoWireOperationLoadedState, -}; +pub use prompt_badge_emit::run_prompt; pub use repo_add::run_add; pub use repo_ai::{run_ai, AiCommandParameters}; pub use repo_edit::{run_edit, EditCommandParameters}; -pub use prompt_badge_emit::run_prompt; -pub use repository_color::{run_color, ColorCommandParameters}; pub use repo_jump::{run_jump, JumpCommandParameters}; pub use repo_listing::run_list; pub use repo_open::{run_open, OpenCommandParameters}; @@ -64,6 +59,11 @@ 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; diff --git a/src/reportal_commands/prompt_badge_emit.rs b/src/reportal_commands/prompt_badge_emit.rs index f6c6530..d3cd673 100644 --- a/src/reportal_commands/prompt_badge_emit.rs +++ b/src/reportal_commands/prompt_badge_emit.rs @@ -69,8 +69,7 @@ impl NonPrintingEscapeWrapper { 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_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}", @@ -83,10 +82,7 @@ impl NonPrintingEscapeWrapper { /// 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 { + 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()); }; diff --git a/src/reportal_commands/prompt_identity.rs b/src/reportal_commands/prompt_identity.rs index c0af29e..d25934d 100644 --- a/src/reportal_commands/prompt_identity.rs +++ b/src/reportal_commands/prompt_identity.rs @@ -96,9 +96,7 @@ impl<'configuration_lifetime> PromptIdentityResolver<'configuration_lifetime> { /// 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> { + 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(), @@ -122,8 +120,7 @@ impl<'configuration_lifetime> PromptIdentityResolver<'configuration_lifetime> { 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() + 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) { @@ -140,7 +137,10 @@ impl<'configuration_lifetime> PromptIdentityResolver<'configuration_lifetime> { }; best_identity = Some(PromptIdentity { display_label: resolved_display_label, - accent_color: workspace_entry.workspace_color().themed_hex_color().cloned(), + accent_color: workspace_entry + .workspace_color() + .themed_hex_color() + .cloned(), }); } Ok(best_identity) @@ -155,8 +155,7 @@ impl<'configuration_lifetime> PromptIdentityResolver<'configuration_lifetime> { 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() + 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) { diff --git a/src/reportal_commands/prompts/color_edit_prompt.rs b/src/reportal_commands/prompts/color_edit_prompt.rs index bf17902..bf08b76 100644 --- a/src/reportal_commands/prompts/color_edit_prompt.rs +++ b/src/reportal_commands/prompts/color_edit_prompt.rs @@ -18,7 +18,9 @@ pub struct ColorEditPromptParameters<'a> { /// 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 { +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)") diff --git a/src/reportal_commands/prompts/color_prompt.rs b/src/reportal_commands/prompts/color_prompt.rs index 84f05f4..d717aab 100644 --- a/src/reportal_commands/prompts/color_prompt.rs +++ b/src/reportal_commands/prompts/color_prompt.rs @@ -25,11 +25,14 @@ pub fn prompt_for_color(prompt_theme: &ColorfulTheme) -> Result return Ok(ColorPromptResult::Provided(valid_color)), - Err(color_error) => { - terminal_style::print_error(&color_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/text_prompt.rs b/src/reportal_commands/prompts/text_prompt.rs index 837bf58..a05e8c7 100644 --- a/src/reportal_commands/prompts/text_prompt.rs +++ b/src/reportal_commands/prompts/text_prompt.rs @@ -17,7 +17,9 @@ pub struct TextPromptParameters<'a> { /// /// 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 { +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()) diff --git a/src/reportal_commands/repo_add/alias_suggestion.rs b/src/reportal_commands/repo_add/alias_suggestion.rs index 400d553..93fceba 100644 --- a/src/reportal_commands/repo_add/alias_suggestion.rs +++ b/src/reportal_commands/repo_add/alias_suggestion.rs @@ -12,10 +12,11 @@ pub enum AliasSuggestion { /// 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()), - ) + 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. @@ -25,7 +26,9 @@ pub fn repo_name_from_git_url(git_url: &str) -> AliasSuggestion { if !repo_name.is_empty() { return AliasSuggestion::Inferred(repo_name.to_owned()); } - let ssh_repo_name = trimmed.rsplit(':').next() + let ssh_repo_name = trimmed + .rsplit(':') + .next() .and_then(|ssh_path| ssh_path.rsplit('/').next()) .unwrap_or(""); if ssh_repo_name.is_empty() { diff --git a/src/reportal_commands/repo_add/clone_destination.rs b/src/reportal_commands/repo_add/clone_destination.rs index 4f52e57..6a0ab76 100644 --- a/src/reportal_commands/repo_add/clone_destination.rs +++ b/src/reportal_commands/repo_add/clone_destination.rs @@ -19,10 +19,13 @@ pub enum CloneDestination { } /// 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 { +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 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()]; diff --git a/src/reportal_commands/repo_add/clone_placement.rs b/src/reportal_commands/repo_add/clone_placement.rs index ea451ce..891af42 100644 --- a/src/reportal_commands/repo_add/clone_placement.rs +++ b/src/reportal_commands/repo_add/clone_placement.rs @@ -5,8 +5,11 @@ 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); +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(); @@ -28,7 +31,8 @@ pub fn collect_registered_parent_directories(loaded_config: &ReportalConfig) -> /// 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); + 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())) diff --git a/src/reportal_commands/repo_add/git_clone_operation.rs b/src/reportal_commands/repo_add/git_clone_operation.rs index 77209bb..84c5e48 100644 --- a/src/reportal_commands/repo_add/git_clone_operation.rs +++ b/src/reportal_commands/repo_add/git_clone_operation.rs @@ -31,9 +31,15 @@ impl GitCloneOperation<'_> { .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}"), - }) }, + 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 index 65cd1a5..14354c7 100644 --- a/src/reportal_commands/repo_add/git_remote_detection.rs +++ b/src/reportal_commands/repo_add/git_remote_detection.rs @@ -1,7 +1,7 @@ //! Detects git remote URLs from local repo directories. -use std::process::Command; use crate::terminal_style; +use std::process::Command; /// Whether a git remote was found in the target directory. pub enum GitRemoteDetection { @@ -22,10 +22,20 @@ pub fn detect_git_remote(directory_path: &str) -> GitRemoteDetection { .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 }, + 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/registration_context.rs b/src/reportal_commands/repo_add/registration_context.rs index 9671fb3..4c159c7 100644 --- a/src/reportal_commands/repo_add/registration_context.rs +++ b/src/reportal_commands/repo_add/registration_context.rs @@ -1,9 +1,7 @@ //! Interactive metadata collection and repo registration flow. use crate::error::ReportalError; -use crate::reportal_commands::prompts::{ - self, ColorPromptResult, TextPromptParameters, -}; +use crate::reportal_commands::prompts::{self, ColorPromptResult, TextPromptParameters}; use crate::reportal_config::{RepoRegistrationBuilder, ReportalConfig}; use crate::terminal_style; use owo_colors::OwoColorize; @@ -63,23 +61,51 @@ impl RegistrationContext<'_> { 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))); + 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)); + 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))); + 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))); + 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))); + 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())); + terminal_style::write_stdout(&format!( + " {} {}\n", + "Color:".style(terminal_style::LABEL_STYLE), + hex_color.raw_value() + )); } ColorPromptResult::Skipped => {} } diff --git a/src/reportal_commands/repo_add/run.rs b/src/reportal_commands/repo_add/run.rs index 77131bc..00dfb04 100644 --- a/src/reportal_commands/repo_add/run.rs +++ b/src/reportal_commands/repo_add/run.rs @@ -15,7 +15,9 @@ 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 { +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() @@ -27,14 +29,19 @@ fn prompt_custom_clone_path(prompt_theme: &dialoguer::theme::ColorfulTheme) -> R } /// Registers a local directory as a repo in the config. -fn add_local_repo(loaded_config: &mut ReportalConfig, local_path: &str) -> Result<(), ReportalError> { +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(), + GitRemoteDetection::NoOriginConfigured | GitRemoteDetection::GitUnavailable => { + String::new() + } }; let registration = RegistrationContext { loaded_config, diff --git a/src/reportal_commands/repo_ai.rs b/src/reportal_commands/repo_ai.rs index c5deb87..729037c 100644 --- a/src/reportal_commands/repo_ai.rs +++ b/src/reportal_commands/repo_ai.rs @@ -1,12 +1,10 @@ //! 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 crate::reportal_commands::repo_selection::{self, SelectedRepoParameters}; -use crate::reportal_commands::terminal_identity_emit::{ - self, TerminalIdentityEmitParameters, -}; use owo_colors::OwoColorize; use std::process::Command; @@ -29,12 +27,19 @@ 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) } + 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 }; + 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"); @@ -57,7 +62,9 @@ pub fn run_ai(ai_params: &AiCommandParameters<'_>) -> Result<(), ReportalError> terminal_style::print_success(&format!( "Launching {} in {}", tool_name.style(terminal_style::ALIAS_STYLE), - loaded_config.path_display_format().format_path(&resolved_repo_path) + loaded_config + .path_display_format() + .format_path(&resolved_repo_path) .style(terminal_style::PATH_STYLE), )); @@ -86,9 +93,11 @@ pub fn run_ai(ai_params: &AiCommandParameters<'_>) -> Result<(), ReportalError> reason: format!("{}: {spawn_error}", ai_tool.cli_command()), })?; - spawned_process.wait().map_err(|wait_error| ReportalError::AiToolLaunchFailure { - reason: format!("process exited unexpectedly: {wait_error}"), - })?; + spawned_process + .wait() + .map_err(|wait_error| ReportalError::AiToolLaunchFailure { + reason: format!("process exited unexpectedly: {wait_error}"), + })?; Ok(()) } diff --git a/src/reportal_commands/repo_edit_mutator.rs b/src/reportal_commands/repo_edit_mutator.rs index a100cdf..f568757 100644 --- a/src/reportal_commands/repo_edit_mutator.rs +++ b/src/reportal_commands/repo_edit_mutator.rs @@ -241,8 +241,9 @@ impl RepoEditFieldMutator<'_, '_, '_> { /// 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); + let affected_workspaces = self + .loaded_config + .workspaces_containing_repo(self.selected_alias); if affected_workspaces.is_empty() { return Ok(()); } diff --git a/src/reportal_commands/repo_jump.rs b/src/reportal_commands/repo_jump.rs index d62d6b9..dc96414 100644 --- a/src/reportal_commands/repo_jump.rs +++ b/src/reportal_commands/repo_jump.rs @@ -1,17 +1,11 @@ //! Fuzzy-selects a repo and prints its path for shell `cd` integration. use crate::error::ReportalError; -use crate::reportal_commands::direct_alias_router::{ - DirectAliasRouter, DirectAliasRouterOutcome, -}; +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_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; @@ -66,9 +60,7 @@ pub fn run_jump(jump_params: &JumpCommandParameters<'_>) -> Result<(), ReportalE } else { let router = DirectAliasRouter::for_config(&loaded_config); match router.classify(jump_params.direct_alias)? { - DirectAliasRouterOutcome::RegisteredRepo => { - jump_params.direct_alias.to_owned() - } + DirectAliasRouterOutcome::RegisteredRepo => jump_params.direct_alias.to_owned(), DirectAliasRouterOutcome::Workspace(canonical_workspace_name) => { return router.jump_to_workspace_parent(&canonical_workspace_name); } @@ -95,7 +87,9 @@ pub fn run_jump(jump_params: &JumpCommandParameters<'_>) -> Result<(), ReportalE }); let resolved_path = selected.repo_config().resolved_path(); - let formatted_path = loaded_config.path_display_format().format_path(&resolved_path); + let formatted_path = loaded_config + .path_display_format() + .format_path(&resolved_path); terminal_style::write_stdout(&formatted_path.clone()); diff --git a/src/reportal_commands/repo_listing_renderer.rs b/src/reportal_commands/repo_listing_renderer.rs index aafb847..27ae54b 100644 --- a/src/reportal_commands/repo_listing_renderer.rs +++ b/src/reportal_commands/repo_listing_renderer.rs @@ -90,10 +90,8 @@ impl<'tree> RepoListingRenderer<'tree> { /// 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(), - ) { + 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() } @@ -104,9 +102,7 @@ impl<'tree> RepoListingRenderer<'tree> { 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}'" - ) + format!("No repos found in workspace '{target_workspace}' with tag '{target_tag}'") } }; terminal_style::write_stdout(&format!("{empty_state_message}\n")); diff --git a/src/reportal_commands/repo_open.rs b/src/reportal_commands/repo_open.rs index bddbb32..6248400 100644 --- a/src/reportal_commands/repo_open.rs +++ b/src/reportal_commands/repo_open.rs @@ -1,17 +1,11 @@ //! Fuzzy-selects a repo and opens it in the configured editor. use crate::error::ReportalError; -use crate::reportal_commands::direct_alias_router::{ - DirectAliasRouter, DirectAliasRouterOutcome, -}; +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_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; @@ -66,9 +60,7 @@ pub fn run_open(open_params: &OpenCommandParameters<'_>) -> Result<(), ReportalE } else { let router = DirectAliasRouter::for_config(&loaded_config); match router.classify(open_params.direct_alias)? { - DirectAliasRouterOutcome::RegisteredRepo => { - open_params.direct_alias.to_owned() - } + DirectAliasRouterOutcome::RegisteredRepo => open_params.direct_alias.to_owned(), DirectAliasRouterOutcome::Workspace(canonical_workspace_name) => { return run_workspace_open(&canonical_workspace_name); } @@ -96,7 +88,11 @@ pub fn run_open(open_params: &OpenCommandParameters<'_>) -> Result<(), ReportalE let resolved_repo_path = selected.repo_config().resolved_path(); - let editor_command = if open_params.editor_override.is_empty() { loaded_config.default_editor() } else { 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")] let spawn_result = Command::new("cmd") @@ -116,8 +112,9 @@ pub fn run_open(open_params: &OpenCommandParameters<'_>) -> Result<(), ReportalE 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), diff --git a/src/reportal_commands/repo_remove.rs b/src/reportal_commands/repo_remove.rs index ff04113..ee39871 100644 --- a/src/reportal_commands/repo_remove.rs +++ b/src/reportal_commands/repo_remove.rs @@ -40,6 +40,10 @@ pub fn run_remove(repository_alias: &str) -> Result<(), ReportalError> { let removed_entry = loaded_config.remove_repo(repository_alias)?; loaded_config.save_to_disk()?; - terminal_style::print_success(&format!("Removed '{}' ({})", repository_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 index 60f6ad2..decd010 100644 --- a/src/reportal_commands/repo_run.rs +++ b/src/reportal_commands/repo_run.rs @@ -1,12 +1,10 @@ //! 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 crate::reportal_commands::repo_selection::{self, SelectedRepoParameters}; -use crate::reportal_commands::terminal_identity_emit::{ - self, TerminalIdentityEmitParameters, -}; use dialoguer::FuzzySelect; use owo_colors::OwoColorize; use std::collections::BTreeMap; @@ -49,7 +47,9 @@ struct ResolveCommandParameters<'a> { /// 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 { +fn resolve_command( + resolve_params: &ResolveCommandParameters<'_>, +) -> Result { let mut merged: Vec> = Vec::new(); for (name, entry) in resolve_params.global_commands { @@ -82,7 +82,9 @@ fn resolve_command(resolve_params: &ResolveCommandParameters<'_>) -> Result = merged @@ -111,21 +113,30 @@ fn resolve_command(resolve_params: &ResolveCommandParameters<'_>) -> Result) -> Result<(), ReportalErro 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) + resolved_command + .command_name + .style(terminal_style::ALIAS_STYLE), + loaded_config + .path_display_format() + .format_path(&resolved_repo_path) .style(terminal_style::PATH_STYLE), )); @@ -192,9 +207,12 @@ pub fn run_run(run_params: &RunCommandParameters<'_>) -> Result<(), ReportalErro 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}"), - })?; + 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() { diff --git a/src/reportal_commands/repo_selection.rs b/src/reportal_commands/repo_selection.rs index 3236021..b98990b 100644 --- a/src/reportal_commands/repo_selection.rs +++ b/src/reportal_commands/repo_selection.rs @@ -56,9 +56,13 @@ fn push_formatted(target: &mut String, format_payload: std::fmt::Arguments<'_>) /// 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> { +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)?; + 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, @@ -66,62 +70,70 @@ pub fn select_repo<'a>(selection_params: &'a SelectedRepoParameters<'a>) -> Resu } { - 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()) { + 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 + 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, }) - .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 53912b9..af1792a 100644 --- a/src/reportal_commands/repo_status.rs +++ b/src/reportal_commands/repo_status.rs @@ -98,7 +98,13 @@ fn read_branch_name(repo_path: &PathBuf) -> String { repo_path, git_subcommand_args: &["rev-parse", "--abbrev-ref", "HEAD"], }) { - GitCommandOutcome::Output(branch) => if branch.is_empty() || branch == "HEAD" { "detached".to_owned() } else { branch }, + 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(), } @@ -110,7 +116,13 @@ fn read_working_tree_state(repo_path: &PathBuf) -> WorkingTreeState { repo_path, git_subcommand_args: &["status", "--porcelain"], }) { - GitCommandOutcome::Output(output) => if output.is_empty() { WorkingTreeState::Clean } else { WorkingTreeState::Dirty }, + GitCommandOutcome::Output(output) => { + if output.is_empty() { + WorkingTreeState::Clean + } else { + WorkingTreeState::Dirty + } + } GitCommandOutcome::NonZeroExit | GitCommandOutcome::SpawnFailed => WorkingTreeState::Dirty, } } @@ -141,7 +153,9 @@ fn read_upstream_delta(repo_path: &PathBuf) -> UpstreamDelta { RevListParse::Parsed(counts) => UpstreamDelta::Tracked(counts), RevListParse::Malformed => UpstreamDelta::NoUpstream, }, - GitCommandOutcome::NonZeroExit | GitCommandOutcome::SpawnFailed => UpstreamDelta::NoUpstream, + GitCommandOutcome::NonZeroExit | GitCommandOutcome::SpawnFailed => { + UpstreamDelta::NoUpstream + } } } @@ -174,10 +188,12 @@ fn collect_repo_status(status_collection_params: &StatusCollectionParameters<'_> last_commit_age, }), } - } else { RepoGitStatus { - alias: status_collection_params.alias.to_owned(), - presence: RepoPresence::Missing, - } } + } else { + RepoGitStatus { + alias: status_collection_params.alias.to_owned(), + presence: RepoPresence::Missing, + } + } } /// Formats the upstream delta into a string like "2↑ 1↓" or "synced". @@ -193,7 +209,11 @@ fn format_upstream_delta(upstream_delta: &UpstreamDelta) -> String { 0 => {} behind_count => parts.push(format!("{behind_count}↓")), } - if parts.is_empty() { "synced".to_owned() } else { parts.join(" ") } + if parts.is_empty() { + "synced".to_owned() + } else { + parts.join(" ") + } } UpstreamDelta::NoUpstream => "no upstream".to_owned(), } @@ -228,8 +248,12 @@ pub fn run_status(tag_filter: &TagFilter) -> Result<(), ReportalError> { match &repo_status.presence { RepoPresence::Present(git_info) => { let status_text = match &git_info.working_tree { - WorkingTreeState::Clean => "clean".style(terminal_style::SUCCESS_STYLE).to_string(), - WorkingTreeState::Dirty => "dirty".style(terminal_style::FAILURE_STYLE).to_string(), + WorkingTreeState::Clean => { + "clean".style(terminal_style::SUCCESS_STYLE).to_string() + } + WorkingTreeState::Dirty => { + "dirty".style(terminal_style::FAILURE_STYLE).to_string() + } }; let upstream_text = format_upstream_delta(&git_info.upstream_delta); @@ -246,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("—"), ]); diff --git a/src/reportal_commands/repo_sync.rs b/src/reportal_commands/repo_sync.rs index 2488ccb..a6419f5 100644 --- a/src/reportal_commands/repo_sync.rs +++ b/src/reportal_commands/repo_sync.rs @@ -44,7 +44,13 @@ fn check_pull_readiness(repo_path: &PathBuf) -> PullReadiness { repo_path, git_subcommand_args: &["status", "--porcelain"], }) { - GitCommandOutcome::Output(output) => if output.is_empty() { PullReadiness::Ready } else { PullReadiness::DirtyWorkingTree }, + GitCommandOutcome::Output(output) => { + if output.is_empty() { + PullReadiness::Ready + } else { + PullReadiness::DirtyWorkingTree + } + } GitCommandOutcome::NonZeroExit | GitCommandOutcome::SpawnFailed => PullReadiness::Unknown, } } @@ -57,13 +63,15 @@ fn pull_repo(repo_path: &PathBuf) -> SyncOutcome { .output(); match pull_result { - Ok(output) => if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned(); - SyncOutcome::Pulled(stdout) - } else { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); - SyncOutcome::PullFailed(stderr) - }, + Ok(output) => { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + SyncOutcome::Pulled(stdout) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + SyncOutcome::PullFailed(stderr) + } + } Err(spawn_error) => SyncOutcome::PullFailed(spawn_error.to_string()), } } @@ -79,7 +87,9 @@ fn sync_single_repo(alias: &str, repo_path: &PathBuf) -> RepoSyncResult { 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()), + PullReadiness::Unknown => { + SyncOutcome::PullFailed("could not determine working tree state".to_owned()) + } }; RepoSyncResult { alias: alias.to_owned(), @@ -91,8 +101,16 @@ fn sync_single_repo(alias: &str, repo_path: &PathBuf) -> RepoSyncResult { 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" }; + 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!( " {} {} {} ", @@ -159,9 +177,13 @@ pub fn run_sync(tag_filter: &TagFilter) -> Result<(), ReportalError> { terminal_style::write_stdout("\n"); terminal_style::write_stdout(&format!( " {} pulled, {} skipped, {} failed\n", - pulled_count.to_string().style(terminal_style::SUCCESS_STYLE), + 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), )); Ok(()) diff --git a/src/reportal_commands/repo_tree_grouping.rs b/src/reportal_commands/repo_tree_grouping.rs index a10dea9..fad0c5e 100644 --- a/src/reportal_commands/repo_tree_grouping.rs +++ b/src/reportal_commands/repo_tree_grouping.rs @@ -167,9 +167,7 @@ impl<'config> RepoTreeGroupingParameters<'config> { /// 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> { + 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(); @@ -247,8 +245,9 @@ impl<'config> RepoTreeGroupingParameters<'config> { if !repo_survives_tag { continue; } - let containing_workspaces = - self.loaded_config.workspaces_containing_repo(repository_alias); + 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)); } @@ -311,8 +310,8 @@ mod tests { ); 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 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()); @@ -335,8 +334,8 @@ mod tests { ); 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 tree_grouping = + build_tree(&test_config, &tag_filter, &workspace_filter).expect("build must succeed"); assert_eq!( tree_grouping.workspace_sections().len(), 2, @@ -371,8 +370,8 @@ mod tests { ); 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"); + 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(), @@ -419,10 +418,13 @@ mod tests { ); 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"); + 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().len(), + 1 + ); assert_eq!( tree_grouping.workspace_sections()[0].member_repos()[0].0, "api", @@ -443,8 +445,8 @@ mod tests { ); 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"); + 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", @@ -473,8 +475,8 @@ mod tests { ); 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 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() @@ -510,8 +512,8 @@ mod tests { ); 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"); + 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(), @@ -521,4 +523,3 @@ mod tests { assert_eq!(tree_grouping.distinct_repo_count(), 2); } } - diff --git a/src/reportal_commands/repo_web.rs b/src/reportal_commands/repo_web.rs index 868d97d..046e753 100644 --- a/src/reportal_commands/repo_web.rs +++ b/src/reportal_commands/repo_web.rs @@ -1,12 +1,10 @@ //! 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 crate::reportal_commands::repo_selection::{self, SelectedRepoParameters}; -use crate::reportal_commands::terminal_identity_emit::{ - self, TerminalIdentityEmitParameters, -}; use owo_colors::OwoColorize; use std::process::Command; @@ -36,14 +34,19 @@ enum RemoteResolution { /// 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}") - }, - ), + || { + 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}") @@ -68,10 +71,18 @@ fn detect_git_remote(repo_path: &std::path::Path) -> RemoteResolution { .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 }, + 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, } } @@ -99,14 +110,18 @@ pub fn run_web(web_params: &WebCommandParameters<'_>) -> Result<(), ReportalErro 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(), - }); + 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() }; + } else { + selected.repo_config().remote().to_owned() + }; let browser_url = remote_url_to_browser_url(&raw_remote_url); diff --git a/src/reportal_commands/repository_color.rs b/src/reportal_commands/repository_color.rs index c4f08d6..4e52e00 100644 --- a/src/reportal_commands/repository_color.rs +++ b/src/reportal_commands/repository_color.rs @@ -95,10 +95,12 @@ impl ResolvedTerminalIdentity { 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 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, diff --git a/src/reportal_commands/shell_alias_emit.rs b/src/reportal_commands/shell_alias_emit.rs index 6de80ee..d6466ac 100644 --- a/src/reportal_commands/shell_alias_emit.rs +++ b/src/reportal_commands/shell_alias_emit.rs @@ -36,19 +36,15 @@ pub struct ShellAliasEmissionParameters<'configuration> { /// 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"]; +const BUILTIN_SHELL_FUNCTION_NAMES: &[&str] = &["rj", "ro", "rjw", "row", "rw", "rr"]; /// @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( - parameters: &ShellAliasEmissionParameters<'_>, -) -> String { - let exported_dispatch_entries = - collect_exported_dispatch_entries(parameters.configuration); +pub fn shell_alias_export_snippet(parameters: &ShellAliasEmissionParameters<'_>) -> String { + let exported_dispatch_entries = collect_exported_dispatch_entries(parameters.configuration); if exported_dispatch_entries.is_empty() { return String::new(); } @@ -56,9 +52,7 @@ pub fn shell_alias_export_snippet( InitializeShell::Zsh | InitializeShell::Bash => { render_bash_snippet(&exported_dispatch_entries) } - InitializeShell::Powershell => { - render_powershell_snippet(&exported_dispatch_entries) - } + InitializeShell::Powershell => render_powershell_snippet(&exported_dispatch_entries), } } @@ -102,25 +96,19 @@ struct ExportedDispatchEntry { /// `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( - configuration: &ReportalConfig, -) -> Vec { +fn collect_exported_dispatch_entries(configuration: &ReportalConfig) -> Vec { let mut collected: Vec = Vec::new(); for (command_key, command_entry) in configuration.global_commands() { append_command_dispatch_entry(&mut collected, command_key, command_entry); } - for (repository_canonical_key, repository_entry) in - configuration.repos_with_aliases() - { + for (repository_canonical_key, repository_entry) in configuration.repos_with_aliases() { append_repository_dispatch_entries( &mut collected, repository_canonical_key, repository_entry, ); } - for (workspace_canonical_name, workspace_entry) in - configuration.workspaces_with_names() - { + for (workspace_canonical_name, workspace_entry) in configuration.workspaces_with_names() { append_workspace_dispatch_entries( &mut collected, workspace_canonical_name, @@ -142,8 +130,7 @@ 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()); + 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; @@ -192,9 +179,7 @@ fn append_repository_dispatch_entries( repository_canonical_key, ExportedDispatchKind::RepositoryJump, ); - for declared_alias in - crate::reportal_config::HasAliases::aliases(repository_entry) - { + for declared_alias in crate::reportal_config::HasAliases::aliases(repository_entry) { push_one_dispatch_entry_if_emittable( collector, declared_alias, @@ -223,9 +208,7 @@ fn append_workspace_dispatch_entries( workspace_canonical_name, ExportedDispatchKind::WorkspaceJump, ); - for declared_alias in - crate::reportal_config::HasAliases::aliases(workspace_entry) - { + for declared_alias in crate::reportal_config::HasAliases::aliases(workspace_entry) { push_one_dispatch_entry_if_emittable( collector, declared_alias, diff --git a/src/reportal_commands/shell_integration_autowire.rs b/src/reportal_commands/shell_integration_autowire.rs index 021680a..84ec798 100644 --- a/src/reportal_commands/shell_integration_autowire.rs +++ b/src/reportal_commands/shell_integration_autowire.rs @@ -129,8 +129,7 @@ impl AutoWireOperationLoadedState { 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(); + let parent_pid_string = parent_process_id.as_raw_nonzero().get().to_string(); if parent_pid_string == stamped_marker_value { Self::LoadedInCurrentShell } else { @@ -150,10 +149,8 @@ impl AutoWireOperationLoadedState { 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, - ); + 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; }; @@ -234,10 +231,10 @@ impl AutoWireOperation { /// 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), - ) + 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` @@ -277,12 +274,8 @@ impl AutoWireOperation { /// 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::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(), } } @@ -327,18 +320,14 @@ impl AutoWireOperation { match self.shell { InitializeShell::Zsh => "eval \"$(rep init zsh)\"", InitializeShell::Bash => "eval \"$(rep init bash)\"", - InitializeShell::Powershell => { - "Invoke-Expression (& rep init powershell | Out-String)" - } + 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 { + fn install_if_missing(&self) -> Result { if self.rc_file.exists() { return self.install_into_existing_rc_file(); } @@ -356,8 +345,7 @@ impl AutoWireOperation { 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); + let leading_newline_policy = Self::leading_newline_policy_for(&existing_rc_file_content); self.write_fenced_block(leading_newline_policy)?; Ok(AutoWireOperationOutcome::Appended) } @@ -377,12 +365,10 @@ impl AutoWireOperation { /// 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, - } + 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, }) } @@ -393,9 +379,7 @@ impl AutoWireOperation { 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') - { + if existing_rc_file_content.is_empty() || existing_rc_file_content.ends_with('\n') { AutoWireOperationLeadingNewlinePolicy::NoSeparator } else { AutoWireOperationLeadingNewlinePolicy::InsertSeparator @@ -405,15 +389,11 @@ impl AutoWireOperation { /// 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> { + 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() - { + 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| { @@ -540,7 +520,5 @@ pub fn current_shell_integration_loaded_state() -> AutoWireOperationLoadedState if stamped_marker_value == "1" { return AutoWireOperationLoadedState::LoadedInCurrentShell; } - AutoWireOperationLoadedState::classify_against_actual_parent_pid( - &stamped_marker_value, - ) + 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 index d2907a7..48a794b 100644 --- a/src/reportal_commands/shell_prompt_badge.rs +++ b/src/reportal_commands/shell_prompt_badge.rs @@ -21,15 +21,12 @@ //! framework already rewrote it) and prepend a freshly //! resolved badge. //! -//! 2. **Run-last self-registration.** The hook moves itself to -//! the end of the shell's prompt-hook chain every time it -//! runs. For zsh that means re-appending to -//! `precmd_functions`; for bash it means rewriting -//! `PROMPT_COMMAND` so our entry is last. Whoever else -//! registered a prompt hook still runs, but rep always runs -//! *after* them, so the strip-and-prepend sees the -//! framework's final prompt content and lays the badge on -//! top. +//! 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, @@ -88,7 +85,6 @@ _reportal_prompt_badge_hook() {{ else _REPORTAL_LAST_BADGE_ZSH="" fi - precmd_functions=(${{precmd_functions:#_reportal_prompt_badge_hook}} _reportal_prompt_badge_hook) }} 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 diff --git a/src/reportal_commands/target_selection.rs b/src/reportal_commands/target_selection.rs index dea20af..6e03750 100644 --- a/src/reportal_commands/target_selection.rs +++ b/src/reportal_commands/target_selection.rs @@ -69,7 +69,9 @@ pub struct SelectedTargetParameters<'borrow> { pub fn select_target( params: &SelectedTargetParameters<'_>, ) -> Result { - let matching_repos = params.loaded_config.repos_matching_tag_filter(params.tag_filter); + 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(), @@ -79,9 +81,8 @@ pub fn select_target( return Err(ReportalError::NoReposMatchFilter); } - let mut display_labels: Vec = Vec::with_capacity( - matching_repos.len() + workspace_rows_included.len(), - ); + 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 = diff --git a/src/reportal_commands/workspace_create.rs b/src/reportal_commands/workspace_create.rs index aa88e20..475c30a 100644 --- a/src/reportal_commands/workspace_create.rs +++ b/src/reportal_commands/workspace_create.rs @@ -40,14 +40,13 @@ pub fn run_workspace_create( 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()?; + 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()?; @@ -55,16 +54,25 @@ pub fn run_workspace_create( 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())?; + 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), + 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), + workspace_file_path + .display() + .to_string() + .style(terminal_style::PATH_STYLE), )); Ok(()) } @@ -89,9 +97,7 @@ fn reinterpret_custom_path_as_directory(raw_path: &str) -> String { { return candidate .parent() - .map_or_else(String::new, |parent_path| { - parent_path.display().to_string() - }); + .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 index c949ca5..1976c27 100644 --- a/src/reportal_commands/workspace_delete.rs +++ b/src/reportal_commands/workspace_delete.rs @@ -46,13 +46,19 @@ pub fn run_workspace_delete( 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), + 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), + workspace_directory + .display() + .to_string() + .style(terminal_style::PATH_STYLE), )); } else { terminal_style::print_success(&format!( diff --git a/src/reportal_commands/workspace_layout.rs b/src/reportal_commands/workspace_layout.rs index 51e1945..d26ab8d 100644 --- a/src/reportal_commands/workspace_layout.rs +++ b/src/reportal_commands/workspace_layout.rs @@ -147,8 +147,7 @@ pub fn materialize_workspace_layout( .iter() .map(|link_spec| PathBuf::from(format!("./{}", link_spec.link_name))) .collect(); - let mut code_workspace_document = - CodeWorkspaceFile::load_or_empty(&workspace_file_path)?; + 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( @@ -164,10 +163,7 @@ pub fn materialize_workspace_layout( /// 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 { +pub fn workspace_file_path_inside_dir(workspace_directory: &Path, workspace_name: &str) -> PathBuf { workspace_directory.join(format!("{workspace_name}.code-workspace")) } @@ -232,11 +228,12 @@ fn ensure_member_link( 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 { + 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 { @@ -334,8 +331,9 @@ mod tests { 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 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 @@ -357,8 +355,7 @@ mod tests { 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"); + 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); @@ -399,8 +396,14 @@ mod tests { }) .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_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", @@ -412,8 +415,7 @@ mod tests { ("charlie", &repo_charlie), ] { let link_path = workspace_dir.join(link_name); - let metadata = - std::fs::symlink_metadata(&link_path).expect("link metadata must exist"); + let metadata = std::fs::symlink_metadata(&link_path).expect("link metadata must exist"); #[cfg(unix)] assert!( metadata.file_type().is_symlink(), @@ -427,8 +429,7 @@ mod tests { let _ = metadata; #[cfg(unix)] { - let resolved = - std::fs::read_link(&link_path).expect("link must be readable"); + let resolved = std::fs::read_link(&link_path).expect("link must be readable"); assert_eq!(&resolved, expected_target); } let _ = expected_target; @@ -483,8 +484,7 @@ mod tests { }) .expect("first run must succeed"); - let resolved_after_first = - std::fs::read_link(&stale_link_path).expect("link must resolve"); + 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", @@ -519,8 +519,11 @@ mod tests { // 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"); + 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(), @@ -558,6 +561,9 @@ mod tests { 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")); + 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 index 81057be..a98308b 100644 --- a/src/reportal_commands/workspace_listing.rs +++ b/src/reportal_commands/workspace_listing.rs @@ -62,7 +62,10 @@ pub fn run_workspace_list() -> Result<(), ReportalError> { terminal_style::write_stdout(&format!( " {} {}\n", "Aliases:".style(terminal_style::LABEL_STYLE), - workspace_entry.aliases().join(", ").style(terminal_style::ALIAS_STYLE), + workspace_entry + .aliases() + .join(", ") + .style(terminal_style::ALIAS_STYLE), )); } @@ -70,7 +73,10 @@ pub fn run_workspace_list() -> Result<(), ReportalError> { terminal_style::write_stdout(&format!( " {} {}\n", "Dir:".style(terminal_style::LABEL_STYLE), - workspace_directory.display().to_string().style(terminal_style::PATH_STYLE), + workspace_directory + .display() + .to_string() + .style(terminal_style::PATH_STYLE), )); } @@ -86,7 +92,9 @@ pub fn run_workspace_list() -> Result<(), ReportalError> { terminal_style::write_stdout(&format!( " {} workspaces total\n\n", - registered_workspaces.len().style(terminal_style::EMPHASIS_STYLE), + 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 index 57f041c..03813ff 100644 --- a/src/reportal_commands/workspace_members.rs +++ b/src/reportal_commands/workspace_members.rs @@ -3,9 +3,7 @@ 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::reportal_config::{ReportalConfig, WorkspaceMember, WorkspaceMemberAliasLookup}; use crate::terminal_style; use owo_colors::OwoColorize; @@ -55,7 +53,9 @@ pub fn run_workspace_add_repo( terminal_style::print_success(&format!( "Added {} to workspace {}", - member_edit.repository_alias().style(terminal_style::ALIAS_STYLE), + member_edit + .repository_alias() + .style(terminal_style::ALIAS_STYLE), canonical_workspace_name.style(terminal_style::ALIAS_STYLE), )); Ok(()) @@ -123,7 +123,9 @@ pub fn run_workspace_remove_repo( terminal_style::print_success(&format!( "Removed {} from workspace {}", - member_edit.repository_alias().style(terminal_style::ALIAS_STYLE), + 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_operations.rs b/src/reportal_commands/workspace_operations.rs index 1f090a4..c85ae4c 100644 --- a/src/reportal_commands/workspace_operations.rs +++ b/src/reportal_commands/workspace_operations.rs @@ -189,10 +189,8 @@ impl<'config_lifetime> WorkspaceRegenerator<'config_lifetime> { /// 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(), - ) + 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 index 9d63ca6..e8d5aa7 100644 --- a/src/reportal_commands/workspace_rebuild.rs +++ b/src/reportal_commands/workspace_rebuild.rs @@ -47,8 +47,7 @@ pub fn run_workspace_rebuild(alias_or_canonical: &str) -> Result<(), ReportalErr .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()); + target_workspace.set_workspace_directory_path(new_directory_path.display().to_string()); loaded_config.save_to_disk()?; } @@ -59,11 +58,17 @@ pub fn run_workspace_rebuild(alias_or_canonical: &str) -> Result<(), ReportalErr 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), + 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), + 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 index c1641be..c358a10 100644 --- a/src/reportal_commands/workspace_selection.rs +++ b/src/reportal_commands/workspace_selection.rs @@ -63,9 +63,7 @@ pub fn select_workspace(loaded_config: &ReportalConfig) -> Result return Err(ReportalError::SelectionCancelled), }; match registered_workspaces.get(chosen_index) { - Some((chosen_workspace_name, _chosen_entry)) => { - Ok((*chosen_workspace_name).clone()) - } + 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 index ce8283c..e4f8107 100644 --- a/src/reportal_commands/workspace_show.rs +++ b/src/reportal_commands/workspace_show.rs @@ -35,7 +35,9 @@ pub fn run_workspace_show(alias_or_canonical: &str) -> Result<(), ReportalError> terminal_style::write_stdout("\n"); terminal_style::write_stdout(&format!( " {}\n", - canonical_name.to_uppercase().style(terminal_style::ALIAS_STYLE), + canonical_name + .to_uppercase() + .style(terminal_style::ALIAS_STYLE), )); if !target_workspace.description().is_empty() { @@ -50,14 +52,20 @@ pub fn run_workspace_show(alias_or_canonical: &str) -> Result<(), ReportalError> terminal_style::write_stdout(&format!( " {} {}\n", "Aliases:".style(terminal_style::LABEL_STYLE), - target_workspace.aliases().join(", ").style(terminal_style::ALIAS_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), + workspace_directory + .display() + .to_string() + .style(terminal_style::PATH_STYLE), )); if !workspace_directory.exists() { terminal_style::write_stdout(&format!( @@ -75,7 +83,10 @@ pub fn run_workspace_show(alias_or_canonical: &str) -> Result<(), ReportalError> terminal_style::write_stdout(&format!( " {} {}\n", "File:".style(terminal_style::LABEL_STYLE), - workspace_file_path.display().to_string().style(terminal_style::PATH_STYLE), + workspace_file_path + .display() + .to_string() + .style(terminal_style::PATH_STYLE), )); terminal_style::write_stdout(&format!( @@ -103,9 +114,10 @@ pub fn run_workspace_show(alias_or_canonical: &str) -> Result<(), ReportalError> 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_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!( diff --git a/src/reportal_config/ai_tool_entry.rs b/src/reportal_config/ai_tool_entry.rs index 985c2d4..603efa2 100644 --- a/src/reportal_config/ai_tool_entry.rs +++ b/src/reportal_config/ai_tool_entry.rs @@ -25,7 +25,6 @@ impl AiToolEntry { } } - /// The executable name used to spawn the AI CLI process. pub fn cli_command(&self) -> &str { &self.command diff --git a/src/reportal_config/global_settings.rs b/src/reportal_config/global_settings.rs index f7055ce..1ef9d52 100644 --- a/src/reportal_config/global_settings.rs +++ b/src/reportal_config/global_settings.rs @@ -1,8 +1,8 @@ //! Global settings that apply across all repos, stored in the //! `[settings]` section of `config.toml`. -use serde::{Deserialize, Serialize}; use crate::terminal_style; +use serde::{Deserialize, Serialize}; /// How repo paths are displayed in output after selecting a repo. #[derive(Debug, Deserialize, Serialize)] @@ -22,21 +22,22 @@ impl PathDisplayFormat { /// 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::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); + 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")); + terminal_style::write_stderr(&format!( + " Could not read working directory: {cwd_read_error}\n" + )); absolute_path.display().to_string() } } diff --git a/src/reportal_config/has_aliases.rs b/src/reportal_config/has_aliases.rs index 29c7a45..07d5b1b 100644 --- a/src/reportal_config/has_aliases.rs +++ b/src/reportal_config/has_aliases.rs @@ -96,7 +96,10 @@ mod tests { #[test] fn canonical_key_match_wins_first() { let entry_map = build_entry_map(); - assert_eq!(resolve_canonical_key(&entry_map, "venoble"), Some("venoble")); + assert_eq!( + resolve_canonical_key(&entry_map, "venoble"), + Some("venoble") + ); } #[test] diff --git a/src/reportal_config/hex_color.rs b/src/reportal_config/hex_color.rs index 0249e29..518ad84 100644 --- a/src/reportal_config/hex_color.rs +++ b/src/reportal_config/hex_color.rs @@ -25,7 +25,9 @@ impl HexColor { }); } let hex_digits = &trimmed[1..]; - let all_hex = hex_digits.chars().all(|character| character.is_ascii_hexdigit()); + let all_hex = hex_digits + .chars() + .all(|character| character.is_ascii_hexdigit()); if !all_hex { return Err(ReportalError::InvalidColor { value: raw.to_owned(), @@ -48,10 +50,9 @@ impl HexColor { 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), - }) + 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])?; @@ -68,9 +69,7 @@ impl HexColor { 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" - ) + format!("\x1b]4;264;rgb:{red_hex}/{green_hex}/{blue_hex}\x07") } } diff --git a/src/reportal_config/mod.rs b/src/reportal_config/mod.rs index 4031141..440332a 100644 --- a/src/reportal_config/mod.rs +++ b/src/reportal_config/mod.rs @@ -9,11 +9,11 @@ mod command_entry; mod global_settings; mod has_aliases; mod hex_color; -mod shell_alias_export; -mod repository_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; @@ -25,10 +25,10 @@ pub use command_entry::CommandEntry; pub use global_settings::PathVisibility; pub use has_aliases::HasAliases; pub use hex_color::HexColor; -pub use repository_color::RepoColor; 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; diff --git a/src/reportal_config/repo_registration_builder.rs b/src/reportal_config/repo_registration_builder.rs index 967de97..f505054 100644 --- a/src/reportal_config/repo_registration_builder.rs +++ b/src/reportal_config/repo_registration_builder.rs @@ -2,8 +2,8 @@ use crate::error::ReportalError; use crate::reportal_config::hex_color::HexColor; -use crate::reportal_config::repository_color::RepoColor; 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; @@ -115,7 +115,9 @@ impl RepoRegistrationBuilder { } match SystemExecutableLookupOutcome::for_candidate_name(self.alias.trim()) { SystemExecutableLookupOutcome::NotFound => {} - SystemExecutableLookupOutcome::ShadowsExisting { existing_executable } => { + SystemExecutableLookupOutcome::ShadowsExisting { + existing_executable, + } => { return Err(ReportalError::ValidationFailure { field: "alias".to_owned(), reason: format!( diff --git a/src/reportal_config/reportal_config_root.rs b/src/reportal_config/reportal_config_root.rs index 1e541d1..b506121 100644 --- a/src/reportal_config/reportal_config_root.rs +++ b/src/reportal_config/reportal_config_root.rs @@ -5,8 +5,10 @@ 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::{HasAliases, resolve_canonical_key}; +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; @@ -148,7 +150,11 @@ impl ReportalConfig { }); } for (repo_key, repo_entry) in &self.repos { - if repo_entry.aliases().iter().any(|declared| declared == workspace_name) { + 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(), @@ -250,16 +256,17 @@ impl ReportalConfig { 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| { + std::fs::create_dir_all(&parent_directory).map_err(|io_error| { ReportalError::ConfigIoFailure { - reason: serialize_error.to_string(), + 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(), @@ -332,11 +339,12 @@ impl ReportalConfig { /// 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(), - }, - )?; + 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 { @@ -370,7 +378,10 @@ impl ReportalConfig { /// 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> { + 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 { @@ -383,9 +394,11 @@ impl ReportalConfig { /// 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(), - }) + self.repos + .remove(alias) + .ok_or_else(|| ReportalError::RepoNotFound { + alias: alias.to_owned(), + }) } /// Returns all registered workspaces with their names, for iteration. @@ -481,9 +494,7 @@ impl ReportalConfig { ) -> Result<(), ReportalError> { let (workspace_name, workspace_entry) = validated_registration; if self.workspaces.contains_key(&workspace_name) { - return Err(ReportalError::WorkspaceAlreadyExists { - workspace_name, - }); + return Err(ReportalError::WorkspaceAlreadyExists { workspace_name }); } for member_alias in workspace_entry.repo_aliases() { if !self.repos.contains_key(member_alias) { @@ -514,9 +525,11 @@ impl ReportalConfig { &mut self, workspace_name: &str, ) -> Result { - self.workspaces.remove(workspace_name).ok_or_else(|| ReportalError::WorkspaceNotFound { - workspace_name: workspace_name.to_owned(), - }) + 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. @@ -538,9 +551,11 @@ impl ReportalConfig { /// 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(), - }) + 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, @@ -569,9 +584,10 @@ impl ReportalConfig { commands: BTreeMap::new(), workspaces: BTreeMap::new(), ai_tools: BTreeMap::from([ - ("aider".to_owned(), AiToolEntry::with_executable("aider".to_owned())), - ("aider".to_owned(), AiToolEntry::with_executable("aider".to_owned())), - ("aider".to_owned(), AiToolEntry::with_executable("aider".to_owned())), + ( + "aider".to_owned(), + AiToolEntry::with_executable("aider".to_owned()), + ), ]), } } @@ -615,10 +631,13 @@ mod tests { 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); + 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) } @@ -632,7 +651,10 @@ mod tests { )?; 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"); + assert_eq!( + config.resolve_workspace_canonical_name("venoble")?, + "venoble" + ); Ok(()) } @@ -640,18 +662,17 @@ mod tests { 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 { .. }))); + 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 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(()) @@ -659,12 +680,8 @@ mod tests { #[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 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()), @@ -678,10 +695,9 @@ mod tests { #[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()]), - ); + 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"); @@ -702,13 +718,15 @@ mod tests { make_workspace(vec!["app".to_owned()], vec![])?, ); let outcome = config.validate_alias_collisions(); - assert!(matches!(outcome, Err(ReportalError::WorkspaceAliasConflict { .. }))); + assert!(matches!( + outcome, + Err(ReportalError::WorkspaceAliasConflict { .. }) + )); Ok(()) } #[test] - fn alias_collision_workspace_alias_equals_repo_canonical_key( - ) -> Result<(), ReportalError> { + 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![])); @@ -717,29 +735,33 @@ mod tests { make_workspace(vec!["app".to_owned()], vec!["vn".to_owned()])?, ); let outcome = config.validate_alias_collisions(); - assert!(matches!(outcome, Err(ReportalError::WorkspaceAliasConflict { .. }))); + 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 + .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 { .. }))); + assert!(matches!( + outcome, + Err(ReportalError::WorkspaceAliasConflict { .. }) + )); Ok(()) } #[test] - fn alias_collision_workspace_name_equals_repo_canonical_key( - ) -> Result<(), ReportalError> { + 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![])); @@ -748,7 +770,10 @@ mod tests { make_workspace(vec!["app".to_owned()], vec![])?, ); let outcome = config.validate_alias_collisions(); - assert!(matches!(outcome, Err(ReportalError::WorkspaceAliasConflict { .. }))); + assert!(matches!( + outcome, + Err(ReportalError::WorkspaceAliasConflict { .. }) + )); Ok(()) } @@ -769,15 +794,21 @@ mod tests { #[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()); + 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()); + 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(), diff --git a/src/reportal_config/workspace_entry.rs b/src/reportal_config/workspace_entry.rs index 19da66d..a090c42 100644 --- a/src/reportal_config/workspace_entry.rs +++ b/src/reportal_config/workspace_entry.rs @@ -5,9 +5,7 @@ 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 crate::reportal_config::workspace_member::{WorkspaceMember, WorkspaceMemberAliasLookup}; use serde::{Deserialize, Serialize}; /// A single registered VSCode/Cursor workspace definition. diff --git a/src/reportal_config/workspace_member.rs b/src/reportal_config/workspace_member.rs index 089ee95..a0d5834 100644 --- a/src/reportal_config/workspace_member.rs +++ b/src/reportal_config/workspace_member.rs @@ -55,9 +55,7 @@ impl WorkspaceMember { #[must_use] pub fn registered_repo_alias(&self) -> WorkspaceMemberAliasLookup<'_> { match self { - Self::RegisteredRepo(alias) => { - WorkspaceMemberAliasLookup::Matches(alias.as_str()) - } + Self::RegisteredRepo(alias) => WorkspaceMemberAliasLookup::Matches(alias.as_str()), Self::InlinePath { .. } => WorkspaceMemberAliasLookup::NotARepoReference, } } diff --git a/src/reportal_config/workspace_registration_builder.rs b/src/reportal_config/workspace_registration_builder.rs index 78e95a2..fa06b17 100644 --- a/src/reportal_config/workspace_registration_builder.rs +++ b/src/reportal_config/workspace_registration_builder.rs @@ -167,9 +167,7 @@ fn validate_alias_list_shape( if declared.trim().is_empty() { return Err(ReportalError::ValidationFailure { field: "workspace alias".to_owned(), - reason: format!( - "workspace '{owning_workspace_name}' declares an empty alias" - ), + reason: format!("workspace '{owning_workspace_name}' declares an empty alias"), }); } if declared == owning_workspace_name { @@ -259,7 +257,11 @@ mod tests { #[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()]) + .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 diff --git a/src/system_executable_lookup.rs b/src/system_executable_lookup.rs index 5f84eb2..aefe668 100644 --- a/src/system_executable_lookup.rs +++ b/src/system_executable_lookup.rs @@ -51,8 +51,7 @@ impl SystemExecutableLookupOutcome { } #[cfg(windows)] { - let extension_probe_path = - path_directory.join(format!("{candidate_name}.exe")); + 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, diff --git a/src/terminal_style.rs b/src/terminal_style.rs index a8618fa..c9912ef 100644 --- a/src/terminal_style.rs +++ b/src/terminal_style.rs @@ -50,15 +50,15 @@ pub fn reportal_prompt_theme() -> dialoguer::theme::ColorfulTheme { /// 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 { +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) - } + crate::reportal_config::RepoColor::ResetToDefault => Ok(DEFAULT_SWATCH_STYLE), } } @@ -74,12 +74,20 @@ pub fn write_stderr(text: &str) { /// Prints an error message with a red "Error:" prefix. pub fn print_error(error_message: &str) { - write_stderr(&format!("{} {}\n", "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) { - write_stdout(&format!("{} {}\n", ">>".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. @@ -146,14 +154,10 @@ 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$"); + 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"); + let console_handle = std::fs::OpenOptions::new().write(true).open("/dev/tty"); match console_handle { Ok(mut console) => { @@ -174,14 +178,10 @@ 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$"); + 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"); + let console_handle = std::fs::OpenOptions::new().write(true).open("/dev/tty"); match console_handle { Ok(mut console) => { From d8c61c35dad87586b2967c1dab4c0e23273d7557 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Sun, 24 May 2026 15:05:01 -0400 Subject: [PATCH 63/67] Make RePortal install durable outside Cargo bin Bump release metadata to 0.18.4. Fixes #1. --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 59 ++++++++++++++----- installer/install.ps1 | 19 ++++-- installer/install.sh | 15 +++-- src/reportal_commands/shell_alias_emit.rs | 40 +++++++++---- src/reportal_commands/shell_integration.rs | 68 ++++++++++++++++------ 7 files changed, 147 insertions(+), 58 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e2c12cc..6a16711 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -587,7 +587,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.18.3" +version = "0.18.4" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 5eb8de1..4b29f4c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.18.3" +version = "0.18.4" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/README.md b/README.md index a04d828..976dfce 100644 --- a/README.md +++ b/README.md @@ -26,12 +26,24 @@ RePortal is a single-binary CLI that keeps a registry of your dev repos and lets 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 | sh +``` + +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 @@ -52,19 +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 -``` - -### PowerShell installer (Windows) - -```powershell -powershell -ExecutionPolicy Bypass -c "irm https://github.com/CheckPickerUpper/reportal/releases/latest/download/reportal-installer.ps1 | iex" +cargo install reportal ``` -Both installers drop the binaries into a user-local directory and auto-append an idempotent `eval "$(rep init )"` block (or the PowerShell equivalent) to your shell rc file, so integration is wired up on the next shell start — no manual rc editing required. +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 @@ -76,27 +82,48 @@ cargo install --path . ## Quick start -Shell integration is auto-wired the first time you run any `rep` subcommand: the binary detects that the integration isn't sourced yet, appends an idempotent fenced `eval` block to your rc file (`~/.zshrc`, `~/.bashrc`, or `$PROFILE`), and prints a single `source your-rc` hint. Subsequent runs are a no-op. If you prefer to wire it yourself — same pattern as `starship`, `zoxide`, `direnv`, and `mise` — add one of these lines manually: +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 -eval "$(rep init 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 | sh" >&2; return 127; } +fi ``` **Bash** (`~/.bashrc`): ```bash -eval "$(rep init bash)" +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 | sh" >&2; return 127; } +fi ``` **PowerShell** (`$PROFILE`): ```powershell -Invoke-Expression (& rep init powershell | Out-String) +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 disk writes beyond the one-shot rc append, no profile editing after setup, no prompts. The integration code is regenerated every shell session, so a `cargo install reportal` upgrade takes effect on the next terminal you open. The first time you run any `rep` subcommand, a default `~/.reportal/config.toml` is created for you. +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 diff --git a/installer/install.ps1 b/installer/install.ps1 index 669393e..4291ba3 100644 --- a/installer/install.ps1 +++ b/installer/install.ps1 @@ -4,15 +4,13 @@ # 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 -# `Invoke-Expression (& rep init powershell | Out-String)` block to -# $PROFILE. +# 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.15.0' Pin to a specific tag. +# $env:REPORTAL_VERSION = 'v0.18.4' Pin to a specific tag. # # Licensed under MIT. @@ -24,7 +22,7 @@ $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 <<<' -$IntegrationLine = 'Invoke-Expression (& rep init powershell | Out-String)' +$ReinstallCommand = 'powershell -ExecutionPolicy Bypass -c "irm https://github.com/CheckPickerUpper/reportal/releases/latest/download/reportal-installer.ps1 | iex"' function Write-Info { param([string]$Message) @@ -117,7 +115,16 @@ function Install-ShellIntegration { } $block += '' $block += $MarkerStart - $block += $IntegrationLine + $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" diff --git a/installer/install.sh b/installer/install.sh index 3a6a77d..43c777b 100644 --- a/installer/install.sh +++ b/installer/install.sh @@ -4,8 +4,8 @@ # # Downloads the latest (or pinned) reportal release archive from GitHub, # installs the `reportal` and `rep` binaries into ~/.local/bin, and appends -# an idempotent `eval "$(rep init )"` block to the user's shell rc -# file. Matches the distribution pattern used by starship/mise/zoxide. +# 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 \ @@ -13,7 +13,7 @@ # | bash # # Optional env: -# REPORTAL_VERSION=v0.15.0 Pin to a specific tag instead of "latest". +# REPORTAL_VERSION=v0.18.4 Pin to a specific tag instead of "latest". # # Licensed under MIT. @@ -25,6 +25,7 @@ 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 | sh" err() { printf 'reportal-installer: error: %s\n' "$*" >&2 @@ -181,7 +182,13 @@ install_shell_integration() { { printf '\n%s\n' "$MARKER_START" - printf 'eval "$(rep init %s)"\n' "$shell_name" + 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" diff --git a/src/reportal_commands/shell_alias_emit.rs b/src/reportal_commands/shell_alias_emit.rs index d6466ac..5a5d315 100644 --- a/src/reportal_commands/shell_alias_emit.rs +++ b/src/reportal_commands/shell_alias_emit.rs @@ -38,17 +38,28 @@ pub struct ShellAliasEmissionParameters<'configuration> { /// 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(parameters: &ShellAliasEmissionParameters<'_>) -> String { - let exported_dispatch_entries = collect_exported_dispatch_entries(parameters.configuration); +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 parameters.target_shell { + match shell_alias_parameters.target_shell { InitializeShell::Zsh | InitializeShell::Bash => { render_bash_snippet(&exported_dispatch_entries) } @@ -96,19 +107,24 @@ struct ExportedDispatchEntry { /// `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(configuration: &ReportalConfig) -> Vec { +fn collect_exported_dispatch_entries( + reportal_configuration: &ReportalConfig, +) -> Vec { let mut collected: Vec = Vec::new(); - for (command_key, command_entry) in configuration.global_commands() { + 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 configuration.repos_with_aliases() { + 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 configuration.workspaces_with_names() { + for (workspace_canonical_name, workspace_entry) in + reportal_configuration.workspaces_with_names() + { append_workspace_dispatch_entries( &mut collected, workspace_canonical_name, @@ -297,14 +313,14 @@ fn render_bash_snippet(exported_entries: &[ExportedDispatchEntry]) -> String { match exported_entry.dispatch_kind { ExportedDispatchKind::RepositoryJump => { rendered_block.push_str(&format!( - "function {name}() {{ cd \"$(rep jump {arg})\"; rep color; }}\n", + "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}() {{ cd \"$(rep workspace jump {arg})\"; rep color; }}\n", + "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, )); @@ -327,7 +343,7 @@ fn render_bash_snippet(exported_entries: &[ExportedDispatchEntry]) -> String { /// integration script was loaded. /// /// Each function definition is preceded by a silent -/// `Remove-Item Alias:` so a pre-existing PowerShell alias +/// `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. @@ -339,14 +355,14 @@ fn render_powershell_snippet(exported_entries: &[ExportedDispatchEntry]) -> Stri match exported_entry.dispatch_kind { ExportedDispatchKind::RepositoryJump => { rendered_block.push_str(&format!( - "Remove-Item Alias:{name} -ErrorAction SilentlyContinue; function global:{name} {{ Set-Location (rep jump {arg}); rep color }}\n", + "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} {{ Set-Location (rep workspace jump {arg}); rep color }}\n", + "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, )); diff --git a/src/reportal_commands/shell_integration.rs b/src/reportal_commands/shell_integration.rs index a75cbd5..cf82fb4 100644 --- a/src/reportal_commands/shell_integration.rs +++ b/src/reportal_commands/shell_integration.rs @@ -2,7 +2,7 @@ //! binary's version. //! //! The content is rendered to stdout by `rep init ` and -//! consumed by the user's rc file via `eval "$(rep init zsh)"`. +//! 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. @@ -15,8 +15,13 @@ //! as "marker inherited, integration not actually loaded in this //! shell" — see `shell_integration_autowire`. -/// @why Renders the bash/zsh integration block for callers piping -/// it through `eval "$(rep init bash|zsh)"`. +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"); @@ -24,40 +29,67 @@ pub fn bash_integration_content() -> String { r#"# RePortal shell integration — v{version} # Generated by 'rep init bash|zsh'. export REPORTAL_LOADED=$$ -rj() {{ cd "$(rep jump "$@")"; rep color; }} -ro() {{ rep open "$@"; rep color; }} -rjw() {{ cd "$(rep workspace jump "$@")"; rep color; }} -row() {{ rep workspace open "$@"; rep color; }} +_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 for callers -/// piping it through `Invoke-Expression (& rep init powershell | -/// Out-String)`. Uses `global:` scope qualifiers so the script -/// works the same whether sourced into `$PROFILE`, dot-sourced -/// into a module, or fed through any other loader. +/// @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{} + r#"# RePortal shell integration — v{version} # Generated by 'rep init powershell'. $env:REPORTAL_LOADED = "$PID" -function global:rj {{ Set-Location (rep jump @args); rep color }} -function global:ro {{ rep open @args; rep color }} -function global:rjw {{ Set-Location (rep workspace jump @args); rep color }} -function global:row {{ rep workspace open @args; rep color }} +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 }} "#, - env!("CARGO_PKG_VERSION"), + version = env!("CARGO_PKG_VERSION"), + reinstall_command = POWERSHELL_REINSTALL_COMMAND, ) } From 3200a4fe9dce1b34421d851e02219ad7988a6037 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Sun, 24 May 2026 15:08:23 -0400 Subject: [PATCH 64/67] Stage custom installers through cargo-dist Declare the guarded shell and PowerShell installers as extra artifacts so release.yml can stay generated. --- .github/workflows/release.yml | 9 --------- dist-workspace.toml | 11 ++++++----- installer/stage_dist_installers.py | 22 ++++++++++++++++++++++ 3 files changed, 28 insertions(+), 14 deletions(-) create mode 100644 installer/stage_dist_installers.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 38955b2..d9aa406 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -266,15 +266,6 @@ jobs: run: | # Remove the granular manifests rm -f artifacts/*-dist-manifest.json - - name: Stage custom installers - run: | - # Ship our own install.sh / install.ps1 (cargo-dist's templated - # installers have no post-install hook, so they can't wire up the - # shell integration). Reuse the historical asset names so existing - # curl/irm URLs in README/docs keep working. - cp installer/install.sh artifacts/reportal-installer.sh - cp installer/install.ps1 artifacts/reportal-installer.ps1 - chmod +x artifacts/reportal-installer.sh - name: Create GitHub Release env: PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" diff --git a/dist-workspace.toml b/dist-workspace.toml index 6002e48..f941264 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -7,11 +7,7 @@ members = ["cargo:."] cargo-dist-version = "0.31.0" # CI backends to support ci = "github" -# The installers to generate for each app. -# Note: we ship our own shell and PowerShell installers from installer/ -# so that we can auto-configure shell integration (cargo-dist's templated -# installers have no post-install hook). cargo-dist still builds the -# platform archives, checksums, homebrew tap, and the GitHub release. +# The installers to generate for each app 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"] @@ -23,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/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() From f4bfb3b5848ee4b3a7fa9074eb5cf8fc4703ed4c Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Sun, 24 May 2026 15:29:48 -0400 Subject: [PATCH 65/67] Fix shell installer invocation Use bash in the published Unix installer command and keep cleanup safe under set -u. --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 6 +++--- installer/install.ps1 | 2 +- installer/install.sh | 7 ++++--- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6a16711..fb19aa7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -587,7 +587,7 @@ dependencies = [ [[package]] name = "reportal" -version = "0.18.4" +version = "0.18.5" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 4b29f4c..44e5288 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reportal" -version = "0.18.4" +version = "0.18.5" edition = "2021" description = "A fast, cross-platform CLI for jumping between, opening, and managing your dev repos" license = "MIT" diff --git a/README.md b/README.md index 976dfce..670b659 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ For daily shell use, prefer the shell installer, Homebrew, or Scoop. Those route ### Shell installer (macOS / Linux) ```bash -curl --proto '=https' --tlsv1.2 -LsSf https://github.com/CheckPickerUpper/reportal/releases/latest/download/reportal-installer.sh | sh +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. @@ -92,7 +92,7 @@ if command -v rep >/dev/null 2>&1; then 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 | sh" >&2; return 127; } + 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 ``` @@ -104,7 +104,7 @@ if command -v rep >/dev/null 2>&1; then 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 | sh" >&2; return 127; } + 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 ``` diff --git a/installer/install.ps1 b/installer/install.ps1 index 4291ba3..2bee5e9 100644 --- a/installer/install.ps1 +++ b/installer/install.ps1 @@ -10,7 +10,7 @@ # powershell -ExecutionPolicy Bypass -c "irm https://github.com/CheckPickerUpper/reportal/releases/latest/download/reportal-installer.ps1 | iex" # # Optional env: -# $env:REPORTAL_VERSION = 'v0.18.4' Pin to a specific tag. +# $env:REPORTAL_VERSION = 'v0.18.5' Pin to a specific tag. # # Licensed under MIT. diff --git a/installer/install.sh b/installer/install.sh index 43c777b..b8613f5 100644 --- a/installer/install.sh +++ b/installer/install.sh @@ -13,7 +13,7 @@ # | bash # # Optional env: -# REPORTAL_VERSION=v0.18.4 Pin to a specific tag instead of "latest". +# REPORTAL_VERSION=v0.18.5 Pin to a specific tag instead of "latest". # # Licensed under MIT. @@ -25,7 +25,7 @@ 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 | sh" +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 @@ -227,7 +227,8 @@ main() { info "installing reportal ${tag} for ${target}" tmpdir=$(mktemp -d) - trap 'rm -rf "$tmpdir"' EXIT + REPORTAL_TMPDIR="$tmpdir" + trap 'if [ -n "${REPORTAL_TMPDIR:-}" ]; then rm -rf "$REPORTAL_TMPDIR"; fi' EXIT archive_path="${tmpdir}/${archive}" sha_path="${archive_path}.sha256" From 1f58863470104bb84dc19de09ca65b0aaac6daa0 Mon Sep 17 00:00:00 2001 From: ChequePickerUpper Date: Tue, 9 Jun 2026 01:20:21 -0400 Subject: [PATCH 66/67] docs: align README with current CLI --- README.md | 41 +++++++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 670b659..68488bc 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ --- -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 @@ -159,6 +159,7 @@ 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) | @@ -183,7 +184,8 @@ ro | `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 ` | | Open a workspace in your default editor | +| `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 @@ -203,7 +205,7 @@ ro | `rr` | Fuzzy-select a repo and run a configured command in it | | `rr my-api` | Skip repo selection, fuzzy-select a command | -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. +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 @@ -224,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" @@ -251,6 +254,8 @@ tags = ["personal", "frontend"] | `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 | ### AI tools @@ -296,6 +301,9 @@ repos = [ 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 | @@ -304,6 +312,9 @@ aliases = ["be", "back"] # short names accepted by rep work | `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. @@ -321,7 +332,7 @@ Define reusable commands that can be run in any repo via `rep run`: ```toml [commands] -test = { command = "cargo test", description = "Run tests" } +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" } ``` @@ -330,6 +341,7 @@ build = { command = "cargo build --release", description = "Production build" } |-------|----------|---------|-----------------| | `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: @@ -341,28 +353,33 @@ migrate = { command = "python manage.py migrate", description = "Run database ## Terminal personalization -When you jump to or open a repo, RePortal automatically sets: +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 alias -2. **Tab color** — uses the `color` config field; repos without a color reset to the terminal default +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' ``` -`rep color` matches your current directory against registered repos (longest prefix wins) and emits the right sequences. +**Zsh:** +```zsh +precmd() { rep color --mode prompt-hook 2>/dev/null } +``` + +`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 From 8825be62bafdc180c9ba1097dd3000bf6b591041 Mon Sep 17 00:00:00 2001 From: Ozzy Date: Sat, 8 Aug 2026 21:41:57 -0400 Subject: [PATCH 67/67] Select the typescript,rust rule packs so writes here are judged A repository with no selection is judged by the universal packs alone, so every language rule stays silent and an unjudged write is reported the same way as a clean one. This names the packs that apply to what this repository is written in. --- .ai-lab/plugins.toml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .ai-lab/plugins.toml 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 = []