Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions src/armillary/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,31 @@ def find_projects_by_name(projects: list[Project], project_name: str) -> list[Pr


def summarize_project_matches(projects: list[Project], *, limit: int = 5) -> str:
"""Return a short human-readable summary of matching project names."""
names = ", ".join(project.name for project in projects[:limit])
"""Return a short human-readable summary of matching project names.

When the visible matches contain duplicate names (common with
duplicate umbrellas like ``RubymineProjects/`` + ``projects_prod/``
holding the same repo), each entry is annotated with its
home-shortened path so the user can disambiguate. Without that
annotation, the message ``'foo' is ambiguous: foo, foo`` gives the
user no way to "be more specific".
"""
if not projects:
return ""

visible = projects[:limit]
names = [project.name for project in visible]
has_duplicates = len(set(names)) < len(names)

if has_duplicates:
rendered = ", ".join(
f"{project.name} ({shorten_home(project.path)})" for project in visible
)
else:
rendered = ", ".join(names)

suffix = "" if len(projects) <= limit else f" (+{len(projects) - limit} more)"
return f"{names}{suffix}"
return f"{rendered}{suffix}"


def _load_json(
Expand Down
60 changes: 59 additions & 1 deletion tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

from __future__ import annotations

from datetime import datetime
from pathlib import Path

from armillary.utils import load_json_str_list
from armillary.models import Project, ProjectType
from armillary.utils import load_json_str_list, summarize_project_matches


def test_load_json_str_list_filters_non_strings(tmp_path: Path) -> None:
Expand All @@ -13,3 +15,59 @@ def test_load_json_str_list_filters_non_strings(tmp_path: Path) -> None:
path.write_text(payload, encoding="utf-8")

assert load_json_str_list(path) == ["ok", "still-ok"]


def _project(name: str, path: str) -> Project:
p = Path(path)
return Project(
path=p,
name=name,
type=ProjectType.GIT,
umbrella=p.parent,
last_modified=datetime(2026, 5, 4, 12, 0, 0),
metadata=None,
)


def test_summarize_project_matches_unique_names_just_names() -> None:
matches = [
_project("alpha", "/repos/alpha"),
_project("beta", "/repos/beta"),
]
assert summarize_project_matches(matches) == "alpha, beta"


def test_summarize_project_matches_duplicate_names_show_paths() -> None:
home = str(Path.home())
matches = [
_project("foo", f"{home}/projects_prod/foo"),
_project("foo", f"{home}/RubymineProjects/foo"),
]
out = summarize_project_matches(matches)
# Both names annotated with their home-shortened paths so the
# CLI's "Be more specific" prompt is actually actionable.
assert "foo (~/projects_prod/foo)" in out
assert "foo (~/RubymineProjects/foo)" in out


def test_summarize_project_matches_partial_duplicate_annotates_all() -> None:
"""When ANY duplicate is in the visible window, every entry gets a
path so the rendered list is consistent and unambiguous."""
matches = [
_project("foo", "/a/foo"),
_project("foo", "/b/foo"),
_project("bar", "/c/bar"),
]
out = summarize_project_matches(matches)
assert "foo (/a/foo)" in out
assert "foo (/b/foo)" in out
assert "bar (/c/bar)" in out


def test_summarize_project_matches_truncates_to_limit() -> None:
matches = [_project(f"p{i}", f"/r/p{i}") for i in range(7)]
assert summarize_project_matches(matches, limit=3) == "p0, p1, p2 (+4 more)"


def test_summarize_project_matches_empty_returns_empty_string() -> None:
assert summarize_project_matches([]) == ""
Loading