Skip to content

Commit d8d3deb

Browse files
committed
fix: refine interactive dns search results
1 parent 55d905d commit d8d3deb

4 files changed

Lines changed: 199 additions & 9 deletions

File tree

‎o2switch_cli/cli/interactive.py‎

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@
99
from o2switch_cli.cli.interactive_support import (
1010
build_dns_search_suggestions,
1111
build_domain_suggestions,
12+
build_hostname_suggestions,
1213
build_subdomain_suggestions,
1314
filter_domains,
15+
filter_hostname_results,
1416
filter_subdomains,
17+
paginate_items,
1518
)
1619
from o2switch_cli.cli.ui import TerminalUI
17-
from o2switch_cli.core.models import DomainDescriptor, SubdomainDescriptor
20+
from o2switch_cli.core.models import DomainDescriptor, HostnameSearchResult, SubdomainDescriptor
1821

1922
MIN_PAGE_SIZE = 6
2023
MAX_PAGE_SIZE = 12
@@ -54,6 +57,77 @@ def _page_size(ui: TerminalUI) -> int:
5457
return max(MIN_PAGE_SIZE, min(MAX_PAGE_SIZE, ui.console.size.height - 15))
5558

5659

60+
def _browse_hostname_results(
61+
ui: TerminalUI,
62+
results: list[HostnameSearchResult],
63+
*,
64+
page_size: int,
65+
empty_message: str,
66+
) -> None:
67+
if not results:
68+
ui.print_info(empty_message)
69+
return
70+
71+
all_results = list(results)
72+
visible_results = list(results)
73+
filter_term = ""
74+
page = 1
75+
76+
while True:
77+
if not visible_results:
78+
ui.console.clear()
79+
ui.print_banner()
80+
ui.print_info(f'No results matched the filter "{filter_term}".')
81+
else:
82+
window = paginate_items(visible_results, page=page, page_size=page_size)
83+
ui.console.clear()
84+
ui.print_banner()
85+
if filter_term:
86+
ui.print_info(f'Filtered view: "{filter_term}"')
87+
ui.print_hostname_search_results(window.items, window)
88+
page = window.page
89+
90+
choices: list[str] = []
91+
total_pages = max(1, (len(visible_results) + page_size - 1) // page_size)
92+
if page > 1:
93+
choices.append("Previous page")
94+
if page < total_pages and visible_results:
95+
choices.append("Next page")
96+
choices.extend(["First page", "Last page", "Filter results"])
97+
if filter_term:
98+
choices.append("Reset filters")
99+
choices.append("Close results")
100+
101+
action = questionary.select(
102+
f"Browse results ({page}/{total_pages})",
103+
choices=choices,
104+
).ask()
105+
if action == "Previous page":
106+
page -= 1
107+
elif action == "Next page":
108+
page += 1
109+
elif action == "First page":
110+
page = 1
111+
elif action == "Last page":
112+
page = total_pages
113+
elif action == "Filter results":
114+
next_filter = ui.prompt_realtime_search(
115+
"Filter current results",
116+
suggestions=build_hostname_suggestions(all_results),
117+
help_text="Type hosted, dns, hostname, record type, IP, zone, or docroot to narrow the result set",
118+
)
119+
if next_filter.strip():
120+
filter_term = next_filter.strip()
121+
visible_results = filter_hostname_results(all_results, filter_term)
122+
page = 1
123+
elif action == "Reset filters":
124+
filter_term = ""
125+
visible_results = list(all_results)
126+
page = 1
127+
else:
128+
return
129+
130+
57131
def run_interactive_menu(app_context: AppContext) -> None:
58132
ui = TerminalUI(app_context.console, app_context.output_format)
59133
cache = InteractiveDataCache()
@@ -115,11 +189,11 @@ def action(active_context: AppContext, selected_choice: str = choice) -> None:
115189
return
116190
with ui.status("Searching hosted subdomains and DNS zones", spinner="dots12"):
117191
matches = active_context.runtime().dns.search(term)
118-
ui.browse_pages(
192+
_browse_hostname_results(
193+
ui,
119194
matches,
120195
page_size=_page_size(ui),
121196
empty_message="No DNS or hosted results matched the search term.",
122-
render_page=lambda page_items, window: ui.print_hostname_search_results(page_items, window),
123197
)
124198
elif selected_choice == "DNS: upsert A record":
125199
host = questionary.text("Hostname or label").ask() or ""

‎o2switch_cli/core/dns_service.py‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,36 @@ def _hosted_subdomains(self, term: str) -> list[SubdomainDescriptor]:
256256
)
257257
return sorted(descriptors, key=lambda item: item.fqdn)
258258

259+
@staticmethod
260+
def _search_rank(item: HostnameSearchResult, needle: str) -> tuple[int, ... | str]:
261+
hostname = item.hostname.lower()
262+
value = (item.value or "").lower()
263+
zone = (item.zone or "").lower()
264+
record_type = (item.record_type or "").upper()
265+
exact_hostname = 0 if hostname == needle else 1
266+
hosted_penalty = 0 if item.category is SearchCategory.HOSTED_SUBDOMAINS else 1
267+
exact_zone = 0 if zone == needle else 1
268+
exact_value = 0 if value == needle else 1
269+
startswith_penalty = 0 if needle and hostname.startswith(needle) else 1
270+
endswith_penalty = 0 if needle and hostname.endswith(f".{needle}") else 1
271+
service_penalty = 1 if hostname.startswith("_") else 0
272+
record_penalty = {"A": 0, "AAAA": 1, "CNAME": 1, "MX": 2, "TXT": 3, "SRV": 4}.get(record_type, 5)
273+
length_penalty = abs(len(hostname) - len(needle)) if needle else len(hostname)
274+
return (
275+
exact_hostname,
276+
hosted_penalty,
277+
exact_zone,
278+
exact_value,
279+
startswith_penalty,
280+
endswith_penalty,
281+
service_penalty,
282+
record_penalty,
283+
length_penalty,
284+
hostname,
285+
item.category.value,
286+
record_type,
287+
)
288+
259289
def search(self, term: str) -> list[HostnameSearchResult]:
260290
needle = term.strip().lower()
261291
matches: list[HostnameSearchResult] = []
@@ -287,7 +317,7 @@ def search(self, term: str) -> list[HostnameSearchResult]:
287317
)
288318
)
289319
if matches:
290-
return sorted(matches, key=lambda item: (item.hostname, item.category.value, item.record_type or ""))
320+
return sorted(matches, key=lambda item: self._search_rank(item, needle))
291321
try:
292322
hostname = normalize_hostname(term)
293323
except Exception:

