From f452af6c62e9c3b2d1bdef81897f7b41672e01d9 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Tue, 7 Apr 2026 11:31:00 +0800 Subject: [PATCH] feat(utils): add abbreviate_path for smart path/URL truncation --- nanobot/utils/path.py | 106 ++++++++++++++++++++++++++++ tests/utils/test_abbreviate_path.py | 79 +++++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 nanobot/utils/path.py create mode 100644 tests/utils/test_abbreviate_path.py diff --git a/nanobot/utils/path.py b/nanobot/utils/path.py new file mode 100644 index 000000000..d23836e08 --- /dev/null +++ b/nanobot/utils/path.py @@ -0,0 +1,106 @@ +"""Path abbreviation utilities for display.""" + +from __future__ import annotations + +import os +import re +from urllib.parse import urlparse + + +def abbreviate_path(path: str, max_len: int = 40) -> str: + """Abbreviate a file path or URL, preserving basename and key directories. + + Strategy: + 1. Return as-is if short enough + 2. Replace home directory with ~/ + 3. From right, keep basename + parent dirs until budget exhausted + 4. Prefix with …/ + """ + if not path: + return path + + # Handle URLs: preserve scheme://domain + filename + if re.match(r"https?://", path): + return _abbreviate_url(path, max_len) + + # Normalize separators to / + normalized = path.replace("\\", "/") + + # Replace home directory + home = os.path.expanduser("~").replace("\\", "/") + if normalized.startswith(home + "/"): + normalized = "~" + normalized[len(home):] + elif normalized == home: + normalized = "~" + + # Return early only after normalization and home replacement + if len(normalized) <= max_len: + return normalized + + # Split into segments + parts = normalized.rstrip("/").split("/") + if len(parts) <= 1: + return normalized[:max_len - 1] + "\u2026" + + # Always keep the basename + basename = parts[-1] + # Budget: max_len minus "…/" prefix (2 chars) minus "/" separator minus basename + budget = max_len - len(basename) - 3 # -3 for "…/" + final "/" + + # Walk backwards from parent, collecting segments + kept: list[str] = [] + for seg in reversed(parts[:-1]): + needed = len(seg) + 1 # segment + "/" + if not kept and needed <= budget: + kept.append(seg) + budget -= needed + elif kept: + needed_with_sep = len(seg) + 1 + if needed_with_sep <= budget: + kept.append(seg) + budget -= needed_with_sep + else: + break + else: + break + + kept.reverse() + if kept: + return "\u2026/" + "/".join(kept) + "/" + basename + return "\u2026/" + basename + + +def _abbreviate_url(url: str, max_len: int = 40) -> str: + """Abbreviate a URL keeping domain and filename.""" + if len(url) <= max_len: + return url + + parsed = urlparse(url) + domain = parsed.netloc # e.g. "example.com" + path_part = parsed.path # e.g. "/api/v2/resource.json" + + # Extract filename from path + segments = path_part.rstrip("/").split("/") + basename = segments[-1] if segments else "" + + if not basename: + # No filename, truncate URL + return url[: max_len - 1] + "\u2026" + + budget = max_len - len(domain) - len(basename) - 4 # "…/" + "/" + if budget < 0: + return domain + "/\u2026" + basename[: max_len - len(domain) - 4] + + # Build abbreviated path + kept: list[str] = [] + for seg in reversed(segments[:-1]): + if len(seg) + 1 <= budget: + kept.append(seg) + budget -= len(seg) + 1 + else: + break + + kept.reverse() + if kept: + return domain + "/\u2026/" + "/".join(kept) + "/" + basename + return domain + "/\u2026/" + basename diff --git a/tests/utils/test_abbreviate_path.py b/tests/utils/test_abbreviate_path.py new file mode 100644 index 000000000..d8ee0a186 --- /dev/null +++ b/tests/utils/test_abbreviate_path.py @@ -0,0 +1,79 @@ +"""Tests for abbreviate_path utility.""" + +import os +from nanobot.utils.path import abbreviate_path + + +class TestAbbreviatePathShort: + def test_short_path_unchanged(self): + assert abbreviate_path("/home/user/file.py") == "/home/user/file.py" + + def test_exact_max_len_unchanged(self): + path = "/a/b/c" # 7 chars + assert abbreviate_path("/a/b/c", max_len=7) == "/a/b/c" + + def test_basename_only(self): + assert abbreviate_path("file.py") == "file.py" + + def test_empty_string(self): + assert abbreviate_path("") == "" + + +class TestAbbreviatePathHome: + def test_home_replacement(self): + home = os.path.expanduser("~") + result = abbreviate_path(f"{home}/project/file.py") + assert result.startswith("~/") + assert result.endswith("file.py") + + def test_home_preserves_short_path(self): + home = os.path.expanduser("~") + result = abbreviate_path(f"{home}/a.py") + assert result == "~/a.py" + + +class TestAbbreviatePathLong: + def test_long_path_keeps_basename(self): + path = "/a/b/c/d/e/f/g/h/very_long_filename.py" + result = abbreviate_path(path, max_len=30) + assert result.endswith("very_long_filename.py") + assert "\u2026" in result + + def test_long_path_keeps_parent_dir(self): + path = "/a/b/c/d/e/f/g/h/src/loop.py" + result = abbreviate_path(path, max_len=30) + assert "loop.py" in result + assert "src" in result + + def test_very_long_path_just_basename(self): + path = "/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z/file.py" + result = abbreviate_path(path, max_len=20) + assert result.endswith("file.py") + assert len(result) <= 20 + + +class TestAbbreviatePathWindows: + def test_windows_drive_path(self): + path = "D:\\Documents\\GitHub\\nanobot\\src\\utils\\helpers.py" + result = abbreviate_path(path, max_len=40) + assert result.endswith("helpers.py") + assert "nanobot" in result + + def test_windows_home(self): + home = os.path.expanduser("~") + path = os.path.join(home, ".nanobot", "workspace", "log.txt") + result = abbreviate_path(path) + assert result.startswith("~/") + assert "log.txt" in result + + +class TestAbbreviatePathURLs: + def test_url_keeps_domain_and_filename(self): + url = "https://example.com/api/v2/long/path/resource.json" + result = abbreviate_path(url, max_len=40) + assert "resource.json" in result + assert "example.com" in result + + def test_short_url_unchanged(self): + url = "https://example.com/api" + assert abbreviate_path(url) == url