diff --git a/AGENTS.md b/AGENTS.md index 7fef9c6d2e..3432304535 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -195,3 +195,69 @@ $ uv run pytest --cov - **QA every edit**: Run formatting and tests before committing - **Minimum Python**: 3.10+ (per pyproject.toml) - **Minimum tmux**: 3.2+ (as per README) + +## CLI Color Semantics (Revision 1, 2026-01-04) + +The CLI uses semantic colors via the `Colors` class in `src/tmuxp/_internal/colors.py`. Colors are chosen based on **hierarchy level** and **semantic meaning**, not just data type. + +### Design Principles + +1. **Structural hierarchy**: Headers > Items > Details +2. **Semantic meaning**: What IS this element? +3. **Visual weight**: What should draw the eye first? +4. **Depth separation**: Parent elements should visually contain children + +Inspired by patterns from **jq** (object keys vs values), **ripgrep** (path/line/match distinction), and **mise/just** (semantic method names). + +### Hierarchy-Based Colors + +| Level | Element Type | Method | Color | Examples | +|-------|--------------|--------|-------|----------| +| **L0** | Section headers | `heading()` | Bright cyan + bold | "Local workspaces:", "Global workspaces:" | +| **L1** | Primary content | `highlight()` | Magenta + bold | Workspace names (braintree, .tmuxp) | +| **L2** | Supplementary info | `info()` | Cyan | Paths (~/.tmuxp, ~/project/.tmuxp.yaml) | +| **L3** | Metadata/labels | `muted()` | Blue | Source labels (Legacy:, XDG default:) | + +### Status-Based Colors (Override hierarchy when applicable) + +| Status | Method | Color | Examples | +|--------|--------|-------|----------| +| Success/Active | `success()` | Green | "active", "18 workspaces" | +| Warning | `warning()` | Yellow | Deprecation notices | +| Error | `error()` | Red | Error messages | + +### Example Output + +``` +Local workspaces: ← heading() bright_cyan+bold + .tmuxp ~/work/python/tmuxp/.tmuxp.yaml ← highlight() + info() + +Global workspaces (~/.tmuxp): ← heading() + info() + braintree ← highlight() + cihai ← highlight() + +Global workspace directories: ← heading() + Legacy: ~/.tmuxp (18 workspaces, active) ← muted() + info() + success() + XDG default: ~/.config/tmuxp (not found) ← muted() + info() + muted() +``` + +### Available Methods + +```python +colors = Colors() +colors.heading("Section:") # Cyan + bold (section headers) +colors.highlight("item") # Magenta + bold (primary content) +colors.info("/path/to/file") # Cyan (paths, supplementary info) +colors.muted("label:") # Blue (metadata, labels) +colors.success("ok") # Green (success states) +colors.warning("caution") # Yellow (warnings) +colors.error("failed") # Red (errors) +``` + +### Key Rules + +**Never use the same color for adjacent hierarchy levels.** If headers and items are both blue, they blend together. Each level must be visually distinct. + +**Avoid dim/faint styling.** The ANSI dim attribute (`\x1b[2m`) is too dark to read on black terminal backgrounds. This includes both standard and bright color variants with dim. + +**Bold may not render distinctly.** Some terminal/font combinations don't differentiate bold from normal weight. Don't rely on bold alone for visual distinction - pair it with color differences. diff --git a/CHANGES b/CHANGES index 4064aceb6e..1b0e43cc10 100644 --- a/CHANGES +++ b/CHANGES @@ -31,6 +31,45 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force +### Features + +#### CLI Colors (#1006) + +New semantic color output for all CLI commands: + +- New `--color` flag (auto/always/never) on root CLI for controlling color output +- Respects `NO_COLOR` and `FORCE_COLOR` environment variables per [no-color.org](https://no-color.org) standard +- Semantic color methods: `success()` (green), `warning()` (yellow), `error()` (red), `info()` (cyan), `highlight()` (magenta), `muted()` (blue) +- All commands updated with colored output: `load`, `ls`, `freeze`, `convert`, `import`, `edit`, `shell`, `debug-info` +- Interactive prompts enhanced with color support +- `PrivatePath` utility masks home directory as `~` for privacy protection in output +- Beautiful `--help` output with usage examples + +#### Search Command (#1006) + +New `tmuxp search` command for finding workspace files: + +- Field-scoped search with prefixes: `name:`, `session:`, `path:`, `window:`, `pane:` +- Matching options: `-i` (ignore-case), `-S` (smart-case), `-F` (fixed-strings), `-w` (word) +- Logic operators: `--any` for OR, `-v` for invert match +- Output formats: human (with match highlighting), `--json`, `--ndjson` for automation and piping to `jq` +- Searches local (cwd and parents) and global (~/.tmuxp/) workspaces + +#### Enhanced ls Command (#1006) + +New output options for `tmuxp ls`: + +- `--tree`: Display workspaces grouped by directory +- `--full`: Include complete parsed config content +- `--json` / `--ndjson`: Machine-readable output for automation and piping to `jq` +- Local workspace discovery from current directory and parents +- Source field distinguishes "local" vs "global" workspaces +- "Global workspace directories" section shows XDG vs legacy paths with status + +#### JSON Output for debug-info (#1006) + +- `tmuxp debug-info --json`: Structured JSON output for automation, issue reporting, and piping to `jq` + ### Development #### Makefile -> Justfile (#1005) diff --git a/conftest.py b/conftest.py index 1c6c63e6b6..0cd35869ca 100644 --- a/conftest.py +++ b/conftest.py @@ -98,18 +98,31 @@ def socket_name(request: pytest.FixtureRequest) -> str: return f"tmuxp_test{next(namer)}" +# Modules that actually need tmux fixtures in their doctests +DOCTEST_NEEDS_TMUX = { + "tmuxp.workspace.builder", +} + + @pytest.fixture(autouse=True) def add_doctest_fixtures( request: pytest.FixtureRequest, doctest_namespace: dict[str, t.Any], tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Harness pytest fixtures to doctests namespace.""" - if isinstance(request._pyfuncitem, DoctestItem) and shutil.which("tmux"): - doctest_namespace["server"] = request.getfixturevalue("server") - session: Session = request.getfixturevalue("session") - doctest_namespace["session"] = session - doctest_namespace["window"] = session.active_window - doctest_namespace["pane"] = session.active_pane + if isinstance(request._pyfuncitem, DoctestItem): + # Always provide lightweight fixtures doctest_namespace["test_utils"] = test_utils doctest_namespace["tmp_path"] = tmp_path + doctest_namespace["monkeypatch"] = monkeypatch + + # Only load expensive tmux fixtures for modules that need them + module_name = request._pyfuncitem.dtest.globs.get("__name__", "") + if module_name in DOCTEST_NEEDS_TMUX and shutil.which("tmux"): + doctest_namespace["server"] = request.getfixturevalue("server") + session: Session = request.getfixturevalue("session") + doctest_namespace["session"] = session + doctest_namespace["window"] = session.active_window + doctest_namespace["pane"] = session.active_pane diff --git a/docs/api/cli/index.md b/docs/api/cli/index.md index 08f49a2131..9289503905 100644 --- a/docs/api/cli/index.md +++ b/docs/api/cli/index.md @@ -16,6 +16,7 @@ freeze import_config load ls +search shell utils ``` diff --git a/docs/api/cli/search.md b/docs/api/cli/search.md new file mode 100644 index 0000000000..bb9747e9d5 --- /dev/null +++ b/docs/api/cli/search.md @@ -0,0 +1,8 @@ +# tmuxp search - `tmuxp.cli.search` + +```{eval-rst} +.. automodule:: tmuxp.cli.search + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/api/internals/colors.md b/docs/api/internals/colors.md new file mode 100644 index 0000000000..b3577d9bae --- /dev/null +++ b/docs/api/internals/colors.md @@ -0,0 +1,14 @@ +# Colors - `tmuxp._internal.colors` + +:::{warning} +Be careful with these! Internal APIs are **not** covered by version policies. They can break or be removed between minor versions! + +If you need an internal API stabilized please [file an issue](https://github.com/tmux-python/tmuxp/issues). +::: + +```{eval-rst} +.. automodule:: tmuxp._internal.colors + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/api/internals/index.md b/docs/api/internals/index.md index 74b5fa0481..b96fc8657b 100644 --- a/docs/api/internals/index.md +++ b/docs/api/internals/index.md @@ -9,6 +9,8 @@ If you need an internal API stabilized please [file an issue](https://github.com ::: ```{toctree} +colors config_reader +private_path types ``` diff --git a/docs/api/internals/private_path.md b/docs/api/internals/private_path.md new file mode 100644 index 0000000000..d329e15169 --- /dev/null +++ b/docs/api/internals/private_path.md @@ -0,0 +1,14 @@ +# Private path - `tmuxp._internal.private_path` + +:::{warning} +Be careful with these! Internal APIs are **not** covered by version policies. They can break or be removed between minor versions! + +If you need an internal API stabilized please [file an issue](https://github.com/tmux-python/tmuxp/issues). +::: + +```{eval-rst} +.. automodule:: tmuxp._internal.private_path + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/cli/convert.md b/docs/cli/convert.md index 082f82abd6..3378cf8b39 100644 --- a/docs/cli/convert.md +++ b/docs/cli/convert.md @@ -2,8 +2,6 @@ # tmuxp convert -Convert between YAML and JSON - ```{eval-rst} .. argparse:: :module: tmuxp.cli diff --git a/docs/cli/debug-info.md b/docs/cli/debug-info.md index 5bb4fd4b62..fbe524c6ff 100644 --- a/docs/cli/debug-info.md +++ b/docs/cli/debug-info.md @@ -4,9 +4,6 @@ # tmuxp debug-info -Use to collect all relevant information for submitting an issue to -the project. - ```{eval-rst} .. argparse:: :module: tmuxp.cli diff --git a/docs/cli/index.md b/docs/cli/index.md index 3205cccc3b..156793a1e1 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -11,6 +11,7 @@ load shell ls +search ``` ```{toctree} diff --git a/docs/cli/ls.md b/docs/cli/ls.md index 4e5ec75fee..a65597db45 100644 --- a/docs/cli/ls.md +++ b/docs/cli/ls.md @@ -4,8 +4,6 @@ # tmuxp ls -List sessions. - ```{eval-rst} .. argparse:: :module: tmuxp.cli diff --git a/docs/cli/search.md b/docs/cli/search.md new file mode 100644 index 0000000000..2446a79de7 --- /dev/null +++ b/docs/cli/search.md @@ -0,0 +1,13 @@ +(cli-search)= + +(search-config)= + +# tmuxp search + +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: search +``` diff --git a/src/tmuxp/_internal/colors.py b/src/tmuxp/_internal/colors.py new file mode 100644 index 0000000000..3bc936cdaf --- /dev/null +++ b/src/tmuxp/_internal/colors.py @@ -0,0 +1,873 @@ +"""Color output utilities for tmuxp CLI. + +This module provides semantic color utilities following patterns from vcspull +and CPython's _colorize module. It includes low-level ANSI styling functions +and high-level semantic color utilities. + +Examples +-------- +Basic usage with automatic TTY detection (AUTO mode is the default). +In a TTY, colored text is returned; otherwise plain text: + +>>> colors = Colors() + +Force colors on or off: + +>>> colors = Colors(ColorMode.ALWAYS) +>>> colors.success("loaded") # doctest: +ELLIPSIS +'...' + +>>> colors = Colors(ColorMode.NEVER) +>>> colors.success("loaded") +'loaded' + +Environment variables NO_COLOR and FORCE_COLOR are respected. +NO_COLOR takes highest priority (disables even in ALWAYS mode): + +>>> monkeypatch.setenv("NO_COLOR", "1") +>>> colors = Colors(ColorMode.ALWAYS) +>>> colors.success("loaded") +'loaded' + +FORCE_COLOR enables colors in AUTO mode even without TTY: + +>>> import sys +>>> monkeypatch.delenv("NO_COLOR", raising=False) +>>> monkeypatch.setenv("FORCE_COLOR", "1") +>>> monkeypatch.setattr(sys.stdout, "isatty", lambda: False) +>>> colors = Colors(ColorMode.AUTO) +>>> colors.success("loaded") # doctest: +ELLIPSIS +'...' +""" + +from __future__ import annotations + +import enum +import os +import re +import sys +import typing as t + +if t.TYPE_CHECKING: + from typing import TypeAlias + + CLIColour: TypeAlias = int | tuple[int, int, int] | str + + +class ColorMode(enum.Enum): + """Color output modes for CLI. + + Examples + -------- + >>> ColorMode.AUTO.value + 'auto' + >>> ColorMode.ALWAYS.value + 'always' + >>> ColorMode.NEVER.value + 'never' + """ + + AUTO = "auto" + ALWAYS = "always" + NEVER = "never" + + +class Colors: + r"""Semantic color utilities for CLI output. + + Provides semantic color methods (success, warning, error, etc.) that + conditionally apply ANSI colors based on the color mode and environment. + + Parameters + ---------- + mode : ColorMode + Color mode to use. Default is AUTO which detects TTY. + + Attributes + ---------- + SUCCESS : str + Color name for success messages (green) + WARNING : str + Color name for warning messages (yellow) + ERROR : str + Color name for error messages (red) + INFO : str + Color name for informational messages (cyan) + HEADING : str + Color name for section headers (bright_cyan) + HIGHLIGHT : str + Color name for highlighted/important text (magenta) + MUTED : str + Color name for subdued/secondary text (blue) + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.success("session loaded") + 'session loaded' + >>> colors.error("failed to load") + 'failed to load' + + >>> colors = Colors(ColorMode.ALWAYS) + >>> result = colors.success("ok") + + Check that result contains ANSI escape codes: + + >>> "\033[" in result + True + """ + + # Semantic color names (used with style()) + SUCCESS = "green" # Success, loaded, up-to-date + WARNING = "yellow" # Warnings, changes needed + ERROR = "red" # Errors, failures + INFO = "cyan" # Information, paths, supplementary (L2) + HEADING = "bright_cyan" # Section headers (L0) - brighter than INFO + HIGHLIGHT = "magenta" # Important labels, session names (L1) + MUTED = "blue" # Subdued info, secondary text (L3) + + def __init__(self, mode: ColorMode = ColorMode.AUTO) -> None: + """Initialize color manager. + + Parameters + ---------- + mode : ColorMode + Color mode to use (auto, always, never). Default is AUTO. + + Examples + -------- + >>> colors = Colors() + >>> colors.mode + + + >>> colors = Colors(ColorMode.NEVER) + >>> colors._enabled + False + """ + self.mode = mode + self._enabled = self._should_enable() + + def _should_enable(self) -> bool: + """Determine if color should be enabled. + + Follows CPython-style precedence: + 1. NO_COLOR env var (any value) -> disable + 2. ColorMode.NEVER -> disable + 3. ColorMode.ALWAYS -> enable + 4. FORCE_COLOR env var (any value) -> enable + 5. TTY check -> enable if stdout is a terminal + + Returns + ------- + bool + True if colors should be enabled. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors._should_enable() + False + """ + # NO_COLOR takes highest priority (standard convention) + if os.environ.get("NO_COLOR"): + return False + + if self.mode == ColorMode.NEVER: + return False + if self.mode == ColorMode.ALWAYS: + return True + + # AUTO mode: check FORCE_COLOR then TTY + if os.environ.get("FORCE_COLOR"): + return True + + return sys.stdout.isatty() + + def _colorize( + self, text: str, fg: str, bold: bool = False, dim: bool = False + ) -> str: + """Apply color using style() function. + + Parameters + ---------- + text : str + Text to colorize. + fg : str + Foreground color name (e.g., "green", "red"). + bold : bool + Whether to apply bold style. Default is False. + dim : bool + Whether to apply dim/faint style. Default is False. + + Returns + ------- + str + Colorized text if enabled, plain text otherwise. + + Examples + -------- + When colors are enabled, applies ANSI escape codes: + + >>> colors = Colors(ColorMode.ALWAYS) + >>> colors._colorize("test", "green") # doctest: +ELLIPSIS + '...' + + When colors are disabled, returns plain text: + + >>> colors = Colors(ColorMode.NEVER) + >>> colors._colorize("test", "green") + 'test' + """ + if self._enabled: + return style(text, fg=fg, bold=bold, dim=dim) + return text + + def success(self, text: str, bold: bool = False) -> str: + """Format text as success (green). + + Parameters + ---------- + text : str + Text to format. + bold : bool + Whether to apply bold style. Default is False. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.success("loaded") + 'loaded' + """ + return self._colorize(text, self.SUCCESS, bold) + + def warning(self, text: str, bold: bool = False) -> str: + """Format text as warning (yellow). + + Parameters + ---------- + text : str + Text to format. + bold : bool + Whether to apply bold style. Default is False. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.warning("check config") + 'check config' + """ + return self._colorize(text, self.WARNING, bold) + + def error(self, text: str, bold: bool = False) -> str: + """Format text as error (red). + + Parameters + ---------- + text : str + Text to format. + bold : bool + Whether to apply bold style. Default is False. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.error("failed") + 'failed' + """ + return self._colorize(text, self.ERROR, bold) + + def info(self, text: str, bold: bool = False) -> str: + """Format text as info (cyan). + + Parameters + ---------- + text : str + Text to format. + bold : bool + Whether to apply bold style. Default is False. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.info("/path/to/config.yaml") + '/path/to/config.yaml' + """ + return self._colorize(text, self.INFO, bold) + + def highlight(self, text: str, bold: bool = True) -> str: + """Format text as highlighted (magenta, bold by default). + + Parameters + ---------- + text : str + Text to format. + bold : bool + Whether to apply bold style. Default is True. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.highlight("my-session") + 'my-session' + """ + return self._colorize(text, self.HIGHLIGHT, bold) + + def muted(self, text: str) -> str: + """Format text as muted (blue). + + Parameters + ---------- + text : str + Text to format. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.muted("(optional)") + '(optional)' + """ + return self._colorize(text, self.MUTED, bold=False) + + def heading(self, text: str) -> str: + """Format text as a section heading (bright cyan, bold). + + Used for section headers like 'Local workspaces:' or 'Global workspaces:'. + Uses bright_cyan to visually distinguish from info() which uses cyan. + + Parameters + ---------- + text : str + Text to format. + + Returns + ------- + str + Formatted text. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.heading("Local workspaces:") + 'Local workspaces:' + """ + return self._colorize(text, self.HEADING, bold=True) + + # Formatting helpers for structured output + + def format_label(self, label: str) -> str: + """Format a label (key in key:value pair). + + Parameters + ---------- + label : str + Label text to format. + + Returns + ------- + str + Highlighted label text (bold magenta when colors enabled). + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_label("tmux path") + 'tmux path' + """ + return self.highlight(label, bold=True) + + def format_path(self, path: str) -> str: + """Format a file path with info color. + + Parameters + ---------- + path : str + Path string to format. + + Returns + ------- + str + Cyan-colored path when colors enabled. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_path("/usr/bin/tmux") + '/usr/bin/tmux' + """ + return self.info(path) + + def format_version(self, version: str) -> str: + """Format a version string. + + Parameters + ---------- + version : str + Version string to format. + + Returns + ------- + str + Cyan-colored version when colors enabled. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_version("3.2a") + '3.2a' + """ + return self.info(version) + + def format_separator(self, length: int = 25) -> str: + """Format a visual separator line. + + Parameters + ---------- + length : int + Length of the separator line. Default is 25. + + Returns + ------- + str + Muted (blue) separator line when colors enabled. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_separator() + '-------------------------' + >>> colors.format_separator(10) + '----------' + """ + return self.muted("-" * length) + + def format_kv(self, key: str, value: str) -> str: + """Format key: value pair with syntax highlighting. + + Parameters + ---------- + key : str + Key/label to highlight. + value : str + Value to display (not colorized, allows caller to format). + + Returns + ------- + str + Formatted "key: value" string with highlighted key. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_kv("tmux version", "3.2a") + 'tmux version: 3.2a' + """ + return f"{self.format_label(key)}: {value}" + + def format_tmux_option(self, line: str) -> str: + """Format tmux option line with syntax highlighting. + + Handles tmux show-options output formats: + - "key value" (space-separated) + - "key=value" (equals-separated) + - "key[index] value" (array-indexed options) + - "key" (empty array options with no value) + + Parameters + ---------- + line : str + Option line to format. + + Returns + ------- + str + Formatted line with highlighted key and info-colored value. + + Examples + -------- + >>> colors = Colors(ColorMode.NEVER) + >>> colors.format_tmux_option("status on") + 'status on' + >>> colors.format_tmux_option("base-index=1") + 'base-index=1' + >>> colors.format_tmux_option("pane-colours") + 'pane-colours' + >>> colors.format_tmux_option('status-format[0] "#[align=left]"') + 'status-format[0] "#[align=left]"' + """ + # Handle "key value" format (space-separated) - check first since values + # may contain '=' (e.g., status-format[0] "#[align=left]") + parts = line.split(None, 1) + if len(parts) == 2: + return f"{self.highlight(parts[0], bold=False)} {self.info(parts[1])}" + + # Handle key=value format (only for single-token lines) + if "=" in line: + key, val = line.split("=", 1) + return f"{self.highlight(key, bold=False)}={self.info(val)}" + + # Single word = key with no value (empty array option like pane-colours) + if len(parts) == 1 and parts[0]: + return self.highlight(parts[0], bold=False) + + # Empty or unparseable - return as-is + return line + + +def get_color_mode(color_arg: str | None = None) -> ColorMode: + """Convert CLI argument string to ColorMode enum. + + Parameters + ---------- + color_arg : str | None + Color mode argument from CLI (auto, always, never). + None defaults to AUTO. + + Returns + ------- + ColorMode + The determined color mode. Invalid values return AUTO. + + Examples + -------- + >>> get_color_mode(None) + + >>> get_color_mode("always") + + >>> get_color_mode("NEVER") + + >>> get_color_mode("invalid") + + """ + if color_arg is None: + return ColorMode.AUTO + + try: + return ColorMode(color_arg.lower()) + except ValueError: + return ColorMode.AUTO + + +# ANSI styling utilities (originally from click, via utils.py) + +_ansi_re = re.compile(r"\033\[[;?0-9]*[a-zA-Z]") + + +def strip_ansi(value: str) -> str: + r"""Clear ANSI escape codes from a string value. + + Parameters + ---------- + value : str + String potentially containing ANSI escape codes. + + Returns + ------- + str + String with ANSI codes removed. + + Examples + -------- + >>> strip_ansi("\033[32mgreen\033[0m") + 'green' + >>> strip_ansi("plain text") + 'plain text' + """ + return _ansi_re.sub("", value) + + +_ansi_colors = { + "black": 30, + "red": 31, + "green": 32, + "yellow": 33, + "blue": 34, + "magenta": 35, + "cyan": 36, + "white": 37, + "reset": 39, + "bright_black": 90, + "bright_red": 91, + "bright_green": 92, + "bright_yellow": 93, + "bright_blue": 94, + "bright_magenta": 95, + "bright_cyan": 96, + "bright_white": 97, +} +_ansi_reset_all = "\033[0m" + + +def _interpret_color( + color: int | tuple[int, int, int] | str, + offset: int = 0, +) -> str: + """Convert color specification to ANSI escape code number. + + Parameters + ---------- + color : int | tuple[int, int, int] | str + Color as 256-color index, RGB tuple, or color name. + offset : int + Offset for background colors (10 for bg). + + Returns + ------- + str + ANSI escape code parameters. + + Examples + -------- + Color name returns base ANSI code: + + >>> _interpret_color("red") + '31' + + 256-color index returns extended format: + + >>> _interpret_color(196) + '38;5;196' + + RGB tuple returns 24-bit format: + + >>> _interpret_color((255, 128, 0)) + '38;2;255;128;0' + """ + if isinstance(color, int): + return f"{38 + offset};5;{color:d}" + + if isinstance(color, (tuple, list)): + if len(color) != 3: + msg = f"RGB color tuple must have exactly 3 values, got {len(color)}" + raise ValueError(msg) + r, g, b = color + for i, component in enumerate((r, g, b)): + if not isinstance(component, int): + msg = ( + f"RGB values must be integers, " + f"got {type(component).__name__} at index {i}" + ) + raise TypeError(msg) + if not 0 <= component <= 255: + msg = f"RGB values must be 0-255, got {component} at index {i}" + raise ValueError(msg) + return f"{38 + offset};2;{r:d};{g:d};{b:d}" + + return str(_ansi_colors[color] + offset) + + +class UnknownStyleColor(Exception): + """Raised when encountering an unknown terminal style color. + + Examples + -------- + >>> try: + ... raise UnknownStyleColor("invalid_color") + ... except UnknownStyleColor as e: + ... "invalid_color" in str(e) + True + """ + + def __init__(self, color: CLIColour, *args: object, **kwargs: object) -> None: + return super().__init__(f"Unknown color {color!r}", *args, **kwargs) + + +def style( + text: t.Any, + fg: CLIColour | None = None, + bg: CLIColour | None = None, + bold: bool | None = None, + dim: bool | None = None, + underline: bool | None = None, + overline: bool | None = None, + italic: bool | None = None, + blink: bool | None = None, + reverse: bool | None = None, + strikethrough: bool | None = None, + reset: bool = True, +) -> str: + r"""Apply ANSI styling to text. + + Credit: click. + + Parameters + ---------- + text : Any + Text to style (will be converted to str). + fg : CLIColour | None + Foreground color (name, 256-index, or RGB tuple). + bg : CLIColour | None + Background color. + bold : bool | None + Apply bold style. + dim : bool | None + Apply dim style. + underline : bool | None + Apply underline style. + overline : bool | None + Apply overline style. + italic : bool | None + Apply italic style. + blink : bool | None + Apply blink style. + reverse : bool | None + Apply reverse video style. + strikethrough : bool | None + Apply strikethrough style. + reset : bool + Append reset code at end. Default True. + + Returns + ------- + str + Styled text with ANSI escape codes. + + Examples + -------- + >>> style("hello", fg="green") # doctest: +ELLIPSIS + '\x1b[32m...' + >>> "hello" in style("hello", fg="green") + True + """ + if not isinstance(text, str): + text = str(text) + + bits = [] + + if fg or fg == 0: + try: + bits.append(f"\033[{_interpret_color(fg)}m") + except (KeyError, ValueError, TypeError): + raise UnknownStyleColor(color=fg) from None + + if bg or bg == 0: + try: + bits.append(f"\033[{_interpret_color(bg, 10)}m") + except (KeyError, ValueError, TypeError): + raise UnknownStyleColor(color=bg) from None + + if bold: + bits.append("\033[1m") + if dim: + bits.append("\033[2m") + if underline is not None: + bits.append(f"\033[{4 if underline else 24}m") + if overline is not None: + bits.append(f"\033[{53 if overline else 55}m") + if italic is not None: + bits.append(f"\033[{3 if italic else 23}m") + if blink is not None: + bits.append(f"\033[{5 if blink else 25}m") + if reverse is not None: + bits.append(f"\033[{7 if reverse else 27}m") + if strikethrough is not None: + bits.append(f"\033[{9 if strikethrough else 29}m") + bits.append(text) + if reset: + bits.append(_ansi_reset_all) + return "".join(bits) + + +def unstyle(text: str) -> str: + r"""Remove ANSI styling information from a string. + + Usually it's not necessary to use this function as tmuxp_echo function will + automatically remove styling if necessary. + + Credit: click. + + Parameters + ---------- + text : str + Text to remove style information from. + + Returns + ------- + str + Text with ANSI codes removed. + + Examples + -------- + >>> unstyle("\033[32mgreen\033[0m") + 'green' + """ + return strip_ansi(text) + + +def build_description( + intro: str, + example_blocks: t.Sequence[tuple[str | None, t.Sequence[str]]], +) -> str: + r"""Assemble help text with optional example sections. + + Parameters + ---------- + intro : str + The introductory description text. + example_blocks : sequence of (heading, commands) tuples + Each tuple contains an optional heading and a sequence of example commands. + If heading is None, the section is titled "examples:". + If heading is provided, it becomes the section title (without "examples:"). + + Returns + ------- + str + Formatted description with examples. + + Examples + -------- + >>> from tmuxp._internal.colors import build_description + >>> build_description("My tool.", [(None, ["mytool run"])]) + 'My tool.\n\nexamples:\n mytool run' + + >>> build_description("My tool.", [("sync", ["mytool sync repo"])]) + 'My tool.\n\nsync:\n mytool sync repo' + + >>> build_description("", [(None, ["cmd"])]) + 'examples:\n cmd' + """ + import textwrap + + sections: list[str] = [] + intro_text = textwrap.dedent(intro).strip() + if intro_text: + sections.append(intro_text) + + for heading, commands in example_blocks: + if not commands: + continue + title = "examples:" if heading is None else f"{heading}:" + lines = [title] + lines.extend(f" {command}" for command in commands) + sections.append("\n".join(lines)) + + return "\n\n".join(sections) diff --git a/src/tmuxp/_internal/private_path.py b/src/tmuxp/_internal/private_path.py new file mode 100644 index 0000000000..2ab8a998ae --- /dev/null +++ b/src/tmuxp/_internal/private_path.py @@ -0,0 +1,140 @@ +"""Privacy-aware path utilities for hiding sensitive directory information. + +This module provides utilities for masking user home directories in path output, +useful for logging, debugging, and displaying paths without exposing PII. +""" + +from __future__ import annotations + +import os +import pathlib +import typing as t + +if t.TYPE_CHECKING: + PrivatePathBase = pathlib.Path +else: + PrivatePathBase = type(pathlib.Path()) + + +class PrivatePath(PrivatePathBase): + """Path subclass that hides the user's home directory in textual output. + + The class behaves like :class:`pathlib.Path`, but normalizes string and + representation output to replace the current user's home directory with + ``~``. This is useful when logging or displaying paths that should not leak + potentially sensitive information. + + Examples + -------- + >>> from pathlib import Path + >>> home = Path.home() + + >>> PrivatePath(home) + PrivatePath('~') + + >>> PrivatePath(home / "projects" / "tmuxp") + PrivatePath('~/projects/tmuxp') + + >>> str(PrivatePath("/tmp/example")) + '/tmp/example' + + >>> f'config: {PrivatePath(home / ".tmuxp" / "config.yaml")}' # doctest: +ELLIPSIS + 'config: ~/.tmuxp/config.yaml' + """ + + def __new__(cls, *args: t.Any, **kwargs: t.Any) -> PrivatePath: + """Create a new PrivatePath instance.""" + return super().__new__(cls, *args, **kwargs) + + @classmethod + def _collapse_home(cls, value: str) -> str: + """Collapse the user's home directory to ``~`` in ``value``. + + Parameters + ---------- + value : str + Path string to process + + Returns + ------- + str + Path with home directory replaced by ``~`` if applicable + + Examples + -------- + >>> import pathlib + >>> home = str(pathlib.Path.home()) + >>> PrivatePath._collapse_home(home) + '~' + >>> PrivatePath._collapse_home(home + "/projects") + '~/projects' + >>> PrivatePath._collapse_home("/tmp/test") + '/tmp/test' + >>> PrivatePath._collapse_home("~/already/collapsed") + '~/already/collapsed' + """ + if value.startswith("~"): + return value + + home = str(pathlib.Path.home()) + if value == home: + return "~" + + separators = {os.sep} + if os.altsep: + separators.add(os.altsep) + + for sep in separators: + home_with_sep = home + sep + if value.startswith(home_with_sep): + return "~" + value[len(home) :] + + return value + + def __str__(self) -> str: + """Return string representation with home directory collapsed to ~.""" + original = pathlib.Path.__str__(self) + return self._collapse_home(original) + + def __repr__(self) -> str: + """Return repr with home directory collapsed to ~.""" + return f"{self.__class__.__name__}({str(self)!r})" + + +def collapse_home_in_string(text: str) -> str: + """Collapse home directory paths within a colon-separated string. + + Useful for processing PATH-like environment variables that may contain + multiple paths, some of which are under the user's home directory. + + Parameters + ---------- + text : str + String potentially containing paths separated by colons (or semicolons + on Windows) + + Returns + ------- + str + String with home directory paths collapsed to ``~`` + + Examples + -------- + >>> import pathlib + >>> home = str(pathlib.Path.home()) + >>> collapse_home_in_string(f"{home}/.local/bin:/usr/bin") # doctest: +ELLIPSIS + '~/.local/bin:/usr/bin' + >>> collapse_home_in_string("/usr/bin:/bin") + '/usr/bin:/bin' + >>> path_str = f"{home}/bin:{home}/.cargo/bin:/usr/bin" + >>> collapse_home_in_string(path_str) # doctest: +ELLIPSIS + '~/bin:~/.cargo/bin:/usr/bin' + """ + # Handle both Unix (:) and Windows (;) path separators + separator = ";" if os.name == "nt" else ":" + parts = text.split(separator) + collapsed = [PrivatePath._collapse_home(part) for part in parts] + return separator.join(collapsed) + + +__all__ = ["PrivatePath", "collapse_home_in_string"] diff --git a/src/tmuxp/cli/__init__.py b/src/tmuxp/cli/__init__.py index 2b542face5..b05708321f 100644 --- a/src/tmuxp/cli/__init__.py +++ b/src/tmuxp/cli/__init__.py @@ -16,27 +16,136 @@ from tmuxp.__about__ import __version__ from tmuxp.log import setup_logger -from .convert import command_convert, create_convert_subparser -from .debug_info import command_debug_info, create_debug_info_subparser -from .edit import command_edit, create_edit_subparser -from .freeze import CLIFreezeNamespace, command_freeze, create_freeze_subparser +from ._colors import build_description +from ._formatter import TmuxpHelpFormatter, create_themed_formatter +from .convert import CONVERT_DESCRIPTION, command_convert, create_convert_subparser +from .debug_info import ( + DEBUG_INFO_DESCRIPTION, + CLIDebugInfoNamespace, + command_debug_info, + create_debug_info_subparser, +) +from .edit import EDIT_DESCRIPTION, command_edit, create_edit_subparser +from .freeze import ( + FREEZE_DESCRIPTION, + CLIFreezeNamespace, + command_freeze, + create_freeze_subparser, +) from .import_config import ( + IMPORT_DESCRIPTION, command_import_teamocil, command_import_tmuxinator, create_import_subparser, ) -from .load import CLILoadNamespace, command_load, create_load_subparser -from .ls import command_ls, create_ls_subparser -from .shell import CLIShellNamespace, command_shell, create_shell_subparser +from .load import ( + LOAD_DESCRIPTION, + CLILoadNamespace, + command_load, + create_load_subparser, +) +from .ls import LS_DESCRIPTION, CLILsNamespace, command_ls, create_ls_subparser +from .search import ( + SEARCH_DESCRIPTION, + CLISearchNamespace, + command_search, + create_search_subparser, +) +from .shell import ( + SHELL_DESCRIPTION, + CLIShellNamespace, + command_shell, + create_shell_subparser, +) from .utils import tmuxp_echo logger = logging.getLogger(__name__) +CLI_DESCRIPTION = build_description( + """ + tmuxp - tmux session manager. + + Manage and launch tmux sessions from YAML/JSON workspace files. + """, + ( + ( + "load", + [ + "tmuxp load myproject", + "tmuxp load ./workspace.yaml", + "tmuxp load -d myproject", + "tmuxp load -y dev staging", + ], + ), + ( + "freeze", + [ + "tmuxp freeze mysession", + "tmuxp freeze mysession -o session.yaml", + ], + ), + ( + "ls", + [ + "tmuxp ls", + "tmuxp ls --tree", + "tmuxp ls --full", + "tmuxp ls --json", + ], + ), + ( + "search", + [ + "tmuxp search dev", + "tmuxp search name:myproject", + "tmuxp search -i DEV", + "tmuxp search --json dev", + ], + ), + ( + "shell", + [ + "tmuxp shell", + "tmuxp shell -L mysocket", + "tmuxp shell -c 'print(server.sessions)'", + ], + ), + ( + "convert", + [ + "tmuxp convert workspace.yaml", + "tmuxp convert workspace.json", + ], + ), + ( + "import", + [ + "tmuxp import teamocil ~/.teamocil/project.yml", + "tmuxp import tmuxinator ~/.tmuxinator/project.yml", + ], + ), + ( + "edit", + [ + "tmuxp edit myproject", + ], + ), + ( + "debug-info", + [ + "tmuxp debug-info", + "tmuxp debug-info --json", + ], + ), + ), +) + if t.TYPE_CHECKING: import pathlib from typing import TypeAlias CLIVerbosity: TypeAlias = t.Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + CLIColorMode: TypeAlias = t.Literal["auto", "always", "never"] CLISubparserName: TypeAlias = t.Literal[ "ls", "load", @@ -44,6 +153,7 @@ "convert", "edit", "import", + "search", "shell", "debug-info", ] @@ -52,7 +162,15 @@ def create_parser() -> argparse.ArgumentParser: """Create CLI :class:`argparse.ArgumentParser` for tmuxp.""" - parser = argparse.ArgumentParser(prog="tmuxp") + # Use factory to create themed formatter with auto-detected color mode + # This respects NO_COLOR, FORCE_COLOR env vars and TTY detection + formatter_class = create_themed_formatter() + + parser = argparse.ArgumentParser( + prog="tmuxp", + description=CLI_DESCRIPTION, + formatter_class=formatter_class, + ) parser.add_argument( "--version", "-V", @@ -67,41 +185,80 @@ def create_parser() -> argparse.ArgumentParser: choices=["debug", "info", "warning", "error", "critical"], help='log level (debug, info, warning, error, critical) (default "info")', ) + parser.add_argument( + "--color", + choices=["auto", "always", "never"], + default="auto", + help="when to use colors: auto (default), always, or never", + ) subparsers = parser.add_subparsers(dest="subparser_name") - load_parser = subparsers.add_parser("load", help="load tmuxp workspaces") + load_parser = subparsers.add_parser( + "load", + help="load tmuxp workspaces", + description=LOAD_DESCRIPTION, + formatter_class=formatter_class, + ) create_load_subparser(load_parser) shell_parser = subparsers.add_parser( "shell", help="launch python shell for tmux server, session, window and pane", + description=SHELL_DESCRIPTION, + formatter_class=formatter_class, ) create_shell_subparser(shell_parser) import_parser = subparsers.add_parser( "import", help="import workspaces from teamocil and tmuxinator.", + description=IMPORT_DESCRIPTION, + formatter_class=formatter_class, ) create_import_subparser(import_parser) convert_parser = subparsers.add_parser( "convert", help="convert workspace files between yaml and json.", + description=CONVERT_DESCRIPTION, + formatter_class=formatter_class, ) create_convert_subparser(convert_parser) debug_info_parser = subparsers.add_parser( "debug-info", help="print out all diagnostic info", + description=DEBUG_INFO_DESCRIPTION, + formatter_class=formatter_class, ) create_debug_info_subparser(debug_info_parser) - ls_parser = subparsers.add_parser("ls", help="list workspaces in tmuxp directory") + ls_parser = subparsers.add_parser( + "ls", + help="list workspaces in tmuxp directory", + description=LS_DESCRIPTION, + formatter_class=formatter_class, + ) create_ls_subparser(ls_parser) - edit_parser = subparsers.add_parser("edit", help="run $EDITOR on workspace file") + search_parser = subparsers.add_parser( + "search", + help="search workspace files by name, session, path, or content", + description=SEARCH_DESCRIPTION, + formatter_class=formatter_class, + ) + create_search_subparser(search_parser) + + edit_parser = subparsers.add_parser( + "edit", + help="run $EDITOR on workspace file", + description=EDIT_DESCRIPTION, + formatter_class=formatter_class, + ) create_edit_subparser(edit_parser) freeze_parser = subparsers.add_parser( "freeze", help="freeze a live tmux session to a tmuxp workspace file", + description=FREEZE_DESCRIPTION, + formatter_class=formatter_class, ) create_freeze_subparser(freeze_parser) @@ -112,6 +269,7 @@ class CLINamespace(argparse.Namespace): """Typed :class:`argparse.Namespace` for tmuxp root-level CLI.""" log_level: CLIVerbosity + color: CLIColorMode subparser_name: CLISubparserName import_subparser_name: CLIImportSubparserName | None version: bool @@ -163,25 +321,32 @@ def cli(_args: list[str] | None = None) -> None: command_import_teamocil( workspace_file=args.workspace_file, parser=parser, + color=args.color, ) elif import_subparser_name == "tmuxinator": command_import_tmuxinator( workspace_file=args.workspace_file, parser=parser, + color=args.color, ) elif args.subparser_name == "convert": command_convert( workspace_file=args.workspace_file, answer_yes=args.answer_yes, parser=parser, + color=args.color, ) elif args.subparser_name == "debug-info": - command_debug_info(parser=parser) + command_debug_info( + args=CLIDebugInfoNamespace(**vars(args)), + parser=parser, + ) elif args.subparser_name == "edit": command_edit( workspace_file=args.workspace_file, parser=parser, + color=args.color, ) elif args.subparser_name == "freeze": command_freeze( @@ -189,7 +354,15 @@ def cli(_args: list[str] | None = None) -> None: parser=parser, ) elif args.subparser_name == "ls": - command_ls(parser=parser) + command_ls( + args=CLILsNamespace(**vars(args)), + parser=parser, + ) + elif args.subparser_name == "search": + command_search( + args=CLISearchNamespace(**vars(args)), + parser=parser, + ) def startup(config_dir: pathlib.Path) -> None: diff --git a/src/tmuxp/cli/_colors.py b/src/tmuxp/cli/_colors.py new file mode 100644 index 0000000000..9932218fb6 --- /dev/null +++ b/src/tmuxp/cli/_colors.py @@ -0,0 +1,32 @@ +"""Backward-compatible re-exports from _internal.colors. + +This module re-exports color utilities from their new location in _internal.colors +for backward compatibility with existing imports. + +.. deprecated:: + Import directly from tmuxp._internal.colors instead. +""" + +from __future__ import annotations + +from tmuxp._internal.colors import ( + ColorMode, + Colors, + UnknownStyleColor, + build_description, + get_color_mode, + strip_ansi, + style, + unstyle, +) + +__all__ = [ + "ColorMode", + "Colors", + "UnknownStyleColor", + "build_description", + "get_color_mode", + "strip_ansi", + "style", + "unstyle", +] diff --git a/src/tmuxp/cli/_formatter.py b/src/tmuxp/cli/_formatter.py new file mode 100644 index 0000000000..9dfceaf29e --- /dev/null +++ b/src/tmuxp/cli/_formatter.py @@ -0,0 +1,373 @@ +"""Custom help formatter for tmuxp CLI with colorized examples. + +This module provides a custom argparse formatter that colorizes example +sections in help output, similar to vcspull's formatter. + +Examples +-------- +>>> from tmuxp.cli._formatter import TmuxpHelpFormatter +>>> TmuxpHelpFormatter # doctest: +ELLIPSIS + +""" + +from __future__ import annotations + +import argparse +import re +import typing as t + +# Options that expect a value (set externally or via --option=value) +OPTIONS_EXPECTING_VALUE = frozenset( + { + "-f", + "--file", + "-s", + "--socket-name", + "-S", + "--socket-path", + "-L", + "--log-level", + "-c", + "--command", + "-t", + "--target", + "-o", + "--output", + # Note: -d is --detached (flag-only), not a value option + "--color", + "-w", + "--workspace", + } +) + +# Standalone flag options (no value) +OPTIONS_FLAG_ONLY = frozenset( + { + "-h", + "--help", + "-V", + "--version", + "-y", + "--yes", + "-n", + "--no", + "-d", + "--detached", + "-2", + "-8", + "-a", + "--append", + "--json", + "--raw", + } +) + + +class TmuxpHelpFormatter(argparse.RawDescriptionHelpFormatter): + """Help formatter with colorized examples for tmuxp CLI. + + This formatter extends RawDescriptionHelpFormatter to preserve formatting + of description text while adding syntax highlighting to example sections. + + The formatter uses a `_theme` attribute (set externally) to apply colors. + If no theme is set, the formatter falls back to plain text output. + + Examples + -------- + >>> formatter = TmuxpHelpFormatter("tmuxp") + >>> formatter # doctest: +ELLIPSIS + <...TmuxpHelpFormatter object at ...> + """ + + # Theme for colorization, set by create_themed_formatter() or externally + _theme: HelpTheme | None = None + + def _fill_text(self, text: str, width: int, indent: str) -> str: + """Fill text, colorizing examples sections if theme is available. + + Parameters + ---------- + text : str + Text to format. + width : int + Maximum line width. + indent : str + Indentation prefix. + + Returns + ------- + str + Formatted text, with colorized examples if theme is set. + + Examples + -------- + Without theme, returns text via parent formatter: + + >>> formatter = TmuxpHelpFormatter("test") + >>> formatter._fill_text("hello", 80, "") + 'hello' + """ + theme = getattr(self, "_theme", None) + if not text or theme is None: + return super()._fill_text(text, width, indent) + + lines = text.splitlines(keepends=True) + formatted_lines: list[str] = [] + in_examples_block = False + expect_value = False + + for line in lines: + if line.strip() == "": + in_examples_block = False + expect_value = False + formatted_lines.append(f"{indent}{line}") + continue + + has_newline = line.endswith("\n") + stripped_line = line.rstrip("\n") + leading_length = len(stripped_line) - len(stripped_line.lstrip(" ")) + leading = stripped_line[:leading_length] + content = stripped_line[leading_length:] + content_lower = content.lower() + # Recognize example section headings: + # - "examples:" starts the examples block + # - "X examples:" or "X:" are sub-section headings within examples + is_examples_start = content_lower == "examples:" + is_category_in_block = ( + in_examples_block and content.endswith(":") and not content[0].isspace() + ) + is_section_heading = ( + content_lower.endswith("examples:") or is_category_in_block + ) and not is_examples_start + + if is_section_heading or is_examples_start: + formatted_content = f"{theme.heading}{content}{theme.reset}" + in_examples_block = True + expect_value = False + elif in_examples_block: + colored_content = self._colorize_example_line( + content, + theme=theme, + expect_value=expect_value, + ) + expect_value = colored_content.expect_value + formatted_content = colored_content.text + else: + formatted_content = stripped_line + + newline = "\n" if has_newline else "" + formatted_lines.append(f"{indent}{leading}{formatted_content}{newline}") + + return "".join(formatted_lines) + + class _ColorizedLine(t.NamedTuple): + """Result of colorizing an example line.""" + + text: str + expect_value: bool + + def _colorize_example_line( + self, + content: str, + *, + theme: t.Any, + expect_value: bool, + ) -> _ColorizedLine: + """Colorize a single example command line. + + Parameters + ---------- + content : str + The line content to colorize. + theme : Any + Theme object with color attributes (prog, action, etc.). + expect_value : bool + Whether the previous token expects a value. + + Returns + ------- + _ColorizedLine + Named tuple with colorized text and updated expect_value state. + + Examples + -------- + With an empty theme (no colors), returns text unchanged: + + >>> formatter = TmuxpHelpFormatter("test") + >>> theme = HelpTheme.from_colors(None) + >>> result = formatter._colorize_example_line( + ... "tmuxp load", theme=theme, expect_value=False + ... ) + >>> result.text + 'tmuxp load' + >>> result.expect_value + False + """ + parts: list[str] = [] + expecting_value = expect_value + first_token = True + colored_subcommand = False + + for match in re.finditer(r"\s+|\S+", content): + token = match.group() + if token.isspace(): + parts.append(token) + continue + + if expecting_value: + color = theme.label + expecting_value = False + elif token.startswith("--"): + color = theme.long_option + expecting_value = ( + token not in OPTIONS_FLAG_ONLY and token in OPTIONS_EXPECTING_VALUE + ) + elif token.startswith("-"): + color = theme.short_option + expecting_value = ( + token not in OPTIONS_FLAG_ONLY and token in OPTIONS_EXPECTING_VALUE + ) + elif first_token: + color = theme.prog + elif not colored_subcommand: + color = theme.action + colored_subcommand = True + else: + color = None + + first_token = False + + if color: + parts.append(f"{color}{token}{theme.reset}") + else: + parts.append(token) + + return self._ColorizedLine(text="".join(parts), expect_value=expecting_value) + + +class HelpTheme(t.NamedTuple): + """Theme colors for help output. + + Examples + -------- + >>> from tmuxp.cli._formatter import HelpTheme + >>> theme = HelpTheme.from_colors(None) + >>> theme.reset + '' + """ + + prog: str + action: str + long_option: str + short_option: str + label: str + heading: str + reset: str + + @classmethod + def from_colors(cls, colors: t.Any) -> HelpTheme: + """Create theme from Colors instance. + + Parameters + ---------- + colors : Colors | None + Colors instance, or None for no colors. + + Returns + ------- + HelpTheme + Theme with ANSI codes if colors enabled, empty strings otherwise. + + Examples + -------- + >>> from tmuxp.cli._colors import Colors, ColorMode + >>> from tmuxp.cli._formatter import HelpTheme + >>> colors = Colors(ColorMode.NEVER) + >>> theme = HelpTheme.from_colors(colors) + >>> theme.reset + '' + """ + if colors is None or not colors._enabled: + return cls( + prog="", + action="", + long_option="", + short_option="", + label="", + heading="", + reset="", + ) + + # Import style here to avoid circular import + from tmuxp.cli._colors import style + + return cls( + prog=style("", fg="magenta", bold=True).removesuffix("\033[0m"), + action=style("", fg="cyan").removesuffix("\033[0m"), + long_option=style("", fg="green").removesuffix("\033[0m"), + short_option=style("", fg="green").removesuffix("\033[0m"), + label=style("", fg="yellow").removesuffix("\033[0m"), + heading=style("", fg="blue").removesuffix("\033[0m"), + reset="\033[0m", + ) + + +def create_themed_formatter( + colors: t.Any | None = None, +) -> type[TmuxpHelpFormatter]: + """Create a help formatter class with theme bound. + + This factory creates a formatter subclass with the theme injected, + allowing colorized help output without modifying argparse internals. + + When no colors argument is provided, uses AUTO mode which respects + NO_COLOR, FORCE_COLOR environment variables and TTY detection. + + Parameters + ---------- + colors : Colors | None + Colors instance for styling. If None, uses ColorMode.AUTO. + + Returns + ------- + type[TmuxpHelpFormatter] + Formatter class with theme bound. + + Examples + -------- + >>> from tmuxp.cli._colors import ColorMode, Colors + >>> from tmuxp.cli._formatter import create_themed_formatter, HelpTheme + + With explicit colors enabled: + + >>> colors = Colors(ColorMode.ALWAYS) + >>> formatter_cls = create_themed_formatter(colors) + >>> formatter = formatter_cls("test") + >>> formatter._theme is not None + True + + With colors disabled: + + >>> colors = Colors(ColorMode.NEVER) + >>> formatter_cls = create_themed_formatter(colors) + >>> formatter = formatter_cls("test") + >>> formatter._theme is None + True + """ + # Import here to avoid circular import at module load + from tmuxp.cli._colors import ColorMode, Colors + + if colors is None: + colors = Colors(ColorMode.AUTO) + + # Create theme if colors are enabled, None otherwise + theme = HelpTheme.from_colors(colors) if colors._enabled else None + + class ThemedTmuxpHelpFormatter(TmuxpHelpFormatter): + """TmuxpHelpFormatter with theme pre-configured.""" + + def __init__(self, prog: str, **kwargs: t.Any) -> None: + super().__init__(prog, **kwargs) + self._theme = theme + + return ThemedTmuxpHelpFormatter diff --git a/src/tmuxp/cli/_output.py b/src/tmuxp/cli/_output.py new file mode 100644 index 0000000000..7ac8df92ef --- /dev/null +++ b/src/tmuxp/cli/_output.py @@ -0,0 +1,173 @@ +"""Output formatting utilities for tmuxp CLI. + +Provides structured output modes (JSON, NDJSON) alongside human-readable output. + +Examples +-------- +>>> from tmuxp.cli._output import OutputMode, OutputFormatter, get_output_mode + +Get output mode from flags: + +>>> get_output_mode(json_flag=False, ndjson_flag=False) + +>>> get_output_mode(json_flag=True, ndjson_flag=False) + +>>> get_output_mode(json_flag=False, ndjson_flag=True) + + +NDJSON takes precedence over JSON: + +>>> get_output_mode(json_flag=True, ndjson_flag=True) + +""" + +from __future__ import annotations + +import enum +import json +import sys +import typing as t + + +class OutputMode(enum.Enum): + """Output format modes for CLI commands. + + Examples + -------- + >>> OutputMode.HUMAN.value + 'human' + >>> OutputMode.JSON.value + 'json' + >>> OutputMode.NDJSON.value + 'ndjson' + """ + + HUMAN = "human" + JSON = "json" + NDJSON = "ndjson" + + +class OutputFormatter: + """Manage output formatting for different modes (human, JSON, NDJSON). + + Parameters + ---------- + mode : OutputMode + The output mode to use (human, json, ndjson). Default is HUMAN. + + Examples + -------- + >>> formatter = OutputFormatter(OutputMode.JSON) + >>> formatter.mode + + + >>> formatter = OutputFormatter() + >>> formatter.mode + + """ + + def __init__(self, mode: OutputMode = OutputMode.HUMAN) -> None: + """Initialize the output formatter.""" + self.mode = mode + self._json_buffer: list[dict[str, t.Any]] = [] + + def emit(self, data: dict[str, t.Any]) -> None: + """Emit a data event. + + In NDJSON mode, immediately writes one JSON object per line. + In JSON mode, buffers data for later output as a single array. + In HUMAN mode, does nothing (use emit_text for human output). + + Parameters + ---------- + data : dict + Event data to emit as JSON. + + Examples + -------- + >>> formatter = OutputFormatter(OutputMode.JSON) + >>> formatter.emit({"name": "test", "path": "/tmp"}) + >>> len(formatter._json_buffer) + 1 + """ + if self.mode == OutputMode.NDJSON: + # Stream one JSON object per line immediately + sys.stdout.write(json.dumps(data) + "\n") + sys.stdout.flush() + elif self.mode == OutputMode.JSON: + # Buffer for later output as single array + self._json_buffer.append(data) + # Human mode: handled by specific command implementations + + def emit_text(self, text: str) -> None: + """Emit human-readable text (only in HUMAN mode). + + Parameters + ---------- + text : str + Text to output. + + Examples + -------- + >>> import io + >>> formatter = OutputFormatter(OutputMode.JSON) + >>> formatter.emit_text("This won't print") # No output in JSON mode + """ + if self.mode == OutputMode.HUMAN: + sys.stdout.write(text + "\n") + sys.stdout.flush() + + def finalize(self) -> None: + """Finalize output (flush JSON buffer if needed). + + In JSON mode, outputs the buffered data as a formatted JSON array. + In other modes, does nothing. + + Examples + -------- + >>> formatter = OutputFormatter(OutputMode.JSON) + >>> formatter.emit({"name": "test1"}) + >>> formatter.emit({"name": "test2"}) + >>> len(formatter._json_buffer) + 2 + >>> # formatter.finalize() would print the JSON array + """ + if self.mode == OutputMode.JSON and self._json_buffer: + sys.stdout.write(json.dumps(self._json_buffer, indent=2) + "\n") + sys.stdout.flush() + self._json_buffer.clear() + + +def get_output_mode(json_flag: bool, ndjson_flag: bool) -> OutputMode: + """Determine output mode from command flags. + + NDJSON takes precedence over JSON if both are specified. + + Parameters + ---------- + json_flag : bool + Whether --json was specified. + ndjson_flag : bool + Whether --ndjson was specified. + + Returns + ------- + OutputMode + The determined output mode. + + Examples + -------- + >>> get_output_mode(json_flag=False, ndjson_flag=False) + + >>> get_output_mode(json_flag=True, ndjson_flag=False) + + >>> get_output_mode(json_flag=False, ndjson_flag=True) + + >>> get_output_mode(json_flag=True, ndjson_flag=True) + + """ + if ndjson_flag: + return OutputMode.NDJSON + if json_flag: + return OutputMode.JSON + return OutputMode.HUMAN diff --git a/src/tmuxp/cli/convert.py b/src/tmuxp/cli/convert.py index f537f5b3d4..97d2d8cd25 100644 --- a/src/tmuxp/cli/convert.py +++ b/src/tmuxp/cli/convert.py @@ -9,14 +9,33 @@ from tmuxp import exc from tmuxp._internal.config_reader import ConfigReader +from tmuxp._internal.private_path import PrivatePath from tmuxp.workspace.finders import find_workspace_file, get_workspace_dir +from ._colors import Colors, build_description, get_color_mode from .utils import prompt_yes_no +CONVERT_DESCRIPTION = build_description( + """ + Convert workspace files between YAML and JSON format. + """, + ( + ( + None, + [ + "tmuxp convert workspace.yaml", + "tmuxp convert workspace.json", + "tmuxp convert -y workspace.yaml", + ], + ), + ), +) + if t.TYPE_CHECKING: import argparse AllowedFileTypes = t.Literal["json", "yaml"] + CLIColorModeLiteral: t.TypeAlias = t.Literal["auto", "always", "never"] def create_convert_subparser( @@ -59,8 +78,12 @@ def command_convert( workspace_file: str | pathlib.Path, answer_yes: bool, parser: argparse.ArgumentParser | None = None, + color: CLIColorModeLiteral | None = None, ) -> None: """Entrypoint for ``tmuxp convert`` convert a tmuxp config between JSON and YAML.""" + color_mode = get_color_mode(color) + colors = Colors(color_mode) + workspace_file = find_workspace_file( workspace_file, workspace_dir=get_workspace_dir(), @@ -90,8 +113,15 @@ def command_convert( if ( not answer_yes - and prompt_yes_no(f"Convert to <{workspace_file}> to {to_filetype}?") - and prompt_yes_no(f"Save workspace to {newfile}?") + and prompt_yes_no( + f"Convert {colors.info(str(PrivatePath(workspace_file)))} to " + f"{colors.highlight(to_filetype)}?", + color_mode=color_mode, + ) + and prompt_yes_no( + f"Save workspace to {colors.info(str(PrivatePath(newfile)))}?", + color_mode=color_mode, + ) ): answer_yes = True @@ -100,4 +130,8 @@ def command_convert( new_workspace, encoding=locale.getpreferredencoding(False), ) - print(f"New workspace file saved to <{newfile}>.") # NOQA: T201 RUF100 + print( # NOQA: T201 RUF100 + colors.success("New workspace file saved to ") + + colors.info(str(PrivatePath(newfile))) + + ".", + ) diff --git a/src/tmuxp/cli/debug_info.py b/src/tmuxp/cli/debug_info.py index fdd55c83fd..e4e28eeb24 100644 --- a/src/tmuxp/cli/debug_info.py +++ b/src/tmuxp/cli/debug_info.py @@ -2,6 +2,7 @@ from __future__ import annotations +import argparse import os import pathlib import platform @@ -9,84 +10,256 @@ import sys import typing as t -from colorama import Fore from libtmux.__about__ import __version__ as libtmux_version from libtmux.common import get_version, tmux_cmd from tmuxp.__about__ import __version__ +from tmuxp._internal.private_path import PrivatePath, collapse_home_in_string +from ._colors import Colors, build_description, get_color_mode from .utils import tmuxp_echo +DEBUG_INFO_DESCRIPTION = build_description( + """ + Print diagnostic information for debugging and issue reports. + """, + ( + ( + None, + [ + "tmuxp debug-info", + ], + ), + ( + "Machine-readable output", + [ + "tmuxp debug-info --json", + ], + ), + ), +) + if t.TYPE_CHECKING: - import argparse + from typing import TypeAlias + + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] tmuxp_path = pathlib.Path(__file__).parent.parent +class CLIDebugInfoNamespace(argparse.Namespace): + """Typed :class:`argparse.Namespace` for tmuxp debug-info command.""" + + color: CLIColorModeLiteral + output_json: bool + + def create_debug_info_subparser( parser: argparse.ArgumentParser, ) -> argparse.ArgumentParser: """Augment :class:`argparse.ArgumentParser` with ``debug-info`` subcommand.""" + parser.add_argument( + "--json", + action="store_true", + dest="output_json", + help="output as JSON", + ) return parser -def command_debug_info( - parser: argparse.ArgumentParser | None = None, -) -> None: - """Entrypoint for ``tmuxp debug-info`` to print debug info to submit with issues.""" +def _private(path: pathlib.Path | str | None) -> str: + """Privacy-mask a path by collapsing home directory to ~. - def prepend_tab(strings: list[str]) -> list[str]: - """Prepend tab to strings in list.""" - return [f"\t{x}" for x in strings] + Parameters + ---------- + path : pathlib.Path | str | None + Path to mask. - def output_break() -> str: - """Generate output break.""" - return "-" * 25 + Returns + ------- + str + Path with home directory replaced by ~. - def format_tmux_resp(std_resp: tmux_cmd) -> str: - """Format tmux command response for tmuxp stdout.""" - return "\n".join( - [ - "\n".join(prepend_tab(std_resp.stdout)), - Fore.RED, - "\n".join(prepend_tab(std_resp.stderr)), - Fore.RESET, - ], - ) + Examples + -------- + >>> _private(None) + '' + >>> _private('') + '' + >>> _private('/usr/bin/tmux') + '/usr/bin/tmux' + """ + if path is None or path == "": + return "" + return str(PrivatePath(path)) + + +def _collect_debug_info() -> dict[str, t.Any]: + """Collect debug information as a structured dictionary. + + All paths are privacy-masked using PrivatePath (home → ~). + + Returns + ------- + dict[str, Any] + Debug information with environment, versions, paths, and tmux state. + + Examples + -------- + >>> data = _collect_debug_info() + >>> 'environment' in data + True + >>> 'tmux_version' in data + True + """ + # Collect tmux command outputs + sessions_resp = tmux_cmd("list-sessions") + windows_resp = tmux_cmd("list-windows") + panes_resp = tmux_cmd("list-panes") + global_opts_resp = tmux_cmd("show-options", "-g") + window_opts_resp = tmux_cmd("show-window-options", "-g") + return { + "environment": { + "dist": platform.platform(), + "arch": platform.machine(), + "uname": list(platform.uname()[:3]), + "version": platform.version(), + }, + "python_version": " ".join(sys.version.split("\n")), + "system_path": collapse_home_in_string(os.environ.get("PATH", "")), + "tmux_version": str(get_version()), + "libtmux_version": libtmux_version, + "tmuxp_version": __version__, + "tmux_path": _private(shutil.which("tmux")), + "tmuxp_path": _private(tmuxp_path), + "shell": _private(os.environ.get("SHELL", "")), + "tmux": { + "sessions": sessions_resp.stdout, + "windows": windows_resp.stdout, + "panes": panes_resp.stdout, + "global_options": global_opts_resp.stdout, + "window_options": window_opts_resp.stdout, + }, + } + + +def _format_human_output(data: dict[str, t.Any], colors: Colors) -> str: + """Format debug info as human-readable colored output. + + Parameters + ---------- + data : dict[str, Any] + Debug information dictionary. + colors : Colors + Color manager for formatting. + + Returns + ------- + str + Formatted human-readable output. + + Examples + -------- + >>> from tmuxp.cli._colors import ColorMode, Colors + >>> colors = Colors(ColorMode.NEVER) + >>> data = { + ... "environment": { + ... "dist": "Linux", + ... "arch": "x86_64", + ... "uname": ["Linux", "host", "6.0"], + ... "version": "#1 SMP", + ... }, + ... "python_version": "3.12.0", + ... "system_path": "/usr/bin", + ... "tmux_version": "3.4", + ... "libtmux_version": "0.40.0", + ... "tmuxp_version": "1.50.0", + ... "tmux_path": "/usr/bin/tmux", + ... "tmuxp_path": "~/tmuxp", + ... "shell": "/bin/bash", + ... "tmux": { + ... "sessions": [], + ... "windows": [], + ... "panes": [], + ... "global_options": [], + ... "window_options": [], + ... }, + ... } + >>> output = _format_human_output(data, colors) + >>> "environment" in output + True + """ + + def format_tmux_section(lines: list[str]) -> str: + """Format tmux command output with syntax highlighting.""" + formatted_lines = [] + for line in lines: + formatted = colors.format_tmux_option(line) + formatted_lines.append(f"\t{formatted}") + return "\n".join(formatted_lines) + + env = data["environment"] + env_items = [ + f"\t{colors.format_kv('dist', env['dist'])}", + f"\t{colors.format_kv('arch', env['arch'])}", + f"\t{colors.format_kv('uname', '; '.join(env['uname']))}", + f"\t{colors.format_kv('version', env['version'])}", + ] + + tmux_data = data["tmux"] output = [ - output_break(), - "environment:\n{}".format( - "\n".join( - prepend_tab( - [ - f"dist: {platform.platform()}", - f"arch: {platform.machine()}", - "uname: {}".format("; ".join(platform.uname()[:3])), - f"version: {platform.version()}", - ], - ), - ), - ), - output_break(), - "python version: {}".format(" ".join(sys.version.split("\n"))), - "system PATH: {}".format(os.environ["PATH"]), - f"tmux version: {get_version()}", - f"libtmux version: {libtmux_version}", - f"tmuxp version: {__version__}", - "tmux path: {}".format(shutil.which("tmux")), - f"tmuxp path: {tmuxp_path}", - "shell: {}".format(os.environ["SHELL"]), - output_break(), - "tmux sessions:\n{}".format(format_tmux_resp(tmux_cmd("list-sessions"))), - "tmux windows:\n{}".format(format_tmux_resp(tmux_cmd("list-windows"))), - "tmux panes:\n{}".format(format_tmux_resp(tmux_cmd("list-panes"))), - "tmux global options:\n{}".format( - format_tmux_resp(tmux_cmd("show-options", "-g")), - ), - "tmux window options:\n{}".format( - format_tmux_resp(tmux_cmd("show-window-options", "-g")), + colors.format_separator(), + f"{colors.format_label('environment')}:\n" + "\n".join(env_items), + colors.format_separator(), + colors.format_kv("python version", data["python_version"]), + colors.format_kv("system PATH", data["system_path"]), + colors.format_kv("tmux version", colors.format_version(data["tmux_version"])), + colors.format_kv( + "libtmux version", colors.format_version(data["libtmux_version"]) ), + colors.format_kv("tmuxp version", colors.format_version(data["tmuxp_version"])), + colors.format_kv("tmux path", colors.format_path(data["tmux_path"])), + colors.format_kv("tmuxp path", colors.format_path(data["tmuxp_path"])), + colors.format_kv("shell", data["shell"]), + colors.format_separator(), + f"{colors.format_label('tmux sessions')}:\n" + + format_tmux_section(tmux_data["sessions"]), + f"{colors.format_label('tmux windows')}:\n" + + format_tmux_section(tmux_data["windows"]), + f"{colors.format_label('tmux panes')}:\n" + + format_tmux_section(tmux_data["panes"]), + f"{colors.format_label('tmux global options')}:\n" + + format_tmux_section(tmux_data["global_options"]), + f"{colors.format_label('tmux window options')}:\n" + + format_tmux_section(tmux_data["window_options"]), ] - tmuxp_echo("\n".join(output)) + return "\n".join(output) + + +def command_debug_info( + args: CLIDebugInfoNamespace | None = None, + parser: argparse.ArgumentParser | None = None, +) -> None: + """Entrypoint for ``tmuxp debug-info`` to print debug info to submit with issues.""" + import json + import sys + + # Get output mode + output_json = args.output_json if args else False + + # Get color mode (only used for human output) + color_mode = get_color_mode(args.color if args else None) + colors = Colors(color_mode) + + # Collect debug info + data = _collect_debug_info() + + # Output based on mode + if output_json: + # Single object, not wrapped in array + sys.stdout.write(json.dumps(data, indent=2) + "\n") + sys.stdout.flush() + else: + tmuxp_echo(_format_human_output(data, colors)) diff --git a/src/tmuxp/cli/edit.py b/src/tmuxp/cli/edit.py index 075ca201dd..006ad6bb12 100644 --- a/src/tmuxp/cli/edit.py +++ b/src/tmuxp/cli/edit.py @@ -6,11 +6,32 @@ import subprocess import typing as t +from tmuxp._internal.private_path import PrivatePath from tmuxp.workspace.finders import find_workspace_file +from ._colors import Colors, build_description, get_color_mode + +EDIT_DESCRIPTION = build_description( + """ + Open tmuxp workspace file in your system editor ($EDITOR). + """, + ( + ( + None, + [ + "tmuxp edit myproject", + "tmuxp edit ./workspace.yaml", + ], + ), + ), +) + if t.TYPE_CHECKING: import argparse import pathlib + from typing import TypeAlias + + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] def create_edit_subparser( @@ -29,9 +50,20 @@ def create_edit_subparser( def command_edit( workspace_file: str | pathlib.Path, parser: argparse.ArgumentParser | None = None, + color: CLIColorModeLiteral | None = None, ) -> None: """Entrypoint for ``tmuxp edit``, open tmuxp workspace file in system editor.""" + color_mode = get_color_mode(color) + colors = Colors(color_mode) + workspace_file = find_workspace_file(workspace_file) sys_editor = os.environ.get("EDITOR", "vim") + print( # NOQA: T201 RUF100 + colors.muted("Opening ") + + colors.info(str(PrivatePath(workspace_file))) + + colors.muted(" in ") + + colors.highlight(sys_editor, bold=False) + + colors.muted("..."), + ) subprocess.call([sys_editor, workspace_file]) diff --git a/src/tmuxp/cli/freeze.py b/src/tmuxp/cli/freeze.py index ebffaab633..9b48ebf01e 100644 --- a/src/tmuxp/cli/freeze.py +++ b/src/tmuxp/cli/freeze.py @@ -13,21 +13,42 @@ from tmuxp import exc, util from tmuxp._internal.config_reader import ConfigReader +from tmuxp._internal.private_path import PrivatePath from tmuxp.exc import TmuxpException from tmuxp.workspace import freezer from tmuxp.workspace.finders import get_workspace_dir +from ._colors import Colors, build_description, get_color_mode from .utils import prompt, prompt_choices, prompt_yes_no +FREEZE_DESCRIPTION = build_description( + """ + Freeze a live tmux session to a tmuxp workspace file. + """, + ( + ( + None, + [ + "tmuxp freeze mysession", + "tmuxp freeze mysession -o session.yaml", + "tmuxp freeze -f json mysession", + "tmuxp freeze -y mysession", + ], + ), + ), +) + if t.TYPE_CHECKING: - from typing import TypeAlias, TypeGuard + from typing import TypeGuard - CLIOutputFormatLiteral: TypeAlias = t.Literal["yaml", "json"] + CLIColorModeLiteral: t.TypeAlias = t.Literal["auto", "always", "never"] + CLIOutputFormatLiteral: t.TypeAlias = t.Literal["yaml", "json"] class CLIFreezeNamespace(argparse.Namespace): """Typed :class:`argparse.Namespace` for tmuxp freeze command.""" + color: CLIColorModeLiteral session_name: str socket_name: str | None socket_path: str | None @@ -106,6 +127,9 @@ def command_freeze( If SESSION_NAME is provided, snapshot that session. Otherwise, use the current session. """ + color_mode = get_color_mode(args.color) + colors = Colors(color_mode) + server = Server(socket_name=args.socket_name, socket_path=args.socket_path) try: @@ -117,7 +141,7 @@ def command_freeze( if not session: raise exc.SessionNotFound except TmuxpException as e: - print(e) # NOQA: T201 RUF100 + print(colors.error(str(e))) # NOQA: T201 RUF100 return frozen_workspace = freezer.freeze(session) @@ -126,21 +150,26 @@ def command_freeze( if not args.quiet: print( # NOQA: T201 RUF100 - "---------------------------------------------------------------" - "\n" - "Freeze does its best to snapshot live tmux sessions.\n", + colors.format_separator(63) + + "\n" + + colors.muted("Freeze does its best to snapshot live tmux sessions.") + + "\n", ) if not ( args.answer_yes or prompt_yes_no( "The new workspace will require adjusting afterwards. Save workspace file?", + color_mode=color_mode, ) ): if not args.quiet: print( # NOQA: T201 RUF100 - "tmuxp has examples in JSON and YAML format at " - "\n" - "View tmuxp docs at .", + colors.muted("tmuxp has examples in JSON and YAML format at ") + + colors.info("") + + "\n" + + colors.muted("View tmuxp docs at ") + + colors.info("") + + ".", ) sys.exit() @@ -156,11 +185,16 @@ def command_freeze( ), ) dest_prompt = prompt( - f"Save to: {save_to}", + f"Save to: {PrivatePath(save_to)}", default=save_to, + color_mode=color_mode, ) if not args.force and os.path.exists(dest_prompt): - print(f"{dest_prompt} exists. Pick a new filename.") # NOQA: T201 RUF100 + print( # NOQA: T201 RUF100 + colors.warning(f"{PrivatePath(dest_prompt)} exists.") + + " " + + colors.muted("Pick a new filename."), + ) continue dest = dest_prompt @@ -192,6 +226,7 @@ def extract_workspace_format( ), choices=t.cast("list[str]", valid_workspace_formats), default="yaml", + color_mode=color_mode, ) assert is_valid_ext(workspace_format_) workspace_format = workspace_format_ @@ -206,7 +241,10 @@ def extract_workspace_format( elif workspace_format == "json": workspace = configparser.dump(fmt="json", indent=2) - if args.answer_yes or prompt_yes_no(f"Save to {dest}?"): + if args.answer_yes or prompt_yes_no( + f"Save to {PrivatePath(dest)}?", + color_mode=color_mode, + ): destdir = os.path.dirname(dest) if not os.path.isdir(destdir): os.makedirs(destdir) @@ -216,4 +254,6 @@ def extract_workspace_format( ) if not args.quiet: - print(f"Saved to {dest}.") # NOQA: T201 RUF100 + print( # NOQA: T201 RUF100 + colors.success("Saved to ") + colors.info(str(PrivatePath(dest))) + ".", + ) diff --git a/src/tmuxp/cli/import_config.py b/src/tmuxp/cli/import_config.py index fc34b8965d..63c2d24a30 100644 --- a/src/tmuxp/cli/import_config.py +++ b/src/tmuxp/cli/import_config.py @@ -1,4 +1,4 @@ -"""CLI for ``tmuxp shell`` subcommand.""" +"""CLI for ``tmuxp import`` subcommand.""" from __future__ import annotations @@ -9,13 +9,38 @@ import typing as t from tmuxp._internal.config_reader import ConfigReader +from tmuxp._internal.private_path import PrivatePath from tmuxp.workspace import importers from tmuxp.workspace.finders import find_workspace_file +from ._colors import ColorMode, Colors, build_description, get_color_mode from .utils import prompt, prompt_choices, prompt_yes_no, tmuxp_echo +IMPORT_DESCRIPTION = build_description( + """ + Import workspaces from teamocil and tmuxinator configuration files. + """, + ( + ( + "teamocil", + [ + "tmuxp import teamocil ~/.teamocil/project.yml", + ], + ), + ( + "tmuxinator", + [ + "tmuxp import tmuxinator ~/.tmuxinator/project.yml", + ], + ), + ), +) + if t.TYPE_CHECKING: import argparse + from typing import TypeAlias + + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] def get_tmuxinator_dir() -> pathlib.Path: @@ -141,8 +166,12 @@ def import_config( workspace_file: str, importfunc: ImportConfigFn, parser: argparse.ArgumentParser | None = None, + colors: Colors | None = None, ) -> None: """Import a configuration from a workspace_file.""" + if colors is None: + colors = Colors(ColorMode.AUTO) + existing_workspace_file = ConfigReader._from_file(pathlib.Path(workspace_file)) cfg_reader = ConfigReader(importfunc(existing_workspace_file)) @@ -150,6 +179,7 @@ def import_config( "Convert to", choices=["yaml", "json"], default="yaml", + color_mode=colors.mode, ) if workspace_file_format == "yaml": @@ -157,25 +187,32 @@ def import_config( elif workspace_file_format == "json": new_config = cfg_reader.dump("json", indent=2) else: - sys.exit("Unknown config format.") + sys.exit(colors.error("Unknown config format.")) tmuxp_echo( - new_config + "---------------------------------------------------------------" - "\n" - "Configuration import does its best to convert files.\n", + new_config + + colors.format_separator(63) + + "\n" + + colors.muted("Configuration import does its best to convert files.") + + "\n", ) if prompt_yes_no( "The new config *WILL* require adjusting afterwards. Save config?", + color_mode=colors.mode, ): dest = None while not dest: dest_path = prompt( - f"Save to [{os.getcwd()}]", + f"Save to [{PrivatePath(os.getcwd())}]", value_proc=_resolve_path_no_overwrite, + color_mode=colors.mode, ) # dest = dest_prompt - if prompt_yes_no(f"Save to {dest_path}?"): + if prompt_yes_no( + f"Save to {PrivatePath(dest_path)}?", + color_mode=colors.mode, + ): dest = dest_path pathlib.Path(dest).write_text( @@ -183,12 +220,16 @@ def import_config( encoding=locale.getpreferredencoding(False), ) - tmuxp_echo(f"Saved to {dest}.") + tmuxp_echo( + colors.success("Saved to ") + colors.info(str(PrivatePath(dest))) + ".", + ) else: tmuxp_echo( - "tmuxp has examples in JSON and YAML format at " - "\n" - "View tmuxp docs at ", + colors.muted("tmuxp has examples in JSON and YAML format at ") + + colors.info("") + + "\n" + + colors.muted("View tmuxp docs at ") + + colors.info(""), ) sys.exit() @@ -196,31 +237,39 @@ def import_config( def command_import_tmuxinator( workspace_file: str, parser: argparse.ArgumentParser | None = None, + color: CLIColorModeLiteral | None = None, ) -> None: """Entrypoint for ``tmuxp import tmuxinator`` subcommand. Converts a tmuxinator config from workspace_file to tmuxp format and import it into tmuxp. """ + color_mode = get_color_mode(color) + colors = Colors(color_mode) + workspace_file = find_workspace_file( workspace_file, workspace_dir=get_tmuxinator_dir(), ) - import_config(workspace_file, importers.import_tmuxinator) + import_config(workspace_file, importers.import_tmuxinator, colors=colors) def command_import_teamocil( workspace_file: str, parser: argparse.ArgumentParser | None = None, + color: CLIColorModeLiteral | None = None, ) -> None: """Entrypoint for ``tmuxp import teamocil`` subcommand. Convert a teamocil config from workspace_file to tmuxp format and import it into tmuxp. """ + color_mode = get_color_mode(color) + colors = Colors(color_mode) + workspace_file = find_workspace_file( workspace_file, workspace_dir=get_teamocil_dir(), ) - import_config(workspace_file, importers.import_teamocil) + import_config(workspace_file, importers.import_teamocil, colors=colors) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index e9339493f0..3e6edbd2b7 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -15,11 +15,32 @@ from tmuxp import exc, log, util from tmuxp._internal import config_reader +from tmuxp._internal.private_path import PrivatePath from tmuxp.workspace import loader from tmuxp.workspace.builder import WorkspaceBuilder from tmuxp.workspace.finders import find_workspace_file, get_workspace_dir -from .utils import prompt_choices, prompt_yes_no, style, tmuxp_echo +from ._colors import ColorMode, Colors, build_description, get_color_mode +from .utils import prompt_choices, prompt_yes_no, tmuxp_echo + +LOAD_DESCRIPTION = build_description( + """ + Load tmuxp workspace file(s) and create or attach to a tmux session. + """, + ( + ( + None, + [ + "tmuxp load myproject", + "tmuxp load ./workspace.yaml", + "tmuxp load -d myproject", + "tmuxp load -y dev staging", + "tmuxp load -L other-socket myproject", + "tmuxp load -a myproject", + ], + ), + ), +) if t.TYPE_CHECKING: from typing import TypeAlias @@ -30,6 +51,7 @@ from tmuxp.types import StrPath CLIColorsLiteral: TypeAlias = t.Literal[56, 88] + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] class OptionOverrides(TypedDict): """Optional argument overrides for tmuxp load.""" @@ -47,13 +69,49 @@ class CLILoadNamespace(argparse.Namespace): tmux_config_file: str | None new_session_name: str | None answer_yes: bool | None + detached: bool append: bool | None colors: CLIColorsLiteral | None + color: CLIColorModeLiteral log_file: str | None -def load_plugins(session_config: dict[str, t.Any]) -> list[t.Any]: - """Load and return plugins in workspace.""" +def load_plugins( + session_config: dict[str, t.Any], + colors: Colors | None = None, +) -> list[t.Any]: + """Load and return plugins in workspace. + + Parameters + ---------- + session_config : dict + Session configuration dictionary. + colors : Colors | None + Colors instance for output formatting. If None, uses AUTO mode. + + Returns + ------- + list + List of loaded plugin instances. + + Examples + -------- + Empty config returns empty list: + + >>> from tmuxp.cli.load import load_plugins + >>> load_plugins({'session_name': 'test'}) + [] + + With explicit Colors instance: + + >>> from tmuxp.cli._colors import ColorMode, Colors + >>> colors = Colors(ColorMode.NEVER) + >>> load_plugins({'session_name': 'test'}, colors=colors) + [] + """ + if colors is None: + colors = Colors(ColorMode.AUTO) + plugins = [] if "plugins" in session_config: for plugin in session_config["plugins"]: @@ -61,11 +119,11 @@ def load_plugins(session_config: dict[str, t.Any]) -> list[t.Any]: module_name = plugin.split(".") module_name = ".".join(module_name[:-1]) plugin_name = plugin.split(".")[-1] - except Exception as error: + except AttributeError as error: tmuxp_echo( - style("[Plugin Error] ", fg="red") - + f"Couldn't load {plugin}\n" - + style(f"{error}", fg="yellow"), + colors.error("[Plugin Error]") + + f" Couldn't load {plugin}\n" + + colors.warning(f"{error}"), ) sys.exit(1) @@ -74,38 +132,35 @@ def load_plugins(session_config: dict[str, t.Any]) -> list[t.Any]: plugins.append(plugin()) except exc.TmuxpPluginException as error: if not prompt_yes_no( - "{}Skip loading {}?".format( - style( - str(error), - fg="yellow", - ), - plugin_name, - ), + f"{colors.warning(str(error))}Skip loading {plugin_name}?", default=True, + color_mode=colors.mode, ): tmuxp_echo( - style("[Not Skipping] ", fg="yellow") - + "Plugin versions constraint not met. Exiting...", + colors.warning("[Not Skipping]") + + " Plugin versions constraint not met. Exiting...", ) sys.exit(1) - except Exception as error: + except (ImportError, AttributeError) as error: tmuxp_echo( - style("[Plugin Error] ", fg="red") - + f"Couldn't load {plugin}\n" - + style(f"{error}", fg="yellow"), + colors.error("[Plugin Error]") + + f" Couldn't load {plugin}\n" + + colors.warning(f"{error}"), ) sys.exit(1) return plugins -def _reattach(builder: WorkspaceBuilder) -> None: +def _reattach(builder: WorkspaceBuilder, colors: Colors | None = None) -> None: """ Reattach session (depending on env being inside tmux already or not). Parameters ---------- builder: :class:`workspace.builder.WorkspaceBuilder` + colors : Colors | None + Optional Colors instance for styled output. Notes ----- @@ -120,7 +175,7 @@ def _reattach(builder: WorkspaceBuilder) -> None: plugin.reattach(builder.session) proc = builder.session.cmd("display-message", "-p", "'#S'") for line in proc.stdout: - print(line) # NOQA: T201 RUF100 + print(colors.info(line) if colors else line) # NOQA: T201 RUF100 if "TMUX" in os.environ: builder.session.switch_client() @@ -152,19 +207,22 @@ def _load_attached(builder: WorkspaceBuilder, detached: bool) -> None: builder.session.attach() -def _load_detached(builder: WorkspaceBuilder) -> None: +def _load_detached(builder: WorkspaceBuilder, colors: Colors | None = None) -> None: """ Load workspace in new session but don't attach. Parameters ---------- builder: :class:`workspace.builder.WorkspaceBuilder` + colors : Colors | None + Optional Colors instance for styled output. """ builder.build() assert builder.session is not None - print("Session created in detached state.") # NOQA: T201 RUF100 + msg = "Session created in detached state." + print(colors.info(msg) if colors else msg) # NOQA: T201 RUF100 def _load_append_windows_to_current_session(builder: WorkspaceBuilder) -> None: @@ -197,13 +255,14 @@ def _setup_plugins(builder: WorkspaceBuilder) -> Session: def load_workspace( workspace_file: StrPath, socket_name: str | None = None, - socket_path: None = None, + socket_path: str | None = None, tmux_config_file: str | None = None, new_session_name: str | None = None, colors: int | None = None, detached: bool = False, answer_yes: bool = False, append: bool = False, + cli_colors: Colors | None = None, ) -> Session | None: """Entrypoint for ``tmuxp load``, load a tmuxp "workspace" session via config file. @@ -217,9 +276,8 @@ def load_workspace( ``tmux -S `` new_session_name: str, options ``tmux new -s `` - colors : str, optional - '-2' - Force tmux to support 256 colors + colors : int, optional + Force tmux to support 256 or 88 colors. detached : bool Force detached state. default False. answer_yes : bool @@ -228,6 +286,8 @@ def load_workspace( append : bool Assume current when given prompt to append windows in same session. Default False. + cli_colors : Colors, optional + Colors instance for CLI output formatting. If None, uses AUTO mode. Notes ----- @@ -276,13 +336,18 @@ def load_workspace( behalf. An exception raised during this process means it's not easy to predict how broken the session is. """ + # Initialize CLI colors if not provided + if cli_colors is None: + cli_colors = Colors(ColorMode.AUTO) + # get the canonical path, eliminating any symlinks if isinstance(workspace_file, (str, os.PathLike)): workspace_file = pathlib.Path(workspace_file) tmuxp_echo( - style("[Loading] ", fg="green") - + style(str(workspace_file), fg="blue", bold=True), + cli_colors.info("[Loading]") + + " " + + cli_colors.highlight(str(PrivatePath(workspace_file))), ) # ConfigReader allows us to open a yaml or json file as a dict @@ -313,11 +378,14 @@ def load_workspace( try: # load WorkspaceBuilder object for tmuxp workspace / tmux server builder = WorkspaceBuilder( session_config=expanded_workspace, - plugins=load_plugins(expanded_workspace), + plugins=load_plugins(expanded_workspace, colors=cli_colors), server=t, ) except exc.EmptyWorkspaceException: - tmuxp_echo(f"{workspace_file} is empty or parsed no workspace data") + tmuxp_echo( + cli_colors.warning("[Warning]") + + f" {PrivatePath(workspace_file)} is empty or parsed no workspace data", + ) return None session_name = expanded_workspace["session_name"] @@ -327,18 +395,17 @@ def load_workspace( if not detached and ( answer_yes or prompt_yes_no( - "{} is already running. Attach?".format( - style(session_name, fg="green"), - ), + f"{cli_colors.highlight(session_name)} is already running. Attach?", default=True, + color_mode=cli_colors.mode, ) ): - _reattach(builder) + _reattach(builder, cli_colors) return None try: if detached: - _load_detached(builder) + _load_detached(builder, cli_colors) return _setup_plugins(builder) if append: @@ -360,14 +427,14 @@ def load_workspace( "Or (a)ppend windows in the current active session?\n[y/n/a]" ) options = ["y", "n", "a"] - choice = prompt_choices(msg, choices=options) + choice = prompt_choices(msg, choices=options, color_mode=cli_colors.mode) if choice == "y": _load_attached(builder, detached) elif choice == "a": _load_append_windows_to_current_session(builder) else: - _load_detached(builder) + _load_detached(builder, cli_colors) else: _load_attached(builder, detached) @@ -375,20 +442,22 @@ def load_workspace( import traceback tmuxp_echo(traceback.format_exc()) - tmuxp_echo(str(e)) + tmuxp_echo(cli_colors.error("[Error]") + f" {e}") choice = prompt_choices( - "Error loading workspace. (k)ill, (a)ttach, (d)etach?", + cli_colors.error("Error loading workspace.") + + " (k)ill, (a)ttach, (d)etach?", choices=["k", "a", "d"], default="k", + color_mode=cli_colors.mode, ) if choice == "k": if builder.session is not None: builder.session.kill() - tmuxp_echo("Session killed.") + tmuxp_echo(cli_colors.muted("Session killed.")) elif choice == "a": - _reattach(builder) + _reattach(builder, cli_colors) else: sys.exit() @@ -517,34 +586,27 @@ def command_load( """ util.oh_my_zsh_auto_title() + # Create Colors instance based on CLI --color flag + cli_colors = Colors(get_color_mode(args.color)) + if args.log_file: logfile_handler = logging.FileHandler(args.log_file) logfile_handler.setFormatter(log.LogFormatter()) - from . import logger - - logger.addHandler(logfile_handler) - - tmux_options = { - "socket_name": args.socket_name, - "socket_path": args.socket_path, - "tmux_config_file": args.tmux_config_file, - "new_session_name": args.new_session_name, - "answer_yes": args.answer_yes, - "colors": args.colors, - "detached": args.detached, - "append": args.append, - } + # Add handler to tmuxp root logger to capture all tmuxp log messages + tmuxp_logger = logging.getLogger("tmuxp") + tmuxp_logger.setLevel(logging.INFO) # Ensure logger level allows INFO + tmuxp_logger.addHandler(logfile_handler) if args.workspace_files is None or len(args.workspace_files) == 0: - tmuxp_echo("Enter at least one config") + tmuxp_echo(cli_colors.error("Enter at least one config")) if parser is not None: parser.print_help() sys.exit() return last_idx = len(args.workspace_files) - 1 - original_detached_option = tmux_options.pop("detached") - original_new_session_name = tmux_options.pop("new_session_name") + original_detached_option = args.detached + original_new_session_name = args.new_session_name for idx, workspace_file in enumerate(args.workspace_files): workspace_file = find_workspace_file( @@ -561,7 +623,13 @@ def command_load( load_workspace( workspace_file, - detached=detached, + socket_name=args.socket_name, + socket_path=args.socket_path, + tmux_config_file=args.tmux_config_file, new_session_name=new_session_name, - **tmux_options, + colors=args.colors, + detached=detached, + answer_yes=args.answer_yes or False, + append=args.append or False, + cli_colors=cli_colors, ) diff --git a/src/tmuxp/cli/ls.py b/src/tmuxp/cli/ls.py index e1a8c7a610..4111f5cbf1 100644 --- a/src/tmuxp/cli/ls.py +++ b/src/tmuxp/cli/ls.py @@ -1,32 +1,648 @@ -"""CLI for ``tmuxp ls`` subcommand.""" +"""CLI for ``tmuxp ls`` subcommand. + +List and display workspace configuration files. + +Examples +-------- +>>> from tmuxp.cli.ls import WorkspaceInfo + +Create workspace info from file path: + +>>> import pathlib +>>> ws = WorkspaceInfo( +... name="dev", +... path="~/.tmuxp/dev.yaml", +... format="yaml", +... size=256, +... mtime="2024-01-15T10:30:00", +... session_name="development", +... source="global", +... ) +>>> ws["name"] +'dev' +>>> ws["source"] +'global' +""" from __future__ import annotations -import os +import argparse +import datetime +import json +import pathlib import typing as t +import yaml + +from tmuxp._internal.config_reader import ConfigReader +from tmuxp._internal.private_path import PrivatePath from tmuxp.workspace.constants import VALID_WORKSPACE_DIR_FILE_EXTENSIONS -from tmuxp.workspace.finders import get_workspace_dir +from tmuxp.workspace.finders import ( + find_local_workspace_files, + get_workspace_dir, + get_workspace_dir_candidates, +) + +from ._colors import Colors, build_description, get_color_mode +from ._output import OutputFormatter, OutputMode, get_output_mode + +LS_DESCRIPTION = build_description( + """ + List workspace files in the tmuxp configuration directory. + """, + ( + ( + None, + [ + "tmuxp ls", + "tmuxp ls --tree", + "tmuxp ls --full", + ], + ), + ( + "Machine-readable output", + [ + "tmuxp ls --json", + "tmuxp ls --json --full", + "tmuxp ls --ndjson", + "tmuxp ls --json | jq '.workspaces[].name'", + ], + ), + ), +) if t.TYPE_CHECKING: - import argparse + from typing import TypeAlias + + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] + + +class WorkspaceInfo(t.TypedDict): + """Workspace file information for JSON output. + + Attributes + ---------- + name : str + Workspace name (file stem without extension). + path : str + Path to workspace file (with ~ contraction). + format : str + File format (yaml or json). + size : int + File size in bytes. + mtime : str + Modification time in ISO format. + session_name : str | None + Session name from config if parseable. + source : str + Source location: "local" (cwd/parents) or "global" (~/.tmuxp/). + """ + + name: str + path: str + format: str + size: int + mtime: str + session_name: str | None + source: str + + +class CLILsNamespace(argparse.Namespace): + """Typed :class:`argparse.Namespace` for tmuxp ls command. + + Examples + -------- + >>> ns = CLILsNamespace() + >>> ns.color = "auto" + >>> ns.color + 'auto' + """ + + color: CLIColorModeLiteral + tree: bool + output_json: bool + output_ndjson: bool + full: bool def create_ls_subparser( parser: argparse.ArgumentParser, ) -> argparse.ArgumentParser: - """Augment :class:`argparse.ArgumentParser` with ``ls`` subcommand.""" + """Augment :class:`argparse.ArgumentParser` with ``ls`` subcommand. + + Parameters + ---------- + parser : argparse.ArgumentParser + The parser to augment. + + Returns + ------- + argparse.ArgumentParser + The augmented parser. + + Examples + -------- + >>> import argparse + >>> parser = argparse.ArgumentParser() + >>> result = create_ls_subparser(parser) + >>> result is parser + True + """ + parser.add_argument( + "--tree", + action="store_true", + help="display workspaces grouped by directory", + ) + parser.add_argument( + "--json", + action="store_true", + dest="output_json", + help="output as JSON", + ) + parser.add_argument( + "--ndjson", + action="store_true", + dest="output_ndjson", + help="output as NDJSON (one JSON per line)", + ) + parser.add_argument( + "--full", + action="store_true", + help="include full config content in output", + ) return parser +def _get_workspace_info( + filepath: pathlib.Path, + *, + source: str = "global", + include_config: bool = False, +) -> dict[str, t.Any]: + """Extract metadata from a workspace file. + + Parameters + ---------- + filepath : pathlib.Path + Path to the workspace file. + source : str + Source location: "local" or "global". Default "global". + include_config : bool + If True, include full parsed config content. Default False. + + Returns + ------- + dict[str, Any] + Workspace metadata dictionary. Includes 'config' key when include_config=True. + + Examples + -------- + >>> content = "session_name: test-session" + chr(10) + "windows: []" + >>> yaml_file = tmp_path / "test.yaml" + >>> _ = yaml_file.write_text(content) + >>> info = _get_workspace_info(yaml_file) + >>> info['session_name'] + 'test-session' + >>> info['format'] + 'yaml' + >>> info['source'] + 'global' + >>> info_local = _get_workspace_info(yaml_file, source="local") + >>> info_local['source'] + 'local' + >>> info_full = _get_workspace_info(yaml_file, include_config=True) + >>> 'config' in info_full + True + >>> info_full['config']['session_name'] + 'test-session' + """ + stat = filepath.stat() + ext = filepath.suffix.lower() + file_format = "json" if ext == ".json" else "yaml" + + # Try to extract session_name and optionally full config + session_name: str | None = None + config_content: dict[str, t.Any] | None = None + try: + config = ConfigReader.from_file(filepath) + if isinstance(config.content, dict): + session_name = config.content.get("session_name") + if include_config: + config_content = config.content + except (yaml.YAMLError, json.JSONDecodeError, OSError): + # If we can't parse it, just skip session_name + pass + + result: dict[str, t.Any] = { + "name": filepath.stem, + "path": str(PrivatePath(filepath)), + "format": file_format, + "size": stat.st_size, + "mtime": datetime.datetime.fromtimestamp( + stat.st_mtime, + tz=datetime.timezone.utc, + ).isoformat(), + "session_name": session_name, + "source": source, + } + + if include_config: + result["config"] = config_content + + return result + + +def _render_config_tree(config: dict[str, t.Any], colors: Colors) -> list[str]: + """Render config windows/panes as tree lines for human output. + + Parameters + ---------- + config : dict[str, Any] + Parsed config content. + colors : Colors + Color manager. + + Returns + ------- + list[str] + Lines of formatted tree output. + + Examples + -------- + >>> from tmuxp.cli._colors import ColorMode, Colors + >>> colors = Colors(ColorMode.NEVER) + >>> config = { + ... "session_name": "dev", + ... "windows": [ + ... {"window_name": "editor", "layout": "main-horizontal"}, + ... {"window_name": "shell"}, + ... ], + ... } + >>> lines = _render_config_tree(config, colors) + >>> "editor" in lines[0] + True + >>> "shell" in lines[1] + True + """ + lines: list[str] = [] + windows = config.get("windows", []) + + for i, window in enumerate(windows): + if not isinstance(window, dict): + continue + + is_last_window = i == len(windows) - 1 + prefix = "└── " if is_last_window else "├── " + child_prefix = " " if is_last_window else "│ " + + # Window line + window_name = window.get("window_name", f"window {i}") + layout = window.get("layout", "") + layout_info = f" [{layout}]" if layout else "" + lines.append(f"{prefix}{colors.info(window_name)}{colors.muted(layout_info)}") + + # Panes + panes = window.get("panes", []) + for j, pane in enumerate(panes): + is_last_pane = j == len(panes) - 1 + pane_prefix = "└── " if is_last_pane else "├── " + + # Get pane command summary + if isinstance(pane, dict): + cmds = pane.get("shell_command", []) + if isinstance(cmds, str): + cmd_str = cmds + elif isinstance(cmds, list) and cmds: + cmd_str = str(cmds[0]) + else: + cmd_str = "" + elif isinstance(pane, str): + cmd_str = pane + else: + cmd_str = "" + + # Truncate long commands + if len(cmd_str) > 40: + cmd_str = cmd_str[:37] + "..." + + pane_info = f": {cmd_str}" if cmd_str else "" + lines.append( + f"{child_prefix}{pane_prefix}{colors.muted(f'pane {j}')}{pane_info}" + ) + + return lines + + +def _render_global_workspace_dirs( + formatter: OutputFormatter, + colors: Colors, + global_dir_candidates: list[dict[str, t.Any]], +) -> None: + """Render global workspace directories section. + + Parameters + ---------- + formatter : OutputFormatter + Output formatter. + colors : Colors + Color manager. + global_dir_candidates : list[dict[str, Any]] + List of global workspace directory candidates with metadata. + + Examples + -------- + >>> from tmuxp.cli._output import OutputFormatter, OutputMode + >>> from tmuxp.cli._colors import Colors, ColorMode + >>> formatter = OutputFormatter(OutputMode.HUMAN) + >>> colors = Colors(ColorMode.NEVER) + >>> candidates = [ + ... {"path": "~/.tmuxp", "source": "Legacy", "exists": True, + ... "workspace_count": 5, "active": True}, + ... {"path": "~/.config/tmuxp", "source": "XDG", "exists": False, + ... "workspace_count": 0, "active": False}, + ... ] + >>> _render_global_workspace_dirs(formatter, colors, candidates) + + Global workspace directories: + Legacy: ~/.tmuxp (5 workspaces, active) + XDG: ~/.config/tmuxp (not found) + """ + formatter.emit_text("") + formatter.emit_text(colors.heading("Global workspace directories:")) + for candidate in global_dir_candidates: + path = candidate["path"] + source = candidate.get("source", "") + source_prefix = f"{source}: " if source else "" + if candidate["exists"]: + count = candidate["workspace_count"] + status = f"{count} workspace{'s' if count != 1 else ''}" + if candidate["active"]: + status += ", active" + formatter.emit_text( + f" {colors.muted(source_prefix)}{colors.info(path)} " + f"({colors.success(status)})" + ) + else: + formatter.emit_text( + f" {colors.muted(source_prefix)}{colors.info(path)} ({status})" + ) + else: + formatter.emit_text( + f" {colors.muted(source_prefix)}{colors.info(path)} " + f"({colors.muted('not found')})" + ) + + +def _output_flat( + workspaces: list[dict[str, t.Any]], + formatter: OutputFormatter, + colors: Colors, + *, + full: bool = False, + global_dir_candidates: list[dict[str, t.Any]] | None = None, +) -> None: + """Output workspaces in flat list format. + + Groups workspaces by source (local vs global) for human output. + + Parameters + ---------- + workspaces : list[dict[str, Any]] + Workspaces to display. + formatter : OutputFormatter + Output formatter. + colors : Colors + Color manager. + full : bool + If True, show full config details in tree format. Default False. + global_dir_candidates : list[dict[str, Any]] | None + List of global workspace directory candidates with metadata. + + Examples + -------- + >>> from tmuxp.cli._output import OutputFormatter, OutputMode + >>> from tmuxp.cli._colors import Colors, ColorMode + >>> formatter = OutputFormatter(OutputMode.HUMAN) + >>> colors = Colors(ColorMode.NEVER) + >>> workspaces = [{"name": "dev", "path": "~/.tmuxp/dev.yaml", "source": "global"}] + >>> _output_flat(workspaces, formatter, colors) + Global workspaces: + dev + """ + # Separate by source for human output grouping + local_workspaces = [ws for ws in workspaces if ws["source"] == "local"] + global_workspaces = [ws for ws in workspaces if ws["source"] == "global"] + + def output_workspace(ws: dict[str, t.Any], show_path: bool) -> None: + """Output a single workspace.""" + formatter.emit(ws) + path_info = f" {colors.info(ws['path'])}" if show_path else "" + formatter.emit_text(f" {colors.highlight(ws['name'])}{path_info}") + + # With --full, show config tree + if full and ws.get("config"): + for line in _render_config_tree(ws["config"], colors): + formatter.emit_text(f" {line}") + + # Output local workspaces first (closest to user's context) + if local_workspaces: + formatter.emit_text(colors.heading("Local workspaces:")) + for ws in local_workspaces: + output_workspace(ws, show_path=True) + + # Output global workspaces with active directory in header + if global_workspaces: + if local_workspaces: + formatter.emit_text("") # Blank line separator + + # Find active directory for header + active_dir = "" + if global_dir_candidates: + for candidate in global_dir_candidates: + if candidate["active"]: + active_dir = candidate["path"] + break + + if active_dir: + formatter.emit_text(colors.heading(f"Global workspaces ({active_dir}):")) + else: + formatter.emit_text(colors.heading("Global workspaces:")) + + for ws in global_workspaces: + output_workspace(ws, show_path=False) + + # Output global workspace directories section + if global_dir_candidates: + _render_global_workspace_dirs(formatter, colors, global_dir_candidates) + + +def _output_tree( + workspaces: list[dict[str, t.Any]], + formatter: OutputFormatter, + colors: Colors, + *, + full: bool = False, + global_dir_candidates: list[dict[str, t.Any]] | None = None, +) -> None: + """Output workspaces grouped by directory (tree view). + + Parameters + ---------- + workspaces : list[dict[str, Any]] + Workspaces to display. + formatter : OutputFormatter + Output formatter. + colors : Colors + Color manager. + full : bool + If True, show full config details in tree format. Default False. + global_dir_candidates : list[dict[str, Any]] | None + List of global workspace directory candidates with metadata. + + Examples + -------- + >>> from tmuxp.cli._output import OutputFormatter, OutputMode + >>> from tmuxp.cli._colors import Colors, ColorMode + >>> formatter = OutputFormatter(OutputMode.HUMAN) + >>> colors = Colors(ColorMode.NEVER) + >>> workspaces = [{"name": "dev", "path": "~/.tmuxp/dev.yaml", "source": "global"}] + >>> _output_tree(workspaces, formatter, colors) + + ~/.tmuxp + dev + """ + # Group by parent directory + by_directory: dict[str, list[dict[str, t.Any]]] = {} + for ws in workspaces: + # Extract parent directory from path + parent = str(pathlib.Path(ws["path"]).parent) + by_directory.setdefault(parent, []).append(ws) + + # Output grouped + for directory in sorted(by_directory.keys()): + dir_workspaces = by_directory[directory] + + # Human output: directory header + formatter.emit_text(f"\n{colors.highlight(directory)}") + + for ws in dir_workspaces: + # JSON/NDJSON output + formatter.emit(ws) + + # Human output: indented workspace name + ws_name = ws["name"] + ws_session = ws.get("session_name") + session_info = "" + if ws_session and ws_session != ws_name: + session_info = f" {colors.muted(f'→ {ws_session}')}" + formatter.emit_text(f" {colors.highlight(ws_name)}{session_info}") + + # With --full, show config tree + if full and ws.get("config"): + for line in _render_config_tree(ws["config"], colors): + formatter.emit_text(f" {line}") + + # Output global workspace directories section + if global_dir_candidates: + _render_global_workspace_dirs(formatter, colors, global_dir_candidates) + + def command_ls( + args: CLILsNamespace | None = None, parser: argparse.ArgumentParser | None = None, ) -> None: - """Entrypoint for ``tmuxp ls`` subcommand.""" - tmuxp_dir = get_workspace_dir() - if os.path.exists(tmuxp_dir) and os.path.isdir(tmuxp_dir): - for f in sorted(os.listdir(tmuxp_dir)): - stem, ext = os.path.splitext(f) - if os.path.isdir(f) or ext not in VALID_WORKSPACE_DIR_FILE_EXTENSIONS: - continue - print(stem) # NOQA: T201 RUF100 + """Entrypoint for ``tmuxp ls`` subcommand. + + Lists both local workspaces (from cwd and parent directories) and + global workspaces (from ~/.tmuxp/). + + Parameters + ---------- + args : CLILsNamespace | None + Parsed command-line arguments. + parser : argparse.ArgumentParser | None + The argument parser (unused but required by CLI interface). + + Examples + -------- + >>> # command_ls() lists workspaces from cwd/parents and ~/.tmuxp/ + """ + import json + import sys + + # Get color mode from args or default to AUTO + color_mode = get_color_mode(args.color if args else None) + colors = Colors(color_mode) + + # Determine output mode and options + output_json = args.output_json if args else False + output_ndjson = args.output_ndjson if args else False + tree = args.tree if args else False + full = args.full if args else False + output_mode = get_output_mode(output_json, output_ndjson) + formatter = OutputFormatter(output_mode) + + # Get global workspace directory candidates + global_dir_candidates = get_workspace_dir_candidates() + + # 1. Collect local workspace files (cwd and parents) + local_files = find_local_workspace_files() + workspaces: list[dict[str, t.Any]] = [ + _get_workspace_info(f, source="local", include_config=full) for f in local_files + ] + + # 2. Collect global workspace files (~/.tmuxp/) + tmuxp_dir = pathlib.Path(get_workspace_dir()) + if tmuxp_dir.exists() and tmuxp_dir.is_dir(): + workspaces.extend( + _get_workspace_info(f, source="global", include_config=full) + for f in sorted(tmuxp_dir.iterdir()) + if not f.is_dir() + and f.suffix.lower() in VALID_WORKSPACE_DIR_FILE_EXTENSIONS + ) + + if not workspaces: + formatter.emit_text(colors.warning("No workspaces found.")) + # Still show global workspace directories even with no workspaces + if output_mode == OutputMode.HUMAN: + _render_global_workspace_dirs(formatter, colors, global_dir_candidates) + elif output_mode == OutputMode.JSON: + # Output structured JSON with empty workspaces + output_data = { + "workspaces": [], + "global_workspace_dirs": global_dir_candidates, + } + sys.stdout.write(json.dumps(output_data, indent=2) + "\n") + sys.stdout.flush() + # NDJSON: just output nothing for empty workspaces + return + + # JSON mode: output structured object instead of using formatter + if output_mode == OutputMode.JSON: + output_data = { + "workspaces": workspaces, + "global_workspace_dirs": global_dir_candidates, + } + sys.stdout.write(json.dumps(output_data, indent=2) + "\n") + sys.stdout.flush() + return + + # Human and NDJSON output + if tree: + _output_tree( + workspaces, + formatter, + colors, + full=full, + global_dir_candidates=global_dir_candidates, + ) + else: + _output_flat( + workspaces, + formatter, + colors, + full=full, + global_dir_candidates=global_dir_candidates, + ) + + formatter.finalize() diff --git a/src/tmuxp/cli/search.py b/src/tmuxp/cli/search.py new file mode 100644 index 0000000000..840e48a4a7 --- /dev/null +++ b/src/tmuxp/cli/search.py @@ -0,0 +1,1300 @@ +"""CLI for ``tmuxp search`` subcommand. + +Search workspace configuration files by name, session, path, and content. + +Examples +-------- +>>> from tmuxp.cli.search import SearchToken, normalize_fields + +Parse field aliases to canonical names: + +>>> normalize_fields(["s", "name"]) +('session_name', 'name') + +Create search tokens from query terms: + +>>> from tmuxp.cli.search import parse_query_terms, DEFAULT_FIELDS +>>> tokens = parse_query_terms(["name:dev", "editor"], default_fields=DEFAULT_FIELDS) +>>> tokens[0] +SearchToken(fields=('name',), pattern='dev') +>>> tokens[1] +SearchToken(fields=('name', 'session_name', 'path', 'window', 'pane'), pattern='editor') +""" + +from __future__ import annotations + +import argparse +import json +import pathlib +import re +import typing as t + +import yaml + +from tmuxp._internal.config_reader import ConfigReader +from tmuxp._internal.private_path import PrivatePath +from tmuxp.workspace.constants import VALID_WORKSPACE_DIR_FILE_EXTENSIONS +from tmuxp.workspace.finders import find_local_workspace_files, get_workspace_dir + +from ._colors import Colors, build_description, get_color_mode +from ._output import OutputFormatter, get_output_mode + +if t.TYPE_CHECKING: + from typing import TypeAlias + + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] + +#: Field name aliases for search queries +FIELD_ALIASES: dict[str, str] = { + "name": "name", + "n": "name", + "session": "session_name", + "session_name": "session_name", + "s": "session_name", + "path": "path", + "p": "path", + "window": "window", + "w": "window", + "pane": "pane", +} + +#: Valid field names after alias resolution +VALID_FIELDS: frozenset[str] = frozenset( + {"name", "session_name", "path", "window", "pane"} +) + +#: Default fields to search when no field prefix is specified +DEFAULT_FIELDS: tuple[str, ...] = ("name", "session_name", "path", "window", "pane") + + +class SearchToken(t.NamedTuple): + """Parsed search token with target fields and raw pattern. + + Attributes + ---------- + fields : tuple[str, ...] + Canonical field names to search (e.g., ('name', 'session_name')). + pattern : str + Raw search pattern before regex compilation. + + Examples + -------- + >>> token = SearchToken(fields=("name",), pattern="dev") + >>> token.fields + ('name',) + >>> token.pattern + 'dev' + """ + + fields: tuple[str, ...] + pattern: str + + +class SearchPattern(t.NamedTuple): + """Compiled search pattern with regex and metadata. + + Attributes + ---------- + fields : tuple[str, ...] + Canonical field names to search. + raw : str + Original pattern string before compilation. + regex : re.Pattern[str] + Compiled regex pattern for matching. + + Examples + -------- + >>> import re + >>> pattern = SearchPattern( + ... fields=("name",), + ... raw="dev", + ... regex=re.compile("dev"), + ... ) + >>> pattern.fields + ('name',) + >>> bool(pattern.regex.search("development")) + True + """ + + fields: tuple[str, ...] + raw: str + regex: re.Pattern[str] + + +class InvalidFieldError(ValueError): + """Raised when an invalid field name is specified. + + Examples + -------- + >>> raise InvalidFieldError("invalid") # doctest: +ELLIPSIS + Traceback (most recent call last): + ... + tmuxp.cli.search.InvalidFieldError: Unknown search field: 'invalid'. ... + """ + + def __init__(self, field: str) -> None: + valid = ", ".join(sorted(FIELD_ALIASES.keys())) + super().__init__(f"Unknown search field: '{field}'. Valid fields: {valid}") + self.field = field + + +def normalize_fields(fields: list[str] | None) -> tuple[str, ...]: + """Normalize field names using aliases. + + Parameters + ---------- + fields : list[str] | None + Field names or aliases to normalize. If None, returns DEFAULT_FIELDS. + + Returns + ------- + tuple[str, ...] + Tuple of canonical field names. + + Raises + ------ + InvalidFieldError + If a field name is not recognized. + + Examples + -------- + >>> normalize_fields(None) + ('name', 'session_name', 'path', 'window', 'pane') + + >>> normalize_fields(["s", "n"]) + ('session_name', 'name') + + >>> normalize_fields(["session_name", "path"]) + ('session_name', 'path') + + >>> normalize_fields(["invalid"]) # doctest: +ELLIPSIS + Traceback (most recent call last): + ... + tmuxp.cli.search.InvalidFieldError: Unknown search field: 'invalid'. ... + """ + if fields is None: + return DEFAULT_FIELDS + + result: list[str] = [] + for field in fields: + field_lower = field.lower() + if field_lower not in FIELD_ALIASES: + raise InvalidFieldError(field) + canonical = FIELD_ALIASES[field_lower] + if canonical not in result: + result.append(canonical) + + return tuple(result) + + +def _parse_field_prefix(term: str) -> tuple[str | None, str]: + """Extract field prefix from a search term. + + Parameters + ---------- + term : str + Search term, possibly with field prefix (e.g., "name:dev"). + + Returns + ------- + tuple[str | None, str] + Tuple of (field_prefix, pattern). field_prefix is None if no prefix. + + Examples + -------- + >>> _parse_field_prefix("name:dev") + ('name', 'dev') + + >>> _parse_field_prefix("s:myproject") + ('s', 'myproject') + + >>> _parse_field_prefix("development") + (None, 'development') + + >>> _parse_field_prefix("path:/home/user") + ('path', '/home/user') + + >>> _parse_field_prefix("window:") + ('window', '') + """ + if ":" not in term: + return None, term + + # Split on first colon only + prefix, _, pattern = term.partition(":") + prefix_lower = prefix.lower() + + # Check if prefix is a valid field alias + if prefix_lower in FIELD_ALIASES: + return prefix, pattern + + # Not a valid field prefix, treat entire term as pattern + return None, term + + +def parse_query_terms( + terms: list[str], + *, + default_fields: tuple[str, ...] = DEFAULT_FIELDS, +) -> list[SearchToken]: + """Parse query terms into search tokens. + + Each term can optionally have a field prefix (e.g., "name:dev"). + Terms without prefixes search the default fields. + + Parameters + ---------- + terms : list[str] + Query terms to parse. + default_fields : tuple[str, ...] + Fields to search when no prefix is specified. + + Returns + ------- + list[SearchToken] + List of parsed search tokens. + + Raises + ------ + InvalidFieldError + If a field prefix is not recognized. + + Examples + -------- + >>> tokens = parse_query_terms(["dev"]) + >>> tokens[0].fields + ('name', 'session_name', 'path', 'window', 'pane') + >>> tokens[0].pattern + 'dev' + + >>> tokens = parse_query_terms(["name:dev", "s:prod"]) + >>> tokens[0] + SearchToken(fields=('name',), pattern='dev') + >>> tokens[1] + SearchToken(fields=('session_name',), pattern='prod') + + >>> tokens = parse_query_terms(["window:editor", "shell"]) + >>> tokens[0].fields + ('window',) + >>> tokens[1].fields + ('name', 'session_name', 'path', 'window', 'pane') + + Unknown prefixes are treated as literal patterns (allows URLs, etc.): + + >>> tokens = parse_query_terms(["http://example.com"]) + >>> tokens[0].pattern + 'http://example.com' + >>> tokens[0].fields # Searches default fields + ('name', 'session_name', 'path', 'window', 'pane') + """ + result: list[SearchToken] = [] + + for term in terms: + if not term: + continue + + prefix, pattern = _parse_field_prefix(term) + + # Validate and resolve field prefix, or use defaults + fields = normalize_fields([prefix]) if prefix is not None else default_fields + + if pattern: # Skip empty patterns + result.append(SearchToken(fields=fields, pattern=pattern)) + + return result + + +def _has_uppercase(pattern: str) -> bool: + """Check if pattern contains uppercase letters. + + Used for smart-case detection. + + Parameters + ---------- + pattern : str + Pattern to check. + + Returns + ------- + bool + True if pattern contains at least one uppercase letter. + + Examples + -------- + >>> _has_uppercase("dev") + False + + >>> _has_uppercase("Dev") + True + + >>> _has_uppercase("DEV") + True + + >>> _has_uppercase("123") + False + + >>> _has_uppercase("") + False + """ + return any(c.isupper() for c in pattern) + + +def compile_search_patterns( + tokens: list[SearchToken], + *, + ignore_case: bool = False, + smart_case: bool = False, + fixed_strings: bool = False, + word_regexp: bool = False, +) -> list[SearchPattern]: + """Compile search tokens into regex patterns. + + Parameters + ---------- + tokens : list[SearchToken] + Parsed search tokens to compile. + ignore_case : bool + If True, always ignore case. Default False. + smart_case : bool + If True, ignore case unless pattern has uppercase. Default False. + fixed_strings : bool + If True, treat patterns as literal strings, not regex. Default False. + word_regexp : bool + If True, match whole words only. Default False. + + Returns + ------- + list[SearchPattern] + List of compiled search patterns. + + Raises + ------ + re.error + If a pattern is invalid regex (when fixed_strings=False). + + Examples + -------- + Basic compilation: + + >>> tokens = [SearchToken(fields=("name",), pattern="dev")] + >>> patterns = compile_search_patterns(tokens) + >>> patterns[0].raw + 'dev' + >>> bool(patterns[0].regex.search("development")) + True + + Case-insensitive matching: + + >>> tokens = [SearchToken(fields=("name",), pattern="DEV")] + >>> patterns = compile_search_patterns(tokens, ignore_case=True) + >>> bool(patterns[0].regex.search("development")) + True + + Smart-case (uppercase = case-sensitive): + + >>> tokens = [SearchToken(fields=("name",), pattern="Dev")] + >>> patterns = compile_search_patterns(tokens, smart_case=True) + >>> bool(patterns[0].regex.search("Developer")) + True + >>> bool(patterns[0].regex.search("developer")) + False + + Smart-case (lowercase = case-insensitive): + + >>> tokens = [SearchToken(fields=("name",), pattern="dev")] + >>> patterns = compile_search_patterns(tokens, smart_case=True) + >>> bool(patterns[0].regex.search("DEVELOPMENT")) + True + + Fixed strings (escape regex metacharacters): + + >>> tokens = [SearchToken(fields=("name",), pattern="dev.*")] + >>> patterns = compile_search_patterns(tokens, fixed_strings=True) + >>> bool(patterns[0].regex.search("dev.*project")) + True + >>> bool(patterns[0].regex.search("development")) + False + + Word boundaries: + + >>> tokens = [SearchToken(fields=("name",), pattern="dev")] + >>> patterns = compile_search_patterns(tokens, word_regexp=True) + >>> bool(patterns[0].regex.search("my dev project")) + True + >>> bool(patterns[0].regex.search("development")) + False + """ + result: list[SearchPattern] = [] + + for token in tokens: + pattern_str = token.pattern + + # Escape for literal matching if requested + if fixed_strings: + pattern_str = re.escape(pattern_str) + + # Add word boundaries if requested + if word_regexp: + pattern_str = rf"\b{pattern_str}\b" + + # Determine case sensitivity + flags = 0 + if ignore_case or (smart_case and not _has_uppercase(token.pattern)): + flags |= re.IGNORECASE + + compiled = re.compile(pattern_str, flags) + result.append( + SearchPattern( + fields=token.fields, + raw=token.pattern, + regex=compiled, + ) + ) + + return result + + +class WorkspaceFields(t.TypedDict): + """Extracted searchable fields from a workspace file. + + Attributes + ---------- + name : str + Workspace name (file stem without extension). + path : str + Path to workspace file (with ~ contraction). + session_name : str + Session name from config, or empty string if not found. + windows : list[str] + List of window names from config. + panes : list[str] + List of pane commands/shell_commands from config. + + Examples + -------- + >>> fields: WorkspaceFields = { + ... "name": "dev", + ... "path": "~/.tmuxp/dev.yaml", + ... "session_name": "development", + ... "windows": ["editor", "shell"], + ... "panes": ["vim", "git status"], + ... } + >>> fields["name"] + 'dev' + """ + + name: str + path: str + session_name: str + windows: list[str] + panes: list[str] + + +class WorkspaceSearchResult(t.TypedDict): + """Search result for a workspace that matched. + + Attributes + ---------- + filepath : str + Absolute path to the workspace file. + source : str + Source location: "local" or "global". + fields : WorkspaceFields + Extracted searchable fields. + matches : dict[str, list[str]] + Mapping of field name to matched strings for highlighting. + + Examples + -------- + >>> result: WorkspaceSearchResult = { + ... "filepath": "/home/user/.tmuxp/dev.yaml", + ... "source": "global", + ... "fields": { + ... "name": "dev", + ... "path": "~/.tmuxp/dev.yaml", + ... "session_name": "development", + ... "windows": ["editor"], + ... "panes": [], + ... }, + ... "matches": {"name": ["dev"]}, + ... } + >>> result["source"] + 'global' + """ + + filepath: str + source: str + fields: WorkspaceFields + matches: dict[str, list[str]] + + +def extract_workspace_fields(filepath: pathlib.Path) -> WorkspaceFields: + """Extract searchable fields from a workspace file. + + Parses the workspace configuration and extracts name, path, session_name, + window names, and pane commands for searching. + + Parameters + ---------- + filepath : pathlib.Path + Path to the workspace file. + + Returns + ------- + WorkspaceFields + Dictionary of extracted fields. + + Examples + -------- + >>> import tempfile + >>> import pathlib + >>> content = ''' + ... session_name: my-project + ... windows: + ... - window_name: editor + ... panes: + ... - vim + ... - shell_command: git status + ... - window_name: shell + ... ''' + >>> with tempfile.NamedTemporaryFile( + ... suffix='.yaml', delete=False, mode='w' + ... ) as f: + ... _ = f.write(content) + ... temp_path = pathlib.Path(f.name) + >>> fields = extract_workspace_fields(temp_path) + >>> fields["session_name"] + 'my-project' + >>> sorted(fields["windows"]) + ['editor', 'shell'] + >>> 'vim' in fields["panes"] + True + >>> temp_path.unlink() + """ + # Basic fields from file + name = filepath.stem + path = str(PrivatePath(filepath)) + + # Try to parse config for session_name, windows, panes + session_name = "" + windows: list[str] = [] + panes: list[str] = [] + + try: + config = ConfigReader.from_file(filepath) + if isinstance(config.content, dict): + session_name = str(config.content.get("session_name", "")) + + # Extract window names and pane commands + for window in config.content.get("windows", []): + if not isinstance(window, dict): + continue + + # Window name + if window_name := window.get("window_name"): + windows.append(str(window_name)) + + # Pane commands + for pane in window.get("panes", []): + if isinstance(pane, str): + panes.append(pane) + elif isinstance(pane, dict): + # shell_command can be str or list + cmds = pane.get("shell_command", []) + if isinstance(cmds, str): + panes.append(cmds) + elif isinstance(cmds, list): + panes.extend(str(cmd) for cmd in cmds if cmd) + except (yaml.YAMLError, json.JSONDecodeError, OSError): + # If config parsing fails, continue with empty content fields + pass + + return WorkspaceFields( + name=name, + path=path, + session_name=session_name, + windows=windows, + panes=panes, + ) + + +def _get_field_values(fields: WorkspaceFields, field_name: str) -> list[str]: + """Get values for a field, normalizing to list. + + Parameters + ---------- + fields : WorkspaceFields + Extracted workspace fields. + field_name : str + Canonical field name to retrieve. + + Returns + ------- + list[str] + List of values for the field. + + Examples + -------- + >>> fields: WorkspaceFields = { + ... "name": "dev", + ... "path": "~/.tmuxp/dev.yaml", + ... "session_name": "development", + ... "windows": ["editor", "shell"], + ... "panes": ["vim"], + ... } + >>> _get_field_values(fields, "name") + ['dev'] + >>> _get_field_values(fields, "windows") + ['editor', 'shell'] + >>> _get_field_values(fields, "window") + ['editor', 'shell'] + """ + # Handle field name aliasing (window -> windows, pane -> panes) + if field_name == "window": + field_name = "windows" + elif field_name == "pane": + field_name = "panes" + + # Access fields directly for type safety + if field_name == "name": + return [fields["name"]] if fields["name"] else [] + if field_name == "path": + return [fields["path"]] if fields["path"] else [] + if field_name == "session_name": + return [fields["session_name"]] if fields["session_name"] else [] + if field_name == "windows": + return fields["windows"] + if field_name == "panes": + return fields["panes"] + + return [] + + +def evaluate_match( + fields: WorkspaceFields, + patterns: list[SearchPattern], + *, + match_any: bool = False, +) -> tuple[bool, dict[str, list[str]]]: + """Evaluate if workspace fields match search patterns. + + Parameters + ---------- + fields : WorkspaceFields + Extracted workspace fields to search. + patterns : list[SearchPattern] + Compiled search patterns. + match_any : bool + If True, match if ANY pattern matches (OR logic). + If False, ALL patterns must match (AND logic). Default False. + + Returns + ------- + tuple[bool, dict[str, list[str]]] + Tuple of (matched, {field_name: [matched_strings]}). + The matches dict contains actual matched text for highlighting. + + Examples + -------- + >>> import re + >>> fields: WorkspaceFields = { + ... "name": "dev-project", + ... "path": "~/.tmuxp/dev-project.yaml", + ... "session_name": "development", + ... "windows": ["editor", "shell"], + ... "panes": ["vim", "git status"], + ... } + + Single pattern match: + + >>> pattern = SearchPattern( + ... fields=("name",), + ... raw="dev", + ... regex=re.compile("dev"), + ... ) + >>> matched, matches = evaluate_match(fields, [pattern]) + >>> matched + True + >>> "name" in matches + True + + AND logic (default) - all patterns must match: + + >>> p1 = SearchPattern(fields=("name",), raw="dev", regex=re.compile("dev")) + >>> p2 = SearchPattern(fields=("name",), raw="xyz", regex=re.compile("xyz")) + >>> matched, _ = evaluate_match(fields, [p1, p2], match_any=False) + >>> matched + False + + OR logic - any pattern can match: + + >>> matched, _ = evaluate_match(fields, [p1, p2], match_any=True) + >>> matched + True + + Window field search: + + >>> p_win = SearchPattern( + ... fields=("window",), + ... raw="editor", + ... regex=re.compile("editor"), + ... ) + >>> matched, matches = evaluate_match(fields, [p_win]) + >>> matched + True + >>> "window" in matches + True + """ + all_matches: dict[str, list[str]] = {} + pattern_results: list[bool] = [] + + for pattern in patterns: + pattern_matched = False + + for field_name in pattern.fields: + values = _get_field_values(fields, field_name) + + for value in values: + if match := pattern.regex.search(value): + pattern_matched = True + # Store matched text for highlighting + if field_name not in all_matches: + all_matches[field_name] = [] + all_matches[field_name].append(match.group()) + + pattern_results.append(pattern_matched) + + # Apply match logic + if match_any: + final_matched = any(pattern_results) + else: + final_matched = all(pattern_results) if pattern_results else False + + return final_matched, all_matches + + +def find_search_matches( + workspaces: list[tuple[pathlib.Path, str]], + patterns: list[SearchPattern], + *, + match_any: bool = False, + invert_match: bool = False, +) -> list[WorkspaceSearchResult]: + """Find workspaces matching search patterns. + + Parameters + ---------- + workspaces : list[tuple[pathlib.Path, str]] + List of (filepath, source) tuples to search. Source is "local" or "global". + patterns : list[SearchPattern] + Compiled search patterns. + match_any : bool + If True, match if ANY pattern matches (OR logic). Default False (AND). + invert_match : bool + If True, return workspaces that do NOT match. Default False. + + Returns + ------- + list[WorkspaceSearchResult] + List of matching workspace results with match information. + + Examples + -------- + >>> import tempfile + >>> import pathlib + >>> import re + >>> content = "session_name: dev-session" + chr(10) + "windows: []" + >>> with tempfile.NamedTemporaryFile( + ... suffix='.yaml', delete=False, mode='w' + ... ) as f: + ... _ = f.write(content) + ... temp_path = pathlib.Path(f.name) + + >>> pattern = SearchPattern( + ... fields=("session_name",), + ... raw="dev", + ... regex=re.compile("dev"), + ... ) + >>> results = find_search_matches([(temp_path, "global")], [pattern]) + >>> len(results) + 1 + >>> results[0]["source"] + 'global' + + Invert match returns non-matching workspaces: + + >>> pattern_nomatch = SearchPattern( + ... fields=("name",), + ... raw="nonexistent", + ... regex=re.compile("nonexistent"), + ... ) + >>> results = find_search_matches( + ... [(temp_path, "global")], [pattern_nomatch], invert_match=True + ... ) + >>> len(results) + 1 + >>> temp_path.unlink() + """ + results: list[WorkspaceSearchResult] = [] + + for filepath, source in workspaces: + fields = extract_workspace_fields(filepath) + matched, matches = evaluate_match(fields, patterns, match_any=match_any) + + # Apply invert logic + if invert_match: + matched = not matched + + if matched: + results.append( + WorkspaceSearchResult( + filepath=str(filepath), + source=source, + fields=fields, + matches=matches, + ) + ) + + return results + + +def highlight_matches( + text: str, + patterns: list[SearchPattern], + *, + colors: Colors, +) -> str: + """Highlight regex matches in text. + + Parameters + ---------- + text : str + Text to search and highlight. + patterns : list[SearchPattern] + Compiled search patterns (uses their regex attribute). + colors : Colors + Color manager for highlighting. + + Returns + ------- + str + Text with matches highlighted, or original text if no matches. + + Examples + -------- + >>> from tmuxp.cli._colors import ColorMode, Colors + >>> colors = Colors(ColorMode.NEVER) + >>> pattern = SearchPattern( + ... fields=("name",), + ... raw="dev", + ... regex=re.compile("dev"), + ... ) + >>> highlight_matches("development", [pattern], colors=colors) + 'development' + + With colors enabled (ALWAYS mode): + + >>> colors_on = Colors(ColorMode.ALWAYS) + >>> result = highlight_matches("development", [pattern], colors=colors_on) + >>> "dev" in result + True + >>> chr(27) in result # Contains ANSI escape + True + """ + if not patterns: + return text + + # Collect all match spans + spans: list[tuple[int, int]] = [] + for pattern in patterns: + spans.extend((m.start(), m.end()) for m in pattern.regex.finditer(text)) + + if not spans: + return text + + # Sort and merge overlapping spans + spans.sort() + merged: list[tuple[int, int]] = [] + for start, end in spans: + if merged and start <= merged[-1][1]: + # Overlapping or adjacent, extend previous + merged[-1] = (merged[-1][0], max(merged[-1][1], end)) + else: + merged.append((start, end)) + + # Build result with highlights + result: list[str] = [] + pos = 0 + for start, end in merged: + # Add non-matching text before this match + if pos < start: + result.append(text[pos:start]) + # Add highlighted match + result.append(colors.highlight(text[start:end])) + pos = end + + # Add any remaining text after last match + if pos < len(text): + result.append(text[pos:]) + + return "".join(result) + + +def _output_search_results( + results: list[WorkspaceSearchResult], + patterns: list[SearchPattern], + formatter: OutputFormatter, + colors: Colors, +) -> None: + """Output search results in human-readable or JSON format. + + Parameters + ---------- + results : list[WorkspaceSearchResult] + Search results to output. + patterns : list[SearchPattern] + Patterns used for highlighting. + formatter : OutputFormatter + Output formatter for JSON/NDJSON/human modes. + colors : Colors + Color manager. + """ + if not results: + formatter.emit_text(colors.warning("No matching workspaces found.")) + return + + # Group by source for human output + local_results = [r for r in results if r["source"] == "local"] + global_results = [r for r in results if r["source"] == "global"] + + def output_result(result: WorkspaceSearchResult, show_path: bool) -> None: + """Output a single search result.""" + fields = result["fields"] + + # JSON/NDJSON output: emit structured data + json_data = { + "name": fields["name"], + "path": fields["path"], + "session_name": fields["session_name"], + "source": result["source"], + "matched_fields": list(result["matches"].keys()), + "matches": result["matches"], + } + formatter.emit(json_data) + + # Human output: formatted text with highlighting + name_display = highlight_matches(fields["name"], patterns, colors=colors) + path_info = f" {colors.info(fields['path'])}" if show_path else "" + formatter.emit_text(f" {colors.highlight(name_display)}{path_info}") + + # Show matched session_name if different from name + session_name = fields["session_name"] + if session_name and session_name != fields["name"]: + session_display = highlight_matches(session_name, patterns, colors=colors) + formatter.emit_text(f" session: {session_display}") + + # Show matched windows + if result["matches"].get("window"): + window_names = [ + highlight_matches(w, patterns, colors=colors) for w in fields["windows"] + ] + if window_names: + formatter.emit_text(f" windows: {', '.join(window_names)}") + + # Show matched panes + if result["matches"].get("pane"): + pane_cmds = fields["panes"][:3] # Limit to first 3 + pane_displays = [ + highlight_matches(p, patterns, colors=colors) for p in pane_cmds + ] + if len(fields["panes"]) > 3: + pane_displays.append("...") + if pane_displays: + formatter.emit_text(f" panes: {', '.join(pane_displays)}") + + # Output local results first + if local_results: + formatter.emit_text(colors.heading("Local workspaces:")) + for result in local_results: + output_result(result, show_path=True) + + # Output global results + if global_results: + if local_results: + formatter.emit_text("") # Blank line separator + formatter.emit_text(colors.heading("Global workspaces:")) + for result in global_results: + output_result(result, show_path=False) + + +SEARCH_DESCRIPTION = build_description( + """ + Search workspace files by name, session, path, window, or pane content. + """, + ( + ( + None, + [ + "tmuxp search dev", + 'tmuxp search "my.*project"', + "tmuxp search name:dev", + "tmuxp search s:development", + ], + ), + ( + "Field-scoped search", + [ + "tmuxp search window:editor", + "tmuxp search pane:vim", + "tmuxp search p:~/.tmuxp", + ], + ), + ( + "Matching options", + [ + "tmuxp search -i DEV", + "tmuxp search -S DevProject", + "tmuxp search -F 'my.project'", + "tmuxp search --word-regexp test", + ], + ), + ( + "Multiple patterns", + [ + "tmuxp search dev production", + "tmuxp search --any dev production", + "tmuxp search -v staging", + ], + ), + ( + "Machine-readable output", + [ + "tmuxp search --json dev", + "tmuxp search --ndjson dev | jq '.name'", + ], + ), + ), +) + + +class CLISearchNamespace(argparse.Namespace): + """Typed :class:`argparse.Namespace` for tmuxp search command. + + Examples + -------- + >>> ns = CLISearchNamespace() + >>> ns.query_terms = ["dev"] + >>> ns.query_terms + ['dev'] + """ + + color: CLIColorModeLiteral + query_terms: list[str] + field: list[str] | None + ignore_case: bool + smart_case: bool + fixed_strings: bool + word_regexp: bool + invert_match: bool + match_any: bool + output_json: bool + output_ndjson: bool + print_help: t.Callable[[], None] + + +def create_search_subparser( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` with ``search`` subcommand. + + Parameters + ---------- + parser : argparse.ArgumentParser + The parser to augment. + + Returns + ------- + argparse.ArgumentParser + The augmented parser. + + Examples + -------- + >>> import argparse + >>> parser = argparse.ArgumentParser() + >>> result = create_search_subparser(parser) + >>> result is parser + True + """ + # Positional arguments + parser.add_argument( + "query_terms", + nargs="*", + metavar="PATTERN", + help="search patterns (prefix with field: for field-scoped search)", + ) + + # Field restriction + parser.add_argument( + "-f", + "--field", + action="append", + metavar="FIELD", + help="restrict search to field(s): name, session/s, path/p, window/w, pane", + ) + + # Matching options + parser.add_argument( + "-i", + "--ignore-case", + action="store_true", + help="case-insensitive matching", + ) + parser.add_argument( + "-S", + "--smart-case", + action="store_true", + help="case-insensitive unless pattern has uppercase", + ) + parser.add_argument( + "-F", + "--fixed-strings", + action="store_true", + help="treat patterns as literal strings, not regex", + ) + parser.add_argument( + "-w", + "--word-regexp", + action="store_true", + help="match whole words only", + ) + parser.add_argument( + "-v", + "--invert-match", + action="store_true", + help="show workspaces that do NOT match", + ) + parser.add_argument( + "--any", + dest="match_any", + action="store_true", + help="match ANY pattern (OR logic); default is ALL (AND logic)", + ) + + # Output format + parser.add_argument( + "--json", + action="store_true", + dest="output_json", + help="output as JSON", + ) + parser.add_argument( + "--ndjson", + action="store_true", + dest="output_ndjson", + help="output as NDJSON (one JSON per line)", + ) + + # Store print_help for use when no arguments provided + parser.set_defaults(print_help=parser.print_help) + + return parser + + +def command_search( + args: CLISearchNamespace | None = None, + parser: argparse.ArgumentParser | None = None, +) -> None: + """Entrypoint for ``tmuxp search`` subcommand. + + Searches workspace files in local (cwd and parents) and global (~/.tmuxp/) + directories. + + Parameters + ---------- + args : CLISearchNamespace | None + Parsed command-line arguments. + parser : argparse.ArgumentParser | None + The argument parser (unused but required by CLI interface). + + Examples + -------- + >>> # command_search() searches workspaces with given patterns + """ + # Get color mode from args or default to AUTO + color_mode = get_color_mode(args.color if args else None) + colors = Colors(color_mode) + + # Determine output mode + output_json = args.output_json if args else False + output_ndjson = args.output_ndjson if args else False + output_mode = get_output_mode(output_json, output_ndjson) + formatter = OutputFormatter(output_mode) + + # Get query terms + query_terms = args.query_terms if args else [] + + if not query_terms: + if args and hasattr(args, "print_help"): + args.print_help() + return + + # Parse and compile patterns + try: + # Get default fields (possibly restricted by --field) + default_fields = normalize_fields(args.field if args else None) + tokens = parse_query_terms(query_terms, default_fields=default_fields) + + if not tokens: + formatter.emit_text(colors.warning("No valid search patterns.")) + formatter.finalize() + return + + patterns = compile_search_patterns( + tokens, + ignore_case=args.ignore_case if args else False, + smart_case=args.smart_case if args else False, + fixed_strings=args.fixed_strings if args else False, + word_regexp=args.word_regexp if args else False, + ) + except InvalidFieldError as e: + formatter.emit_text(colors.error(str(e))) + formatter.finalize() + return + except re.error as e: + formatter.emit_text(colors.error(f"Invalid regex pattern: {e}")) + formatter.finalize() + return + + # Collect workspaces: local (cwd + parents) + global (~/.tmuxp/) + workspaces: list[tuple[pathlib.Path, str]] = [] + + # Local workspace files + local_files = find_local_workspace_files() + workspaces.extend((f, "local") for f in local_files) + + # Global workspace files + tmuxp_dir = pathlib.Path(get_workspace_dir()) + if tmuxp_dir.exists() and tmuxp_dir.is_dir(): + workspaces.extend( + (f, "global") + for f in sorted(tmuxp_dir.iterdir()) + if not f.is_dir() + and f.suffix.lower() in VALID_WORKSPACE_DIR_FILE_EXTENSIONS + ) + + if not workspaces: + formatter.emit_text(colors.warning("No workspaces found.")) + formatter.finalize() + return + + # Find matches + results = find_search_matches( + workspaces, + patterns, + match_any=args.match_any if args else False, + invert_match=args.invert_match if args else False, + ) + + # Output results + _output_search_results(results, patterns, formatter, colors) + formatter.finalize() diff --git a/src/tmuxp/cli/shell.py b/src/tmuxp/cli/shell.py index 459e5350dc..e62f0a0758 100644 --- a/src/tmuxp/cli/shell.py +++ b/src/tmuxp/cli/shell.py @@ -12,9 +12,29 @@ from tmuxp import util from tmuxp._compat import PY3, PYMINOR +from ._colors import Colors, build_description, get_color_mode + +SHELL_DESCRIPTION = build_description( + """ + Launch interactive Python shell with tmux server, session, window and pane. + """, + ( + ( + None, + [ + "tmuxp shell", + "tmuxp shell -L mysocket", + "tmuxp shell -c 'print(server.sessions)'", + "tmuxp shell --best", + ], + ), + ), +) + if t.TYPE_CHECKING: from typing import TypeAlias + CLIColorModeLiteral: TypeAlias = t.Literal["auto", "always", "never"] CLIColorsLiteral: TypeAlias = t.Literal[56, 88] CLIShellLiteral: TypeAlias = t.Literal[ "best", @@ -30,6 +50,7 @@ class CLIShellNamespace(argparse.Namespace): """Typed :class:`argparse.Namespace` for tmuxp shell command.""" + color: CLIColorModeLiteral session_name: str socket_name: str | None socket_path: str | None @@ -160,6 +181,9 @@ def command_shell( - :attr:`libtmux.Server.attached_sessions`, :attr:`libtmux.Session.active_window`, :attr:`libtmux.Window.active_pane` """ + color_mode = get_color_mode(args.color) + cli_colors = Colors(color_mode) + # If inside a server, detect socket_path env_tmux = os.getenv("TMUX") if env_tmux is not None and isinstance(env_tmux, str): @@ -198,11 +222,25 @@ def command_shell( ): from tmuxp._compat import breakpoint as tmuxp_breakpoint + print( # NOQA: T201 RUF100 + cli_colors.muted("Launching ") + + cli_colors.highlight("pdb", bold=False) + + cli_colors.muted(" shell..."), + ) tmuxp_breakpoint() return else: from tmuxp.shell import launch + shell_name = args.shell or "best" + print( # NOQA: T201 RUF100 + cli_colors.muted("Launching ") + + cli_colors.highlight(shell_name, bold=False) + + cli_colors.muted(" shell for session ") + + cli_colors.info(session.name or "") + + cli_colors.muted("..."), + ) + launch( shell=args.shell, use_pythonrc=args.use_pythonrc, # shell: code diff --git a/src/tmuxp/cli/utils.py b/src/tmuxp/cli/utils.py index 68de22f7c0..58896deb0f 100644 --- a/src/tmuxp/cli/utils.py +++ b/src/tmuxp/cli/utils.py @@ -2,43 +2,44 @@ from __future__ import annotations -import logging -import re import typing as t -from tmuxp import log +from tmuxp._internal.colors import ( + ColorMode, + Colors, + UnknownStyleColor, + strip_ansi, + style, + unstyle, +) +from tmuxp._internal.private_path import PrivatePath +from tmuxp.log import tmuxp_echo if t.TYPE_CHECKING: from collections.abc import Callable, Sequence - from typing import TypeAlias - CLIColour: TypeAlias = int | tuple[int, int, int] | str - - -logger = logging.getLogger(__name__) - - -def tmuxp_echo( - message: str | None = None, - log_level: str = "INFO", - style_log: bool = False, -) -> None: - """Combine logging.log and click.echo.""" - if message is None: - return - - if style_log: - logger.log(log.LOG_LEVELS[log_level], message) - else: - logger.log(log.LOG_LEVELS[log_level], unstyle(message)) - - print(message) # NOQA: T201 RUF100 +# Re-export for backward compatibility +__all__ = [ + "ColorMode", + "Colors", + "UnknownStyleColor", + "prompt", + "prompt_bool", + "prompt_choices", + "prompt_yes_no", + "strip_ansi", + "style", + "tmuxp_echo", + "unstyle", +] def prompt( name: str, default: str | None = None, value_proc: Callable[[str], str] | None = None, + *, + color_mode: ColorMode | None = None, ) -> str: """Return user input from command line. @@ -48,6 +49,8 @@ def prompt( prompt text default : default value if no input provided. + color_mode : + color mode for prompt styling. Defaults to AUTO if not specified. Returns ------- @@ -59,21 +62,32 @@ def prompt( `flask-script `_. See the `flask-script license `_. """ - prompt_ = name + ((default and f" [{default}]") or "") + colors = Colors(color_mode if color_mode is not None else ColorMode.AUTO) + # Use PrivatePath to mask home directory in displayed default + display_default = str(PrivatePath(default)) if default else None + prompt_ = name + ( + (display_default and " " + colors.info(f"[{display_default}]")) or "" + ) prompt_ += (name.endswith("?") and " ") or ": " while True: rv = input(prompt_) or default - try: - if value_proc is not None and callable(value_proc): - assert isinstance(rv, str) + # Validate with value_proc only if we have a string value + if rv is not None and value_proc is not None and callable(value_proc): + try: value_proc(rv) - except ValueError as e: - return prompt(str(e), default=default, value_proc=value_proc) + except ValueError as e: + return prompt( + str(e), + default=default, + value_proc=value_proc, + color_mode=color_mode, + ) if rv: return rv if default is not None: return default + # No input and no default - loop to re-prompt def prompt_bool( @@ -81,6 +95,8 @@ def prompt_bool( default: bool = False, yes_choices: Sequence[t.Any] | None = None, no_choices: Sequence[t.Any] | None = None, + *, + color_mode: ColorMode | None = None, ) -> bool: """Return True / False by prompting user input from command line. @@ -94,11 +110,14 @@ def prompt_bool( default 'y', 'yes', '1', 'on', 'true', 't' no_choices : default 'n', 'no', '0', 'off', 'false', 'f' + color_mode : + color mode for prompt styling. Defaults to AUTO if not specified. Returns ------- bool """ + colors = Colors(color_mode if color_mode is not None else ColorMode.AUTO) yes_choices = yes_choices or ("y", "yes", "1", "on", "true", "t") no_choices = no_choices or ("n", "no", "0", "off", "false", "f") @@ -109,7 +128,7 @@ def prompt_bool( else: prompt_choice = "y/N" - prompt_ = name + f" [{prompt_choice}]" + prompt_ = name + " " + colors.muted(f"[{prompt_choice}]") prompt_ += (name.endswith("?") and " ") or ": " while True: @@ -122,16 +141,33 @@ def prompt_bool( return False -def prompt_yes_no(name: str, default: bool = True) -> bool: - """:meth:`prompt_bool()` returning yes by default.""" - return prompt_bool(name, default=default) +def prompt_yes_no( + name: str, + default: bool = True, + *, + color_mode: ColorMode | None = None, +) -> bool: + """:meth:`prompt_bool()` returning yes by default. + + Parameters + ---------- + name : + prompt text + default : + default value if no input provided. + color_mode : + color mode for prompt styling. Defaults to AUTO if not specified. + """ + return prompt_bool(name, default=default, color_mode=color_mode) def prompt_choices( name: str, - choices: list[str] | tuple[str, str], + choices: Sequence[str | tuple[str, str]], default: str | None = None, no_choice: Sequence[str] = ("none",), + *, + color_mode: ColorMode | None = None, ) -> str | None: """Return user input from command line from set of provided choices. @@ -140,17 +176,20 @@ def prompt_choices( name : prompt text choices : - list or tuple of available choices. Choices may be single strings or - (key, value) tuples. + Sequence of available choices. Each choice may be a single string or + a (key, value) tuple where key is used for matching. default : default value if no input provided. no_choice : acceptable list of strings for "null choice" + color_mode : + color mode for prompt styling. Defaults to AUTO if not specified. Returns ------- str """ + colors = Colors(color_mode if color_mode is not None else ColorMode.AUTO) choices_: list[str] = [] options: list[str] = [] @@ -162,8 +201,13 @@ def prompt_choices( choice = choice[0] choices_.append(choice) + choices_str = colors.muted(f"({', '.join(options)})") + default_str = " " + colors.info(f"[{default}]") if default else "" + prompt_text = f"{name} - {choices_str}{default_str}" + while True: - rv = prompt(name + " - ({})".format(", ".join(options)), default=default) + prompt_ = prompt_text + ": " + rv = input(prompt_) or default if not rv or rv == default: return default rv = rv.lower() @@ -171,121 +215,7 @@ def prompt_choices( return None if rv in choices_: return rv - - -_ansi_re = re.compile(r"\033\[[;?0-9]*[a-zA-Z]") - - -def strip_ansi(value: str) -> str: - """Clear ANSI from a string value.""" - return _ansi_re.sub("", value) - - -_ansi_colors = { - "black": 30, - "red": 31, - "green": 32, - "yellow": 33, - "blue": 34, - "magenta": 35, - "cyan": 36, - "white": 37, - "reset": 39, - "bright_black": 90, - "bright_red": 91, - "bright_green": 92, - "bright_yellow": 93, - "bright_blue": 94, - "bright_magenta": 95, - "bright_cyan": 96, - "bright_white": 97, -} -_ansi_reset_all = "\033[0m" - - -def _interpret_color( - color: int | tuple[int, int, int] | str, - offset: int = 0, -) -> str: - if isinstance(color, int): - return f"{38 + offset};5;{color:d}" - - if isinstance(color, (tuple, list)): - r, g, b = color - return f"{38 + offset};2;{r:d};{g:d};{b:d}" - - return str(_ansi_colors[color] + offset) - - -class UnknownStyleColor(Exception): - """Raised when encountering an unknown terminal style color.""" - - def __init__(self, color: CLIColour, *args: object, **kwargs: object) -> None: - return super().__init__(f"Unknown color {color!r}", *args, **kwargs) - - -def style( - text: t.Any, - fg: CLIColour | None = None, - bg: CLIColour | None = None, - bold: bool | None = None, - dim: bool | None = None, - underline: bool | None = None, - overline: bool | None = None, - italic: bool | None = None, - blink: bool | None = None, - reverse: bool | None = None, - strikethrough: bool | None = None, - reset: bool = True, -) -> str: - """Credit: click.""" - if not isinstance(text, str): - text = str(text) - - bits = [] - - if fg: - try: - bits.append(f"\033[{_interpret_color(fg)}m") - except KeyError: - raise UnknownStyleColor(color=fg) from None - - if bg: - try: - bits.append(f"\033[{_interpret_color(bg, 10)}m") - except KeyError: - raise UnknownStyleColor(color=bg) from None - - if bold is not None: - bits.append(f"\033[{1 if bold else 22}m") - if dim is not None: - bits.append(f"\033[{2 if dim else 22}m") - if underline is not None: - bits.append(f"\033[{4 if underline else 24}m") - if overline is not None: - bits.append(f"\033[{53 if overline else 55}m") - if italic is not None: - bits.append(f"\033[{3 if italic else 23}m") - if blink is not None: - bits.append(f"\033[{5 if blink else 25}m") - if reverse is not None: - bits.append(f"\033[{7 if reverse else 27}m") - if strikethrough is not None: - bits.append(f"\033[{9 if strikethrough else 29}m") - bits.append(text) - if reset: - bits.append(_ansi_reset_all) - return "".join(bits) - - -def unstyle(text: str) -> str: - """Remove ANSI styling information from a string. - - Usually it's not necessary to use this function as tmuxp_echo function will - automatically remove styling if necessary. - - Credit: click. - - text : the text to remove style information from. - """ - return strip_ansi(text) + print( + colors.warning(f"Invalid choice '{rv}'. ") + + f"Please choose from: {', '.join(choices_)}" + ) diff --git a/src/tmuxp/log.py b/src/tmuxp/log.py index 6e53ab05b5..e4429eda6a 100644 --- a/src/tmuxp/log.py +++ b/src/tmuxp/log.py @@ -9,6 +9,8 @@ from colorama import Fore, Style +from tmuxp._internal.colors import unstyle + LEVEL_COLORS = { "DEBUG": Fore.BLUE, # Blue "INFO": Fore.GREEN, # Green @@ -200,3 +202,44 @@ class DebugLogFormatter(LogFormatter): """Provides greater technical details than standard log Formatter.""" template = debug_log_template + + +# Use tmuxp root logger so messages propagate to CLI handlers +_echo_logger = logging.getLogger("tmuxp") + + +def tmuxp_echo( + message: str | None = None, + log_level: str = "INFO", + style_log: bool = False, +) -> None: + """Combine logging.log and print for CLI output. + + Parameters + ---------- + message : str | None + Message to log and print. If None, does nothing. + log_level : str + Log level to use (DEBUG, INFO, WARNING, ERROR, CRITICAL). + Default is INFO. + style_log : bool + If True, preserve ANSI styling in log output. + If False, strip ANSI codes from log output. Default is False. + + Examples + -------- + >>> tmuxp_echo("Session loaded") # doctest: +ELLIPSIS + Session loaded + + >>> tmuxp_echo("Warning message", log_level="WARNING") # doctest: +ELLIPSIS + Warning message + """ + if message is None: + return + + if style_log: + _echo_logger.log(LOG_LEVELS[log_level], message) + else: + _echo_logger.log(LOG_LEVELS[log_level], unstyle(message)) + + print(message) diff --git a/src/tmuxp/workspace/finders.py b/src/tmuxp/workspace/finders.py index 84e6048685..da19bcc887 100644 --- a/src/tmuxp/workspace/finders.py +++ b/src/tmuxp/workspace/finders.py @@ -7,9 +7,9 @@ import pathlib import typing as t -from colorama import Fore - -from tmuxp.cli.utils import tmuxp_echo +from tmuxp._internal.colors import ColorMode, Colors +from tmuxp._internal.private_path import PrivatePath +from tmuxp.log import tmuxp_echo from tmuxp.workspace.constants import VALID_WORKSPACE_DIR_FILE_EXTENSIONS logger = logging.getLogger(__name__) @@ -197,6 +197,85 @@ def get_workspace_dir() -> str: return path +def get_workspace_dir_candidates() -> list[dict[str, t.Any]]: + """Return all candidate workspace directories with existence status. + + Returns a list of all directories that tmuxp checks for workspaces, + in priority order, with metadata about each. + + The priority order is: + 1. ``TMUXP_CONFIGDIR`` environment variable (if set) + 2. ``XDG_CONFIG_HOME/tmuxp`` (if XDG_CONFIG_HOME set) OR ``~/.config/tmuxp/`` + 3. ``~/.tmuxp`` (legacy default) + + Returns + ------- + list[dict[str, Any]] + List of dicts with: + - path: str (privacy-masked via PrivatePath) + - source: str (e.g., "$TMUXP_CONFIGDIR", "$XDG_CONFIG_HOME/tmuxp", "Legacy") + - exists: bool + - workspace_count: int (0 if not exists) + - active: bool (True if this is the directory get_workspace_dir() returns) + + Examples + -------- + >>> candidates = get_workspace_dir_candidates() + >>> isinstance(candidates, list) + True + >>> all('path' in c and 'exists' in c for c in candidates) + True + """ + # Build list of candidate paths with sources (same logic as get_workspace_dir) + # Each entry is (raw_path, source_label) + path_sources: list[tuple[str, str]] = [] + if "TMUXP_CONFIGDIR" in os.environ: + path_sources.append((os.environ["TMUXP_CONFIGDIR"], "$TMUXP_CONFIGDIR")) + if "XDG_CONFIG_HOME" in os.environ: + path_sources.append( + ( + os.path.join(os.environ["XDG_CONFIG_HOME"], "tmuxp"), + "$XDG_CONFIG_HOME/tmuxp", + ) + ) + else: + path_sources.append(("~/.config/tmuxp/", "XDG default")) + path_sources.append(("~/.tmuxp", "Legacy")) + + # Get the active directory for comparison + active_dir = get_workspace_dir() + + candidates: list[dict[str, t.Any]] = [] + for raw_path, source in path_sources: + expanded = os.path.expanduser(raw_path) + exists = os.path.isdir(expanded) + + # Count workspace files if directory exists + workspace_count = 0 + if exists: + workspace_count = len( + [ + f + for f in os.listdir(expanded) + if not f.startswith(".") + and os.path.splitext(f)[1].lower() + in VALID_WORKSPACE_DIR_FILE_EXTENSIONS + ] + ) + + candidates.append( + { + "path": str(PrivatePath(expanded)), + "source": source, + "exists": exists, + "workspace_count": workspace_count, + "active": expanded == active_dir, + } + ) + + return candidates + + def find_workspace_file( workspace_file: StrPath, workspace_dir: StrPath | None = None, @@ -282,11 +361,12 @@ def find_workspace_file( ] if len(candidates) > 1: + colors = Colors(ColorMode.AUTO) tmuxp_echo( - Fore.RED - + "Multiple .tmuxp.{yml,yaml,json} workspace_files in " - + dirname(workspace_file) - + Fore.RESET, + colors.error( + "Multiple .tmuxp.{yml,yaml,json} workspace_files in " + + dirname(workspace_file) + ), ) tmuxp_echo( "This is undefined behavior, use only one. " diff --git a/tests/_internal/__init__.py b/tests/_internal/__init__.py new file mode 100644 index 0000000000..10efabce8c --- /dev/null +++ b/tests/_internal/__init__.py @@ -0,0 +1 @@ +"""Tests for tmuxp internal modules.""" diff --git a/tests/_internal/conftest.py b/tests/_internal/conftest.py new file mode 100644 index 0000000000..c0630520dd --- /dev/null +++ b/tests/_internal/conftest.py @@ -0,0 +1,32 @@ +"""Shared pytest fixtures for _internal tests.""" + +from __future__ import annotations + +import pytest + +from tmuxp._internal.colors import ColorMode, Colors + +# ANSI escape codes for test assertions +# These constants improve test readability by giving semantic names to color codes +ANSI_GREEN = "\033[32m" +ANSI_RED = "\033[31m" +ANSI_YELLOW = "\033[33m" +ANSI_BLUE = "\033[34m" +ANSI_MAGENTA = "\033[35m" +ANSI_CYAN = "\033[36m" +ANSI_BRIGHT_CYAN = "\033[96m" +ANSI_RESET = "\033[0m" +ANSI_BOLD = "\033[1m" + + +@pytest.fixture +def colors_always(monkeypatch: pytest.MonkeyPatch) -> Colors: + """Colors instance with ALWAYS mode and NO_COLOR cleared.""" + monkeypatch.delenv("NO_COLOR", raising=False) + return Colors(ColorMode.ALWAYS) + + +@pytest.fixture +def colors_never() -> Colors: + """Colors instance with colors disabled.""" + return Colors(ColorMode.NEVER) diff --git a/tests/_internal/test_colors.py b/tests/_internal/test_colors.py new file mode 100644 index 0000000000..3aef1e8040 --- /dev/null +++ b/tests/_internal/test_colors.py @@ -0,0 +1,314 @@ +"""Tests for _internal color utilities.""" + +from __future__ import annotations + +import sys + +import pytest + +from tests._internal.conftest import ( + ANSI_BLUE, + ANSI_BOLD, + ANSI_BRIGHT_CYAN, + ANSI_CYAN, + ANSI_GREEN, + ANSI_MAGENTA, + ANSI_RED, + ANSI_RESET, + ANSI_YELLOW, +) +from tmuxp._internal.colors import ( + ColorMode, + Colors, + UnknownStyleColor, + get_color_mode, + style, +) + +# ColorMode tests + + +def test_auto_tty_enabled(monkeypatch: pytest.MonkeyPatch) -> None: + """Colors enabled when stdout is TTY in AUTO mode.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.delenv("FORCE_COLOR", raising=False) + colors = Colors(ColorMode.AUTO) + assert colors._enabled is True + + +def test_auto_no_tty_disabled(monkeypatch: pytest.MonkeyPatch) -> None: + """Colors disabled when stdout is not TTY in AUTO mode.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.delenv("FORCE_COLOR", raising=False) + colors = Colors(ColorMode.AUTO) + assert colors._enabled is False + + +def test_no_color_env_respected(monkeypatch: pytest.MonkeyPatch) -> None: + """NO_COLOR environment variable disables colors even in ALWAYS mode.""" + monkeypatch.setenv("NO_COLOR", "1") + colors = Colors(ColorMode.ALWAYS) + assert colors._enabled is False + + +def test_no_color_any_value(monkeypatch: pytest.MonkeyPatch) -> None: + """NO_COLOR with any non-empty value disables colors.""" + monkeypatch.setenv("NO_COLOR", "yes") + colors = Colors(ColorMode.ALWAYS) + assert colors._enabled is False + + +def test_force_color_env_respected(monkeypatch: pytest.MonkeyPatch) -> None: + """FORCE_COLOR environment variable enables colors in AUTO mode without TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + monkeypatch.setenv("FORCE_COLOR", "1") + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.AUTO) + assert colors._enabled is True + + +def test_no_color_takes_precedence(monkeypatch: pytest.MonkeyPatch) -> None: + """NO_COLOR takes precedence over FORCE_COLOR.""" + monkeypatch.setenv("NO_COLOR", "1") + monkeypatch.setenv("FORCE_COLOR", "1") + colors = Colors(ColorMode.ALWAYS) + assert colors._enabled is False + + +def test_never_mode_disables(monkeypatch: pytest.MonkeyPatch) -> None: + """ColorMode.NEVER always disables colors.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.NEVER) + assert colors._enabled is False + assert colors.success("test") == "test" + assert colors.error("fail") == "fail" + assert colors.warning("warn") == "warn" + assert colors.info("info") == "info" + assert colors.highlight("hl") == "hl" + assert colors.muted("mute") == "mute" + + +def test_always_mode_enables(monkeypatch: pytest.MonkeyPatch) -> None: + """ColorMode.ALWAYS enables colors (unless NO_COLOR set).""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + assert colors._enabled is True + assert "\033[" in colors.success("test") + + +# Semantic color tests + + +def test_success_applies_green(monkeypatch: pytest.MonkeyPatch) -> None: + """success() applies green color.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.success("ok") + assert ANSI_GREEN in result + assert "ok" in result + assert result.endswith(ANSI_RESET) + + +def test_error_applies_red(monkeypatch: pytest.MonkeyPatch) -> None: + """error() applies red color.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.error("fail") + assert ANSI_RED in result + assert "fail" in result + + +def test_warning_applies_yellow(monkeypatch: pytest.MonkeyPatch) -> None: + """warning() applies yellow color.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.warning("caution") + assert ANSI_YELLOW in result + assert "caution" in result + + +def test_info_applies_cyan(monkeypatch: pytest.MonkeyPatch) -> None: + """info() applies cyan color.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.info("message") + assert ANSI_CYAN in result + assert "message" in result + + +def test_highlight_applies_magenta_bold(monkeypatch: pytest.MonkeyPatch) -> None: + """highlight() applies magenta color with bold by default.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.highlight("important") + assert ANSI_MAGENTA in result + assert ANSI_BOLD in result + assert "important" in result + + +def test_highlight_no_bold(monkeypatch: pytest.MonkeyPatch) -> None: + """highlight() can be used without bold.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.highlight("important", bold=False) + assert ANSI_MAGENTA in result + assert ANSI_BOLD not in result + assert "important" in result + + +def test_muted_applies_blue(monkeypatch: pytest.MonkeyPatch) -> None: + """muted() applies blue color without bold.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.muted("secondary") + assert ANSI_BLUE in result + assert ANSI_BOLD not in result + assert "secondary" in result + + +def test_success_with_bold(monkeypatch: pytest.MonkeyPatch) -> None: + """success() can be used with bold.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.success("done", bold=True) + assert ANSI_GREEN in result + assert ANSI_BOLD in result + assert "done" in result + + +# get_color_mode tests + + +def test_get_color_mode_none_returns_auto() -> None: + """None argument returns AUTO mode.""" + assert get_color_mode(None) == ColorMode.AUTO + + +def test_get_color_mode_auto_string() -> None: + """'auto' string returns AUTO mode.""" + assert get_color_mode("auto") == ColorMode.AUTO + + +def test_get_color_mode_always_string() -> None: + """'always' string returns ALWAYS mode.""" + assert get_color_mode("always") == ColorMode.ALWAYS + + +def test_get_color_mode_never_string() -> None: + """'never' string returns NEVER mode.""" + assert get_color_mode("never") == ColorMode.NEVER + + +def test_get_color_mode_case_insensitive() -> None: + """Color mode strings are case insensitive.""" + assert get_color_mode("ALWAYS") == ColorMode.ALWAYS + assert get_color_mode("Never") == ColorMode.NEVER + assert get_color_mode("AUTO") == ColorMode.AUTO + + +def test_get_color_mode_invalid_returns_auto() -> None: + """Invalid color mode strings return AUTO as fallback.""" + assert get_color_mode("invalid") == ColorMode.AUTO + assert get_color_mode("yes") == ColorMode.AUTO + assert get_color_mode("") == ColorMode.AUTO + + +# Colors class attribute tests + + +def test_semantic_color_names() -> None: + """Verify semantic color name attributes exist.""" + assert Colors.SUCCESS == "green" + assert Colors.WARNING == "yellow" + assert Colors.ERROR == "red" + assert Colors.INFO == "cyan" + assert Colors.HIGHLIGHT == "magenta" + assert Colors.MUTED == "blue" + + +# Colors disabled tests + + +def test_disabled_returns_plain_text() -> None: + """When colors are disabled, methods return plain text.""" + colors = Colors(ColorMode.NEVER) + assert colors.success("text") == "text" + assert colors.error("text") == "text" + assert colors.warning("text") == "text" + assert colors.info("text") == "text" + assert colors.highlight("text") == "text" + assert colors.muted("text") == "text" + + +def test_disabled_preserves_text() -> None: + """Disabled colors preserve special characters.""" + colors = Colors(ColorMode.NEVER) + special = "path/to/file.yaml" + assert colors.info(special) == special + + with_spaces = "some message" + assert colors.success(with_spaces) == with_spaces + + +# RGB tuple validation tests + + +def test_style_with_valid_rgb_tuple() -> None: + """style() should accept valid RGB tuple.""" + result = style("test", fg=(255, 128, 0)) + assert "\033[38;2;255;128;0m" in result + assert "test" in result + + +def test_style_with_invalid_2_element_tuple() -> None: + """style() should raise UnknownStyleColor for 2-element tuple.""" + with pytest.raises(UnknownStyleColor): + style("test", fg=(255, 128)) # type: ignore[arg-type] + + +def test_style_with_invalid_4_element_tuple() -> None: + """style() should raise UnknownStyleColor for 4-element tuple.""" + with pytest.raises(UnknownStyleColor): + style("test", fg=(255, 128, 0, 64)) # type: ignore[arg-type] + + +def test_style_with_empty_tuple() -> None: + """style() treats empty tuple as 'no color' (falsy value).""" + result = style("test", fg=()) # type: ignore[arg-type] + # Empty tuple is falsy, so no fg color is applied + assert "test" in result + assert "\033[38" not in result # No foreground color escape + + +def test_style_with_rgb_value_too_high() -> None: + """style() should reject RGB values > 255.""" + with pytest.raises(UnknownStyleColor): + style("test", fg=(256, 0, 0)) + + +def test_style_with_rgb_value_negative() -> None: + """style() should reject negative RGB values.""" + with pytest.raises(UnknownStyleColor): + style("test", fg=(-1, 128, 0)) + + +def test_style_with_rgb_non_integer() -> None: + """style() should reject non-integer RGB values.""" + with pytest.raises(UnknownStyleColor): + style("test", fg=(255.5, 128, 0)) # type: ignore[arg-type] + + +# heading() method tests + + +def test_heading_applies_bright_cyan_bold(monkeypatch: pytest.MonkeyPatch) -> None: + """heading() applies bright_cyan with bold when colors are enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + result = colors.heading("Local workspaces:") + assert ANSI_BRIGHT_CYAN in result + assert ANSI_BOLD in result + assert "Local workspaces:" in result + assert ANSI_RESET in result diff --git a/tests/_internal/test_colors_formatters.py b/tests/_internal/test_colors_formatters.py new file mode 100644 index 0000000000..c7f9d80297 --- /dev/null +++ b/tests/_internal/test_colors_formatters.py @@ -0,0 +1,236 @@ +"""Tests for Colors class formatting helper methods.""" + +from __future__ import annotations + +import pytest + +from tests._internal.conftest import ANSI_BLUE, ANSI_BOLD, ANSI_CYAN, ANSI_MAGENTA +from tmuxp._internal.colors import ColorMode, Colors + +# format_label tests + + +def test_format_label_plain_text() -> None: + """format_label returns plain text when colors disabled.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_label("tmux path") == "tmux path" + + +def test_format_label_applies_highlight(monkeypatch: pytest.MonkeyPatch) -> None: + """format_label applies highlight (bold magenta) when enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_label("tmux path") + assert ANSI_MAGENTA in result # magenta + assert ANSI_BOLD in result # bold + assert "tmux path" in result + + +# format_path tests + + +def test_format_path_plain_text() -> None: + """format_path returns plain text when colors disabled.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_path("/usr/bin/tmux") == "/usr/bin/tmux" + + +def test_format_path_applies_info(monkeypatch: pytest.MonkeyPatch) -> None: + """format_path applies info color (cyan) when enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_path("/usr/bin/tmux") + assert ANSI_CYAN in result # cyan + assert "/usr/bin/tmux" in result + + +# format_version tests + + +def test_format_version_plain_text() -> None: + """format_version returns plain text when colors disabled.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_version("3.2a") == "3.2a" + + +def test_format_version_applies_info(monkeypatch: pytest.MonkeyPatch) -> None: + """format_version applies info color (cyan) when enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_version("3.2a") + assert ANSI_CYAN in result # cyan + assert "3.2a" in result + + +# format_separator tests + + +def test_format_separator_default_length() -> None: + """format_separator creates 25-character separator by default.""" + colors = Colors(ColorMode.NEVER) + result = colors.format_separator() + assert result == "-" * 25 + + +def test_format_separator_custom_length() -> None: + """format_separator respects custom length.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_separator(10) == "-" * 10 + assert colors.format_separator(50) == "-" * 50 + + +def test_format_separator_applies_muted(monkeypatch: pytest.MonkeyPatch) -> None: + """format_separator applies muted color (blue) when enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_separator() + assert ANSI_BLUE in result # blue + assert "-" * 25 in result + + +# format_kv tests + + +def test_format_kv_plain_text() -> None: + """format_kv returns plain key: value when colors disabled.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_kv("tmux version", "3.2a") == "tmux version: 3.2a" + + +def test_format_kv_highlights_key(monkeypatch: pytest.MonkeyPatch) -> None: + """format_kv highlights the key but not the value.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_kv("tmux version", "3.2a") + assert ANSI_MAGENTA in result # magenta for key + assert ANSI_BOLD in result # bold for key + assert "tmux version" in result + assert ": 3.2a" in result + + +def test_format_kv_empty_value() -> None: + """format_kv handles empty value.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_kv("environment", "") == "environment: " + + +# format_tmux_option tests + + +def test_format_tmux_option_plain_text_key_value() -> None: + """format_tmux_option returns plain text when colors disabled (key=value).""" + colors = Colors(ColorMode.NEVER) + assert colors.format_tmux_option("base-index=1") == "base-index=1" + + +def test_format_tmux_option_plain_text_space_sep() -> None: + """format_tmux_option returns plain text when colors disabled (space-sep).""" + colors = Colors(ColorMode.NEVER) + assert colors.format_tmux_option("status on") == "status on" + + +def test_format_tmux_option_key_value_format(monkeypatch: pytest.MonkeyPatch) -> None: + """format_tmux_option highlights key=value format.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_tmux_option("base-index=1") + assert ANSI_MAGENTA in result # magenta for key + assert ANSI_CYAN in result # cyan for value + assert "base-index" in result + assert "=1" in result or "1" in result + + +def test_format_tmux_option_space_separated(monkeypatch: pytest.MonkeyPatch) -> None: + """format_tmux_option highlights space-separated format.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_tmux_option("status on") + assert ANSI_MAGENTA in result # magenta for key + assert ANSI_CYAN in result # cyan for value + assert "status" in result + assert "on" in result + + +def test_format_tmux_option_single_word() -> None: + """format_tmux_option returns single words (empty array options) unchanged.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_tmux_option("pane-colours") == "pane-colours" + + +def test_format_tmux_option_single_word_highlighted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """format_tmux_option highlights single words (empty array options) as keys.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_tmux_option("pane-colours") + assert ANSI_MAGENTA in result # magenta for key + assert "pane-colours" in result + + +def test_format_tmux_option_empty() -> None: + """format_tmux_option handles empty string.""" + colors = Colors(ColorMode.NEVER) + assert colors.format_tmux_option("") == "" + + +def test_format_tmux_option_array_indexed(monkeypatch: pytest.MonkeyPatch) -> None: + """format_tmux_option handles array-indexed keys like status-format[0].""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_tmux_option('status-format[0] "#[align=left]"') + assert ANSI_MAGENTA in result # magenta for key + assert ANSI_CYAN in result # cyan for value + assert "status-format[0]" in result + assert "#[align=left]" in result + + +def test_format_tmux_option_array_indexed_complex_value( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """format_tmux_option handles complex format strings as values.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + # Real tmux status-format value (truncated for test) + line = 'status-format[0] "#[align=left range=left #{E:status-left-style}]"' + result = colors.format_tmux_option(line) + assert "status-format[0]" in result + assert "#[align=left" in result + + +def test_format_tmux_option_value_with_spaces( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """format_tmux_option handles values containing spaces.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + # tmux options can have values with spaces like status-left "string here" + result = colors.format_tmux_option('status-left "#S: #W"') + assert "status-left" in result + assert '"#S: #W"' in result + + +def test_format_tmux_option_value_with_equals( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """format_tmux_option splits only on first equals for key=value format.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + # Only split on first equals (no spaces = key=value format) + result = colors.format_tmux_option("option=a=b=c") + assert "option" in result + assert "a=b=c" in result + assert ANSI_MAGENTA in result # magenta for key + assert ANSI_CYAN in result # cyan for value diff --git a/tests/_internal/test_colors_integration.py b/tests/_internal/test_colors_integration.py new file mode 100644 index 0000000000..5927a3b71b --- /dev/null +++ b/tests/_internal/test_colors_integration.py @@ -0,0 +1,193 @@ +"""Integration tests for color output across all commands.""" + +from __future__ import annotations + +import sys +import typing as t + +import pytest + +from tests._internal.conftest import ANSI_BOLD, ANSI_MAGENTA, ANSI_RESET +from tmuxp._internal.colors import ColorMode, Colors, get_color_mode + +# Color flag integration tests + + +def test_color_flag_auto_with_tty(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --color=auto enables colors when stdout is TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.delenv("FORCE_COLOR", raising=False) + + color_mode = get_color_mode("auto") + colors = Colors(color_mode) + assert colors._enabled is True + + +def test_color_flag_auto_without_tty(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --color=auto disables colors when stdout is not TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.delenv("FORCE_COLOR", raising=False) + + color_mode = get_color_mode("auto") + colors = Colors(color_mode) + assert colors._enabled is False + + +def test_color_flag_always_forces_colors(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --color=always forces colors even without TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + monkeypatch.delenv("NO_COLOR", raising=False) + + color_mode = get_color_mode("always") + colors = Colors(color_mode) + assert colors._enabled is True + # Verify output contains ANSI codes + assert "\033[" in colors.success("test") + + +def test_color_flag_never_disables_colors(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --color=never disables colors even with TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + monkeypatch.delenv("NO_COLOR", raising=False) + + color_mode = get_color_mode("never") + colors = Colors(color_mode) + assert colors._enabled is False + # Verify output is plain text + assert colors.success("test") == "test" + assert colors.error("test") == "test" + assert colors.warning("test") == "test" + + +# Environment variable integration tests + + +def test_no_color_env_overrides_always(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify NO_COLOR environment variable overrides --color=always.""" + monkeypatch.setenv("NO_COLOR", "1") + + color_mode = get_color_mode("always") + colors = Colors(color_mode) + assert colors._enabled is False + assert colors.success("test") == "test" + + +def test_no_color_env_with_empty_value(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify empty NO_COLOR is ignored (per spec).""" + monkeypatch.setenv("NO_COLOR", "") + monkeypatch.delenv("FORCE_COLOR", raising=False) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + + colors = Colors(ColorMode.ALWAYS) + # Empty NO_COLOR should be ignored, colors should be enabled + assert colors._enabled is True + + +def test_force_color_env_with_auto(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify FORCE_COLOR enables colors in auto mode without TTY.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + monkeypatch.setenv("FORCE_COLOR", "1") + monkeypatch.delenv("NO_COLOR", raising=False) + + colors = Colors(ColorMode.AUTO) + assert colors._enabled is True + + +def test_no_color_takes_precedence_over_force_color( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify NO_COLOR takes precedence over FORCE_COLOR.""" + monkeypatch.setenv("NO_COLOR", "1") + monkeypatch.setenv("FORCE_COLOR", "1") + + colors = Colors(ColorMode.ALWAYS) + assert colors._enabled is False + + +# Color mode consistency tests + + +def test_all_semantic_methods_respect_enabled_state( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify all semantic color methods include ANSI codes when enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + methods: list[t.Callable[..., str]] = [ + colors.success, + colors.error, + colors.warning, + colors.info, + colors.muted, + ] + for method in methods: + result = method("test") + assert "\033[" in result, f"{method.__name__} should include ANSI codes" + assert result.endswith(ANSI_RESET), f"{method.__name__} should reset color" + + +def test_all_semantic_methods_respect_disabled_state() -> None: + """Verify all semantic color methods return plain text when disabled.""" + colors = Colors(ColorMode.NEVER) + + methods: list[t.Callable[..., str]] = [ + colors.success, + colors.error, + colors.warning, + colors.info, + colors.muted, + ] + for method in methods: + result = method("test") + assert result == "test", f"{method.__name__} should return plain text" + assert "\033[" not in result, f"{method.__name__} should not have ANSI codes" + + +def test_highlight_bold_parameter(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify highlight respects bold parameter.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + with_bold = colors.highlight("test", bold=True) + without_bold = colors.highlight("test", bold=False) + + assert ANSI_BOLD in with_bold + assert ANSI_BOLD not in without_bold + # Both should have magenta + assert ANSI_MAGENTA in with_bold + assert ANSI_MAGENTA in without_bold + + +# get_color_mode function tests + + +def test_get_color_mode_none_defaults_to_auto() -> None: + """Verify None input returns AUTO mode.""" + assert get_color_mode(None) == ColorMode.AUTO + + +def test_get_color_mode_valid_string_values() -> None: + """Verify all valid string values are converted correctly.""" + assert get_color_mode("auto") == ColorMode.AUTO + assert get_color_mode("always") == ColorMode.ALWAYS + assert get_color_mode("never") == ColorMode.NEVER + + +def test_get_color_mode_case_insensitive() -> None: + """Verify string values are case insensitive.""" + assert get_color_mode("AUTO") == ColorMode.AUTO + assert get_color_mode("Always") == ColorMode.ALWAYS + assert get_color_mode("NEVER") == ColorMode.NEVER + assert get_color_mode("aUtO") == ColorMode.AUTO + + +def test_get_color_mode_invalid_values_fallback_to_auto() -> None: + """Verify invalid values fallback to AUTO mode.""" + assert get_color_mode("invalid") == ColorMode.AUTO + assert get_color_mode("yes") == ColorMode.AUTO + assert get_color_mode("no") == ColorMode.AUTO + assert get_color_mode("true") == ColorMode.AUTO + assert get_color_mode("") == ColorMode.AUTO diff --git a/tests/_internal/test_private_path.py b/tests/_internal/test_private_path.py new file mode 100644 index 0000000000..7e9f3f4979 --- /dev/null +++ b/tests/_internal/test_private_path.py @@ -0,0 +1,124 @@ +"""Tests for PrivatePath privacy-masking utilities.""" + +from __future__ import annotations + +import pathlib + +import pytest + +from tmuxp._internal.private_path import PrivatePath, collapse_home_in_string + +# PrivatePath tests + + +def test_private_path_collapses_home(monkeypatch: pytest.MonkeyPatch) -> None: + """PrivatePath replaces home directory with ~.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + path = PrivatePath("/home/testuser/projects/tmuxp") + assert str(path) == "~/projects/tmuxp" + + +def test_private_path_collapses_home_exact(monkeypatch: pytest.MonkeyPatch) -> None: + """PrivatePath handles exact home directory match.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + path = PrivatePath("/home/testuser") + assert str(path) == "~" + + +def test_private_path_preserves_non_home(monkeypatch: pytest.MonkeyPatch) -> None: + """PrivatePath preserves paths outside home directory.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + path = PrivatePath("/usr/bin/tmux") + assert str(path) == "/usr/bin/tmux" + + +def test_private_path_preserves_tmp(monkeypatch: pytest.MonkeyPatch) -> None: + """PrivatePath preserves /tmp paths.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + path = PrivatePath("/tmp/example") + assert str(path) == "/tmp/example" + + +def test_private_path_preserves_already_collapsed() -> None: + """PrivatePath preserves paths already starting with ~.""" + path = PrivatePath("~/already/collapsed") + assert str(path) == "~/already/collapsed" + + +def test_private_path_repr(monkeypatch: pytest.MonkeyPatch) -> None: + """PrivatePath repr shows class name and collapsed path.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + path = PrivatePath("/home/testuser/config.yaml") + assert repr(path) == "PrivatePath('~/config.yaml')" + + +def test_private_path_in_fstring(monkeypatch: pytest.MonkeyPatch) -> None: + """PrivatePath works in f-strings with collapsed home.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + path = PrivatePath("/home/testuser/.tmuxp/session.yaml") + result = f"config: {path}" + assert result == "config: ~/.tmuxp/session.yaml" + + +def test_private_path_similar_prefix_not_collapsed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """PrivatePath does not collapse paths with similar prefix but different user.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + # /home/testuser2 should NOT be collapsed even though it starts with /home/testuser + path = PrivatePath("/home/testuser2/projects") + assert str(path) == "/home/testuser2/projects" + + +# collapse_home_in_string tests + + +def test_collapse_home_in_string_single_path(monkeypatch: pytest.MonkeyPatch) -> None: + """collapse_home_in_string handles a single path.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + result = collapse_home_in_string("/home/testuser/.local/bin") + assert result == "~/.local/bin" + + +def test_collapse_home_in_string_multiple_paths( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """collapse_home_in_string handles colon-separated paths.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + result = collapse_home_in_string( + "/home/testuser/bin:/home/testuser/.cargo/bin:/usr/bin" + ) + assert result == "~/bin:~/.cargo/bin:/usr/bin" + + +def test_collapse_home_in_string_no_home_paths( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """collapse_home_in_string preserves paths not under home.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + result = collapse_home_in_string("/usr/bin:/bin:/usr/local/bin") + assert result == "/usr/bin:/bin:/usr/local/bin" + + +def test_collapse_home_in_string_mixed_paths(monkeypatch: pytest.MonkeyPatch) -> None: + """collapse_home_in_string handles mixed home and non-home paths.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + + result = collapse_home_in_string("/usr/bin:/home/testuser/.local/bin:/bin") + assert result == "/usr/bin:~/.local/bin:/bin" + + +def test_collapse_home_in_string_empty() -> None: + """collapse_home_in_string handles empty string.""" + result = collapse_home_in_string("") + assert result == "" diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py new file mode 100644 index 0000000000..32e5748f21 --- /dev/null +++ b/tests/cli/conftest.py @@ -0,0 +1,63 @@ +"""Shared pytest fixtures for CLI tests.""" + +from __future__ import annotations + +import pathlib + +import pytest + +from tmuxp._internal.colors import ColorMode, Colors + +# ANSI escape codes for test assertions +# These constants improve test readability by giving semantic names to color codes +ANSI_GREEN = "\033[32m" +ANSI_RED = "\033[31m" +ANSI_YELLOW = "\033[33m" +ANSI_BLUE = "\033[34m" +ANSI_MAGENTA = "\033[35m" +ANSI_CYAN = "\033[36m" +ANSI_RESET = "\033[0m" +ANSI_BOLD = "\033[1m" + + +@pytest.fixture +def colors_always(monkeypatch: pytest.MonkeyPatch) -> Colors: + """Colors instance with ALWAYS mode and NO_COLOR cleared.""" + monkeypatch.delenv("NO_COLOR", raising=False) + return Colors(ColorMode.ALWAYS) + + +@pytest.fixture +def colors_never() -> Colors: + """Colors instance with colors disabled.""" + return Colors(ColorMode.NEVER) + + +@pytest.fixture +def mock_home(monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: + """Mock home directory for privacy tests. + + Sets pathlib.Path.home() to return /home/testuser. + """ + home = pathlib.Path("/home/testuser") + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + return home + + +@pytest.fixture +def isolated_home( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, +) -> pathlib.Path: + """Isolate test from user's home directory and environment. + + Sets up tmp_path as HOME with XDG_CONFIG_HOME, clears TMUXP_CONFIGDIR + and NO_COLOR, and changes the working directory to tmp_path. + """ + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) + return tmp_path diff --git a/tests/cli/test_convert_colors.py b/tests/cli/test_convert_colors.py new file mode 100644 index 0000000000..5d395553a6 --- /dev/null +++ b/tests/cli/test_convert_colors.py @@ -0,0 +1,124 @@ +"""Tests for CLI colors in convert command.""" + +from __future__ import annotations + +import pathlib + +from tests.cli.conftest import ANSI_BOLD, ANSI_CYAN, ANSI_GREEN, ANSI_MAGENTA +from tmuxp._internal.private_path import PrivatePath +from tmuxp.cli._colors import Colors + +# Convert command color output tests + + +def test_convert_success_message(colors_always: Colors) -> None: + """Verify success messages use success color (green).""" + result = colors_always.success("New workspace file saved to ") + assert ANSI_GREEN in result # green foreground + assert "New workspace file saved to" in result + + +def test_convert_file_path_uses_info(colors_always: Colors) -> None: + """Verify file paths use info color (cyan).""" + path = "/path/to/config.yaml" + result = colors_always.info(path) + assert ANSI_CYAN in result # cyan foreground + assert path in result + + +def test_convert_format_type_highlighted(colors_always: Colors) -> None: + """Verify format type uses highlight color (magenta + bold).""" + for fmt in ["json", "yaml"]: + result = colors_always.highlight(fmt) + assert ANSI_MAGENTA in result # magenta foreground + assert ANSI_BOLD in result # bold + assert fmt in result + + +def test_convert_colors_disabled_plain_text(colors_never: Colors) -> None: + """Verify disabled colors return plain text.""" + assert colors_never.success("success") == "success" + assert colors_never.info("info") == "info" + assert colors_never.highlight("highlight") == "highlight" + + +def test_convert_combined_success_format(colors_always: Colors) -> None: + """Verify combined success + info format for save message.""" + newfile = "/home/user/.tmuxp/session.json" + output = ( + colors_always.success("New workspace file saved to ") + + colors_always.info(f"<{newfile}>") + + "." + ) + # Should contain both green and cyan ANSI codes + assert ANSI_GREEN in output # green for success text + assert ANSI_CYAN in output # cyan for path + assert "New workspace file saved to" in output + assert newfile in output + assert output.endswith(".") + + +def test_convert_prompt_format_with_highlight(colors_always: Colors) -> None: + """Verify prompt uses info for path and highlight for format.""" + workspace_file = "/path/to/config.yaml" + to_filetype = "json" + prompt = ( + f"Convert {colors_always.info(workspace_file)} " + f"to {colors_always.highlight(to_filetype)}?" + ) + assert ANSI_CYAN in prompt # cyan for file path + assert ANSI_MAGENTA in prompt # magenta for format type + assert workspace_file in prompt + assert to_filetype in prompt + + +def test_convert_save_prompt_format(colors_always: Colors) -> None: + """Verify save prompt uses info color for new file path.""" + newfile = "/path/to/config.json" + prompt = f"Save workspace to {colors_always.info(newfile)}?" + assert ANSI_CYAN in prompt # cyan for file path + assert newfile in prompt + assert "Save workspace to" in prompt + + +# Privacy masking tests + + +def test_convert_masks_home_in_convert_prompt( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: + """Convert should mask home directory in convert prompt.""" + workspace_file = mock_home / ".tmuxp/session.yaml" + prompt = f"Convert {colors_always.info(str(PrivatePath(workspace_file)))} to json?" + + assert "~/.tmuxp/session.yaml" in prompt + assert "/home/testuser" not in prompt + + +def test_convert_masks_home_in_save_prompt( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: + """Convert should mask home directory in save prompt.""" + newfile = mock_home / ".tmuxp/session.json" + prompt = f"Save workspace to {colors_always.info(str(PrivatePath(newfile)))}?" + + assert "~/.tmuxp/session.json" in prompt + assert "/home/testuser" not in prompt + + +def test_convert_masks_home_in_saved_message( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: + """Convert should mask home directory in saved message.""" + newfile = mock_home / ".tmuxp/session.json" + output = ( + colors_always.success("New workspace file saved to ") + + colors_always.info(str(PrivatePath(newfile))) + + "." + ) + + assert "~/.tmuxp/session.json" in output + assert "/home/testuser" not in output diff --git a/tests/cli/test_debug_info.py b/tests/cli/test_debug_info.py index 1729f1e9cc..bb1c0bb479 100644 --- a/tests/cli/test_debug_info.py +++ b/tests/cli/test_debug_info.py @@ -1,15 +1,68 @@ -"""CLI tests for tmuxp debuginfo.""" +"""CLI tests for tmuxp debug-info.""" from __future__ import annotations +import json import typing as t +import pytest + from tmuxp import cli if t.TYPE_CHECKING: import pathlib - import pytest + +class DebugInfoOutputFixture(t.NamedTuple): + """Test fixture for debug-info output modes.""" + + test_id: str + args: list[str] + expected_keys: list[str] + is_json: bool + + +DEBUG_INFO_OUTPUT_FIXTURES: list[DebugInfoOutputFixture] = [ + DebugInfoOutputFixture( + test_id="human_output_has_labels", + args=["debug-info"], + expected_keys=["environment", "python version", "tmux version"], + is_json=False, + ), + DebugInfoOutputFixture( + test_id="json_output_valid", + args=["debug-info", "--json"], + expected_keys=["environment", "python_version", "tmux_version"], + is_json=True, + ), +] + + +@pytest.mark.parametrize( + DEBUG_INFO_OUTPUT_FIXTURES[0]._fields, + [pytest.param(*f, id=f.test_id) for f in DEBUG_INFO_OUTPUT_FIXTURES], +) +def test_debug_info_output_modes( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + test_id: str, + args: list[str], + expected_keys: list[str], + is_json: bool, +) -> None: + """Test debug-info output modes (human and JSON).""" + monkeypatch.setenv("SHELL", "/bin/bash") + + cli.cli(args) + output = capsys.readouterr().out + + if is_json: + data = json.loads(output) + for key in expected_keys: + assert key in data, f"Expected key '{key}' in JSON output" + else: + for key in expected_keys: + assert key in output, f"Expected '{key}' in human output" def test_debug_info_cli( @@ -17,7 +70,7 @@ def test_debug_info_cli( tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str], ) -> None: - """Basic CLI test for tmuxp debug-info.""" + """Basic CLI test for tmuxp debug-info (human output).""" monkeypatch.setenv("SHELL", "/bin/bash") cli.cli(["debug-info"]) @@ -36,3 +89,81 @@ def test_debug_info_cli( assert "tmux panes" in cli_output assert "tmux global options" in cli_output assert "tmux window options" in cli_output + + +def test_debug_info_json_output( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON output is valid JSON with expected structure.""" + monkeypatch.setenv("SHELL", "/bin/bash") + + cli.cli(["debug-info", "--json"]) + output = capsys.readouterr().out + + data = json.loads(output) + + # Top-level keys + assert "environment" in data + assert "python_version" in data + assert "system_path" in data + assert "tmux_version" in data + assert "libtmux_version" in data + assert "tmuxp_version" in data + assert "tmux_path" in data + assert "tmuxp_path" in data + assert "shell" in data + assert "tmux" in data + + # Environment structure + env = data["environment"] + assert "dist" in env + assert "arch" in env + assert "uname" in env + assert "version" in env + assert isinstance(env["uname"], list) + + # Tmux structure + tmux = data["tmux"] + assert "sessions" in tmux + assert "windows" in tmux + assert "panes" in tmux + assert "global_options" in tmux + assert "window_options" in tmux + assert isinstance(tmux["sessions"], list) + + +def test_debug_info_json_no_ansi( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON output should not contain ANSI escape codes.""" + monkeypatch.setenv("SHELL", "/bin/bash") + + cli.cli(["debug-info", "--json"]) + output = capsys.readouterr().out + + # ANSI escape codes start with \x1b[ or \033[ + assert "\x1b[" not in output, "JSON output contains ANSI escape codes" + assert "\033[" not in output, "JSON output contains ANSI escape codes" + + +def test_debug_info_json_paths_use_private_path( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON output should mask home directory with ~.""" + import pathlib + + # Set SHELL to a path under home directory + shell_path = pathlib.Path.home() / ".local" / "bin" / "zsh" + monkeypatch.setenv("SHELL", str(shell_path)) + + cli.cli(["debug-info", "--json"]) + output = capsys.readouterr().out + data = json.loads(output) + + # The shell path should be masked with ~ + assert data["shell"] == "~/.local/bin/zsh", ( + f"Expected shell path to be masked with ~, got: {data['shell']}" + ) diff --git a/tests/cli/test_debug_info_colors.py b/tests/cli/test_debug_info_colors.py new file mode 100644 index 0000000000..ec2ac283db --- /dev/null +++ b/tests/cli/test_debug_info_colors.py @@ -0,0 +1,154 @@ +"""Tests for debug-info command color output and privacy masking.""" + +from __future__ import annotations + +import pathlib + +import pytest + +from tests.cli.conftest import ANSI_BLUE, ANSI_BOLD, ANSI_CYAN, ANSI_MAGENTA +from tmuxp._internal.private_path import PrivatePath, collapse_home_in_string +from tmuxp.cli._colors import ColorMode, Colors + +# Privacy masking in debug-info context + + +def test_debug_info_masks_home_in_paths(mock_home: pathlib.Path) -> None: + """debug-info should mask home directory in paths.""" + # Simulate what debug-info does with tmuxp_path + tmuxp_path = mock_home / "work/python/tmuxp/src/tmuxp" + private_path = str(PrivatePath(tmuxp_path)) + + assert private_path == "~/work/python/tmuxp/src/tmuxp" + assert "/home/testuser" not in private_path + + +def test_debug_info_masks_home_in_system_path(mock_home: pathlib.Path) -> None: + """debug-info should mask home directory in system PATH.""" + path_env = "/home/testuser/.local/bin:/usr/bin:/home/testuser/.cargo/bin" + masked = collapse_home_in_string(path_env) + + assert masked == "~/.local/bin:/usr/bin:~/.cargo/bin" + assert "/home/testuser" not in masked + + +def test_debug_info_preserves_system_paths(mock_home: pathlib.Path) -> None: + """debug-info should preserve paths outside home directory.""" + tmux_path = "/usr/bin/tmux" + private_path = str(PrivatePath(tmux_path)) + + assert private_path == "/usr/bin/tmux" + + +# Formatting helpers in debug-info context + + +def test_debug_info_format_kv_labels(colors_always: Colors) -> None: + """debug-info should highlight labels in key-value pairs.""" + result = colors_always.format_kv("tmux version", "3.2a") + assert ANSI_MAGENTA in result # magenta for label + assert ANSI_BOLD in result # bold for label + assert "tmux version" in result + assert "3.2a" in result + + +def test_debug_info_format_version(colors_always: Colors) -> None: + """debug-info should highlight version strings.""" + result = colors_always.format_kv( + "tmux version", colors_always.format_version("3.2a") + ) + assert ANSI_CYAN in result # cyan for version + assert "3.2a" in result + + +def test_debug_info_format_path(colors_always: Colors) -> None: + """debug-info should highlight paths.""" + result = colors_always.format_kv( + "tmux path", colors_always.format_path("/usr/bin/tmux") + ) + assert ANSI_CYAN in result # cyan for path + assert "/usr/bin/tmux" in result + + +def test_debug_info_format_separator(colors_always: Colors) -> None: + """debug-info should use muted separators.""" + result = colors_always.format_separator() + assert ANSI_BLUE in result # blue for muted + assert "-" * 25 in result + + +# tmux option formatting + + +def test_debug_info_format_tmux_option_space_sep(colors_always: Colors) -> None: + """debug-info should format space-separated tmux options.""" + result = colors_always.format_tmux_option("status on") + assert ANSI_MAGENTA in result # magenta for key + assert ANSI_CYAN in result # cyan for value + assert "status" in result + assert "on" in result + + +def test_debug_info_format_tmux_option_equals_sep(colors_always: Colors) -> None: + """debug-info should format equals-separated tmux options.""" + result = colors_always.format_tmux_option("base-index=0") + assert ANSI_MAGENTA in result # magenta for key + assert ANSI_CYAN in result # cyan for value + assert "base-index" in result + assert "0" in result + + +# Color mode behavior + + +def test_debug_info_respects_never_mode(colors_never: Colors) -> None: + """debug-info should return plain text in NEVER mode.""" + result = colors_never.format_kv("tmux version", colors_never.format_version("3.2a")) + assert "\033[" not in result + assert result == "tmux version: 3.2a" + + +def test_debug_info_respects_no_color_env(monkeypatch: pytest.MonkeyPatch) -> None: + """debug-info should respect NO_COLOR environment variable.""" + monkeypatch.setenv("NO_COLOR", "1") + colors = Colors(ColorMode.ALWAYS) + + result = colors.format_kv("tmux path", "/usr/bin/tmux") + assert "\033[" not in result + assert result == "tmux path: /usr/bin/tmux" + + +# Combined formatting + + +def test_debug_info_combined_path_with_privacy( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: + """debug-info should combine privacy masking with color formatting.""" + # Simulate what debug-info does + raw_path = mock_home / "work/tmuxp/src/tmuxp" + private_path = str(PrivatePath(raw_path)) + formatted = colors_always.format_kv( + "tmuxp path", colors_always.format_path(private_path) + ) + + assert "~/work/tmuxp/src/tmuxp" in formatted + assert "/home/testuser" not in formatted + assert ANSI_CYAN in formatted # cyan for path + assert ANSI_MAGENTA in formatted # magenta for label + + +def test_debug_info_environment_section_format(colors_always: Colors) -> None: + """debug-info environment section should have proper format.""" + # Simulate environment section format + env_items = [ + f"\t{colors_always.format_kv('dist', 'Linux-6.6.87')}", + f"\t{colors_always.format_kv('arch', 'x86_64')}", + ] + section = f"{colors_always.format_label('environment')}:\n" + "\n".join(env_items) + + assert "environment" in section + assert "\t" in section # indented items + assert "dist" in section + assert "arch" in section diff --git a/tests/cli/test_edit_colors.py b/tests/cli/test_edit_colors.py new file mode 100644 index 0000000000..53c2663d80 --- /dev/null +++ b/tests/cli/test_edit_colors.py @@ -0,0 +1,101 @@ +"""Tests for CLI colors in edit command.""" + +from __future__ import annotations + +import pathlib + +from tests.cli.conftest import ANSI_BLUE, ANSI_BOLD, ANSI_CYAN, ANSI_MAGENTA +from tmuxp._internal.private_path import PrivatePath +from tmuxp.cli._colors import Colors + +# Edit command color output tests + + +def test_edit_opening_message_format(colors_always: Colors) -> None: + """Verify opening message format with file path and editor.""" + workspace_file = "/home/user/.tmuxp/dev.yaml" + editor = "vim" + output = ( + colors_always.muted("Opening ") + + colors_always.info(workspace_file) + + colors_always.muted(" in ") + + colors_always.highlight(editor, bold=False) + + colors_always.muted("...") + ) + # Should contain blue, cyan, and magenta ANSI codes + assert ANSI_BLUE in output # blue for muted + assert ANSI_CYAN in output # cyan for file path + assert ANSI_MAGENTA in output # magenta for editor + assert workspace_file in output + assert editor in output + + +def test_edit_file_path_uses_info(colors_always: Colors) -> None: + """Verify file paths use info color (cyan).""" + path = "/path/to/workspace.yaml" + result = colors_always.info(path) + assert ANSI_CYAN in result # cyan foreground + assert path in result + + +def test_edit_editor_highlighted(colors_always: Colors) -> None: + """Verify editor name uses highlight color without bold.""" + for editor in ["vim", "nano", "code", "emacs", "nvim"]: + result = colors_always.highlight(editor, bold=False) + assert ANSI_MAGENTA in result # magenta foreground + assert ANSI_BOLD not in result # no bold - subtle + assert editor in result + + +def test_edit_muted_for_static_text(colors_always: Colors) -> None: + """Verify static text uses muted color (blue).""" + result = colors_always.muted("Opening ") + assert ANSI_BLUE in result # blue foreground + assert "Opening" in result + + +def test_edit_colors_disabled_plain_text(colors_never: Colors) -> None: + """Verify disabled colors return plain text.""" + workspace_file = "/home/user/.tmuxp/dev.yaml" + editor = "vim" + output = ( + colors_never.muted("Opening ") + + colors_never.info(workspace_file) + + colors_never.muted(" in ") + + colors_never.highlight(editor, bold=False) + + colors_never.muted("...") + ) + # Should be plain text without ANSI codes + assert "\033[" not in output + assert output == f"Opening {workspace_file} in {editor}..." + + +def test_edit_various_editors(colors_always: Colors) -> None: + """Verify common editors can be highlighted.""" + editors = ["vim", "nvim", "nano", "code", "emacs", "hx", "micro"] + for editor in editors: + result = colors_always.highlight(editor, bold=False) + assert ANSI_MAGENTA in result + assert editor in result + + +# Privacy masking tests + + +def test_edit_masks_home_in_opening_message( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: + """Edit should mask home directory in 'Opening' message.""" + workspace_file = mock_home / ".tmuxp/dev.yaml" + editor = "vim" + output = ( + colors_always.muted("Opening ") + + colors_always.info(str(PrivatePath(workspace_file))) + + colors_always.muted(" in ") + + colors_always.highlight(editor, bold=False) + + colors_always.muted("...") + ) + + assert "~/.tmuxp/dev.yaml" in output + assert "/home/testuser" not in output diff --git a/tests/cli/test_formatter.py b/tests/cli/test_formatter.py new file mode 100644 index 0000000000..9902eb387d --- /dev/null +++ b/tests/cli/test_formatter.py @@ -0,0 +1,218 @@ +"""Tests for TmuxpHelpFormatter and themed formatter factory.""" + +from __future__ import annotations + +import argparse + +import pytest + +from tests.cli.conftest import ANSI_RESET +from tmuxp.cli._colors import ColorMode, Colors +from tmuxp.cli._formatter import ( + HelpTheme, + TmuxpHelpFormatter, + create_themed_formatter, +) + + +def test_create_themed_formatter_returns_subclass() -> None: + """Factory returns a TmuxpHelpFormatter subclass.""" + formatter_cls = create_themed_formatter() + assert issubclass(formatter_cls, TmuxpHelpFormatter) + + +def test_create_themed_formatter_with_colors_enabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Formatter has theme when colors enabled.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("test") + + assert formatter._theme is not None + assert formatter._theme.prog != "" # Has color codes + + +def test_create_themed_formatter_with_colors_disabled() -> None: + """Formatter has no theme when colors disabled.""" + colors = Colors(ColorMode.NEVER) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("test") + + assert formatter._theme is None + + +def test_create_themed_formatter_auto_mode_respects_no_color( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Auto mode respects NO_COLOR environment variable.""" + monkeypatch.setenv("NO_COLOR", "1") + formatter_cls = create_themed_formatter() + formatter = formatter_cls("test") + + assert formatter._theme is None + + +def test_create_themed_formatter_auto_mode_respects_force_color( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Auto mode respects FORCE_COLOR environment variable.""" + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.setenv("FORCE_COLOR", "1") + formatter_cls = create_themed_formatter() + formatter = formatter_cls("test") + + assert formatter._theme is not None + + +def test_fill_text_with_theme_colorizes_examples( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Examples section is colorized when theme is set.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("tmuxp") + + text = "Examples:\n tmuxp load myproject" + result = formatter._fill_text(text, 80, "") + + # Should contain ANSI escape codes + assert "\033[" in result + assert "tmuxp" in result + assert "load" in result + + +def test_fill_text_without_theme_plain_text() -> None: + """Examples section is plain text when no theme.""" + colors = Colors(ColorMode.NEVER) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("tmuxp") + + text = "Examples:\n tmuxp load myproject" + result = formatter._fill_text(text, 80, "") + + # Should NOT contain ANSI escape codes + assert "\033[" not in result + assert "tmuxp load myproject" in result + + +def test_fill_text_category_headings_colorized( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Category headings within examples block are colorized.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("tmuxp") + + # Test category heading without "examples:" suffix + text = "examples:\n tmuxp ls\n\nMachine-readable output:\n tmuxp ls --json" + result = formatter._fill_text(text, 80, "") + + # Both headings should be colorized + assert "\033[" in result + assert "examples:" in result + assert "Machine-readable output:" in result + # Commands should also be colorized + assert "tmuxp" in result + assert "--json" in result + + +def test_fill_text_category_heading_only_in_examples_block( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Category headings are only recognized within examples block.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + formatter_cls = create_themed_formatter(colors) + formatter = formatter_cls("tmuxp") + + # Text before examples block should not be colorized as heading + text = "Some heading:\n not a command\n\nexamples:\n tmuxp load" + result = formatter._fill_text(text, 80, "") + + # "Some heading:" should NOT be colorized (it's before examples block) + # "examples:" and the command should be colorized + lines = result.split("\n") + # First line should be plain (no ANSI in "Some heading:") + assert "Some heading:" in lines[0] + + +def test_parser_help_respects_no_color( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Parser --help output is plain when NO_COLOR set.""" + monkeypatch.setenv("NO_COLOR", "1") + monkeypatch.setenv("COLUMNS", "100") + + formatter_cls = create_themed_formatter() + parser = argparse.ArgumentParser( + prog="test", + description="Examples:\n test command", + formatter_class=formatter_cls, + ) + + with pytest.raises(SystemExit): + parser.parse_args(["--help"]) + + captured = capsys.readouterr() + assert "\033[" not in captured.out + + +def test_parser_help_colorized_with_force_color( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Parser --help output is colorized when FORCE_COLOR set.""" + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.setenv("FORCE_COLOR", "1") + monkeypatch.setenv("COLUMNS", "100") + + formatter_cls = create_themed_formatter() + parser = argparse.ArgumentParser( + prog="test", + description="Examples:\n test command", + formatter_class=formatter_cls, + ) + + with pytest.raises(SystemExit): + parser.parse_args(["--help"]) + + captured = capsys.readouterr() + assert "\033[" in captured.out + + +def test_help_theme_from_colors_with_none_returns_empty() -> None: + """HelpTheme.from_colors(None) returns empty theme.""" + theme = HelpTheme.from_colors(None) + + assert theme.prog == "" + assert theme.action == "" + assert theme.reset == "" + + +def test_help_theme_from_colors_disabled_returns_empty() -> None: + """HelpTheme.from_colors with disabled colors returns empty theme.""" + colors = Colors(ColorMode.NEVER) + theme = HelpTheme.from_colors(colors) + + assert theme.prog == "" + assert theme.action == "" + assert theme.reset == "" + + +def test_help_theme_from_colors_enabled_returns_colored( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """HelpTheme.from_colors with enabled colors returns colored theme.""" + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + theme = HelpTheme.from_colors(colors) + + # Should have ANSI codes + assert "\033[" in theme.prog + assert "\033[" in theme.action + assert theme.reset == ANSI_RESET diff --git a/tests/cli/test_freeze_colors.py b/tests/cli/test_freeze_colors.py new file mode 100644 index 0000000000..3d6afaa012 --- /dev/null +++ b/tests/cli/test_freeze_colors.py @@ -0,0 +1,152 @@ +"""Tests for CLI colors in freeze command.""" + +from __future__ import annotations + +import pathlib + +from tests.cli.conftest import ( + ANSI_BLUE, + ANSI_CYAN, + ANSI_GREEN, + ANSI_RED, + ANSI_RESET, + ANSI_YELLOW, +) +from tmuxp._internal.private_path import PrivatePath +from tmuxp.cli._colors import Colors + +# Freeze command color output tests + + +def test_freeze_error_uses_red(colors_always: Colors) -> None: + """Verify error messages use error color (red).""" + msg = "Session not found" + result = colors_always.error(msg) + assert ANSI_RED in result # red foreground + assert msg in result + assert result.endswith(ANSI_RESET) # reset at end + + +def test_freeze_success_message(colors_always: Colors) -> None: + """Verify success messages use success color (green).""" + result = colors_always.success("Saved to ") + assert ANSI_GREEN in result # green foreground + assert "Saved to" in result + + +def test_freeze_file_path_uses_info(colors_always: Colors) -> None: + """Verify file paths use info color (cyan).""" + path = "/path/to/config.yaml" + result = colors_always.info(path) + assert ANSI_CYAN in result # cyan foreground + assert path in result + + +def test_freeze_warning_file_exists(colors_always: Colors) -> None: + """Verify file exists warning uses warning color (yellow).""" + msg = "/path/to/config.yaml exists." + result = colors_always.warning(msg) + assert ANSI_YELLOW in result # yellow foreground + assert msg in result + + +def test_freeze_muted_for_secondary_text(colors_always: Colors) -> None: + """Verify secondary text uses muted color (blue).""" + msg = "Freeze does its best to snapshot live tmux sessions." + result = colors_always.muted(msg) + assert ANSI_BLUE in result # blue foreground + assert msg in result + + +def test_freeze_colors_disabled_plain_text(colors_never: Colors) -> None: + """Verify disabled colors return plain text.""" + assert colors_never.error("error") == "error" + assert colors_never.success("success") == "success" + assert colors_never.warning("warning") == "warning" + assert colors_never.info("info") == "info" + assert colors_never.muted("muted") == "muted" + + +def test_freeze_combined_output_format(colors_always: Colors) -> None: + """Verify combined success + info format for 'Saved to ' message.""" + dest = "/home/user/.tmuxp/session.yaml" + output = colors_always.success("Saved to ") + colors_always.info(dest) + "." + # Should contain both green and cyan ANSI codes + assert ANSI_GREEN in output # green for "Saved to" + assert ANSI_CYAN in output # cyan for path + assert "Saved to" in output + assert dest in output + assert output.endswith(".") + + +def test_freeze_warning_with_instructions(colors_always: Colors) -> None: + """Verify warning + muted format for file exists message.""" + path = "/path/to/config.yaml" + output = ( + colors_always.warning(f"{path} exists.") + + " " + + colors_always.muted("Pick a new filename.") + ) + # Should contain both yellow and blue ANSI codes + assert ANSI_YELLOW in output # yellow for warning + assert ANSI_BLUE in output # blue for muted + assert path in output + assert "Pick a new filename." in output + + +def test_freeze_url_highlighted_in_help(colors_always: Colors) -> None: + """Verify URLs use info color in help text.""" + url = "" + help_text = colors_always.muted("tmuxp has examples at ") + colors_always.info(url) + assert ANSI_BLUE in help_text # blue for muted text + assert ANSI_CYAN in help_text # cyan for URL + assert url in help_text + + +# Privacy masking tests + + +def test_freeze_masks_home_in_saved_message( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: + """Freeze should mask home directory in 'Saved to' message.""" + dest = mock_home / ".tmuxp/session.yaml" + output = ( + colors_always.success("Saved to ") + + colors_always.info(str(PrivatePath(dest))) + + "." + ) + + assert "~/.tmuxp/session.yaml" in output + assert "/home/testuser" not in output + + +def test_freeze_masks_home_in_exists_warning( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: + """Freeze should mask home directory in 'exists' warning.""" + dest_prompt = mock_home / ".tmuxp/session.yaml" + output = colors_always.warning(f"{PrivatePath(dest_prompt)} exists.") + + assert "~/.tmuxp/session.yaml exists." in output + assert "/home/testuser" not in output + + +def test_freeze_masks_home_in_save_to_prompt(mock_home: pathlib.Path) -> None: + """Freeze should mask home directory in 'Save to:' prompt.""" + save_to = mock_home / ".tmuxp/session.yaml" + prompt_text = f"Save to: {PrivatePath(save_to)}" + + assert "~/.tmuxp/session.yaml" in prompt_text + assert "/home/testuser" not in prompt_text + + +def test_freeze_masks_home_in_save_confirmation(mock_home: pathlib.Path) -> None: + """Freeze should mask home directory in 'Save to ...?' confirmation.""" + dest = mock_home / ".tmuxp/session.yaml" + prompt_text = f"Save to {PrivatePath(dest)}?" + + assert "~/.tmuxp/session.yaml" in prompt_text + assert "/home/testuser" not in prompt_text diff --git a/tests/cli/test_help_examples.py b/tests/cli/test_help_examples.py new file mode 100644 index 0000000000..85e8d2e879 --- /dev/null +++ b/tests/cli/test_help_examples.py @@ -0,0 +1,252 @@ +"""Tests to ensure CLI help examples are valid commands.""" + +from __future__ import annotations + +import argparse +import subprocess + +import pytest + +from tmuxp.cli import create_parser + + +def _get_help_text(subcommand: str | None = None) -> str: + """Get CLI help text without spawning subprocess. + + Parameters + ---------- + subcommand : str | None + Subcommand name, or None for main help. + + Returns + ------- + str + The formatted help text. + """ + parser = create_parser() + if subcommand is None: + return parser.format_help() + + # Access subparser via _subparsers._group_actions + subparsers = parser._subparsers + if subparsers is not None: + for action in subparsers._group_actions: + if isinstance(action, argparse._SubParsersAction): + choices = action.choices + if choices is not None and subcommand in choices: + return str(choices[subcommand].format_help()) + + return parser.format_help() + + +def extract_examples_from_help(help_text: str) -> list[str]: + r"""Extract example commands from help text. + + Parameters + ---------- + help_text : str + The help output text to extract examples from. + + Returns + ------- + list[str] + List of extracted example commands. + + Examples + -------- + >>> text = "load:\n tmuxp load myproject\n\npositions:" + >>> extract_examples_from_help(text) + ['tmuxp load myproject'] + + >>> text2 = "examples:\n tmuxp debug-info\n\noptions:" + >>> extract_examples_from_help(text2) + ['tmuxp debug-info'] + + >>> text3 = "Field-scoped search:\n tmuxp search window:editor" + >>> extract_examples_from_help(text3) + ['tmuxp search window:editor'] + """ + examples = [] + in_examples = False + for line in help_text.splitlines(): + # Match example section headings: + # - "examples:" (default examples section) + # - "load examples:" or "load:" (category headings) + # - "Field-scoped search:" (multi-word category headings) + # Exclude argparse sections like "positional arguments:", "options:" + stripped = line.strip() + is_section_heading = ( + stripped.endswith(":") + and stripped not in ("positional arguments:", "options:") + and not stripped.startswith("-") + ) + if is_section_heading: + in_examples = True + elif in_examples and line.startswith(" "): + cmd = line.strip() + if cmd.startswith("tmuxp"): + examples.append(cmd) + elif line and not line[0].isspace(): + in_examples = False + return examples + + +def test_main_help_has_examples() -> None: + """Main --help should have at least one example.""" + help_text = _get_help_text() + examples = extract_examples_from_help(help_text) + assert len(examples) > 0, "Main --help should have at least one example" + + +def test_main_help_examples_are_valid_subcommands() -> None: + """All examples in main --help should reference valid subcommands.""" + help_text = _get_help_text() + examples = extract_examples_from_help(help_text) + + # Extract valid subcommands from help output + valid_subcommands = { + "load", + "shell", + "import", + "convert", + "debug-info", + "ls", + "edit", + "freeze", + "search", + } + + for example in examples: + parts = example.split() + if len(parts) >= 2: + subcommand = parts[1] + assert subcommand in valid_subcommands, ( + f"Example '{example}' uses unknown subcommand '{subcommand}'" + ) + + +@pytest.mark.parametrize( + "subcommand", + [ + "load", + "shell", + "import", + "convert", + "debug-info", + "ls", + "edit", + "freeze", + "search", + ], +) +def test_subcommand_help_has_examples(subcommand: str) -> None: + """Each subcommand --help should have at least one example.""" + help_text = _get_help_text(subcommand) + examples = extract_examples_from_help(help_text) + assert len(examples) > 0, f"{subcommand} --help should have at least one example" + + +def test_load_subcommand_examples_are_valid() -> None: + """Load subcommand examples should have valid flags.""" + help_text = _get_help_text("load") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp load"), f"Bad example format: {example}" + + +def test_freeze_subcommand_examples_are_valid() -> None: + """Freeze subcommand examples should have valid flags.""" + help_text = _get_help_text("freeze") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp freeze"), f"Bad example format: {example}" + + +def test_shell_subcommand_examples_are_valid() -> None: + """Shell subcommand examples should have valid flags.""" + help_text = _get_help_text("shell") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp shell"), f"Bad example format: {example}" + + +def test_convert_subcommand_examples_are_valid() -> None: + """Convert subcommand examples should have valid flags.""" + help_text = _get_help_text("convert") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp convert"), f"Bad example format: {example}" + + +def test_import_subcommand_examples_are_valid() -> None: + """Import subcommand examples should have valid flags.""" + help_text = _get_help_text("import") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp import"), f"Bad example format: {example}" + + +def test_edit_subcommand_examples_are_valid() -> None: + """Edit subcommand examples should have valid flags.""" + help_text = _get_help_text("edit") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp edit"), f"Bad example format: {example}" + + +def test_ls_subcommand_examples_are_valid() -> None: + """Ls subcommand examples should have valid flags.""" + help_text = _get_help_text("ls") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp ls"), f"Bad example format: {example}" + + +def test_debug_info_subcommand_examples_are_valid() -> None: + """Debug-info subcommand examples should have valid flags.""" + help_text = _get_help_text("debug-info") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp debug-info"), f"Bad example format: {example}" + + +def test_search_subcommand_examples_are_valid() -> None: + """Search subcommand examples should have valid flags.""" + help_text = _get_help_text("search") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp search"), f"Bad example format: {example}" + + +def test_search_no_args_shows_help() -> None: + """Running 'tmuxp search' with no args shows help. + + Note: This test uses subprocess to verify actual CLI behavior and exit code. + """ + result = subprocess.run( + ["tmuxp", "search"], + capture_output=True, + text=True, + ) + # Should show help (usage line present) + assert "usage: tmuxp search" in result.stdout + # Should exit successfully (not error) + assert result.returncode == 0 diff --git a/tests/cli/test_import_colors.py b/tests/cli/test_import_colors.py new file mode 100644 index 0000000000..158e69b83f --- /dev/null +++ b/tests/cli/test_import_colors.py @@ -0,0 +1,143 @@ +"""Tests for CLI colors in import command.""" + +from __future__ import annotations + +import pathlib + +from tests.cli.conftest import ( + ANSI_BLUE, + ANSI_CYAN, + ANSI_GREEN, + ANSI_RED, + ANSI_RESET, +) +from tmuxp._internal.private_path import PrivatePath +from tmuxp.cli._colors import Colors + +# Import command color output tests + + +def test_import_error_unknown_format(colors_always: Colors) -> None: + """Verify unknown format error uses error color (red).""" + msg = "Unknown config format." + result = colors_always.error(msg) + assert ANSI_RED in result # red foreground + assert msg in result + assert result.endswith(ANSI_RESET) # reset at end + + +def test_import_success_message(colors_always: Colors) -> None: + """Verify success messages use success color (green).""" + result = colors_always.success("Saved to ") + assert ANSI_GREEN in result # green foreground + assert "Saved to" in result + + +def test_import_file_path_uses_info(colors_always: Colors) -> None: + """Verify file paths use info color (cyan).""" + path = "/path/to/config.yaml" + result = colors_always.info(path) + assert ANSI_CYAN in result # cyan foreground + assert path in result + + +def test_import_muted_for_banner(colors_always: Colors) -> None: + """Verify banner text uses muted color (blue).""" + msg = "Configuration import does its best to convert files." + result = colors_always.muted(msg) + assert ANSI_BLUE in result # blue foreground + assert msg in result + + +def test_import_muted_for_separator(colors_always: Colors) -> None: + """Verify separator uses muted color (blue).""" + separator = "---------------------------------------------------------------" + result = colors_always.muted(separator) + assert ANSI_BLUE in result # blue foreground + assert separator in result + + +def test_import_colors_disabled_plain_text(colors_never: Colors) -> None: + """Verify disabled colors return plain text.""" + assert colors_never.error("error") == "error" + assert colors_never.success("success") == "success" + assert colors_never.muted("muted") == "muted" + assert colors_never.info("info") == "info" + + +def test_import_combined_success_format(colors_always: Colors) -> None: + """Verify combined success + info format for 'Saved to ' message.""" + dest = "/home/user/.tmuxp/session.yaml" + output = colors_always.success("Saved to ") + colors_always.info(dest) + "." + # Should contain both green and cyan ANSI codes + assert ANSI_GREEN in output # green for "Saved to" + assert ANSI_CYAN in output # cyan for path + assert "Saved to" in output + assert dest in output + assert output.endswith(".") + + +def test_import_help_text_with_urls(colors_always: Colors) -> None: + """Verify help text uses muted for text and info for URLs.""" + url = "" + help_text = colors_always.muted( + "tmuxp has examples in JSON and YAML format at " + ) + colors_always.info(url) + assert ANSI_BLUE in help_text # blue for muted text + assert ANSI_CYAN in help_text # cyan for URL + assert url in help_text + + +def test_import_banner_with_separator(colors_always: Colors) -> None: + """Verify banner format with separator and instruction text.""" + config_content = "session_name: test\n" + separator = "---------------------------------------------------------------" + output = ( + config_content + + colors_always.muted(separator) + + "\n" + + colors_always.muted("Configuration import does its best to convert files.") + + "\n" + ) + # Should contain blue ANSI code for muted sections + assert ANSI_BLUE in output + assert separator in output + assert "Configuration import" in output + assert config_content in output + + +# Privacy masking tests + + +def test_import_masks_home_in_save_prompt(mock_home: pathlib.Path) -> None: + """Import should mask home directory in save prompt.""" + cwd = mock_home / "projects" + prompt = f"Save to [{PrivatePath(cwd)}]" + + assert "[~/projects]" in prompt + assert "/home/testuser" not in prompt + + +def test_import_masks_home_in_confirm_prompt(mock_home: pathlib.Path) -> None: + """Import should mask home directory in confirmation prompt.""" + dest_path = mock_home / ".tmuxp/imported.yaml" + prompt = f"Save to {PrivatePath(dest_path)}?" + + assert "~/.tmuxp/imported.yaml" in prompt + assert "/home/testuser" not in prompt + + +def test_import_masks_home_in_saved_message( + colors_always: Colors, + mock_home: pathlib.Path, +) -> None: + """Import should mask home directory in 'Saved to' message.""" + dest = mock_home / ".tmuxp/imported.yaml" + output = ( + colors_always.success("Saved to ") + + colors_always.info(str(PrivatePath(dest))) + + "." + ) + + assert "~/.tmuxp/imported.yaml" in output + assert "/home/testuser" not in output diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index e45bbc4f26..2191b7320c 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -16,6 +16,8 @@ from tests.fixtures import utils as test_utils from tmuxp import cli from tmuxp._internal.config_reader import ConfigReader +from tmuxp._internal.private_path import PrivatePath +from tmuxp.cli._colors import ColorMode, Colors from tmuxp.cli.load import ( _load_append_windows_to_current_session, _load_attached, @@ -751,3 +753,23 @@ def test_load_append_windows_to_current_session( assert len(server.sessions) == 1 assert len(server.windows) == 6 + + +# Privacy masking in load command + + +def test_load_masks_home_in_loading_message(monkeypatch: pytest.MonkeyPatch) -> None: + """Load command should mask home directory in [Loading] message.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) + monkeypatch.delenv("NO_COLOR", raising=False) + colors = Colors(ColorMode.ALWAYS) + + workspace_file = pathlib.Path("/home/testuser/work/project/.tmuxp.yaml") + output = ( + colors.info("[Loading]") + + " " + + colors.highlight(str(PrivatePath(workspace_file))) + ) + + assert "~/work/project/.tmuxp.yaml" in output + assert "/home/testuser" not in output diff --git a/tests/cli/test_ls.py b/tests/cli/test_ls.py index 1811e636c4..40e1526839 100644 --- a/tests/cli/test_ls.py +++ b/tests/cli/test_ls.py @@ -3,24 +3,117 @@ from __future__ import annotations import contextlib +import json import pathlib -import typing as t + +import pytest from tmuxp import cli +from tmuxp.cli.ls import ( + _get_workspace_info, + create_ls_subparser, +) + + +def test_get_workspace_info_yaml(tmp_path: pathlib.Path) -> None: + """Extract metadata from YAML workspace file.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("session_name: my-session\nwindows: []") + + info = _get_workspace_info(workspace) + + assert info["name"] == "test" + assert info["format"] == "yaml" + assert info["session_name"] == "my-session" + assert info["size"] > 0 + assert "T" in info["mtime"] # ISO format contains T + assert info["source"] == "global" # Default source + + +def test_get_workspace_info_source_local(tmp_path: pathlib.Path) -> None: + """Extract metadata with source=local.""" + workspace = tmp_path / ".tmuxp.yaml" + workspace.write_text("session_name: local-session\nwindows: []") + + info = _get_workspace_info(workspace, source="local") + + assert info["name"] == ".tmuxp" + assert info["source"] == "local" + assert info["session_name"] == "local-session" + + +def test_get_workspace_info_json(tmp_path: pathlib.Path) -> None: + """Extract metadata from JSON workspace file.""" + workspace = tmp_path / "test.json" + workspace.write_text('{"session_name": "json-session", "windows": []}') + + info = _get_workspace_info(workspace) + + assert info["name"] == "test" + assert info["format"] == "json" + assert info["session_name"] == "json-session" + + +def test_get_workspace_info_no_session_name(tmp_path: pathlib.Path) -> None: + """Handle workspace without session_name.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("windows: []") + + info = _get_workspace_info(workspace) + + assert info["name"] == "test" + assert info["session_name"] is None -if t.TYPE_CHECKING: - import pytest + +def test_get_workspace_info_invalid_yaml(tmp_path: pathlib.Path) -> None: + """Handle invalid YAML gracefully.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("{{{{invalid yaml") + + info = _get_workspace_info(workspace) + + assert info["name"] == "test" + assert info["session_name"] is None # Couldn't parse, so None + + +def test_ls_subparser_adds_tree_flag() -> None: + """Verify --tree argument is added.""" + import argparse + + parser = argparse.ArgumentParser() + create_ls_subparser(parser) + args = parser.parse_args(["--tree"]) + + assert args.tree is True + + +def test_ls_subparser_adds_json_flag() -> None: + """Verify --json argument is added.""" + import argparse + + parser = argparse.ArgumentParser() + create_ls_subparser(parser) + args = parser.parse_args(["--json"]) + + assert args.output_json is True + + +def test_ls_subparser_adds_ndjson_flag() -> None: + """Verify --ndjson argument is added.""" + import argparse + + parser = argparse.ArgumentParser() + create_ls_subparser(parser) + args = parser.parse_args(["--ndjson"]) + + assert args.output_ndjson is True def test_ls_cli( - monkeypatch: pytest.MonkeyPatch, - tmp_path: pathlib.Path, + isolated_home: pathlib.Path, capsys: pytest.CaptureFixture[str], ) -> None: """CLI test for tmuxp ls.""" - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) - filenames = [ ".git/", ".gitignore/", @@ -37,15 +130,615 @@ def test_ls_cli( stems = [pathlib.Path(f).stem for f in filenames if f not in ignored_filenames] for filename in filenames: - location = tmp_path / f".tmuxp/{filename}" + location = isolated_home / f".tmuxp/{filename}" if filename.endswith("/"): location.mkdir(parents=True) else: location.touch() with contextlib.suppress(SystemExit): - cli.cli(["ls"]) + cli.cli(["--color=never", "ls"]) cli_output = capsys.readouterr().out - assert cli_output == "\n".join(stems) + "\n" + # Output now has headers with directory path, check for workspace names + assert "Global workspaces (~/.tmuxp):" in cli_output + for stem in stems: + assert stem in cli_output + + +def test_ls_json_output( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """CLI test for tmuxp ls --json.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text("session_name: development\nwindows: []") + (tmuxp_dir / "prod.json").write_text('{"session_name": "production"}') + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--json"]) + + output = capsys.readouterr().out + data = json.loads(output) + + # JSON output is now an object with workspaces and global_workspace_dirs + assert isinstance(data, dict) + assert "workspaces" in data + assert "global_workspace_dirs" in data + + workspaces = data["workspaces"] + assert len(workspaces) == 2 + + names = {item["name"] for item in workspaces} + assert names == {"dev", "prod"} + + # Verify all expected fields are present + for item in workspaces: + assert "name" in item + assert "path" in item + assert "format" in item + assert "size" in item + assert "mtime" in item + assert "session_name" in item + assert "source" in item + assert item["source"] == "global" + + +def test_ls_ndjson_output( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """CLI test for tmuxp ls --ndjson.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "ws1.yaml").write_text("session_name: s1\nwindows: []") + (tmuxp_dir / "ws2.yaml").write_text("session_name: s2\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--ndjson"]) + + output = capsys.readouterr().out + lines = [line for line in output.strip().split("\n") if line] + + assert len(lines) == 2 + + # Each line should be valid JSON + for line in lines: + data = json.loads(line) + assert "name" in data + assert "session_name" in data + assert "source" in data + + +def test_ls_tree_output( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """CLI test for tmuxp ls --tree.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text("session_name: development\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--tree"]) + + output = capsys.readouterr().out + + # Tree mode shows directory header + assert "~/.tmuxp" in output + # And indented workspace name + assert "dev" in output + + +def test_ls_empty_directory( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """CLI test for tmuxp ls with no workspaces.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + assert "No workspaces found" in output + + +def test_ls_tree_shows_session_name_if_different( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Tree mode shows session_name if it differs from file name.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + # File named "myfile" but session is "actual-session" + (tmuxp_dir / "myfile.yaml").write_text("session_name: actual-session\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--tree"]) + + output = capsys.readouterr().out + + assert "myfile" in output + assert "actual-session" in output + + +def test_ls_finds_local_workspace_in_cwd( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Ls should find .tmuxp.yaml in current directory.""" + home = tmp_path / "home" + project = home / "project" + project.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(project) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + (project / ".tmuxp.yaml").write_text("session_name: local\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + assert "Local workspaces:" in output + assert ".tmuxp" in output + + +def test_ls_finds_local_workspace_in_parent( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Ls should find .tmuxp.yaml in parent directory.""" + home = tmp_path / "home" + project = home / "project" + subdir = project / "src" / "module" + subdir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(subdir) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + (project / ".tmuxp.yaml").write_text("session_name: parent-local\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + assert "Local workspaces:" in output + assert ".tmuxp" in output + + +def test_ls_shows_local_and_global( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Ls should show both local and global workspaces.""" + home = tmp_path / "home" + project = home / "project" + project.mkdir(parents=True) + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(project) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + # Local workspace + (project / ".tmuxp.yaml").write_text("session_name: local\nwindows: []") + # Global workspace + (tmuxp_dir / "global.yaml").write_text("session_name: global\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + assert "Local workspaces:" in output + assert "Global workspaces (~/.tmuxp):" in output + assert ".tmuxp" in output + assert "global" in output + + +def test_ls_json_includes_source_for_local( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON output should include source=local for local workspaces.""" + home = tmp_path / "home" + project = home / "project" + project.mkdir(parents=True) + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(project) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + (project / ".tmuxp.yaml").write_text("session_name: local\nwindows: []") + (tmuxp_dir / "global.yaml").write_text("session_name: global\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--json"]) + + output = capsys.readouterr().out + data = json.loads(output) + + # JSON output is now an object with workspaces and global_workspace_dirs + assert isinstance(data, dict) + workspaces = data["workspaces"] + + sources = {item["source"] for item in workspaces} + assert sources == {"local", "global"} + + local_items = [item for item in workspaces if item["source"] == "local"] + global_items = [item for item in workspaces if item["source"] == "global"] + + assert len(local_items) == 1 + assert len(global_items) == 1 + assert local_items[0]["session_name"] == "local" + assert global_items[0]["session_name"] == "global" + + +def test_ls_local_shows_path( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Local workspaces should show their path in flat mode.""" + home = tmp_path / "home" + project = home / "project" + project.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(project) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + (project / ".tmuxp.yaml").write_text("session_name: local\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + # Local workspace output shows path (with ~ contraction) + assert "~/project/.tmuxp.yaml" in output + + +def test_ls_full_flag_subparser() -> None: + """Verify --full argument is added to subparser.""" + import argparse + + from tmuxp.cli.ls import create_ls_subparser + + parser = argparse.ArgumentParser() + create_ls_subparser(parser) + args = parser.parse_args(["--full"]) + + assert args.full is True + + +def test_get_workspace_info_include_config(tmp_path: pathlib.Path) -> None: + """Test _get_workspace_info with include_config=True.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("session_name: test\nwindows:\n - window_name: editor\n") + + info = _get_workspace_info(workspace, include_config=True) + + assert "config" in info + assert info["config"]["session_name"] == "test" + assert len(info["config"]["windows"]) == 1 + + +def test_get_workspace_info_no_config_by_default(tmp_path: pathlib.Path) -> None: + """Test _get_workspace_info without include_config doesn't include config.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("session_name: test\nwindows: []\n") + + info = _get_workspace_info(workspace) + + assert "config" not in info + + +def test_ls_json_full_includes_config( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON output with --full includes config content.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text( + "session_name: dev\n" + "windows:\n" + " - window_name: editor\n" + " panes:\n" + " - vim\n" + ) + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--json", "--full"]) + + output = capsys.readouterr().out + data = json.loads(output) + + # JSON output is now an object with workspaces and global_workspace_dirs + assert isinstance(data, dict) + workspaces = data["workspaces"] + + assert len(workspaces) == 1 + assert "config" in workspaces[0] + assert workspaces[0]["config"]["session_name"] == "dev" + assert workspaces[0]["config"]["windows"][0]["window_name"] == "editor" + + +def test_ls_full_tree_shows_windows( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Tree mode with --full shows window/pane hierarchy.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text( + "session_name: dev\n" + "windows:\n" + " - window_name: editor\n" + " layout: main-horizontal\n" + " panes:\n" + " - vim\n" + " - window_name: shell\n" + ) + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--tree", "--full"]) + + output = capsys.readouterr().out + + assert "dev" in output + assert "editor" in output + assert "main-horizontal" in output + assert "shell" in output + assert "pane 0" in output + + +def test_ls_full_flat_shows_windows( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Flat mode with --full shows window/pane hierarchy.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text( + "session_name: dev\nwindows:\n - window_name: code\n panes:\n - nvim\n" + ) + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--full"]) + + output = capsys.readouterr().out + + assert "Global workspaces (~/.tmuxp):" in output + assert "dev" in output + assert "code" in output + assert "pane 0" in output + + +def test_ls_full_without_json_no_config_in_output( + isolated_home: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Non-JSON with --full shows tree but not raw config.""" + tmuxp_dir = isolated_home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + (tmuxp_dir / "dev.yaml").write_text( + "session_name: dev\nwindows:\n - window_name: editor\n" + ) + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--full"]) + + output = capsys.readouterr().out + + # Should show tree structure, not raw config keys + assert "editor" in output + assert "session_name:" not in output # Raw YAML not in output + + +def test_ls_shows_global_workspace_dirs_section( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Human output shows global workspace directories section.""" + home = tmp_path / "home" + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + (tmuxp_dir / "workspace.yaml").write_text("session_name: test\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + + assert "Global workspace directories:" in output + assert "Legacy: ~/.tmuxp" in output + assert "1 workspace" in output + assert "active" in output + assert "~/.config/tmuxp" in output + assert "not found" in output + + +def test_ls_global_header_shows_active_dir( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Global workspaces header shows active directory path.""" + home = tmp_path / "home" + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + (tmuxp_dir / "workspace.yaml").write_text("session_name: test\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + + # Header should include the active directory + assert "Global workspaces (~/.tmuxp):" in output + + +def test_ls_json_includes_global_workspace_dirs( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON output includes global_workspace_dirs array.""" + home = tmp_path / "home" + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + (tmuxp_dir / "workspace.yaml").write_text("session_name: test\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--json"]) + + output = capsys.readouterr().out + data = json.loads(output) + + # JSON should be an object with workspaces and global_workspace_dirs + assert isinstance(data, dict) + assert "workspaces" in data + assert "global_workspace_dirs" in data + + # Check global_workspace_dirs structure + dirs = data["global_workspace_dirs"] + assert isinstance(dirs, list) + assert len(dirs) >= 1 + + for d in dirs: + assert "path" in d + assert "source" in d + assert "exists" in d + assert "workspace_count" in d + assert "active" in d + + # Find the active one + active_dirs = [d for d in dirs if d["active"]] + assert len(active_dirs) == 1 + assert active_dirs[0]["path"] == "~/.tmuxp" + assert active_dirs[0]["exists"] is True + assert active_dirs[0]["workspace_count"] == 1 + + +def test_ls_json_empty_still_has_global_workspace_dirs( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON output with no workspaces still includes global_workspace_dirs.""" + home = tmp_path / "home" + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) # Empty directory + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + with contextlib.suppress(SystemExit): + cli.cli(["ls", "--json"]) + + output = capsys.readouterr().out + data = json.loads(output) + + assert "workspaces" in data + assert "global_workspace_dirs" in data + assert len(data["workspaces"]) == 0 + assert len(data["global_workspace_dirs"]) >= 1 + + +def test_ls_xdg_takes_precedence_in_header( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """When XDG dir exists, it shows in header instead of ~/.tmuxp.""" + home = tmp_path / "home" + xdg_tmuxp = home / ".config" / "tmuxp" + xdg_tmuxp.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + (xdg_tmuxp / "workspace.yaml").write_text("session_name: test\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls"]) + + output = capsys.readouterr().out + + # Header should show XDG path when it's active + assert "Global workspaces (~/.config/tmuxp):" in output + + +def test_ls_tree_shows_global_workspace_dirs( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Tree mode also shows global workspace directories section.""" + home = tmp_path / "home" + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.chdir(home) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + + (tmuxp_dir / "workspace.yaml").write_text("session_name: test\nwindows: []") + + with contextlib.suppress(SystemExit): + cli.cli(["--color=never", "ls", "--tree"]) + + output = capsys.readouterr().out + + assert "Global workspace directories:" in output + assert "Legacy: ~/.tmuxp" in output + assert "active" in output diff --git a/tests/cli/test_output.py b/tests/cli/test_output.py new file mode 100644 index 0000000000..3112f60d02 --- /dev/null +++ b/tests/cli/test_output.py @@ -0,0 +1,242 @@ +"""Tests for output formatting utilities.""" + +from __future__ import annotations + +import io +import json +import sys + +import pytest + +from tmuxp.cli._output import OutputFormatter, OutputMode, get_output_mode + + +def test_output_mode_values() -> None: + """Verify OutputMode enum values.""" + assert OutputMode.HUMAN.value == "human" + assert OutputMode.JSON.value == "json" + assert OutputMode.NDJSON.value == "ndjson" + + +def test_output_mode_members() -> None: + """Verify all expected members exist.""" + members = list(OutputMode) + assert len(members) == 3 + assert OutputMode.HUMAN in members + assert OutputMode.JSON in members + assert OutputMode.NDJSON in members + + +def test_get_output_mode_default_is_human() -> None: + """Default mode should be HUMAN when no flags.""" + assert get_output_mode(json_flag=False, ndjson_flag=False) == OutputMode.HUMAN + + +def test_get_output_mode_json_flag() -> None: + """JSON flag should return JSON mode.""" + assert get_output_mode(json_flag=True, ndjson_flag=False) == OutputMode.JSON + + +def test_get_output_mode_ndjson_flag() -> None: + """NDJSON flag should return NDJSON mode.""" + assert get_output_mode(json_flag=False, ndjson_flag=True) == OutputMode.NDJSON + + +def test_get_output_mode_ndjson_takes_precedence() -> None: + """NDJSON should take precedence when both flags set.""" + assert get_output_mode(json_flag=True, ndjson_flag=True) == OutputMode.NDJSON + + +def test_output_formatter_default_mode_is_human() -> None: + """Default mode should be HUMAN.""" + formatter = OutputFormatter() + assert formatter.mode == OutputMode.HUMAN + + +def test_output_formatter_explicit_mode() -> None: + """Mode can be set explicitly.""" + formatter = OutputFormatter(OutputMode.JSON) + assert formatter.mode == OutputMode.JSON + + +def test_output_formatter_json_buffer_initially_empty() -> None: + """JSON buffer should start empty.""" + formatter = OutputFormatter(OutputMode.JSON) + assert formatter._json_buffer == [] + + +def test_emit_json_buffers_data() -> None: + """JSON mode should buffer data.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.emit({"name": "test1"}) + formatter.emit({"name": "test2"}) + assert len(formatter._json_buffer) == 2 + assert formatter._json_buffer[0] == {"name": "test1"} + assert formatter._json_buffer[1] == {"name": "test2"} + + +def test_emit_human_does_nothing() -> None: + """HUMAN mode emit should not buffer or output.""" + formatter = OutputFormatter(OutputMode.HUMAN) + formatter.emit({"name": "test"}) + assert formatter._json_buffer == [] + + +def test_emit_ndjson_writes_immediately(capsys: pytest.CaptureFixture[str]) -> None: + """NDJSON mode should write one JSON object per line immediately.""" + formatter = OutputFormatter(OutputMode.NDJSON) + formatter.emit({"name": "test1", "value": 42}) + formatter.emit({"name": "test2", "value": 43}) + + captured = capsys.readouterr() + lines = captured.out.strip().split("\n") + assert len(lines) == 2 + assert json.loads(lines[0]) == {"name": "test1", "value": 42} + assert json.loads(lines[1]) == {"name": "test2", "value": 43} + + +def test_emit_text_human_outputs(capsys: pytest.CaptureFixture[str]) -> None: + """HUMAN mode should output text.""" + formatter = OutputFormatter(OutputMode.HUMAN) + formatter.emit_text("Hello, world!") + + captured = capsys.readouterr() + assert captured.out == "Hello, world!\n" + + +def test_emit_text_json_silent(capsys: pytest.CaptureFixture[str]) -> None: + """JSON mode should not output text.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.emit_text("Hello, world!") + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_emit_text_ndjson_silent(capsys: pytest.CaptureFixture[str]) -> None: + """NDJSON mode should not output text.""" + formatter = OutputFormatter(OutputMode.NDJSON) + formatter.emit_text("Hello, world!") + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_finalize_json_outputs_array(capsys: pytest.CaptureFixture[str]) -> None: + """JSON mode finalize should output formatted array.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.emit({"name": "test1"}) + formatter.emit({"name": "test2"}) + formatter.finalize() + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert isinstance(data, list) + assert len(data) == 2 + assert data[0] == {"name": "test1"} + assert data[1] == {"name": "test2"} + + +def test_finalize_json_clears_buffer() -> None: + """JSON mode finalize should clear the buffer.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.emit({"name": "test"}) + assert len(formatter._json_buffer) == 1 + + # Capture output to prevent test pollution + old_stdout = sys.stdout + sys.stdout = io.StringIO() + try: + formatter.finalize() + finally: + sys.stdout = old_stdout + + assert formatter._json_buffer == [] + + +def test_finalize_json_empty_buffer_no_output( + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON mode finalize with empty buffer should not output.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.finalize() + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_finalize_human_no_op(capsys: pytest.CaptureFixture[str]) -> None: + """HUMAN mode finalize should do nothing.""" + formatter = OutputFormatter(OutputMode.HUMAN) + formatter.finalize() + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_finalize_ndjson_no_op(capsys: pytest.CaptureFixture[str]) -> None: + """NDJSON mode finalize should do nothing (already streamed).""" + formatter = OutputFormatter(OutputMode.NDJSON) + formatter.finalize() + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_json_workflow(capsys: pytest.CaptureFixture[str]) -> None: + """Test complete JSON output workflow.""" + formatter = OutputFormatter(OutputMode.JSON) + + # Emit several records + formatter.emit({"name": "workspace1", "path": "/path/1"}) + formatter.emit({"name": "workspace2", "path": "/path/2"}) + + # Nothing output yet + captured = capsys.readouterr() + assert captured.out == "" + + # Finalize outputs everything + formatter.finalize() + captured = capsys.readouterr() + data = json.loads(captured.out) + assert len(data) == 2 + + +def test_ndjson_workflow(capsys: pytest.CaptureFixture[str]) -> None: + """Test complete NDJSON output workflow.""" + formatter = OutputFormatter(OutputMode.NDJSON) + + # Each emit outputs immediately + formatter.emit({"name": "workspace1"}) + captured = capsys.readouterr() + assert json.loads(captured.out.strip()) == {"name": "workspace1"} + + formatter.emit({"name": "workspace2"}) + captured = capsys.readouterr() + assert json.loads(captured.out.strip()) == {"name": "workspace2"} + + # Finalize is no-op + formatter.finalize() + captured = capsys.readouterr() + assert captured.out == "" + + +def test_human_workflow(capsys: pytest.CaptureFixture[str]) -> None: + """Test complete HUMAN output workflow.""" + formatter = OutputFormatter(OutputMode.HUMAN) + + # emit does nothing in human mode + formatter.emit({"name": "ignored"}) + + # emit_text outputs text + formatter.emit_text("Workspace: test") + formatter.emit_text(" Path: /path/to/test") + + captured = capsys.readouterr() + assert "Workspace: test" in captured.out + assert "Path: /path/to/test" in captured.out + + # Finalize is no-op + formatter.finalize() + captured = capsys.readouterr() + assert captured.out == "" diff --git a/tests/cli/test_prompt_colors.py b/tests/cli/test_prompt_colors.py new file mode 100644 index 0000000000..65a7093f65 --- /dev/null +++ b/tests/cli/test_prompt_colors.py @@ -0,0 +1,156 @@ +"""Tests for colored prompt utilities.""" + +from __future__ import annotations + +import pathlib + +import pytest + +from tests.cli.conftest import ANSI_BLUE, ANSI_CYAN, ANSI_RESET +from tmuxp.cli._colors import ColorMode, Colors + + +def test_prompt_bool_choice_indicator_muted(colors_always: Colors) -> None: + """Verify [Y/n] uses muted color (blue).""" + # Test the muted color is applied to choice indicators + result = colors_always.muted("[Y/n]") + assert ANSI_BLUE in result # blue foreground + assert "[Y/n]" in result + assert result.endswith(ANSI_RESET) + + +def test_prompt_bool_choice_indicator_variants(colors_always: Colors) -> None: + """Verify all choice indicator variants are colored.""" + for indicator in ["[Y/n]", "[y/N]", "[y/n]"]: + result = colors_always.muted(indicator) + assert ANSI_BLUE in result + assert indicator in result + + +def test_prompt_default_value_uses_info(colors_always: Colors) -> None: + """Verify default path uses info color (cyan).""" + path = "/home/user/.tmuxp/session.yaml" + result = colors_always.info(f"[{path}]") + assert ANSI_CYAN in result # cyan foreground + assert path in result + assert result.endswith(ANSI_RESET) + + +def test_prompt_choices_list_muted(colors_always: Colors) -> None: + """Verify (yaml, json) uses muted color (blue).""" + choices = "(yaml, json)" + result = colors_always.muted(choices) + assert ANSI_BLUE in result # blue foreground + assert choices in result + + +def test_prompts_respect_no_color_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify NO_COLOR disables prompt colors.""" + monkeypatch.setenv("NO_COLOR", "1") + colors = Colors(ColorMode.AUTO) + + assert colors.muted("[Y/n]") == "[Y/n]" + assert colors.info("[default]") == "[default]" + + +def test_prompt_combined_format(colors_always: Colors) -> None: + """Verify combined prompt format with choices and default.""" + name = "Convert to" + choices_str = colors_always.muted("(yaml, json)") + default_str = colors_always.info("[yaml]") + prompt = f"{name} - {choices_str} {default_str}" + + # Should contain both blue (muted) and cyan (info) ANSI codes + assert ANSI_BLUE in prompt # blue for choices + assert ANSI_CYAN in prompt # cyan for default + assert "Convert to" in prompt + assert "yaml, json" in prompt + + +def test_prompt_colors_disabled_returns_plain_text(colors_never: Colors) -> None: + """Verify disabled colors return plain text without ANSI codes.""" + assert colors_never.muted("[Y/n]") == "[Y/n]" + assert colors_never.info("[/path/to/file]") == "[/path/to/file]" + assert "\033[" not in colors_never.muted("test") + assert "\033[" not in colors_never.info("test") + + +def test_prompt_empty_input_no_default_reprompts( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify prompt() re-prompts when user enters empty input with no default. + + This is a regression test for the bug where pressing Enter with no default + would cause an AssertionError instead of re-prompting. + """ + from tmuxp.cli.utils import prompt + + # Simulate: first input is empty (user presses Enter), second input is valid + inputs = iter(["", "valid_input"]) + monkeypatch.setattr("builtins.input", lambda _: next(inputs)) + + result = prompt("Enter value") + assert result == "valid_input" + + +def test_prompt_empty_input_with_value_proc_no_crash( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify prompt() with value_proc doesn't crash on empty input. + + This is a regression test for the AssertionError that occurred when + value_proc was provided but input was empty and no default was set. + """ + from tmuxp.cli.utils import prompt + + def validate_path(val: str) -> str: + """Validate that path is absolute.""" + if not val.startswith("/"): + msg = "Must be absolute path" + raise ValueError(msg) + return val + + # Simulate: first input is empty, second input is valid + inputs = iter(["", "/valid/path"]) + monkeypatch.setattr("builtins.input", lambda _: next(inputs)) + + result = prompt("Enter path", value_proc=validate_path) + assert result == "/valid/path" + + +def test_prompt_default_uses_private_path( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, +) -> None: + """Verify prompt() masks home directory in default value display. + + The displayed default should use PrivatePath to show ~ instead of + the full home directory path. + """ + import pathlib + + from tmuxp.cli.utils import prompt + + # Create a path under the user's home directory + home = pathlib.Path.home() + test_path = str(home / ".tmuxp" / "session.yaml") + + # Capture what prompt displays + displayed_prompt = None + + def capture_input(prompt_text: str) -> str: + nonlocal displayed_prompt + displayed_prompt = prompt_text + return "" # User presses Enter, accepting default + + monkeypatch.setattr("builtins.input", capture_input) + + result = prompt("Save to", default=test_path) + + # The result should be the original path (for actual saving) + assert result == test_path + + # The displayed prompt should use ~ instead of full home path + assert displayed_prompt is not None + assert "~/.tmuxp/session.yaml" in displayed_prompt + assert str(home) not in displayed_prompt diff --git a/tests/cli/test_search.py b/tests/cli/test_search.py new file mode 100644 index 0000000000..e4140b2645 --- /dev/null +++ b/tests/cli/test_search.py @@ -0,0 +1,865 @@ +"""CLI tests for tmuxp search command.""" + +from __future__ import annotations + +import json +import pathlib +import re +import typing as t + +import pytest + +from tmuxp.cli._colors import ColorMode, Colors +from tmuxp.cli._output import OutputFormatter, OutputMode +from tmuxp.cli.search import ( + DEFAULT_FIELDS, + InvalidFieldError, + SearchPattern, + SearchToken, + WorkspaceFields, + WorkspaceSearchResult, + _get_field_values, + _output_search_results, + compile_search_patterns, + create_search_subparser, + evaluate_match, + extract_workspace_fields, + find_search_matches, + highlight_matches, + normalize_fields, + parse_query_terms, +) + + +class NormalizeFieldsFixture(t.NamedTuple): + """Test fixture for normalize_fields.""" + + test_id: str + fields: list[str] | None + expected: tuple[str, ...] + raises: type[Exception] | None + + +NORMALIZE_FIELDS_FIXTURES: list[NormalizeFieldsFixture] = [ + NormalizeFieldsFixture( + test_id="none_returns_defaults", + fields=None, + expected=DEFAULT_FIELDS, + raises=None, + ), + NormalizeFieldsFixture( + test_id="name_alias", + fields=["n"], + expected=("name",), + raises=None, + ), + NormalizeFieldsFixture( + test_id="session_aliases", + fields=["s", "session", "session_name"], + expected=("session_name",), + raises=None, + ), + NormalizeFieldsFixture( + test_id="path_alias", + fields=["p"], + expected=("path",), + raises=None, + ), + NormalizeFieldsFixture( + test_id="window_alias", + fields=["w"], + expected=("window",), + raises=None, + ), + NormalizeFieldsFixture( + test_id="multiple_fields", + fields=["name", "s", "window"], + expected=("name", "session_name", "window"), + raises=None, + ), + NormalizeFieldsFixture( + test_id="invalid_field", + fields=["invalid"], + expected=(), + raises=InvalidFieldError, + ), + NormalizeFieldsFixture( + test_id="case_insensitive", + fields=["NAME", "Session"], + expected=("name", "session_name"), + raises=None, + ), +] + + +@pytest.mark.parametrize( + NormalizeFieldsFixture._fields, + NORMALIZE_FIELDS_FIXTURES, + ids=[test.test_id for test in NORMALIZE_FIELDS_FIXTURES], +) +def test_normalize_fields( + test_id: str, + fields: list[str] | None, + expected: tuple[str, ...], + raises: type[Exception] | None, +) -> None: + """Test normalize_fields function.""" + if raises: + with pytest.raises(raises): + normalize_fields(fields) + else: + result = normalize_fields(fields) + assert result == expected + + +class ParseQueryTermsFixture(t.NamedTuple): + """Test fixture for parse_query_terms.""" + + test_id: str + terms: list[str] + expected_count: int + expected_first_fields: tuple[str, ...] | None + expected_first_pattern: str | None + + +PARSE_QUERY_TERMS_FIXTURES: list[ParseQueryTermsFixture] = [ + ParseQueryTermsFixture( + test_id="simple_term", + terms=["dev"], + expected_count=1, + expected_first_fields=DEFAULT_FIELDS, + expected_first_pattern="dev", + ), + ParseQueryTermsFixture( + test_id="name_prefix", + terms=["name:dev"], + expected_count=1, + expected_first_fields=("name",), + expected_first_pattern="dev", + ), + ParseQueryTermsFixture( + test_id="session_prefix", + terms=["s:production"], + expected_count=1, + expected_first_fields=("session_name",), + expected_first_pattern="production", + ), + ParseQueryTermsFixture( + test_id="multiple_terms", + terms=["dev", "production"], + expected_count=2, + expected_first_fields=DEFAULT_FIELDS, + expected_first_pattern="dev", + ), + ParseQueryTermsFixture( + test_id="url_not_field", + terms=["http://example.com"], + expected_count=1, + expected_first_fields=DEFAULT_FIELDS, + expected_first_pattern="http://example.com", + ), + ParseQueryTermsFixture( + test_id="empty_pattern_skipped", + terms=["name:"], + expected_count=0, + expected_first_fields=None, + expected_first_pattern=None, + ), + ParseQueryTermsFixture( + test_id="path_with_colons", + terms=["path:/home/user/project"], + expected_count=1, + expected_first_fields=("path",), + expected_first_pattern="/home/user/project", + ), +] + + +@pytest.mark.parametrize( + ParseQueryTermsFixture._fields, + PARSE_QUERY_TERMS_FIXTURES, + ids=[test.test_id for test in PARSE_QUERY_TERMS_FIXTURES], +) +def test_parse_query_terms( + test_id: str, + terms: list[str], + expected_count: int, + expected_first_fields: tuple[str, ...] | None, + expected_first_pattern: str | None, +) -> None: + """Test parse_query_terms function.""" + result = parse_query_terms(terms) + + assert len(result) == expected_count + + if expected_count > 0: + assert result[0].fields == expected_first_fields + assert result[0].pattern == expected_first_pattern + + +class CompileSearchPatternsFixture(t.NamedTuple): + """Test fixture for compile_search_patterns.""" + + test_id: str + pattern: str + ignore_case: bool + smart_case: bool + fixed_strings: bool + word_regexp: bool + test_string: str + should_match: bool + + +COMPILE_SEARCH_PATTERNS_FIXTURES: list[CompileSearchPatternsFixture] = [ + CompileSearchPatternsFixture( + test_id="basic_match", + pattern="dev", + ignore_case=False, + smart_case=False, + fixed_strings=False, + word_regexp=False, + test_string="development", + should_match=True, + ), + CompileSearchPatternsFixture( + test_id="case_sensitive_no_match", + pattern="DEV", + ignore_case=False, + smart_case=False, + fixed_strings=False, + word_regexp=False, + test_string="development", + should_match=False, + ), + CompileSearchPatternsFixture( + test_id="ignore_case_match", + pattern="DEV", + ignore_case=True, + smart_case=False, + fixed_strings=False, + word_regexp=False, + test_string="development", + should_match=True, + ), + CompileSearchPatternsFixture( + test_id="smart_case_lowercase", + pattern="dev", + ignore_case=False, + smart_case=True, + fixed_strings=False, + word_regexp=False, + test_string="DEVELOPMENT", + should_match=True, + ), + CompileSearchPatternsFixture( + test_id="smart_case_uppercase_no_match", + pattern="Dev", + ignore_case=False, + smart_case=True, + fixed_strings=False, + word_regexp=False, + test_string="development", + should_match=False, + ), + CompileSearchPatternsFixture( + test_id="fixed_strings_literal", + pattern="dev.*", + ignore_case=False, + smart_case=False, + fixed_strings=True, + word_regexp=False, + test_string="dev.*project", + should_match=True, + ), + CompileSearchPatternsFixture( + test_id="fixed_strings_no_regex", + pattern="dev.*", + ignore_case=False, + smart_case=False, + fixed_strings=True, + word_regexp=False, + test_string="development", + should_match=False, + ), + CompileSearchPatternsFixture( + test_id="word_boundary_match", + pattern="dev", + ignore_case=False, + smart_case=False, + fixed_strings=False, + word_regexp=True, + test_string="my dev project", + should_match=True, + ), + CompileSearchPatternsFixture( + test_id="word_boundary_no_match", + pattern="dev", + ignore_case=False, + smart_case=False, + fixed_strings=False, + word_regexp=True, + test_string="development", + should_match=False, + ), + CompileSearchPatternsFixture( + test_id="regex_pattern", + pattern="dev.*proj", + ignore_case=False, + smart_case=False, + fixed_strings=False, + word_regexp=False, + test_string="dev-project", + should_match=True, + ), +] + + +@pytest.mark.parametrize( + CompileSearchPatternsFixture._fields, + COMPILE_SEARCH_PATTERNS_FIXTURES, + ids=[test.test_id for test in COMPILE_SEARCH_PATTERNS_FIXTURES], +) +def test_compile_search_patterns( + test_id: str, + pattern: str, + ignore_case: bool, + smart_case: bool, + fixed_strings: bool, + word_regexp: bool, + test_string: str, + should_match: bool, +) -> None: + """Test compile_search_patterns function.""" + tokens = [SearchToken(fields=("name",), pattern=pattern)] + + patterns = compile_search_patterns( + tokens, + ignore_case=ignore_case, + smart_case=smart_case, + fixed_strings=fixed_strings, + word_regexp=word_regexp, + ) + + assert len(patterns) == 1 + match = patterns[0].regex.search(test_string) + assert bool(match) == should_match + + +def test_compile_search_patterns_invalid_regex_raises() -> None: + """Invalid regex pattern raises re.error.""" + tokens = [SearchToken(fields=("name",), pattern="[invalid(")] + with pytest.raises(re.error): + compile_search_patterns(tokens) + + +def test_extract_workspace_fields_basic(tmp_path: pathlib.Path) -> None: + """Extract fields from basic workspace file.""" + workspace = tmp_path / "test.yaml" + workspace.write_text( + "session_name: my-session\n" + "windows:\n" + " - window_name: editor\n" + " panes:\n" + " - vim\n" + " - window_name: shell\n" + ) + + fields = extract_workspace_fields(workspace) + + assert fields["name"] == "test" + assert fields["session_name"] == "my-session" + assert "editor" in fields["windows"] + assert "shell" in fields["windows"] + assert "vim" in fields["panes"] + + +def test_extract_workspace_fields_pane_shell_command_dict( + tmp_path: pathlib.Path, +) -> None: + """Extract pane commands from dict format.""" + workspace = tmp_path / "test.yaml" + workspace.write_text( + "session_name: test\n" + "windows:\n" + " - window_name: main\n" + " panes:\n" + " - shell_command: git status\n" + " - shell_command:\n" + " - npm install\n" + " - npm start\n" + ) + + fields = extract_workspace_fields(workspace) + + assert "git status" in fields["panes"] + assert "npm install" in fields["panes"] + assert "npm start" in fields["panes"] + + +def test_extract_workspace_fields_missing_session_name(tmp_path: pathlib.Path) -> None: + """Handle workspace without session_name.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("windows:\n - window_name: main\n") + + fields = extract_workspace_fields(workspace) + + assert fields["session_name"] == "" + assert fields["name"] == "test" + + +def test_extract_workspace_fields_invalid_yaml(tmp_path: pathlib.Path) -> None: + """Handle invalid YAML gracefully.""" + workspace = tmp_path / "test.yaml" + workspace.write_text("{{{{invalid yaml") + + fields = extract_workspace_fields(workspace) + + assert fields["name"] == "test" + assert fields["session_name"] == "" + assert fields["windows"] == [] + + +def test_extract_workspace_fields_path_uses_privacy( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Path should use PrivatePath for home contraction.""" + monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) + workspace = tmp_path / "test.yaml" + workspace.write_text("session_name: test\n") + + fields = extract_workspace_fields(workspace) + + assert fields["path"] == "~/test.yaml" + + +@pytest.fixture() +def sample_fields() -> WorkspaceFields: + """Sample workspace fields for testing.""" + return WorkspaceFields( + name="dev-project", + path="~/.tmuxp/dev-project.yaml", + session_name="development", + windows=["editor", "shell", "logs"], + panes=["vim", "git status", "tail -f"], + ) + + +def test_evaluate_match_single_pattern(sample_fields: WorkspaceFields) -> None: + """Single pattern should match.""" + pattern = SearchPattern( + fields=("name",), + raw="dev", + regex=re.compile("dev"), + ) + + matched, matches = evaluate_match(sample_fields, [pattern]) + + assert matched is True + assert "name" in matches + + +def test_evaluate_match_single_pattern_no_match(sample_fields: WorkspaceFields) -> None: + """Single pattern should not match.""" + pattern = SearchPattern( + fields=("name",), + raw="xyz", + regex=re.compile("xyz"), + ) + + matched, matches = evaluate_match(sample_fields, [pattern]) + + assert matched is False + assert matches == {} + + +def test_evaluate_match_and_logic_all_match(sample_fields: WorkspaceFields) -> None: + """AND logic - all patterns match.""" + p1 = SearchPattern(fields=("name",), raw="dev", regex=re.compile("dev")) + p2 = SearchPattern(fields=("name",), raw="project", regex=re.compile("project")) + + matched, _ = evaluate_match(sample_fields, [p1, p2], match_any=False) + + assert matched is True + + +def test_evaluate_match_and_logic_partial_no_match( + sample_fields: WorkspaceFields, +) -> None: + """AND logic - only some patterns match.""" + p1 = SearchPattern(fields=("name",), raw="dev", regex=re.compile("dev")) + p2 = SearchPattern(fields=("name",), raw="xyz", regex=re.compile("xyz")) + + matched, _ = evaluate_match(sample_fields, [p1, p2], match_any=False) + + assert matched is False + + +def test_evaluate_match_or_logic_any_match(sample_fields: WorkspaceFields) -> None: + """OR logic - any pattern matches.""" + p1 = SearchPattern(fields=("name",), raw="xyz", regex=re.compile("xyz")) + p2 = SearchPattern(fields=("name",), raw="dev", regex=re.compile("dev")) + + matched, _ = evaluate_match(sample_fields, [p1, p2], match_any=True) + + assert matched is True + + +def test_evaluate_match_window_field(sample_fields: WorkspaceFields) -> None: + """Search in window field.""" + pattern = SearchPattern( + fields=("window",), + raw="editor", + regex=re.compile("editor"), + ) + + matched, matches = evaluate_match(sample_fields, [pattern]) + + assert matched is True + assert "window" in matches + + +def test_evaluate_match_pane_field(sample_fields: WorkspaceFields) -> None: + """Search in pane field.""" + pattern = SearchPattern( + fields=("pane",), + raw="vim", + regex=re.compile("vim"), + ) + + matched, matches = evaluate_match(sample_fields, [pattern]) + + assert matched is True + assert "pane" in matches + + +def test_evaluate_match_multiple_fields(sample_fields: WorkspaceFields) -> None: + """Pattern searches multiple fields.""" + pattern = SearchPattern( + fields=("name", "session_name"), + raw="dev", + regex=re.compile("dev"), + ) + + matched, matches = evaluate_match(sample_fields, [pattern]) + + assert matched is True + # Should find matches in both name and session_name + assert "name" in matches or "session_name" in matches + + +def test_find_search_matches_basic(tmp_path: pathlib.Path) -> None: + """Basic search finds matching workspace.""" + workspace = tmp_path / "dev.yaml" + workspace.write_text("session_name: development\n") + + pattern = SearchPattern( + fields=("session_name",), + raw="dev", + regex=re.compile("dev"), + ) + + results = find_search_matches([(workspace, "global")], [pattern]) + + assert len(results) == 1 + assert results[0]["source"] == "global" + + +def test_find_search_matches_no_match(tmp_path: pathlib.Path) -> None: + """Search returns empty when no match.""" + workspace = tmp_path / "production.yaml" + workspace.write_text("session_name: production\n") + + pattern = SearchPattern( + fields=("name",), + raw="dev", + regex=re.compile("dev"), + ) + + results = find_search_matches([(workspace, "global")], [pattern]) + + assert len(results) == 0 + + +def test_find_search_matches_invert(tmp_path: pathlib.Path) -> None: + """Invert match returns non-matching workspaces.""" + workspace = tmp_path / "production.yaml" + workspace.write_text("session_name: production\n") + + pattern = SearchPattern( + fields=("name",), + raw="dev", + regex=re.compile("dev"), + ) + + results = find_search_matches([(workspace, "global")], [pattern], invert_match=True) + + assert len(results) == 1 + + +def test_find_search_matches_multiple_workspaces(tmp_path: pathlib.Path) -> None: + """Search across multiple workspaces.""" + ws1 = tmp_path / "dev.yaml" + ws1.write_text("session_name: development\n") + + ws2 = tmp_path / "prod.yaml" + ws2.write_text("session_name: production\n") + + pattern = SearchPattern( + fields=("name", "session_name"), + raw="dev", + regex=re.compile("dev"), + ) + + results = find_search_matches([(ws1, "global"), (ws2, "global")], [pattern]) + + assert len(results) == 1 + assert results[0]["fields"]["name"] == "dev" + + +def test_highlight_matches_no_colors() -> None: + """Colors disabled returns original text.""" + colors = Colors(ColorMode.NEVER) + pattern = SearchPattern( + fields=("name",), + raw="dev", + regex=re.compile("dev"), + ) + + result = highlight_matches("development", [pattern], colors=colors) + + assert result == "development" + + +def test_highlight_matches_with_colors() -> None: + """Colors enabled adds ANSI codes.""" + colors = Colors(ColorMode.ALWAYS) + pattern = SearchPattern( + fields=("name",), + raw="dev", + regex=re.compile("dev"), + ) + + result = highlight_matches("development", [pattern], colors=colors) + + assert "\033[" in result # Contains ANSI escape + assert "dev" in result + + +def test_highlight_matches_no_match() -> None: + """No match returns original text.""" + colors = Colors(ColorMode.ALWAYS) + pattern = SearchPattern( + fields=("name",), + raw="xyz", + regex=re.compile("xyz"), + ) + + result = highlight_matches("development", [pattern], colors=colors) + + assert result == "development" + + +def test_highlight_matches_multiple() -> None: + """Multiple matches in same string.""" + colors = Colors(ColorMode.ALWAYS) + pattern = SearchPattern( + fields=("name",), + raw="e", + regex=re.compile("e"), + ) + + result = highlight_matches("development", [pattern], colors=colors) + + # Should contain multiple highlights + assert result.count("\033[") > 1 + + +def test_highlight_matches_empty_patterns() -> None: + """Empty patterns returns original text.""" + colors = Colors(ColorMode.ALWAYS) + + result = highlight_matches("development", [], colors=colors) + + assert result == "development" + + +@pytest.fixture() +def sample_fields_for_get_field_values() -> WorkspaceFields: + """Sample workspace fields.""" + return WorkspaceFields( + name="test", + path="~/.tmuxp/test.yaml", + session_name="test-session", + windows=["editor", "shell"], + panes=["vim", "bash"], + ) + + +def test_get_field_values_scalar( + sample_fields_for_get_field_values: WorkspaceFields, +) -> None: + """Scalar field returns list with one item.""" + result = _get_field_values(sample_fields_for_get_field_values, "name") + assert result == ["test"] + + +def test_get_field_values_list( + sample_fields_for_get_field_values: WorkspaceFields, +) -> None: + """List field returns the list.""" + result = _get_field_values(sample_fields_for_get_field_values, "windows") + assert result == ["editor", "shell"] + + +def test_get_field_values_window_alias( + sample_fields_for_get_field_values: WorkspaceFields, +) -> None: + """Window alias maps to windows.""" + result = _get_field_values(sample_fields_for_get_field_values, "window") + assert result == ["editor", "shell"] + + +def test_get_field_values_pane_alias( + sample_fields_for_get_field_values: WorkspaceFields, +) -> None: + """Pane alias maps to panes.""" + result = _get_field_values(sample_fields_for_get_field_values, "pane") + assert result == ["vim", "bash"] + + +def test_get_field_values_empty() -> None: + """Empty value returns empty list.""" + fields = WorkspaceFields( + name="", + path="", + session_name="", + windows=[], + panes=[], + ) + result = _get_field_values(fields, "name") + assert result == [] + + +def test_search_subparser_creation() -> None: + """Subparser can be created successfully.""" + import argparse + + parser = argparse.ArgumentParser() + result = create_search_subparser(parser) + + assert result is parser + + +def test_search_subparser_options() -> None: + """Parser has expected options.""" + import argparse + + parser = argparse.ArgumentParser() + create_search_subparser(parser) + + # Parse with various options + args = parser.parse_args(["-i", "-S", "-F", "-w", "-v", "--any", "pattern"]) + + assert args.ignore_case is True + assert args.smart_case is True + assert args.fixed_strings is True + assert args.word_regexp is True + assert args.invert_match is True + assert args.match_any is True + assert args.query_terms == ["pattern"] + + +def test_search_subparser_output_format_options() -> None: + """Parser supports output format options.""" + import argparse + + parser = argparse.ArgumentParser() + create_search_subparser(parser) + + args_json = parser.parse_args(["--json", "test"]) + assert args_json.output_json is True + + args_ndjson = parser.parse_args(["--ndjson", "test"]) + assert args_ndjson.output_ndjson is True + + +def test_search_subparser_field_option() -> None: + """Parser supports field option.""" + import argparse + + parser = argparse.ArgumentParser() + create_search_subparser(parser) + + args = parser.parse_args(["-f", "name", "-f", "session", "test"]) + + assert args.field == ["name", "session"] + + +def test_output_search_results_no_results(capsys: pytest.CaptureFixture[str]) -> None: + """No results outputs warning message.""" + colors = Colors(ColorMode.NEVER) + formatter = OutputFormatter(OutputMode.HUMAN) + + _output_search_results([], [], formatter, colors) + formatter.finalize() + + captured = capsys.readouterr() + assert "No matching" in captured.out + + +def test_output_search_results_json(capsys: pytest.CaptureFixture[str]) -> None: + """JSON output mode produces valid JSON.""" + colors = Colors(ColorMode.NEVER) + formatter = OutputFormatter(OutputMode.JSON) + + result: WorkspaceSearchResult = { + "filepath": "/test/dev.yaml", + "source": "global", + "fields": WorkspaceFields( + name="dev", + path="~/.tmuxp/dev.yaml", + session_name="development", + windows=["editor"], + panes=["vim"], + ), + "matches": {"name": ["dev"]}, + } + + _output_search_results([result], [], formatter, colors) + formatter.finalize() + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert len(data) == 1 + assert data[0]["name"] == "dev" + + +def test_output_search_results_ndjson(capsys: pytest.CaptureFixture[str]) -> None: + """NDJSON output mode produces one JSON per line.""" + colors = Colors(ColorMode.NEVER) + formatter = OutputFormatter(OutputMode.NDJSON) + + result: WorkspaceSearchResult = { + "filepath": "/test/dev.yaml", + "source": "global", + "fields": WorkspaceFields( + name="dev", + path="~/.tmuxp/dev.yaml", + session_name="development", + windows=[], + panes=[], + ), + "matches": {"name": ["dev"]}, + } + + _output_search_results([result], [], formatter, colors) + formatter.finalize() + + captured = capsys.readouterr() + lines = captured.out.strip().split("\n") + # Filter out human-readable lines + json_lines = [line for line in lines if line.startswith("{")] + assert len(json_lines) >= 1 + data = json.loads(json_lines[0]) + assert data["name"] == "dev" diff --git a/tests/cli/test_shell_colors.py b/tests/cli/test_shell_colors.py new file mode 100644 index 0000000000..de92e748ae --- /dev/null +++ b/tests/cli/test_shell_colors.py @@ -0,0 +1,95 @@ +"""Tests for CLI colors in shell command.""" + +from __future__ import annotations + +from tests.cli.conftest import ANSI_BLUE, ANSI_BOLD, ANSI_CYAN, ANSI_MAGENTA +from tmuxp.cli._colors import Colors + +# Shell command color output tests + + +def test_shell_launch_message_format(colors_always: Colors) -> None: + """Verify launch message format with shell type and session.""" + shell_name = "ipython" + session_name = "my-session" + output = ( + colors_always.muted("Launching ") + + colors_always.highlight(shell_name, bold=False) + + colors_always.muted(" shell for session ") + + colors_always.info(session_name) + + colors_always.muted("...") + ) + # Should contain blue, magenta, and cyan ANSI codes + assert ANSI_BLUE in output # blue for muted + assert ANSI_MAGENTA in output # magenta for highlight + assert ANSI_CYAN in output # cyan for session name + assert shell_name in output + assert session_name in output + + +def test_shell_pdb_launch_message(colors_always: Colors) -> None: + """Verify pdb launch message format.""" + output = ( + colors_always.muted("Launching ") + + colors_always.highlight("pdb", bold=False) + + colors_always.muted(" shell...") + ) + assert ANSI_BLUE in output # blue for muted + assert ANSI_MAGENTA in output # magenta for pdb + assert "pdb" in output + + +def test_shell_highlight_not_bold(colors_always: Colors) -> None: + """Verify shell name uses highlight without bold for subtlety.""" + result = colors_always.highlight("best", bold=False) + assert ANSI_MAGENTA in result # magenta foreground + assert ANSI_BOLD not in result # no bold - subtle emphasis + assert "best" in result + + +def test_shell_session_name_uses_info(colors_always: Colors) -> None: + """Verify session name uses info color (cyan).""" + session_name = "dev-session" + result = colors_always.info(session_name) + assert ANSI_CYAN in result # cyan foreground + assert session_name in result + + +def test_shell_muted_for_static_text(colors_always: Colors) -> None: + """Verify static text uses muted color (blue).""" + result = colors_always.muted("Launching ") + assert ANSI_BLUE in result # blue foreground + assert "Launching" in result + + +def test_shell_colors_disabled_plain_text(colors_never: Colors) -> None: + """Verify disabled colors return plain text.""" + shell_name = "ipython" + session_name = "my-session" + output = ( + colors_never.muted("Launching ") + + colors_never.highlight(shell_name, bold=False) + + colors_never.muted(" shell for session ") + + colors_never.info(session_name) + + colors_never.muted("...") + ) + # Should be plain text without ANSI codes + assert "\033[" not in output + assert output == f"Launching {shell_name} shell for session {session_name}..." + + +def test_shell_various_shell_names(colors_always: Colors) -> None: + """Verify all shell types can be highlighted.""" + shell_types = [ + "best", + "pdb", + "code", + "ptipython", + "ptpython", + "ipython", + "bpython", + ] + for shell_name in shell_types: + result = colors_always.highlight(shell_name, bold=False) + assert ANSI_MAGENTA in result + assert shell_name in result diff --git a/tests/workspace/test_finder.py b/tests/workspace/test_finder.py index 20aee8703a..dd0b270bb8 100644 --- a/tests/workspace/test_finder.py +++ b/tests/workspace/test_finder.py @@ -13,6 +13,7 @@ from tmuxp.workspace.finders import ( find_workspace_file, get_workspace_dir, + get_workspace_dir_candidates, in_cwd, in_dir, is_pure_name, @@ -357,3 +358,159 @@ def check_cmd(config_arg: str) -> _pytest.capture.CaptureResult[str]: match="workspace-file not found in workspace dir", ): assert "workspace-file not found in workspace dir" in check_cmd("moo").err + + +class GetWorkspaceDirCandidatesFixture(t.NamedTuple): + """Test fixture for get_workspace_dir_candidates().""" + + test_id: str + env_vars: dict[str, str] # Relative to tmp_path + dirs_to_create: list[str] # Relative to tmp_path + workspace_files: dict[str, int] # dir -> count of .yaml files to create + expected_active_suffix: str # Suffix of active dir (e.g., ".tmuxp") + expected_candidates_count: int + + +GET_WORKSPACE_DIR_CANDIDATES_FIXTURES: list[GetWorkspaceDirCandidatesFixture] = [ + GetWorkspaceDirCandidatesFixture( + test_id="default_tmuxp_only", + env_vars={}, + dirs_to_create=["home/.tmuxp"], + workspace_files={"home/.tmuxp": 3}, + expected_active_suffix=".tmuxp", + expected_candidates_count=2, # ~/.config/tmuxp (not found) + ~/.tmuxp + ), + GetWorkspaceDirCandidatesFixture( + test_id="xdg_exists_tmuxp_not", + env_vars={"XDG_CONFIG_HOME": "home/.config"}, + dirs_to_create=["home/.config/tmuxp"], + workspace_files={"home/.config/tmuxp": 2}, + expected_active_suffix="tmuxp", # XDG takes precedence + expected_candidates_count=2, + ), + GetWorkspaceDirCandidatesFixture( + test_id="both_exist_xdg_wins", + env_vars={"XDG_CONFIG_HOME": "home/.config"}, + dirs_to_create=["home/.config/tmuxp", "home/.tmuxp"], + workspace_files={"home/.config/tmuxp": 2, "home/.tmuxp": 5}, + expected_active_suffix="tmuxp", # XDG wins when both exist + expected_candidates_count=2, + ), + GetWorkspaceDirCandidatesFixture( + test_id="custom_configdir", + env_vars={"TMUXP_CONFIGDIR": "custom/workspaces"}, + dirs_to_create=["custom/workspaces", "home/.tmuxp"], + workspace_files={"custom/workspaces": 4}, + expected_active_suffix="workspaces", + expected_candidates_count=3, # custom + ~/.config/tmuxp + ~/.tmuxp + ), + GetWorkspaceDirCandidatesFixture( + test_id="none_exist_fallback", + env_vars={}, + dirs_to_create=[], # No dirs created + workspace_files={}, + expected_active_suffix=".tmuxp", # Falls back to ~/.tmuxp + expected_candidates_count=2, + ), +] + + +@pytest.mark.parametrize( + list(GetWorkspaceDirCandidatesFixture._fields), + GET_WORKSPACE_DIR_CANDIDATES_FIXTURES, + ids=[test.test_id for test in GET_WORKSPACE_DIR_CANDIDATES_FIXTURES], +) +def test_get_workspace_dir_candidates( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + env_vars: dict[str, str], + dirs_to_create: list[str], + workspace_files: dict[str, int], + expected_active_suffix: str, + expected_candidates_count: int, +) -> None: + """Test get_workspace_dir_candidates() returns correct candidates.""" + # Setup home directory + home = tmp_path / "home" + home.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("HOME", str(home)) + + # Clear any existing env vars that might interfere + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + + # Create directories + for dir_path in dirs_to_create: + (tmp_path / dir_path).mkdir(parents=True, exist_ok=True) + + # Create workspace files + for dir_path, count in workspace_files.items(): + dir_full = tmp_path / dir_path + for i in range(count): + (dir_full / f"workspace{i}.yaml").touch() + + # Set environment variables (resolve relative paths) + for var, path in env_vars.items(): + monkeypatch.setenv(var, str(tmp_path / path)) + + # Get candidates + candidates = get_workspace_dir_candidates() + + # Verify count + assert len(candidates) == expected_candidates_count, ( + f"Expected {expected_candidates_count} candidates, got {len(candidates)}" + ) + + # Verify structure + for candidate in candidates: + assert "path" in candidate + assert "source" in candidate + assert "exists" in candidate + assert "workspace_count" in candidate + assert "active" in candidate + + # Verify exactly one is active + active_candidates = [c for c in candidates if c["active"]] + assert len(active_candidates) == 1, "Expected exactly one active candidate" + + # Verify active suffix + active = active_candidates[0] + assert active["path"].endswith(expected_active_suffix), ( + f"Expected active path to end with '{expected_active_suffix}', " + f"got '{active['path']}'" + ) + + # Verify workspace counts for existing directories + for candidate in candidates: + if candidate["exists"]: + # Find the matching dir in workspace_files by the last path component + candidate_suffix = candidate["path"].split("/")[-1] + for dir_path, expected_count in workspace_files.items(): + if dir_path.endswith(candidate_suffix): + assert candidate["workspace_count"] == expected_count, ( + f"Expected {expected_count} workspaces in {candidate['path']}, " + f"got {candidate['workspace_count']}" + ) + break # Found match, stop checking + + +def test_get_workspace_dir_candidates_uses_private_path( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that get_workspace_dir_candidates() masks home directory with ~.""" + home = tmp_path / "home" + tmuxp_dir = home / ".tmuxp" + tmuxp_dir.mkdir(parents=True) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.delenv("TMUXP_CONFIGDIR", raising=False) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + + candidates = get_workspace_dir_candidates() + + # All paths should use ~ instead of full home path + for candidate in candidates: + path = candidate["path"] + assert str(home) not in path, f"Path should be masked: {path}" + assert path.startswith("~"), f"Path should start with ~: {path}"