‎tests/test_dns_service.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,21 @@ def test_search_combines_hosted_dns_and_available_states() -> None:
311311
assert available[0].category is SearchCategory.AVAILABLE
312312

313313

314+
def test_search_prioritizes_hosted_and_app_records_over_service_noise() -> None:
315+
_, service = build_service(
316+
[
317+
{"dname": "_autodiscover._tcp.app", "record_type": "SRV", "data": ["0"], "ttl": 300, "line_index": 1},
318+
{"dname": "app", "record_type": "A", "address": "203.0.113.25", "ttl": 300, "line_index": 2},
319+
],
320+
hosted=[{"domain": "app.ginutech.com", "rootdomain": "ginutech.com", "dir": "/public_html/app"}],
321+
)
322+
323+
results = service.search("ginutech.com")
324+
hostnames = [item.hostname for item in results]
325+
326+
assert hostnames.index("app.ginutech.com") < hostnames.index("_autodiscover._tcp.app.ginutech.com")
327+
328+
314329
def test_search_returns_available_when_hostname_is_free() -> None:
315330
_, service = build_service([])
316331
results = service.search("free.ginutech.com")

‎tests/test_interactive.py‎

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,8 @@ class FakeRuntime:
110110
subdomains = FakeSubdomains()
111111
dns = FakeDNS()
112112

113-
answers = iter(["DNS: search", "Exit"])
114-
browsed: list[list[HostnameSearchResult]] = []
113+
answers = iter(["DNS: search", "Close results", "Exit"])
114+
rendered: list[list[str]] = []
115115

