diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index c69d484..e3ef59d 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -1,7 +1,7 @@ ````markdown # RCDir Development Guidelines -Auto-generated from all feature plans. Last updated: 2026-04-19 +Auto-generated from all feature plans. Last updated: 2026-04-20 ## Active Technologies - Rust stable (edition 2024, toolchain 1.85+) + `windows` crate 0.62 (Win32 API), `widestring` 1 (UTF-16) (003-file-icons) @@ -12,6 +12,7 @@ Auto-generated from all feature plans. Last updated: 2026-04-19 - Single flat file (`%USERPROFILE%\.rcdirconfig`), UTF-8 with optional BOM (006-config-file-support) - Rust stable (latest stable release, per rust-toolchain.toml) + `windows` crate (Win32 API: `CreateFileW`, `DeviceIoControl`, `FSCTL_GET_REPARSE_POINT`) (007-symlink-junction-targets) - N/A (filesystem reads only, no persistent state) (007-symlink-junction-targets) +- Rust stable (latest) + `windows` crate (Win32 API), `widestring` (UTF-16) (008-ellipsize-targets) - Rust stable (1.93.0), Edition 2024 + `windows` crate (Win32 APIs), `widestring` (UTF-16 interop) (master) @@ -31,6 +32,7 @@ cargo test; cargo clippy Rust stable (1.93.0), Edition 2024: Follow standard conventions ## Recent Changes +- 008-ellipsize-targets: Added Rust stable (latest) + `windows` crate (Win32 API), `widestring` (UTF-16) - 007-symlink-junction-targets: Added Rust stable (latest stable release, per rust-toolchain.toml) + `windows` crate (Win32 API: `CreateFileW`, `DeviceIoControl`, `FSCTL_GET_REPARSE_POINT`) - 006-config-file-support: Added Rust stable (latest stable release) + `windows` crate for Win32 console API; standard library for file I/O (`std::fs::read`) Fix- 004-tree-view: Added Rust stable (edition 2024) + `windows` crate (Win32 API), `widestring` crate, Rust std library only (no third-party libraries) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6e6886..805fcbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to RCDir are documented in this file. +## [5.5] - 2026-04-20 + +### Added + +- Ellipsize long link target paths: middle-truncate long reparse target paths with `…` (U+2026) to prevent line wrapping + - Priority-based truncation: preserves first two dirs + leaf dir + filename, degrades gracefully + - Works in both normal and tree modes (tree mode accounts for connector prefix width) + - Ellipsis character rendered in Default color for visual distinction from path text + - `--Ellipsize` switch (on by default); `--Ellipsize-` to disable and show full paths + - Configurable via `.rcdirconfig` and `RCDIR` environment variable +- 23 new unit tests for path truncation algorithm +- 1 new output parity test for `--Ellipsize-` disabled mode + ## [5.4] - 2026-04-20 ### Added diff --git a/README.md b/README.md index b140b71..83c33cc 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ It's designed as a practical `dir`-style command with useful defaults (color by | Version | Highlights | |---------|------------| +| **5.5** | Ellipsize long link target paths — middle-truncate with `…` to prevent line wrapping | | **5.4** | Symlink, junction, and AppExecLink target display (`→ target`) | | **5.3** | Config file support (`.rcdirconfig`), `--config` diagnostics, `--settings` merged view | | **5.2** | Interactive PowerShell alias configuration (`--set-aliases`, `--get-aliases`, `--remove-aliases`) | diff --git a/Version.toml b/Version.toml index d05de05..b21af0d 100644 --- a/Version.toml +++ b/Version.toml @@ -2,4 +2,4 @@ # Major and minor are updated manually. major = 5 minor = 4 -build = 1404 +build = 1406 diff --git a/specs/008-ellipsize-targets/checklists/requirements.md b/specs/008-ellipsize-targets/checklists/requirements.md new file mode 100644 index 0000000..a0d4bef --- /dev/null +++ b/specs/008-ellipsize-targets/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Ellipsize Long Link Target Paths + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-20 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Spec ported 1:1 from TCDir spec 008 — all clarifications already resolved in TCDir session 2026-04-19 +- All items pass validation — spec is ready for `/speckit.clarify` or `/speckit.plan` diff --git a/specs/008-ellipsize-targets/data-model.md b/specs/008-ellipsize-targets/data-model.md new file mode 100644 index 0000000..8aca50c --- /dev/null +++ b/specs/008-ellipsize-targets/data-model.md @@ -0,0 +1,64 @@ +# Data Model: Ellipsize Long Link Target Paths + +**Date**: 2026-04-20 +**Feature**: 008-ellipsize-targets + +## Entities + +### EllipsizedPath (new — in `src/path_ellipsis.rs`) + +Return type from `ellipsize_path()`. Enables the displayer to render prefix and suffix in the source file's color with the `…` in Default color. + +| Field | Type | Description | +|-------|------|-------------| +| `prefix` | `String` | Path text before the ellipsis (e.g., `C:\Program Files\`). Full path if not truncated. | +| `suffix` | `String` | Path text after the ellipsis (e.g., `\python3.12.exe`). Empty if not truncated. | +| `truncated` | `bool` | `true` if the path was middle-truncated, `false` if shown in full. | + +**Rules:** +- When `truncated` is false: `prefix` contains the full path, `suffix` is empty +- When `truncated` is true: display is `prefix` + `…` + `suffix` +- Total display width when truncated: `prefix.len() + 1 + suffix.len()` (the `…` is 1 char wide) + +### Config (modified — in `src/config/mod.rs`) + +| Field | Type | Default | New? | +|-------|------|---------|------| +| `ellipsize` | `Option` | `None` (treated as true) | Yes | + +### CommandLine (modified — in `src/command_line.rs`) + +| Field | Type | Default | New? | +|-------|------|---------|------| +| `ellipsize` | `Option` | `None` (treated as true) | Yes | + +## State Transitions + +None. `EllipsizedPath` is computed per-line during display, not stored. + +## Relationships + +``` +NormalDisplayer / TreeDisplayer + │ + │ Computes available_width from metadata column widths + console.width() + │ + ▼ +ellipsize_path(&reparse_target, available_width) + │ + ▼ +EllipsizedPath { prefix, suffix, truncated } + │ + ▼ +if truncated: + writef(text_attr, prefix) + printf(Default, "…") + writef_line(text_attr, suffix) +else: + writef_line(text_attr, prefix) +``` + +## Validation Rules + +- `available_width` is computed from `console.width()` minus all metadata columns — never negative (clamped to 0) +- Truncation only applies when `cmd.ellipsize.unwrap_or(true)` is true +- Paths with fewer than 3 components are never truncated (nothing to elide) +- The `…` character must save space: if the truncated form isn't shorter than the original, return the original diff --git a/specs/008-ellipsize-targets/plan.md b/specs/008-ellipsize-targets/plan.md new file mode 100644 index 0000000..a686ab0 --- /dev/null +++ b/specs/008-ellipsize-targets/plan.md @@ -0,0 +1,72 @@ +# Implementation Plan: Ellipsize Long Link Target Paths + +**Branch**: `008-ellipsize-targets` | **Date**: 2026-04-20 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `specs/008-ellipsize-targets/spec.md` + +## Summary + +Middle-truncate long link target paths using `…` (U+2026) to prevent line wrapping in normal and tree modes. Truncation preserves first two directory components and leaf filename where possible, falling back gracefully. New `--Ellipsize` switch (default on) with `--Ellipsize-` to disable. Ellipsis rendered in `Default` color attribute to be visually distinct from path text. + +## Technical Context + +**Language/Version**: Rust stable (latest) +**Primary Dependencies**: `windows` crate (Win32 API), `widestring` (UTF-16) +**Storage**: N/A +**Testing**: `cargo test` (Rust built-in `#[test]` + `#[cfg(test)]`) +**Target Platform**: Windows 10/11, x64 and ARM64 +**Project Type**: CLI tool (desktop) +**Performance Goals**: Pure string operations — no I/O, no measurable cost +**Constraints**: Truncation logic must be a pure function testable with synthetic data +**Scale/Scope**: One new module (`path_ellipsis.rs`), switch plumbing in 3 files, display changes in 2 displayers + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Code Quality | PASS | Pure function for truncation logic; `Result` not needed (infallible string operations); follows existing module patterns | +| II. Testing Discipline | PASS | Pure truncation function testable with synthetic data; real WindowsApps paths as test inputs; no system state accessed; output parity tests added | +| III. UX Consistency | PASS | New `--Ellipsize` switch follows `--Icons`/`--Tree` pattern; documented in `-?` help and `--Settings`; `…` in Default color for visual distinction | +| IV. Performance | PASS | String operations only, called 0–N times per directory for reparse entries — negligible cost | +| V. Simplicity | PASS | One pure function, minimal display changes, follows existing switch infrastructure exactly | + +**Gate result: PASS** — No violations. + +## Project Structure + +### Documentation (this feature) + +```text +specs/008-ellipsize-targets/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +└── tasks.md # Phase 2 output (/speckit.tasks) +``` + +### Source Code (files affected) + +```text +src/ +├── path_ellipsis.rs # New — EllipsizedPath struct, ellipsize_path() pure function, unit tests +├── lib.rs # Add `pub mod path_ellipsis;` +├── command_line.rs # Add `ellipsize: Option` field, parse --Ellipsize/--Ellipsize- switch +├── config/ +│ ├── mod.rs # Add `ellipsize: Option` field, bump SWITCH_COUNT 9→10, add to SWITCH_MEMBER_ORDER +│ └── env_overrides.rs # Add ellipsize/ellipsize- to SWITCH_MAPPINGS, switch_name_to_source_index +├── results_displayer/ +│ ├── normal.rs # Compute available width, call ellipsize_path(), render with split colors +│ └── tree.rs # Same as normal + account for tree prefix width +├── usage.rs # Add --Ellipsize to help output, SwitchInfo, and --Settings display + +tests/ +└── output_parity.rs # Add parity test cases for ellipsize in normal and tree modes +``` + +**Structure Decision**: New `path_ellipsis.rs` module encapsulates the truncation logic as a pure function — keeps displayers clean and makes the algorithm independently testable without mocks. Follows the same pattern as other single-purpose modules (`reparse_resolver.rs`, `cloud_status.rs`). + +## Complexity Tracking + +No constitution violations — this section is empty. diff --git a/specs/008-ellipsize-targets/quickstart.md b/specs/008-ellipsize-targets/quickstart.md new file mode 100644 index 0000000..2aeeb00 --- /dev/null +++ b/specs/008-ellipsize-targets/quickstart.md @@ -0,0 +1,54 @@ +# Quickstart: Ellipsize Long Link Target Paths + +**Date**: 2026-04-20 +**Feature**: 008-ellipsize-targets + +## What's Changing + +Long link target paths are middle-truncated with `…` to prevent line wrapping: + +``` +Before (wraps at 120 chars): +python.exe → C:\Program Files\WindowsApps\Microsoft.DesktopAppInstaller_1.29.30.0_arm64__8wekyb3d8bbwe\AppInstallerPythonRedirector.exe + +After (fits in 120 chars): +python.exe → C:\Program Files\…\AppInstallerPythonRedirector.exe +``` + +New switch: `--Ellipsize` (default on), `--Ellipsize-` to disable. + +## Files to Modify + +| File | Change | +|------|--------| +| `src/path_ellipsis.rs` | **New** — `EllipsizedPath` struct, `ellipsize_path()` pure function, `#[cfg(test)]` unit tests | +| `src/lib.rs` | Add `pub mod path_ellipsis;` | +| `src/command_line.rs` | Add `ellipsize: Option` field, parse `--Ellipsize` / `--Ellipsize-`, add to recognized switches, apply config defaults | +| `src/config/mod.rs` | Add `ellipsize: Option` field, bump `SWITCH_COUNT` 9→10, add to `SWITCH_MEMBER_ORDER` | +| `src/config/env_overrides.rs` | Add `ellipsize`/`ellipsize-` to `SWITCH_MAPPINGS`, `switch_name_to_source_index` | +| `src/results_displayer/normal.rs` | Compute available width, call `ellipsize_path()`, render with split colors | +| `src/results_displayer/tree.rs` | Same as normal + account for tree prefix width | +| `src/usage.rs` | Add `--Ellipsize` to help output, `SWITCH_INFOS`, and `--Settings` display | +| `tests/output_parity.rs` | Add parity test cases for ellipsize in normal and tree modes | + +## Build & Test + +```powershell +# Build (uses VS Code task or Build.ps1 — never raw cargo build) +# Use VS Code task: "Build Debug (current arch)" + +# Test +cargo test + +# Clippy +cargo clippy -- -D warnings +``` + +## Key Design Decisions + +1. **Pure function** — `ellipsize_path()` takes a path string and available width, returns a struct with prefix/suffix split +2. **Arithmetic width calculation** — no character counter needed; compute from known column widths + `console.width()` +3. **Priority-based truncation** — first two dirs + leaf dir + filename > first two dirs + filename > first dir + filename > leaf only +4. **Ellipsis color** — `Attribute::Default`, not file color, so it's visually distinct +5. **Default on** — most users benefit from truncation; `--Ellipsize-` opts out +6. **Same pattern as --Icons/--Tree** — `Option` with conditional merge in `apply_config_defaults` diff --git a/specs/008-ellipsize-targets/research.md b/specs/008-ellipsize-targets/research.md new file mode 100644 index 0000000..959cc51 --- /dev/null +++ b/specs/008-ellipsize-targets/research.md @@ -0,0 +1,125 @@ +# Research: Ellipsize Long Link Target Paths + +**Date**: 2026-04-20 +**Feature**: 008-ellipsize-targets + +## R1: Available Width Calculation + +### Decision +Compute available width arithmetically from known column widths. No character counter needed. + +### Rationale +`Console` has no column tracker — it buffers ANSI-escaped text and flushes in bulk. But all metadata columns have deterministic widths computable from the same data the displayers already have access to. + +### Formula (Normal Mode) +``` +available_width = console.width() + - 21 // date+time: "MM/dd/yyyy hh:mm tt " + - 7 // attributes: 7 single-char flags + trailing space + - (2 + max(max_size_width, 5)) // file size (Bytes) or 9 (Auto/) + leading/trailing space + - cloud_status_width // 3-4 chars if in sync root, 0 otherwise + - debug_width // 14 if debug mode, 0 otherwise + - owner_width // max_owner_len + 1 if --Owner, 0 otherwise + - icon_width // 2 if icons active, 0 otherwise + - filename_len // source filename length + - 3 // " → " (space + arrow + space) +``` + +### Formula (Tree Mode) +Same as normal mode, but additionally subtract: +``` + - tree_prefix_width // tree_state.get_prefix(is_last).len() +``` +Tree prefix width varies with depth and `--TreeIndent` setting. + +### Alternatives Considered +- **Add a column counter to Console**: Invasive change, modifies every write method. Rejected — arithmetic is sufficient and matches TCDir's approach. +- **Measure buffer length before/after**: Fragile — buffer contains ANSI escape sequences that don't correspond to visible character positions. + +## R2: ellipsize_path Pure Function Design + +### Decision +Create `ellipsize_path(target_path: &str, available_width: usize) -> EllipsizedPath` as a pure function in `src/path_ellipsis.rs`. Returns a struct with prefix/suffix split for split-color rendering. + +### Algorithm +``` +if target_path.len() <= available_width: + return EllipsizedPath::full(target_path) // fits, no truncation + +Split target_path into path components using '\' separator. + +Try these forms in priority order, return first that fits: + 1. components[0] + "\" + components[1] + "\…\" + components[N-2] + "\" + components[N-1] + (first two dirs + … + leaf dir + filename) + 2. components[0] + "\" + components[1] + "\…\" + components[N-1] + (first two dirs + … + filename) + 3. components[0] + "\…\" + components[N-1] + (first dir + … + filename) + 4. Leaf filename only, truncated to available_width with trailing … + (e.g., "DesktopStickerEdito…") +``` + +### Return Type +```rust +struct EllipsizedPath { + prefix: String, // Path text before the ellipsis (full path if not truncated) + suffix: String, // Path text after the ellipsis (empty if not truncated) + truncated: bool, // true if path was middle-truncated +} +``` + +When `truncated` is true, the displayer renders: `prefix` (in file color) + `…` (in Default color) + `suffix` (in file color). + +### Rationale +- Pure function = trivially testable with synthetic data, no mocks needed +- Struct return enables split-color rendering for the ellipsis character +- Priority order matches user's information preference (first two dirs + leaf are most actionable) +- Graceful degradation to leaf-only ensures something always displays + +### Alternatives Considered +- **Return a single truncated string**: Rejected — cannot render ellipsis in a different color without knowing the split point +- **Return indices into original string**: More fragile, not worth the allocation savings for a path (short string) + +## R3: Switch Infrastructure + +### Decision +Add `ellipsize: Option` to both `Config` and `CommandLine`. Default behavior: on (truncation active). Follow the exact same pattern as `--Icons` and `--Tree` (supports negation via `-` suffix, conditional merge in `apply_config_defaults`). + +### Changes Required +1. **config/mod.rs**: Add `pub ellipsize: Option`; bump `SWITCH_COUNT` from 9 to 10; add to `SWITCH_MEMBER_ORDER` +2. **config/env_overrides.rs**: Add `("ellipsize", true, ...)` and `("ellipsize-", false, ...)` to `SWITCH_MAPPINGS`; add `"ellipsize" => Some(9)` to `switch_name_to_source_index`; update error message to include "Ellipsize" +3. **command_line.rs**: Add `pub ellipsize: Option` field; add `("ellipsize", ..., "ellipsize-", ...)` to `bool_switches` table in `handle_long_switch`; add `"ellipsize"` to `is_recognized_long_switch`; add to `apply_config_defaults` with conditional merge +4. **usage.rs**: Add `SwitchInfo` entry; add to help text; add to `--Settings` display + +### Default Behavior +When `ellipsize` is `None` (not set by user), treat as **true** (on). The displayer checks: `cmd.ellipsize.unwrap_or(true)`. + +## R4: Ellipsis Color + +### Decision +Render the `…` character using `Attribute::Default` color — visually distinct from the path text which uses the source file's color attribute (`text_attr`). + +### Implementation +The display code in `normal.rs` and `tree.rs` splits the rendering: +```rust +let ep = ellipsize_path(&file_info.reparse_target, available_width); +if ep.truncated { + console.writef(text_attr, format_args!("{}", ep.prefix)); + console.printf(config.attributes[Attribute::Default as usize], "\u{2026}"); + console.writef(text_attr, format_args!("{}", ep.suffix)); + console.puts(Attribute::Default, ""); +} else { + console.writef_line(text_attr, format_args!("{}", ep.prefix)); +} +``` + +### Rationale +Default attribute ensures the ellipsis stands out regardless of the file's color assignment, signaling to the user that path components were elided. + +## R5: Separator Character + +### Decision +Use `\` (backslash) as the path separator for splitting and reassembly, matching Windows conventions. The ellipsis forms use `\…\` as the elision marker. + +### Rationale +All target paths in RCDir come from Windows reparse point resolution, which always produces backslash-separated paths. No need to handle forward slashes. diff --git a/specs/008-ellipsize-targets/spec.md b/specs/008-ellipsize-targets/spec.md new file mode 100644 index 0000000..0a85a58 --- /dev/null +++ b/specs/008-ellipsize-targets/spec.md @@ -0,0 +1,145 @@ +# Feature Specification: Ellipsize Long Link Target Paths + +**Feature Branch**: `008-ellipsize-targets` +**Created**: 2026-04-20 +**Status**: Draft +**Input**: User description: "Middle-truncate long link target paths to prevent line wrapping" + +## Clarifications + +### Session 2026-04-19 + +- Q: How should the middle-truncation algorithm select the split point? → A: Priority order: (1) keep first two dirs + …\ + leaf dir + filename, (2) first two dirs + …\ + filename, (3) first dir + …\ + filename. Always keep as much as fits at the highest priority level. +- Q: What happens when even the minimum truncation form doesn’t fit? → A: Show the leaf filename truncated with a trailing `…` to indicate cutoff (e.g., `→ DesktopStickerEdito…`) +- Q: What color should the ellipsis character use? → A: Default attribute, NOT the source file color — must be visually distinct so it’s obvious it’s not part of the actual path + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Middle-Truncate Long Target Paths (Priority: P1) + +A user runs `rcdir` in a directory containing AppExecLink entries or symlinks with long absolute target paths (e.g., `C:\Program Files\WindowsApps\PythonSoftwareFoundation.Python.3.12_3.12.1024.0_x64__qbz5n2kfra8p0\python3.12.exe`). Instead of the line wrapping past the terminal edge, the target path is middle-truncated with `…` so the entire line fits within the terminal width. The user sees the drive/root prefix and the leaf filename, which are the most actionable parts. + +**Why this priority**: Core value proposition — preventing line wrap is the entire feature. + +**Independent Test**: Run `rcdir` in `%LOCALAPPDATA%\Microsoft\WindowsApps` at a standard terminal width (120 chars). Verify that AppExecLink targets show `→ C:\Program Files\…\python3.12.exe` instead of wrapping. + +**Acceptance Scenarios**: + +1. **Given** an AppExecLink `python.exe` with target `C:\Program Files\WindowsApps\PythonSoftwareFoundation.Python.3.12_3.12.1024.0_x64__qbz5n2kfra8p0\python3.12.exe`, **When** the user runs `rcdir` at 120-char terminal width, **Then** the target is displayed as `C:\Program Files\…\python3.12.exe` (or similar middle-truncation that fits within the terminal width) +2. **Given** a junction with a short target path `C:\Dev\Projects`, **When** the user runs `rcdir`, **Then** the target is displayed in full — no truncation applied +3. **Given** a file symlink `config.yml → ..\shared\config.yml`, **When** the user runs `rcdir`, **Then** the relative target is displayed in full (relative paths are typically short) +4. **Given** a directory with no reparse points, **When** the user runs `rcdir`, **Then** output is identical to current behavior — no change + +--- + +### User Story 2 — Ellipsize in Tree Mode (Priority: P2) + +A user runs `rcdir --Tree` in a directory tree containing links with long target paths. The target path is middle-truncated just as in normal mode, accounting for the tree connector prefix width in the available-width calculation. + +**Why this priority**: Tree mode has even less horizontal space due to indentation, making truncation more important. + +**Independent Test**: Run `rcdir --Tree` in a tree containing a junction with a long target path. Verify the target is truncated and the line does not wrap. + +**Acceptance Scenarios**: + +1. **Given** a tree with a junction at depth 2 whose target is a long absolute path, **When** the user runs `rcdir --Tree`, **Then** the target is middle-truncated to fit within the remaining terminal width after tree connectors and metadata columns +2. **Given** a tree with a symlink whose target is short, **When** the user runs `rcdir --Tree`, **Then** the target is shown in full + +--- + +### User Story 3 — Disable Truncation with --Ellipsize- (Priority: P3) + +A user who needs to see full target paths can disable middle-truncation using the `--Ellipsize-` switch. This causes targets to display in full, wrapping as they did before this feature. + +**Why this priority**: Opt-out mechanism — most users want truncation, but power users may need full paths for scripting or debugging. + +**Independent Test**: Run `rcdir --Ellipsize-` in a directory with long targets. Verify full paths are shown and lines wrap as before. + +**Acceptance Scenarios**: + +1. **Given** a long target path that would normally be truncated, **When** the user runs `rcdir --Ellipsize-`, **Then** the full target path is displayed without truncation +2. **Given** `Ellipsize-` set in `.rcdirconfig`, **When** the user runs `rcdir`, **Then** truncation is disabled by default +3. **Given** `Ellipsize-` set in the `RCDIR` environment variable, **When** the user runs `rcdir`, **Then** truncation is disabled by default +4. **Given** `Ellipsize-` set in `.rcdirconfig`, **When** the user runs `rcdir --Ellipsize`, **Then** the CLI switch overrides the config and truncation is active + +--- + +### Edge Cases + +- **Target path is just a leaf filename** (relative symlink like `../config.yml`): Never truncated — already short +- **Target path has only two components** (e.g., `C:\file.exe`): Never truncated — nothing to elide from the middle +- **Terminal width is very narrow** (< 80): Truncation still works; in extreme cases, even the leaf filename may be the only thing shown after `…` +- **`…` would be longer than the truncated middle**: Don’t truncate — show full path (truncation must actually save space) +- **Wide mode (`/W`) and bare mode (`/B`)**: No target paths displayed in these modes, so ellipsize is not applicable +- **Filename itself is very long**: Filename is never truncated; only the target path is subject to ellipsize. If even the leaf filename of the target doesn’t fit, truncate it with a trailing `…` to indicate cutoff. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST middle-truncate link target paths using `…` (U+2026) when the full line (metadata + filename + ` → ` + target) would exceed the terminal width +- **FR-002**: Truncation MUST follow this priority order for what to preserve: + 1. First two directory components + `…\` + last directory component + leaf filename (e.g., `C:\Program Files\…\Notepad\Notepad.exe`) + 2. First two directory components + `…\` + leaf filename (e.g., `C:\Program Files\…\Notepad.exe`) + 3. First directory component + `…\` + leaf filename (e.g., `C:\…\Notepad.exe`) + 4. Leaf filename only — no directory prefix, no ellipsis (e.g., `Notepad.exe`) + 5. Leaf filename truncated with trailing `…` to fit within available width (e.g., `DesktopStickerEdito…`) + The algorithm MUST use the highest-priority form that fits within the available width. +- **FR-003**: System MUST NOT truncate target paths that fit within the terminal width. Paths with fewer than 3 components (e.g., `C:\file.exe`) are never truncated — there are no intermediate directories to elide. +- **FR-004**: System MUST NOT truncate the source filename — only the target path is subject to ellipsize +- **FR-005**: System MUST apply ellipsize in both normal mode and tree mode +- **FR-006**: In tree mode, the available width calculation MUST account for tree connector prefix width and indentation depth +- **FR-007**: System MUST provide an `--Ellipsize` switch (default: on) and `--Ellipsize-` to disable truncation +- **FR-008**: The `Ellipsize` switch MUST be configurable via `.rcdirconfig` and the `RCDIR` environment variable, following existing switch precedence (defaults < config file < env var < CLI) +- **FR-009**: System MUST document the `--Ellipsize` switch in `-?` help output and `--Settings` display +- **FR-010**: When truncation is disabled (`--Ellipsize-`), target paths MUST display in full, wrapping as before +- **FR-011**: The `…` ellipsis character MUST be rendered using the `Default` color attribute, NOT the source file’s color — it must be visually distinct from the surrounding path text to signal that path components were elided + +### Key Entities + +- **Available Width**: The number of characters remaining on the current line after metadata columns (date/time, attributes, size, cloud status, debug if active, owner if active), icon, filename, and ` → ` have been rendered. This is the maximum space the target path can occupy without wrapping. In tree mode, the tree connector prefix width is also subtracted. +- **Ellipsis Character**: `…` (U+2026, ELLIPSIS) — a single character used to replace elided path components. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: No line wrapping occurs for any link target in a default `rcdir` listing at standard terminal widths (120+ characters) +- **SC-002**: Users can identify the target’s location (root/drive) and actual filename (leaf) from the truncated display +- **SC-003**: Short target paths (under available width) are never modified — zero information loss for typical cases +- **SC-004**: All existing unit tests continue to pass with no modifications +- **SC-005**: New unit tests cover truncation logic: long path truncation, short path passthrough, edge cases (two-component paths, very narrow terminals) +- **SC-006**: `--Ellipsize-` switch correctly disables truncation and shows full paths + +## Assumptions + +- The `…` (U+2026) character renders as a single character width in all target terminal environments — consistent with existing Unicode symbol usage in rcdir +- The metadata column widths (date, time, attributes, size, cloud status, icon) are deterministic per line and can be computed or measured to calculate available width +- Ellipsize is only relevant for normal and tree modes — wide and bare modes do not display link targets +- The feature builds on the existing reparse target field and `→` display from spec 007 + +## Test Data (from `%LOCALAPPDATA%\Microsoft\WindowsApps`) + +The following real filename/target pairs from the developer’s machine MUST be used as unit test inputs to validate truncation behavior with realistic data: + +| Source Filename | Target Path | Notes | +| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `MicrosoftWindows.DesktopStickerEditorCentennial.exe` | `C:\Windows\SystemApps\MicrosoftWindows.Client.CBS_cw5n1h2txyewy\DesktopStickerEditorWin32Exe\DesktopStickerEditorWin32Exe.exe` | Long filename + long target — most aggressive truncation | +| `wingetcreate.exe` | `C:\Program Files\WindowsApps\Microsoft.WindowsPackageManagerManifestCreator_1.12.8.0_x64__8wekyb3d8bbwe\WingetCreateCLI\WingetCreateCLI.exe` | Short filename + very long target | +| `GameBarElevatedFT_Alias.exe` | `C:\Program Files\WindowsApps\Microsoft.XboxGamingOverlay_7.326.4151.0_arm64__8wekyb3d8bbwe\GameBarElevatedFT.exe` | Medium filename + long target | +| `wt.exe` | `C:\Program Files\WindowsApps\Microsoft.WindowsTerminal_1.24.10921.0_arm64__8wekyb3d8bbwe\wt.exe` | Short filename + medium target | +| `notepad.exe` | `C:\Program Files\WindowsApps\Microsoft.WindowsNotepad_11.2601.26.0_arm64__8wekyb3d8bbwe\Notepad\Notepad.exe` | Short filename + medium target with subdirectory | +| `winget.exe` | `C:\Program Files\WindowsApps\Microsoft.DesktopAppInstaller_1.29.30.0_arm64__8wekyb3d8bbwe\winget.exe` | Short target — should NOT be truncated at 120-wide | +| `AzureVpn.exe` | `C:\Windows\system32\SystemUWPLauncher.exe` | Short target — should never be truncated | + +## Release Checklist + +The following items MUST be completed before this feature is considered shipped: + +- Bump the minor version number — done at branch creation +- Update `CHANGELOG.md` with the new version entry and feature description +- Update `README.md` “What’s New” table with a new version row +- Update `TCDir/specs/sync-status.md` with spec 008 row (RCDir status, version) +- Add `--Ellipsize` to help output +- Add `Ellipsize` to `--Settings` display +- Add output parity tests covering ellipsize in normal and tree modes diff --git a/specs/008-ellipsize-targets/tasks.md b/specs/008-ellipsize-targets/tasks.md new file mode 100644 index 0000000..9ebaaf6 --- /dev/null +++ b/specs/008-ellipsize-targets/tasks.md @@ -0,0 +1,216 @@ +# Tasks: Ellipsize Long Link Target Paths + +**Input**: Design documents from `/specs/008-ellipsize-targets/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, quickstart.md + +**Tests**: Included — spec requires new unit tests (SC-005) and output parity tests (release checklist). + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +--- + +## Phase 1: Setup + +**Purpose**: Create the feature branch and new module skeleton + +- [X] T001 Create and checkout `008-ellipsize-targets` branch from `main` (version bump is handled automatically by `Build.ps1` / `IncrementVersion.ps1`) +- [X] T002 Create `src/path_ellipsis.rs` with `EllipsizedPath` struct (fields: `prefix: String`, `suffix: String`, `truncated: bool`) and a stub `ellipsize_path(target_path: &str, available_width: usize) -> EllipsizedPath` that returns the full path unchanged +- [X] T003 Register the new module by adding `pub mod path_ellipsis;` to `src/lib.rs` + +--- + +## Phase 2: Foundational — Switch Infrastructure (Blocking) + +**Purpose**: Wire up `--Ellipsize` / `--Ellipsize-` switch through the entire config pipeline so displayers can query it. MUST be complete before any user story phase. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +- [X] T004 Add `pub ellipsize: Option` field to `Config` in `src/config/mod.rs`; bump `SWITCH_COUNT` from 9 to 10; add accessor to `SWITCH_MEMBER_ORDER`; initialize to `None` in `Config::new()`; add to `switch_sources` default array +- [X] T005 [P] Add `ellipsize`/`ellipsize-` entries to `SWITCH_MAPPINGS` in `src/config/env_overrides.rs`; add `"ellipsize" => Some(9)` to `switch_name_to_source_index`; update the error message string to include "Ellipsize" +- [X] T006 [P] Add `pub ellipsize: Option` field to `CommandLine` in `src/command_line.rs`; add `("ellipsize", ..., "ellipsize-", ...)` to the `bool_switches` table in `handle_long_switch`; add `"ellipsize"` to `is_recognized_long_switch`; add conditional merge in `apply_config_defaults` (default: `true` — `cmd.ellipsize.unwrap_or(true)`) +- [X] T007 Add `--Ellipsize` to help output in `src/usage.rs`: add `SwitchInfo` entry to `SWITCH_INFOS`; add line to `-?` help text; add to `--Settings` display output + +**Checkpoint**: `cargo check` passes; `--Ellipsize` appears in `rcdir -?` output and `rcdir --Settings` output. Switch defaults to on. + +--- + +## Phase 3: User Story 1 — Middle-Truncate Long Target Paths (Priority: P1) 🎯 MVP + +**Goal**: In normal mode, long link target paths are middle-truncated with `…` (U+2026) to prevent line wrapping. The ellipsis renders in Default color for visual distinction. + +**Independent Test**: Run `rcdir` in `%LOCALAPPDATA%\Microsoft\WindowsApps` at 120-char terminal width. Verify AppExecLink targets show truncated paths like `C:\Program Files\…\python3.12.exe` instead of wrapping. + +### Tests for User Story 1 ⚠️ + +> **NOTE: Write these tests FIRST, ensure they FAIL before implementation** + +- [X] T008 [P] [US1] Write unit tests for `ellipsize_path()` in `src/path_ellipsis.rs` (inside `#[cfg(test)] mod tests`). Test cases MUST include all 7 real filename/target pairs from spec.md Test Data table at width 120, plus: + - Short path that fits (no truncation) + - Path with exactly 2 components (never truncated — nothing to elide) + - Path with 3 components (minimal truncation case) + - Priority level 1 form: first two dirs + `…\` + leaf dir + filename + - Priority level 2 form: first two dirs + `…\` + filename + - Priority level 3 form: first dir + `…\` + filename + - Leaf-only fallback with trailing `…` when even level 3 doesn't fit + - Edge case: `available_width` of 0 or 1 + - Edge case: truncated form is not shorter than original (should return original) + - FR-004 guard: verify that only the target path is passed to `ellipsize_path()` — source filename is never modified by the truncation logic +- [X] T009 [P] [US1] Write unit test verifying `EllipsizedPath` struct fields: when `truncated` is false, `prefix` is full path and `suffix` is empty; when `truncated` is true, `prefix` + `…` + `suffix` fits within `available_width` + +### Implementation for User Story 1 + +- [X] T010 [US1] Implement `ellipsize_path()` algorithm in `src/path_ellipsis.rs` per research.md R2: split on `\`, try priority forms (first two dirs + leaf dir + filename → first two dirs + filename → first dir + filename → leaf-only with trailing `…`), return `EllipsizedPath` with prefix/suffix split. Ensure all T008/T009 tests pass. +- [X] T011 [US1] Integrate ellipsize into normal displayer in `src/results_displayer/normal.rs`: compute `available_width` using formula from research.md R1 (console width minus date/time/attributes/size/cloud/debug/owner/icon/filename/arrow columns); call `ellipsize_path()` when `cmd.ellipsize.unwrap_or(true)` is true; render with split colors — prefix in `text_attr`, `…` in `Attribute::Default`, suffix in `text_attr` + +**Checkpoint**: `cargo test` passes. Normal-mode output shows truncated targets for long paths. Short paths are unaffected. + +--- + +## Phase 4: User Story 2 — Ellipsize in Tree Mode (Priority: P2) + +**Goal**: Tree mode also middle-truncates long target paths, accounting for tree connector prefix width in the available-width calculation. + +**Independent Test**: Run `rcdir --Tree` in a tree containing a junction with a long target path. Verify the target is truncated and the line does not wrap. + +### Tests for User Story 2 ⚠️ + +- [X] T012 [P] [US2] Write unit tests for tree-mode available-width calculation in `src/path_ellipsis.rs` (or as part of tree displayer tests): verify that tree prefix width (from `tree_state.get_prefix(is_last).len()` + indent) is subtracted from available width before calling `ellipsize_path()` + +### Implementation for User Story 2 + +- [X] T013 [US2] Integrate ellipsize into tree displayer in `src/results_displayer/tree.rs`: compute `available_width` using the same formula as normal mode but additionally subtracting tree prefix width (depth × indent + connector chars); call `ellipsize_path()` when `cmd.ellipsize.unwrap_or(true)` is true; render with same split-color pattern as normal mode + +**Checkpoint**: `cargo test` passes. Tree-mode output shows truncated targets for long paths at all depths. + +--- + +## Phase 5: User Story 3 — Disable Truncation with --Ellipsize- (Priority: P3) + +**Goal**: Users can opt out of truncation via `--Ellipsize-` switch, config file, or environment variable. + +**Independent Test**: Run `rcdir --Ellipsize-` in a directory with long targets. Verify full paths are shown and lines wrap as before. + +### Tests for User Story 3 ⚠️ + +- [X] T014 [P] [US3] Write unit tests verifying `ellipsize_path()` is NOT called (or is bypassed) when `cmd.ellipsize == Some(false)`: test that both normal and tree displayer code paths respect the switch. Also test config precedence: CLI `--Ellipsize` overrides config file `Ellipsize-`. + +### Implementation for User Story 3 + +- [X] T015 [US3] Verify the `--Ellipsize-` code path in `src/results_displayer/normal.rs` and `src/results_displayer/tree.rs`: when `cmd.ellipsize == Some(false)`, skip the call to `ellipsize_path()` and display the full target path unchanged (existing behavior). This should already work from T011/T013 guard checks — verify and add explicit test coverage. + +**Checkpoint**: `cargo test` passes. `rcdir --Ellipsize-` shows full untruncated paths. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Output parity tests, documentation updates, and release checklist items. + +- [X] T016 [P] Add output parity test cases in `tests/output_parity.rs`: add test cases for ellipsize in normal mode (default on) and tree mode, comparing `rcdir` vs `tcdir` output. Tests must gracefully skip when `tcdir.exe` is not available. +- [X] T017 [P] Add output parity test case in `tests/output_parity.rs` for `--Ellipsize-` (disabled) to verify both tools produce identical full-path output. +- [X] T018 [P] Update `CHANGELOG.md` with the new version entry and feature description for ellipsize +- [X] T019 [P] Update `README.md` "What's New" table with a row for the ellipsize feature +- [ ] T020 [P] Update `TCDir/specs/sync-status.md` with spec 008 row (RCDir status, version) — confirm with user before modifying TCDir workspace +- [X] T021 Run `cargo clippy -- -D warnings` and fix any warnings +- [X] T022 Run `cargo test` and verify all tests pass (unit + output parity) +- [X] T023 Run quickstart.md validation: build with VS Code task "Build Debug (current arch)", then manually test `rcdir` in `%LOCALAPPDATA%\Microsoft\WindowsApps` to confirm truncation behavior + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — start immediately +- **Foundational (Phase 2)**: Depends on Phase 1 — BLOCKS all user stories +- **User Story 1 (Phase 3)**: Depends on Phase 2 — core truncation algorithm + normal mode +- **User Story 2 (Phase 4)**: Depends on Phase 2 + Phase 3 (reuses same `ellipsize_path()` function and rendering pattern) +- **User Story 3 (Phase 5)**: Depends on Phases 3 and 4 — T014/T015 test displayer code paths that must exist first +- **Polish (Phase 6)**: Depends on Phases 3–5 being complete + +### User Story Dependencies + +- **User Story 1 (P1)**: After Foundational — implements the core pure function and normal-mode integration +- **User Story 2 (P2)**: After US1 — reuses `ellipsize_path()` with different available-width calculation +- **User Story 3 (P3)**: After US1 and US2 — displayer guard tests require normal and tree integration to be complete + +### Within Each User Story + +- Tests MUST be written and FAIL before implementation +- Pure function before displayer integration +- Normal mode before tree mode (shared algorithm) + +### Parallel Opportunities + +- T005 and T006 can run in parallel (different files: `env_overrides.rs` vs `command_line.rs`) +- T008 and T009 can run in parallel (independent test cases in same module) +- T012 can run in parallel with T014 (different story tests) +- T016, T017, T018, T019, T020 can all run in parallel (different files) + +--- + +## Parallel Example: Phase 2 + +``` +# These can run in parallel (different files): +T005: SWITCH_MAPPINGS in src/config/env_overrides.rs +T006: CommandLine field in src/command_line.rs +T007: Usage/help in src/usage.rs (after T004 for Config field) +``` + +## Parallel Example: User Story 1 + +``` +# Tests first (parallel — same file, independent test functions): +T008: Unit tests for ellipsize_path() algorithm +T009: Unit tests for EllipsizedPath struct contract + +# Then implementation (sequential): +T010: Implement ellipsize_path() — make tests pass +T011: Integrate into normal displayer +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup (branch + module skeleton) +2. Complete Phase 2: Foundational (switch infrastructure — 4 files) +3. Complete Phase 3: User Story 1 (pure function + normal mode) +4. **STOP and VALIDATE**: `cargo test`, `cargo clippy`, manual test in WindowsApps +5. Commit — this is a shippable MVP + +### Incremental Delivery + +1. Setup + Foundational → Switch wired, `rcdir -?` shows `--Ellipsize` +2. User Story 1 → Normal mode truncation works → Commit +3. User Story 2 → Tree mode truncation works → Commit +4. User Story 3 → `--Ellipsize-` opt-out verified → Commit +5. Polish → Parity tests, docs, clippy clean → Final commit + +### Commit Points (per project rules) + +- After Phase 2 (switch infrastructure complete) +- After Phase 3 (US1 MVP — normal mode working) +- After Phase 4 (US2 — tree mode working) +- After Phase 5 (US3 — opt-out verified) +- After Phase 6 (polish — docs, parity tests, clippy clean) + +--- + +## Notes + +- [P] tasks = different files, no dependencies on incomplete tasks +- [Story] label maps task to specific user story for traceability +- All unit tests use synthetic data — no system state (per copilot-instructions.md) +- Output parity tests are the allowed exception — they run real `rcdir`/`tcdir` binaries +- Build MUST use VS Code task "Build Debug (current arch)" or `scripts/Build.ps1` — never raw `cargo build` +- `cargo test`, `cargo check`, `cargo clippy` are fine to run directly diff --git a/src/command_line.rs b/src/command_line.rs index 1809d97..cef86e3 100644 --- a/src/command_line.rs +++ b/src/command_line.rs @@ -95,6 +95,7 @@ pub struct CommandLine { pub max_depth: i32, pub tree_indent: i32, pub size_format: SizeFormat, + pub ellipsize: Option, pub set_aliases: bool, pub get_aliases: bool, pub remove_aliases: bool, @@ -147,6 +148,7 @@ impl Default for CommandLine { max_depth: 0, tree_indent: 4, size_format: SizeFormat::Default, + ellipsize: None, set_aliases: false, get_aliases: false, remove_aliases: false, @@ -312,6 +314,7 @@ impl CommandLine { "depth", "treeindent", "size", + "ellipsize", "set-aliases", "get-aliases", "remove-aliases", @@ -498,6 +501,11 @@ impl CommandLine { { self.size_format = sf; } + + // Ellipsize: conditional merge — only apply config default if CLI didn't specify + if self.ellipsize.is_none() { + self.ellipsize = config.ellipsize; + } } @@ -580,6 +588,8 @@ impl CommandLine { ("icons-", |cmd| cmd.icons = Some (false)), ("tree", |cmd| cmd.tree = Some (true)), ("tree-", |cmd| cmd.tree = Some (false)), + ("ellipsize", |cmd| cmd.ellipsize = Some (true)), + ("ellipsize-", |cmd| cmd.ellipsize = Some (false)), ("set-aliases", |cmd| cmd.set_aliases = true), ("get-aliases", |cmd| cmd.get_aliases = true), ("remove-aliases", |cmd| cmd.remove_aliases = true), diff --git a/src/config/env_overrides.rs b/src/config/env_overrides.rs index cb2a9a6..22c4054 100644 --- a/src/config/env_overrides.rs +++ b/src/config/env_overrides.rs @@ -392,7 +392,7 @@ impl Config { } } - self.active_errors().push (ErrorInfo::new ("Invalid switch (expected W, S, P, M, B, Owner, Streams, Tree, or Icons)".into(), entry.into(), entry.into(), 0)); + self.active_errors().push (ErrorInfo::new ("Invalid switch (expected W, S, P, M, B, Owner, Streams, Tree, Icons, or Ellipsize)".into(), entry.into(), entry.into(), 0)); } @@ -727,6 +727,8 @@ const SWITCH_MAPPINGS: &[(&str, bool, SwitchAccessor)] = &[ ("icons-", false, |c| &mut c.icons), ("tree", true, |c| &mut c.tree), ("tree-", false, |c| &mut c.tree), + ("ellipsize", true, |c| &mut c.ellipsize), + ("ellipsize-", false, |c| &mut c.ellipsize), ]; @@ -773,6 +775,7 @@ fn switch_name_to_source_index(name: &str) -> Option { "streams" => Some (6), "icons" => Some (7), "tree" => Some (8), + "ellipsize" => Some (9), _ => None, } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 05db47f..c408124 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -373,6 +373,7 @@ pub struct Config { pub max_depth: Option, pub tree_indent: Option, pub size_format: Option, + pub ellipsize: Option, /// Validation results from last env var parse pub last_parse_result: ValidationResult, @@ -427,11 +428,11 @@ impl Default for Config { impl Config { - pub const SWITCH_COUNT: usize = 9; + pub const SWITCH_COUNT: usize = 10; /// Ordered member accessors for switch source tracking. - /// Index 0..8 maps to: wide_listing, bare_listing, recurse, perf_timer, - /// multi_threaded, show_owner, show_streams, icons, tree + /// Index 0..9 maps to: wide_listing, bare_listing, recurse, perf_timer, + /// multi_threaded, show_owner, show_streams, icons, tree, ellipsize pub const SWITCH_MEMBER_ORDER: [fn(&Config) -> &Option; Self::SWITCH_COUNT] = [ |c| &c.wide_listing, |c| &c.bare_listing, @@ -442,6 +443,7 @@ impl Config { |c| &c.show_streams, |c| &c.icons, |c| &c.tree, + |c| &c.ellipsize, ]; //////////////////////////////////////////////////////////////////////////// @@ -484,6 +486,7 @@ impl Config { max_depth: None, tree_indent: None, size_format: None, + ellipsize: None, last_parse_result: ValidationResult::default(), config_file_path: String::new(), config_file_loaded: false, diff --git a/src/lib.rs b/src/lib.rs index 59602ac..cd787f4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,6 +34,7 @@ pub mod profile_file_manager; pub mod alias_block_generator; pub mod tui_widgets; pub mod alias_manager; +pub mod path_ellipsis; diff --git a/src/path_ellipsis.rs b/src/path_ellipsis.rs new file mode 100644 index 0000000..56637f5 --- /dev/null +++ b/src/path_ellipsis.rs @@ -0,0 +1,580 @@ +// path_ellipsis.rs — Middle-truncate long link target paths with ellipsis +// +// Pure function for truncating long paths using `…` (U+2026) to prevent +// line wrapping in normal and tree display modes. Preserves first two +// directory components and leaf filename where possible, falling back +// gracefully to shorter forms. + +/// Ellipsis character used for path truncation (U+2026 HORIZONTAL ELLIPSIS). +pub const ELLIPSIS: char = '\u{2026}'; + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// EllipsizedPath +// +// Return type from `ellipsize_path()`. Enables the displayer to render +// prefix and suffix in the source file's color with the `…` character +// in Default color. +// +//////////////////////////////////////////////////////////////////////////////// + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EllipsizedPath { + /// Path text before the ellipsis. Full path if not truncated. + pub prefix: String, + + /// Path text after the ellipsis. Empty if not truncated. + pub suffix: String, + + /// `true` if the path was middle-truncated, `false` if shown in full. + pub truncated: bool, +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// ellipsize_path +// +// Middle-truncate a target path to fit within `available_width` characters. +// +// Algorithm (priority order — uses highest-priority form that fits): +// 1. Full path (no truncation needed) +// 2. first two dirs + `\…\` + leaf dir + filename +// 3. first two dirs + `\…\` + filename +// 4. first dir + `\…\` + filename +// 5. Leaf filename only (no prefix, no ellipsis) +// 6. Leaf filename truncated with trailing `…` +// +// Paths with fewer than 3 components are never truncated. +// +//////////////////////////////////////////////////////////////////////////////// + +pub fn ellipsize_path (target_path: &str, available_width: usize) -> EllipsizedPath { + // If the path fits, return it unchanged + if target_path.len() <= available_width { + return EllipsizedPath { + prefix: target_path.to_string(), + suffix: String::new(), + truncated: false, + }; + } + + // Split into components on backslash + let components: Vec<&str> = target_path.split ('\\').collect(); + + // Paths with fewer than 3 components — nothing to elide + if components.len() < 3 { + return EllipsizedPath { + prefix: target_path.to_string(), + suffix: String::new(), + truncated: false, + }; + } + + let leaf = components[components.len() - 1]; + + // Priority 1: first two dirs + \…\ + leaf dir + filename + // e.g. "C:\Program Files\…\Notepad\Notepad.exe" + if components.len() >= 4 { + let leaf_dir = components[components.len() - 2]; + let prefix = format! ("{}\\{}", components[0], components[1]); + let suffix = format! ("{}\\{}", leaf_dir, leaf); + // Total: prefix + \…\ + suffix = prefix.len() + 3 + suffix.len() + let total = prefix.len() + 3 + suffix.len(); + if total <= available_width && total < target_path.len() { + return EllipsizedPath { + prefix: format! ("{}\\", prefix), + suffix: format! ("\\{}", suffix), + truncated: true, + }; + } + } + + // Priority 2: first two dirs + \…\ + filename + // e.g. "C:\Program Files\…\Notepad.exe" + if components.len() >= 3 { + let prefix = format! ("{}\\{}", components[0], components[1]); + let suffix = leaf; + let total = prefix.len() + 3 + suffix.len(); + if total <= available_width && total < target_path.len() { + return EllipsizedPath { + prefix: format! ("{}\\", prefix), + suffix: format! ("\\{}", suffix), + truncated: true, + }; + } + } + + // Priority 3: first dir + \…\ + filename + // e.g. "C:\…\Notepad.exe" + { + let prefix = components[0]; + let suffix = leaf; + let total = prefix.len() + 3 + suffix.len(); + if total <= available_width && total < target_path.len() { + return EllipsizedPath { + prefix: format! ("{}\\", prefix), + suffix: format! ("\\{}", suffix), + truncated: true, + }; + } + } + + // Priority 4: Leaf filename only (no ellipsis) + if leaf.len() <= available_width { + return EllipsizedPath { + prefix: leaf.to_string(), + suffix: String::new(), + truncated: false, + }; + } + + // Priority 5: Leaf filename truncated with trailing … + if available_width >= 2 { + let truncated_leaf = &leaf[..available_width - 1]; + return EllipsizedPath { + prefix: truncated_leaf.to_string(), + suffix: String::new(), + truncated: true, + }; + } + + // Edge case: available_width is 0 or 1 — return what we can + if available_width == 1 { + return EllipsizedPath { + prefix: String::new(), + suffix: String::new(), + truncated: true, + }; + } + + // available_width == 0 + EllipsizedPath { + prefix: String::new(), + suffix: String::new(), + truncated: true, + } +} + + + + + +#[cfg(test)] +mod tests { + use super::*; + + + + //////////////////////////////////////////////////////////////////////////// + // + // Spec Test Data — Real WindowsApps paths from spec.md + // + // Each entry: (source_filename, target_path, notes) + // Available width for each test is calculated from a hypothetical + // 120-char console minus the metadata columns and filename columns. + // + //////////////////////////////////////////////////////////////////////////// + + // Helper: compute a typical available width for normal mode at 120-char + // terminal. The formula from research.md R1: + // available = console_width - 21 (date/time) - 9 (attrs) - size_col + // - cloud_col - icon_col - filename_len - 3 (arrow) + // + // For these tests, use a simplified "available width" parameter directly. + // The displayer computes the real value; here we test the pure function. + + + + //////////////////////////////////////////////////////////////////////////// + // + // T009: EllipsizedPath struct contract tests + // + //////////////////////////////////////////////////////////////////////////// + + #[test] + fn not_truncated_has_full_prefix_empty_suffix() { + let result = ellipsize_path ("C:\\Windows\\system32\\cmd.exe", 100); + assert!(!result.truncated); + assert_eq!(result.prefix, "C:\\Windows\\system32\\cmd.exe"); + assert!(result.suffix.is_empty()); + } + + + #[test] + fn truncated_prefix_plus_ellipsis_plus_suffix_fits() { + // Force truncation by using a narrow width + let path = "C:\\Program Files\\WindowsApps\\Microsoft.Long_1.0_x64__pkg\\app.exe"; + let result = ellipsize_path (path, 40); + assert!(result.truncated); + // prefix + … + suffix must fit within available_width + let total = result.prefix.len() + 1 + result.suffix.len(); + assert!(total <= 40, "total {} should fit in 40", total); + } + + + #[test] + fn truncated_result_is_shorter_than_original() { + let path = "C:\\Program Files\\WindowsApps\\Microsoft.Long_1.0_x64__pkg\\Sub\\app.exe"; + let width = 50; + let result = ellipsize_path (path, width); + if result.truncated && !result.suffix.is_empty() { + let total = result.prefix.len() + 1 + result.suffix.len(); + assert!(total < path.len(), "truncated form {} must be shorter than original {}", total, path.len()); + } + } + + + + //////////////////////////////////////////////////////////////////////////// + // + // T008: Short path — fits without truncation + // + //////////////////////////////////////////////////////////////////////////// + + #[test] + fn short_path_no_truncation() { + let result = ellipsize_path ("C:\\Windows\\system32\\SystemUWPLauncher.exe", 100); + assert!(!result.truncated); + assert_eq!(result.prefix, "C:\\Windows\\system32\\SystemUWPLauncher.exe"); + assert!(result.suffix.is_empty()); + } + + + + //////////////////////////////////////////////////////////////////////////// + // + // T008: Path with exactly 2 components — never truncated + // + //////////////////////////////////////////////////////////////////////////// + + #[test] + fn two_component_path_never_truncated() { + let result = ellipsize_path ("C:\\file.exe", 5); + assert!(!result.truncated); + assert_eq!(result.prefix, "C:\\file.exe"); + } + + + + //////////////////////////////////////////////////////////////////////////// + // + // T008: Path with 3 components — minimal truncation + // + //////////////////////////////////////////////////////////////////////////// + + #[test] + fn three_component_path_truncation() { + // "C:\Windows\cmd.exe" has 3 components: C:, Windows, cmd.exe + // Priority 2 should apply: C:\Windows\…\cmd.exe + // But that's the same length — so only if forced narrow + let path = "C:\\VeryLongDirectoryName\\cmd.exe"; + let result = ellipsize_path (path, 20); + assert!(result.truncated); + // Should get priority 3: C:\…\cmd.exe + assert_eq!(result.prefix, "C:\\"); + assert_eq!(result.suffix, "\\cmd.exe"); + } + + + + //////////////////////////////////////////////////////////////////////////// + // + // T008: Priority level 1 — first two dirs + …\ + leaf dir + filename + // + //////////////////////////////////////////////////////////////////////////// + + #[test] + fn priority_1_two_dirs_leaf_dir_filename() { + // C:\Program Files\WindowsApps\Microsoft.Notepad_1.0\Notepad\Notepad.exe + // → C:\Program Files\…\Notepad\Notepad.exe + let path = "C:\\Program Files\\WindowsApps\\Microsoft.Notepad_1.0\\Notepad\\Notepad.exe"; + // Priority 1: "C:\Program Files\" + "…" + "\Notepad\Notepad.exe" = 17+1+20 = 38 + let result = ellipsize_path (path, 50); + assert!(result.truncated); + assert_eq!(result.prefix, "C:\\Program Files\\"); + assert_eq!(result.suffix, "\\Notepad\\Notepad.exe"); + } + + + + //////////////////////////////////////////////////////////////////////////// + // + // T008: Priority level 2 — first two dirs + …\ + filename + // + //////////////////////////////////////////////////////////////////////////// + + #[test] + fn priority_2_two_dirs_filename() { + // Make a path where priority 1 doesn't fit but priority 2 does + let path = "C:\\Program Files\\WindowsApps\\SomeLongPackage\\SubDir\\app.exe"; + // Priority 1: "C:\Program Files\" + "…" + "\SubDir\app.exe" = 17+1+15 = 33 + // Priority 2: "C:\Program Files\" + "…" + "\app.exe" = 17+1+8 = 26 + let result = ellipsize_path (path, 30); + assert!(result.truncated); + assert_eq!(result.prefix, "C:\\Program Files\\"); + assert_eq!(result.suffix, "\\app.exe"); + } + + + + //////////////////////////////////////////////////////////////////////////// + // + // T008: Priority level 3 — first dir + …\ + filename + // + //////////////////////////////////////////////////////////////////////////// + + #[test] + fn priority_3_first_dir_filename() { + // Make a path where priority 2 doesn't fit but priority 3 does + let path = "C:\\Program Files\\WindowsApps\\SomeLong\\VeryLongFilename.exe"; + // Priority 2: "C:\Program Files\" + "…" + "\VeryLongFilename.exe" = 17+1+21 = 39 + // Priority 3: "C:\" + "…" + "\VeryLongFilename.exe" = 3+1+21 = 25 + let result = ellipsize_path (path, 26); + assert!(result.truncated); + assert_eq!(result.prefix, "C:\\"); + assert_eq!(result.suffix, "\\VeryLongFilename.exe"); + } + + + + //////////////////////////////////////////////////////////////////////////// + // + // T008: Priority level 4 — leaf filename only (no ellipsis) + // + //////////////////////////////////////////////////////////////////////////// + + #[test] + fn priority_4_leaf_only() { + // Width too narrow for even level 3, but leaf fits + let path = "C:\\Very\\Long\\Path\\app.exe"; + // Priority 3: "C:\" + "…" + "\app.exe" = 3+1+8 = 12 + // Leaf only: "app.exe" = 7 + let result = ellipsize_path (path, 10); + assert!(!result.truncated); // leaf only — no ellipsis in output + assert_eq!(result.prefix, "app.exe"); + assert!(result.suffix.is_empty()); + } + + + + //////////////////////////////////////////////////////////////////////////// + // + // T008: Priority level 5 — leaf filename truncated with trailing … + // + //////////////////////////////////////////////////////////////////////////// + + #[test] + fn priority_5_leaf_truncated_with_trailing_ellipsis() { + // Width too narrow even for the full leaf filename + let path = "C:\\Very\\Long\\Path\\VeryLongFilename.exe"; + // Leaf = "VeryLongFilename.exe" = 20 chars + // Width = 10: should get "VeryLongF…" (9 + 1 ellipsis) + let result = ellipsize_path (path, 10); + assert!(result.truncated); + assert_eq!(result.prefix, "VeryLongF"); + assert!(result.suffix.is_empty()); + } + + + + //////////////////////////////////////////////////////////////////////////// + // + // T008: Edge case — available_width of 0 + // + //////////////////////////////////////////////////////////////////////////// + + #[test] + fn available_width_zero() { + let result = ellipsize_path ("C:\\Windows\\cmd.exe", 0); + assert!(result.truncated); + assert!(result.prefix.is_empty()); + assert!(result.suffix.is_empty()); + } + + + + //////////////////////////////////////////////////////////////////////////// + // + // T008: Edge case — available_width of 1 + // + //////////////////////////////////////////////////////////////////////////// + + #[test] + fn available_width_one() { + let result = ellipsize_path ("C:\\Windows\\cmd.exe", 1); + assert!(result.truncated); + } + + + + //////////////////////////////////////////////////////////////////////////// + // + // T008: Edge case — truncated form not shorter than original + // + //////////////////////////////////////////////////////////////////////////// + + #[test] + fn truncation_must_save_space() { + // "C:\ab\c.e" — 3 components, 10 chars + // Priority 2: "C:\ab\" + "…" + "\c.e" = 6+1+4 = 11 — longer! + // Priority 3: "C:\" + "…" + "\c.e" = 3+1+4 = 8 — shorter, so use this + let path = "C:\\ab\\c.e"; + let result = ellipsize_path (path, 9); + if result.truncated && !result.suffix.is_empty() { + let total = result.prefix.len() + 1 + result.suffix.len(); + assert!(total < path.len()); + } + } + + + + //////////////////////////////////////////////////////////////////////////// + // + // T008: FR-004 guard — source filename is never modified + // + // ellipsize_path only receives the target path, never the source + // filename. Verify the function doesn't need or affect anything + // outside its input. + // + //////////////////////////////////////////////////////////////////////////// + + #[test] + fn fr004_only_target_path_is_processed() { + let source_filename = "python.exe"; + let target_path = "C:\\Program Files\\WindowsApps\\Very.Long.Package_1.0_x64__hash\\python3.12.exe"; + let result = ellipsize_path (target_path, 40); + // Source filename is completely untouched — it's not even an input + assert_eq!(source_filename, "python.exe"); + // The result only contains parts of the target path + assert!(result.truncated); + assert!(!result.prefix.contains(source_filename) || target_path.contains(source_filename)); + } + + + + //////////////////////////////////////////////////////////////////////////// + // + // T008: Spec Test Data — 7 real WindowsApps paths + // + // These test the algorithm against real-world data from the spec. + // Available width is computed assuming a typical normal-mode layout + // at 120-char terminal. + // + //////////////////////////////////////////////////////////////////////////// + + #[test] + fn spec_data_desktop_sticker_editor() { + // Long filename (52 chars) + long target + // Source: MicrosoftWindows.DesktopStickerEditorCentennial.exe (52) + // At 120 console: available ~= 120 - 21 - 9 - 11 - 3 - 2 - 52 - 3 = 19 + let target = "C:\\Windows\\SystemApps\\MicrosoftWindows.Client.CBS_cw5n1h2txyewy\\DesktopStickerEditorWin32Exe\\DesktopStickerEditorWin32Exe.exe"; + let result = ellipsize_path (target, 19); + assert!(result.truncated); + // At 19 chars, even level 3 "C:\…\DesktopStickerEditorWin32Exe.exe" is 37 chars + // So we get level 5: leaf truncated with trailing … + let total = result.prefix.len() + if result.suffix.is_empty() { 0 } else { 1 + result.suffix.len() }; + assert!(total <= 19, "total {} must fit in 19", total); + } + + + #[test] + fn spec_data_wingetcreate() { + // Short filename (17 chars) + very long target + // Source: wingetcreate.exe (16) + // available ~= 120 - 21 - 9 - 11 - 3 - 2 - 16 - 3 = 55 + let target = "C:\\Program Files\\WindowsApps\\Microsoft.WindowsPackageManagerManifestCreator_1.12.8.0_x64__8wekyb3d8bbwe\\WingetCreateCLI\\WingetCreateCLI.exe"; + let result = ellipsize_path (target, 55); + assert!(result.truncated); + // Level 1: "C:\Program Files\" + "…" + "\WingetCreateCLI\WingetCreateCLI.exe" = 17+1+36 = 54 — fits! + assert_eq!(result.prefix, "C:\\Program Files\\"); + assert_eq!(result.suffix, "\\WingetCreateCLI\\WingetCreateCLI.exe"); + } + + + #[test] + fn spec_data_gamebar() { + // Medium filename (27 chars) + // Source: GameBarElevatedFT_Alias.exe (27) + // available ~= 120 - 21 - 9 - 11 - 3 - 2 - 27 - 3 = 44 + let target = "C:\\Program Files\\WindowsApps\\Microsoft.XboxGamingOverlay_7.326.4151.0_arm64__8wekyb3d8bbwe\\GameBarElevatedFT.exe"; + let result = ellipsize_path (target, 44); + assert!(result.truncated); + // Level 2: "C:\Program Files\" + "…" + "\GameBarElevatedFT.exe" = 17+1+21 = 39 — fits! + assert_eq!(result.prefix, "C:\\Program Files\\"); + assert_eq!(result.suffix, "\\GameBarElevatedFT.exe"); + } + + + #[test] + fn spec_data_wt() { + // Short filename (6 chars) + medium target + // Source: wt.exe (6) + // available ~= 120 - 21 - 9 - 11 - 3 - 2 - 6 - 3 = 65 + let target = "C:\\Program Files\\WindowsApps\\Microsoft.WindowsTerminal_1.24.10921.0_arm64__8wekyb3d8bbwe\\wt.exe"; + let result = ellipsize_path (target, 65); + assert!(result.truncated); + // Level 2: "C:\Program Files\" + "…" + "\wt.exe" = 17+1+7 = 25 — fits! + // But also level 1: "C:\Program Files\" + "…" + "\Microsoft.WindowsTerminal_1.24.10921.0_arm64__8wekyb3d8bbwe\wt.exe" + // Level 1 = 17+1+len("\Microsoft.WindowsTerminal_1.24.10921.0_arm64__8wekyb3d8bbwe\wt.exe") — won't fit + // So level 2. + assert_eq!(result.prefix, "C:\\Program Files\\"); + assert_eq!(result.suffix, "\\wt.exe"); + } + + + #[test] + fn spec_data_notepad() { + // Short filename (11 chars) + medium target with subdirectory + // Source: notepad.exe (11) + // available ~= 120 - 21 - 9 - 11 - 3 - 2 - 11 - 3 = 60 + let target = "C:\\Program Files\\WindowsApps\\Microsoft.WindowsNotepad_11.2601.26.0_arm64__8wekyb3d8bbwe\\Notepad\\Notepad.exe"; + let result = ellipsize_path (target, 60); + assert!(result.truncated); + // Level 1: "C:\Program Files\" + "…" + "\Notepad\Notepad.exe" = 17+1+20 = 38 — fits! + assert_eq!(result.prefix, "C:\\Program Files\\"); + assert_eq!(result.suffix, "\\Notepad\\Notepad.exe"); + } + + + #[test] + fn spec_data_winget_not_truncated() { + // Short filename + short-ish target + // Source: winget.exe (10) + // available ~= 120 - 21 - 9 - 11 - 3 - 2 - 10 - 3 = 61 + let target = "C:\\Program Files\\WindowsApps\\Microsoft.DesktopAppInstaller_1.29.30.0_arm64__8wekyb3d8bbwe\\winget.exe"; + // Target is 101 chars — longer than 61, so WILL be truncated + let result = ellipsize_path (target, 61); + assert!(result.truncated); + // Level 2: "C:\Program Files\" + "…" + "\winget.exe" = 17+1+11 = 29 — fits! + assert_eq!(result.prefix, "C:\\Program Files\\"); + assert_eq!(result.suffix, "\\winget.exe"); + } + + + #[test] + fn spec_data_winget_wide_terminal_not_truncated() { + // At a wider terminal or with shorter metadata, target fits entirely + let target = "C:\\Program Files\\WindowsApps\\Microsoft.DesktopAppInstaller_1.29.30.0_arm64__8wekyb3d8bbwe\\winget.exe"; + let result = ellipsize_path (target, 120); + assert!(!result.truncated); + assert_eq!(result.prefix, target); + } + + + #[test] + fn spec_data_azurevpn_never_truncated() { + // Short target — should never be truncated even at available_width 50 + let target = "C:\\Windows\\system32\\SystemUWPLauncher.exe"; + let result = ellipsize_path (target, 50); + assert!(!result.truncated); + assert_eq!(result.prefix, target); + } +} diff --git a/src/results_displayer/normal.rs b/src/results_displayer/normal.rs index e0b4e9f..83937c5 100644 --- a/src/results_displayer/normal.rs +++ b/src/results_displayer/normal.rs @@ -13,6 +13,7 @@ use crate::drive_info::DriveInfo; use crate::file_info::{FileInfo, FILE_ATTRIBUTE_MAP}; use crate::listing_totals::ListingTotals; use crate::owner; +use crate::path_ellipsis; use super::common::{ display_cloud_status_symbol, @@ -255,7 +256,32 @@ fn display_file_results( // Reparse point: filename → target (FR-003, FR-006, FR-007) console.writef (text_attr, format_args! ("{}", name_str)); console.printf (config.attributes[Attribute::Information as usize], " \u{2192} "); - console.writef_line (text_attr, format_args! ("{}", file_info.reparse_target)); + + // Ellipsize long target paths to prevent line wrapping (spec 008) + if cmd.ellipsize.unwrap_or (true) { + let available_width = compute_available_width_for_target ( + console.width() as usize, + max_size_width, + cmd.resolved_size_format(), + icons_active, + #[cfg(debug_assertions)] + cmd.debug, + cmd.show_owner, + max_owner_len, + 0, // tree_prefix_width: 0 for normal mode + name_str.len(), + ); + let ep = path_ellipsis::ellipsize_path (&file_info.reparse_target, available_width); + if ep.truncated { + console.writef (text_attr, format_args! ("{}", ep.prefix)); + console.printf (config.attributes[Attribute::Default as usize], "\u{2026}"); + console.writef_line (text_attr, format_args! ("{}", ep.suffix)); + } else { + console.writef_line (text_attr, format_args! ("{}", ep.prefix)); + } + } else { + console.writef_line (text_attr, format_args! ("{}", file_info.reparse_target)); + } } else { console.writef_line (text_attr, format_args! ("{}", name_str)); } @@ -501,6 +527,83 @@ pub(super) fn display_file_owner(console: &mut Console, config: &Config, owner: +//////////////////////////////////////////////////////////////////////////////// +// +// compute_available_width_for_target +// +// Calculate how many characters remain on the current line for the +// reparse target path, after all metadata columns have been rendered. +// Used by both normal and tree displayers to feed `ellipsize_path()`. +// Port of: ComputeAvailableWidthForTarget +// +//////////////////////////////////////////////////////////////////////////////// + +#[allow(clippy::too_many_arguments)] +pub(super) fn compute_available_width_for_target( + console_width: usize, + max_size_width: usize, + size_format: SizeFormat, + icons_active: bool, + #[cfg(debug_assertions)] + debug: bool, + show_owner: bool, + max_owner_len: usize, + tree_prefix_width: usize, + filename_len: usize, +) -> usize { + // Date/time: "MM/dd/yyyy hh:mm tt " = 21 chars + let date_time_width = 21; + + // Attributes: one char per FILE_ATTRIBUTE_MAP entry = 9 chars + let attributes_width = FILE_ATTRIBUTE_MAP.len(); + + // File size column: + // Auto mode: " " + 7-char abbreviated = 9 + // Bytes mode: " " + max(max_size_width, 5) + let size_col_width = if size_format == SizeFormat::Auto { + 9 + } else { + 2 + max_size_width.max (5) + }; + + // Cloud status: always displayed (even CloudStatus::None emits a space) + // With icons: " {icon} " = 4 visual columns + // Without icons: " ● " = 3 chars + let cloud_width = if icons_active { 4 } else { 3 }; + + // Debug column: "[XXXXXXXX:YY] " = 14 chars (debug builds only) + #[cfg(debug_assertions)] + let debug_width = if debug { 14 } else { 0 }; + #[cfg(not(debug_assertions))] + let debug_width = 0; + + // Owner column: "owner + padding + space" = max_owner_len + 1 + let owner_width = if show_owner { max_owner_len + 1 } else { 0 }; + + // Icon glyph: icon (2 cols) + space = 3 visual columns + let icon_width = if icons_active { 3 } else { 0 }; + + // Arrow separator: " → " = 3 chars + let arrow_width = 3; + + let used = date_time_width + + attributes_width + + size_col_width + + cloud_width + + debug_width + + owner_width + + icon_width + + tree_prefix_width + + filename_len + + arrow_width; + + console_width.saturating_sub (used) +} + + + + + //////////////////////////////////////////////////////////////////////////////// // // display_file_streams diff --git a/src/results_displayer/tree.rs b/src/results_displayer/tree.rs index 4519dce..83d42d8 100644 --- a/src/results_displayer/tree.rs +++ b/src/results_displayer/tree.rs @@ -19,6 +19,7 @@ use crate::drive_info::DriveInfo; use crate::file_info::FileInfo; use crate::listing_totals::ListingTotals; use crate::owner; +use crate::path_ellipsis; use crate::tree_connector_state::TreeConnectorState; use super::common::{ @@ -31,6 +32,7 @@ use super::common::{ get_string_length_of_max_file_size, }; use super::normal::{ + compute_available_width_for_target, display_attributes, display_date_and_time, display_file_owner, @@ -356,7 +358,32 @@ impl TreeDisplayer { // Reparse point: filename → target (FR-003, FR-006, FR-007) console.writef (text_attr, format_args! ("{}", name_str)); console.printf (self.config.attributes[Attribute::Information as usize], " \u{2192} "); - console.writef_line (text_attr, format_args! ("{}", file_info.reparse_target)); + + // Ellipsize long target paths (spec 008) — tree mode subtracts prefix width + if self.cmd.ellipsize.unwrap_or (true) { + let available_width = compute_available_width_for_target ( + console.width() as usize, + self.largest_file_size_str_len, + self.cmd.resolved_size_format(), + self.icons_active, + #[cfg(debug_assertions)] + self.cmd.debug, + self.cmd.show_owner, + self.max_owner_len, + prefix.len(), + name_str.len(), + ); + let ep = path_ellipsis::ellipsize_path (&file_info.reparse_target, available_width); + if ep.truncated { + console.writef (text_attr, format_args! ("{}", ep.prefix)); + console.printf (self.config.attributes[Attribute::Default as usize], "\u{2026}"); + console.writef_line (text_attr, format_args! ("{}", ep.suffix)); + } else { + console.writef_line (text_attr, format_args! ("{}", ep.prefix)); + } + } else { + console.writef_line (text_attr, format_args! ("{}", file_info.reparse_target)); + } } else { console.writef_line (text_attr, format_args! ("{}", name_str)); } diff --git a/src/usage.rs b/src/usage.rs index cfad6c9..9f287a2 100644 --- a/src/usage.rs +++ b/src/usage.rs @@ -181,6 +181,7 @@ const SWITCH_INFOS: &[SwitchInfo] = &[ SwitchInfo { name: "Streams", description: "Display alternate data streams" }, SwitchInfo { name: "Icons", description: "Enable file-type icons" }, SwitchInfo { name: "Tree", description: "Display directory tree view" }, + SwitchInfo { name: "Ellipsize", description: "Truncate long link target paths" }, SwitchInfo { name: "Set-Aliases", description: "Configure PowerShell aliases" }, SwitchInfo { name: "Get-Aliases", description: "Display current alias configuration" }, SwitchInfo { name: "Remove-Aliases", description: "Remove PowerShell aliases" }, @@ -305,6 +306,7 @@ fn display_synopsis(console: &mut Console, prefix: char) { format!("[{{InformationHighlight}}{long}Streams{{Information}}] "), format!("[{{InformationHighlight}}{long}Icons{{Information}}] "), format!("[{{InformationHighlight}}{long}Tree{{Information}}] "), + format!("[{{InformationHighlight}}{long}Ellipsize{{Information}}] "), format!("[{{InformationHighlight}}{long}Depth{{Information}}={{InformationHighlight}}N{{Information}}] "), format!("[{{InformationHighlight}}{long}TreeIndent{{Information}}={{InformationHighlight}}N{{Information}}] "), format!("[{{InformationHighlight}}{long}Size{{Information}}={{InformationHighlight}}Auto{{Information}}|{{InformationHighlight}}Bytes{{Information}}]"), @@ -431,6 +433,7 @@ Copyright {copy} 2004-{year} by Robert Elmer {{InformationHighlight}}{long}Streams{{Information}} {lpad}Displays alternate data streams (NTFS only). {{InformationHighlight}}{long}Icons{{Information}} {lpad}Enables file-type icons (Nerd Font required). Use {{InformationHighlight}}{long}Icons-{{Information}} to disable. {{InformationHighlight}}{long}Tree{{Information}} {lpad}Displays a hierarchical directory tree view. Use {{InformationHighlight}}{long}Tree-{{Information}} to disable. + {{InformationHighlight}}{long}Ellipsize{{Information}} {lpad}Truncates long link target paths with \u{2026} to prevent line wrapping. Default: on. Use {{InformationHighlight}}{long}Ellipsize-{{Information}} to disable. {{InformationHighlight}}{long}Depth{{Information}}={{InformationHighlight}}N{{Information}} {lpad}Limits tree depth to N levels (requires {{InformationHighlight}}{long}Tree{{Information}}). {{InformationHighlight}}{long}TreeIndent{{Information}}={{InformationHighlight}}N{{Information}} {lpad}Sets tree indent width (1-8, default 4; requires {{InformationHighlight}}{long}Tree{{Information}}). {{InformationHighlight}}{long}Size{{Information}}={{InformationHighlight}}Auto{{Information}}|{{InformationHighlight}}Bytes{{Information}} {lpad}File size format: {{InformationHighlight}}Auto{{Information}} = abbreviated (KB/MB/GB), {{InformationHighlight}}Bytes{{Information}} = exact with commas. @@ -500,6 +503,7 @@ display items, file attributes, or file extensions: {{InformationHighlight}}Streams{{Information}} Display alternate data streams (NTFS) {{InformationHighlight}}Icons{{Information}} Enable file-type icons; use {{InformationHighlight}}Icons-{{Information}} to disable {{InformationHighlight}}Tree{{Information}} Display hierarchical tree view; use {{InformationHighlight}}Tree-{{Information}} to disable + {{InformationHighlight}}Ellipsize{{Information}} Truncate long link target paths; use {{InformationHighlight}}Ellipsize-{{Information}} to disable {{InformationHighlight}}Depth=N{{Information}} Limit tree depth to N levels {{InformationHighlight}}TreeIndent=N{{Information}} Tree indent width (1-8) {{InformationHighlight}}Size=Auto|Bytes{{Information}} File size format @@ -595,7 +599,8 @@ pub fn display_config_file_help (console: &mut Console, prefix: char) { \x20 {InformationHighlight}M{Information} Enables multi-threaded enumeration (default); use {InformationHighlight}M-{Information} to disable\n\ \x20 {InformationHighlight}Owner{Information} Display file ownership\n\ \x20 {InformationHighlight}Streams{Information} Display alternate data streams (NTFS)\n\ - \x20 {InformationHighlight}Icons{Information} Enable file-type icons; use {InformationHighlight}Icons-{Information} to disable\n" + \x20 {InformationHighlight}Icons{Information} Enable file-type icons; use {InformationHighlight}Icons-{Information} to disable\n\ + \x20 {InformationHighlight}Ellipsize{Information} Truncate long link target paths; use {InformationHighlight}Ellipsize-{Information} to disable\n" ); console.color_puts ( @@ -1455,7 +1460,8 @@ fn display_env_var_decoded_settings(console: &mut Console) { || config.show_owner.is_some() || config.show_streams.is_some() || config.icons.is_some() - || config.tree.is_some(); + || config.tree.is_some() + || config.ellipsize.is_some(); let has_display_items = DISPLAY_ITEM_INFOS.iter().any(|i| { config.attribute_sources[i.attr as usize] == AttributeSource::Environment @@ -1483,7 +1489,7 @@ fn display_env_var_decoded_settings(console: &mut Console) { if has_switches { console.puts(Attribute::Information, " Switches:"); - let switch_values: [&Option; 9] = [ + let switch_values: [&Option; 10] = [ &config.wide_listing, &config.recurse, &config.perf_timer, @@ -1493,9 +1499,10 @@ fn display_env_var_decoded_settings(console: &mut Console) { &config.show_streams, &config.icons, &config.tree, + &config.ellipsize, ]; for (i, info) in SWITCH_INFOS.iter().enumerate() { - if switch_values[i].is_some() { + if i < switch_values.len() && switch_values[i].is_some() { console.printf( config.attributes[Attribute::Default as usize], &format!(" {:<8} {}\n", info.name, info.description), diff --git a/tests/output_parity.rs b/tests/output_parity.rs index 3c6db57..03c1137 100644 --- a/tests/output_parity.rs +++ b/tests/output_parity.rs @@ -1283,3 +1283,36 @@ fn parity_reparse_appexeclink() { } } } + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// parity_ellipsize_disabled +// +// Verifies output parity when ellipsize is disabled (--Ellipsize-). +// Both tools should display full untruncated target paths. +// +//////////////////////////////////////////////////////////////////////////////// + +#[test] +fn parity_ellipsize_disabled() { + let apps_dir = r"C:\Users\relmer\AppData\Local\Microsoft\WindowsApps"; + if std::path::Path::new (apps_dir).exists() { + let pattern = format! ("{}\\*", apps_dir); + let (matching, total, diffs) = compare_output (&["/Ellipsize-", &pattern]); + if total > 0 && !diffs.is_empty() && !diffs[0].contains ("not found") { + let pct = (matching as f64 / total as f64) * 100.0; + assert!( + pct >= 95.0, + "Ellipsize disabled parity too low: {:.1}% ({}/{} lines). Diffs:\n{}", + pct, + matching, + total, + diffs.join ("\n"), + ); + } + } +}