116116
class FakeSelect:
117117
def ask(self):
@@ -129,12 +129,83 @@ def ask(self):
129129
)
130130
monkeypatch.setattr("o2switch_cli.cli.interactive.TerminalUI.print_info", lambda self, message: None)
131131
monkeypatch.setattr(
132-
"o2switch_cli.cli.interactive.TerminalUI.browse_pages",
133-
lambda self, items, **kwargs: browsed.append(list(items)),
132+
"o2switch_cli.cli.interactive.TerminalUI.print_hostname_search_results",
133+
lambda self, items, page_window=None: rendered.append([item.hostname for item in items]),
134134
)
135135
monkeypatch.setattr("o2switch_cli.cli.interactive.TerminalUI.status", _noop_status)
136+
monkeypatch.setattr("o2switch_cli.cli.interactive._page_size", lambda ui: 12)
136137

137138
run_interactive_menu(_app_context())
138139

139140
assert calls == ["name.ginutech.com"]
140-
assert [item.hostname for item in browsed[0]] == ["name.ginutech.com"]
141+
assert rendered[0] == ["name.ginutech.com"]
142+
143+
144+
def test_interactive_dns_search_can_refine_results_in_place(monkeypatch) -> None:
145+
class FakeDomains:
146+
def list_domains(self) -> list[DomainDescriptor]:
147+
return [DomainDescriptor(domain="ginutech.com", type=DomainType.ADDON)]
148+
149+
class FakeSubdomains:
150+
def search(self, term: str) -> list[SubdomainDescriptor]:
151+
assert term == ""
152+
return [SubdomainDescriptor(fqdn="app.ginutech.com", label="app", root_domain="ginutech.com")]
153+
154+
calls: list[str] = []
155+
156+
class FakeDNS:
157+
def search(self, term: str) -> list[HostnameSearchResult]:
158+
calls.append(term)
159+
return [
160+
HostnameSearchResult(
161+
category=SearchCategory.HOSTED_SUBDOMAINS,
162+
hostname="app.ginutech.com",
163+
zone="ginutech.com",
164+
managed_by_cpanel=True,
165+
docroot="/public_html/app",
166+
),
167+
HostnameSearchResult(
168+
category=SearchCategory.DNS_RECORDS,
169+
hostname="_autodiscover._tcp.app.ginutech.com",
170+
record_type="SRV",
171+
value="0",
172+
zone="ginutech.com",
173+
),
174+
]
175+
176+
class FakeRuntime:
177+
domains = FakeDomains()
178+
subdomains = FakeSubdomains()
179+
dns = FakeDNS()
180+
181+
answers = iter(["DNS: search", "Filter results", "Close results", "Exit"])
182+
prompts = iter(["ginutech.com", "hosted"])
183+
rendered: list[list[str]] = []
184+
185+
class FakeSelect:
186+
def ask(self):
187+
return next(answers)
188+
189+
monkeypatch.setattr(AppContext, "runtime", lambda self: FakeRuntime())
190+
monkeypatch.setattr(
191+
"o2switch_cli.cli.interactive.questionary.select",
192+
lambda *args, **kwargs: FakeSelect(),
193+
)
194+
monkeypatch.setattr("o2switch_cli.cli.interactive.TerminalUI.print_banner", lambda self: None)
195+
monkeypatch.setattr(
196+
"o2switch_cli.cli.interactive.TerminalUI.prompt_realtime_search",
197+
lambda self, *args, **kwargs: next(prompts),
198+
)
199+
monkeypatch.setattr("o2switch_cli.cli.interactive.TerminalUI.print_info", lambda self, message: None)
200+
monkeypatch.setattr(
201+
"o2switch_cli.cli.interactive.TerminalUI.print_hostname_search_results",
202+
lambda self, items, page_window=None: rendered.append([item.hostname for item in items]),
203+
)
204+
monkeypatch.setattr("o2switch_cli.cli.interactive.TerminalUI.status", _noop_status)
205+
monkeypatch.setattr("o2switch_cli.cli.interactive._page_size", lambda ui: 12)
206+
207+
run_interactive_menu(_app_context())
208+
209+
assert calls == ["ginutech.com"]
210+
assert rendered[0] == ["app.ginutech.com", "_autodiscover._tcp.app.ginutech.com"]
211+
assert rendered[1] == ["app.ginutech.com"]

0 commit comments

Comments
 (0)