feat(cli): homepage landing screen for bare msdl; fix search substring bug

The bare `msdl` invocation showed all 22 products (17 consumer + 5
eval) as a flat numbered list. Replaces it with a curated landing
screen: 5 popular products (picked from real usage telemetry) plus a
2-item eval shortlist, with "list" as an escape hatch to today's full
picker and free text routed through the existing search flow.

Also fixes a search bug: querying "windows 10" incorrectly matched
Windows 11 24H2 because "10" is a substring of its build number
(26100.1742). Query words now must start at a word boundary rather
than matching anywhere, so digit fragments inside unrelated numbers
can no longer collide -- while "arm" still fuzzy-matches "ARM64".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Shekhar Vaidya 2026-07-13 12:24:43 +05:30
parent b1ee80f9b6
commit 3994a82d6d
5 changed files with 391 additions and 23 deletions

View file

@ -60,7 +60,7 @@ func searchProducts(query string) []Product {
name := strings.ToLower(p.Name)
match := true
for _, w := range words {
if !strings.Contains(name, w) {
if !containsWordStart(name, w) {
match = false
break
}
@ -72,6 +72,31 @@ func searchProducts(query string) []Product {
return results
}
// containsWordStart reports whether s contains substr starting at a word
// boundary: the start of s, or right after a non-alphanumeric character.
// Plain strings.Contains would let a query word match a digit fragment
// buried inside an unrelated number -- e.g. "10" matching inside a build
// number like "26100.1742" -- which made "windows 10" incorrectly return
// Windows 11 24H2 results.
func containsWordStart(s, substr string) bool {
from := 0
for {
i := strings.Index(s[from:], substr)
if i < 0 {
return false
}
pos := from + i
if pos == 0 || !isAlphanumeric(s[pos-1]) {
return true
}
from = pos + 1
}
}
func isAlphanumeric(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9')
}
func findEvalProduct(slug string) (EvalProduct, bool) {
for _, p := range evalProducts {
if p.Slug == slug {

View file

@ -1,6 +1,9 @@
package main
import "testing"
import (
"strings"
"testing"
)
func TestFindProductByID_found(t *testing.T) {
p, ok := findProductByID("3262")
@ -40,6 +43,35 @@ func TestSearchProducts_caseInsensitive(t *testing.T) {
}
}
func TestSearchProducts_noDigitFragmentCollision(t *testing.T) {
// "10" must not match inside the "26100.1742" build number of Windows 11
// 24H2/25H2 products -- regression test for the reported "windows 10"
// bug that returned Windows 11 results.
results := searchProducts("windows 10")
for _, p := range results {
if strings.Contains(p.Name, "11") {
t.Errorf("query %q matched %q, a Windows 11 product", "windows 10", p.Name)
}
}
if len(results) == 0 {
t.Fatal("expected windows 10 to match the actual Windows 10 products")
}
}
func TestSearchProducts_prefixSubstringStillWorks(t *testing.T) {
// "arm" should still fuzzy-match "ARM64" -- the word-boundary fix must
// only reject matches that start mid-digit-run, not all substrings.
results := searchProducts("arm")
if len(results) == 0 {
t.Fatal("expected arm to match ARM64 products")
}
for _, p := range results {
if !strings.Contains(strings.ToLower(p.Name), "arm") {
t.Errorf("unexpected match %q for query %q", p.Name, "arm")
}
}
}
func TestFindEvalProduct_found(t *testing.T) {
p, ok := findEvalProduct("server-2025")
if !ok {

114
cli/homepage.go Normal file
View file

@ -0,0 +1,114 @@
package main
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
// homepageFeaturedIDs are the consumer products shown on the bare `msdl`
// landing screen, picked from real usage telemetry rather than the full
// catalog. The full catalog is still one step away via "list".
var homepageFeaturedIDs = []string{"3262", "2618", "3321", "3113", "3265"}
// homepageEvalSlugs are the eval products shown on the landing screen.
var homepageEvalSlugs = []string{"server-2025", "win11-ent"}
type homepageKind int
const (
homepageKindProduct homepageKind = iota
homepageKindEval
homepageKindList
homepageKindSearch
)
type homepageChoice struct {
kind homepageKind
productID string
evalSlug string
query string
}
const homepageRule = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
// showHomepage prints the landing screen for a bare `msdl` invocation and
// reads the user's choice: a number, "list" for the full catalog, or free
// text to search by name.
func showHomepage(latestVersion string) (homepageChoice, error) {
fmt.Fprintln(os.Stderr, homepageRule)
fmt.Fprintln(os.Stderr, "msdl — Windows ISO Downloader")
fmt.Fprintln(os.Stderr, "Direct links from Microsoft's CDN. No browser, no Media Creation Tool.")
fmt.Fprintln(os.Stderr, homepageRule)
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, "Popular:")
featured := make([]Product, 0, len(homepageFeaturedIDs))
for _, id := range homepageFeaturedIDs {
if p, ok := findProductByID(id); ok {
featured = append(featured, p)
}
}
for i, p := range featured {
fmt.Fprintf(os.Stderr, "%d. %s\n", i+1, p.Name)
}
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, "Evaluation / Enterprise:")
evalShortlist := make([]EvalProduct, 0, len(homepageEvalSlugs))
for _, slug := range homepageEvalSlugs {
if ep, ok := findEvalProduct(slug); ok {
evalShortlist = append(evalShortlist, ep)
}
}
for i, ep := range evalShortlist {
fmt.Fprintf(os.Stderr, "%d. %s\n", len(featured)+i+1, ep.Name)
}
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, `Commands: --list (full catalog) · --help`)
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, homepageRule)
if latestVersion != "" && latestVersion != Version && Version != "dev" {
fmt.Fprintf(os.Stderr, "Version %s (Update Available %s) - a TechLatest Open-source Contribution\n", Version, latestVersion)
} else {
fmt.Fprintf(os.Stderr, "Version %s - a TechLatest Open-source Contribution\n", Version)
}
fmt.Fprintln(os.Stderr)
max := len(featured) + len(evalShortlist)
fmt.Fprintf(os.Stderr, `Choice [1-%d], search by name, or "list": `, max)
scanner := bufio.NewScanner(os.Stdin)
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return homepageChoice{}, fmt.Errorf("reading input: %w", err)
}
return homepageChoice{}, fmt.Errorf("no input")
}
return parseHomepageInput(scanner.Text(), featured, evalShortlist)
}
// parseHomepageInput interprets the raw input line from the homepage prompt.
func parseHomepageInput(input string, featured []Product, evalShortlist []EvalProduct) (homepageChoice, error) {
text := strings.TrimSpace(input)
if text == "" {
return homepageChoice{}, fmt.Errorf(`enter a choice, search term, or "list"`)
}
if strings.EqualFold(text, "list") {
return homepageChoice{kind: homepageKindList}, nil
}
if n, err := strconv.Atoi(text); err == nil {
max := len(featured) + len(evalShortlist)
if n < 1 || n > max {
return homepageChoice{}, fmt.Errorf(`enter a number between 1 and %d, a search term, or "list"`, max)
}
if n <= len(featured) {
return homepageChoice{kind: homepageKindProduct, productID: featured[n-1].ID}, nil
}
return homepageChoice{kind: homepageKindEval, evalSlug: evalShortlist[n-len(featured)-1].Slug}, nil
}
return homepageChoice{kind: homepageKindSearch, query: text}, nil
}

166
cli/homepage_test.go Normal file
View file

@ -0,0 +1,166 @@
package main
import (
"bytes"
"io"
"os"
"strings"
"testing"
)
func testFeatured() []Product {
return []Product{
{ID: "3262", Name: "Windows 11 25H2 (26200.6584)"},
{ID: "2618", Name: "Windows 10 22H2 v1 (19045.2965)"},
}
}
func testEvalShortlist() []EvalProduct {
return []EvalProduct{
{Slug: "server-2025", Name: "Windows Server 2025"},
{Slug: "win11-ent", Name: "Windows 11 Enterprise"},
}
}
func TestParseHomepageInput_product(t *testing.T) {
choice, err := parseHomepageInput("1", testFeatured(), testEvalShortlist())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if choice.kind != homepageKindProduct || choice.productID != "3262" {
t.Errorf("got %+v, want product 3262", choice)
}
}
func TestParseHomepageInput_eval(t *testing.T) {
choice, err := parseHomepageInput("3", testFeatured(), testEvalShortlist())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if choice.kind != homepageKindEval || choice.evalSlug != "server-2025" {
t.Errorf("got %+v, want eval server-2025", choice)
}
}
func TestParseHomepageInput_evalSecond(t *testing.T) {
choice, err := parseHomepageInput("4", testFeatured(), testEvalShortlist())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if choice.kind != homepageKindEval || choice.evalSlug != "win11-ent" {
t.Errorf("got %+v, want eval win11-ent", choice)
}
}
func TestParseHomepageInput_outOfRange(t *testing.T) {
_, err := parseHomepageInput("5", testFeatured(), testEvalShortlist())
if err == nil {
t.Fatal("expected error for out-of-range choice")
}
}
func TestParseHomepageInput_listCaseInsensitive(t *testing.T) {
for _, s := range []string{"list", "List", "LIST"} {
choice, err := parseHomepageInput(s, testFeatured(), testEvalShortlist())
if err != nil {
t.Fatalf("unexpected error for %q: %v", s, err)
}
if choice.kind != homepageKindList {
t.Errorf("input %q: got kind %v, want list", s, choice.kind)
}
}
}
func TestParseHomepageInput_searchFallback(t *testing.T) {
choice, err := parseHomepageInput("windows 10", testFeatured(), testEvalShortlist())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if choice.kind != homepageKindSearch || choice.query != "windows 10" {
t.Errorf("got %+v, want search query \"windows 10\"", choice)
}
}
func TestParseHomepageInput_emptyInput(t *testing.T) {
_, err := parseHomepageInput(" ", testFeatured(), testEvalShortlist())
if err == nil {
t.Fatal("expected error for empty input")
}
}
// captureHomepage runs showHomepage with stdin/stderr redirected to pipes so
// the rendered output and the parsed choice can both be inspected without a
// real terminal.
func captureHomepage(t *testing.T, stdin string, latestVersion string) (homepageChoice, string) {
t.Helper()
oldStdin, oldStderr := os.Stdin, os.Stderr
defer func() { os.Stdin, os.Stderr = oldStdin, oldStderr }()
inR, inW, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stdin = inR
inW.WriteString(stdin)
inW.Close()
outR, outW, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stderr = outW
choice, hpErr := showHomepage(latestVersion)
outW.Close()
var buf bytes.Buffer
io.Copy(&buf, outR)
if hpErr != nil {
t.Fatalf("unexpected error: %v", hpErr)
}
return choice, buf.String()
}
func TestShowHomepage_rendersFeaturedAndEval(t *testing.T) {
choice, output := captureHomepage(t, "1\n", "")
if choice.kind != homepageKindProduct || choice.productID != "3262" {
t.Errorf("got %+v, want product 3262", choice)
}
for _, want := range []string{
"1. Windows 11 25H2 (26200.6584)",
"2. Windows 10 22H2 v1 (19045.2965)",
"6. Windows Server 2025",
"7. Windows 11 Enterprise",
`Commands: --list (full catalog) · --help`,
`Choice [1-7], search by name, or "list":`,
} {
if !strings.Contains(output, want) {
t.Errorf("output missing %q, got:\n%s", want, output)
}
}
}
func TestShowHomepage_updateAvailableFooter(t *testing.T) {
oldVersion := Version
Version = "0.3.3"
defer func() { Version = oldVersion }()
_, output := captureHomepage(t, "1\n", "0.3.4")
if !strings.Contains(output, "Version 0.3.3 (Update Available 0.3.4)") {
t.Errorf("output missing update-available footer, got:\n%s", output)
}
}
func TestShowHomepage_noUpdateFooter(t *testing.T) {
oldVersion := Version
Version = "0.3.4"
defer func() { Version = oldVersion }()
_, output := captureHomepage(t, "1\n", "0.3.4")
if strings.Contains(output, "Update Available") {
t.Errorf("footer should not mention an update when already current, got:\n%s", output)
}
if !strings.Contains(output, "Version 0.3.4 - a TechLatest Open-source Contribution") {
t.Errorf("output missing plain version footer, got:\n%s", output)
}
}

View file

@ -46,9 +46,9 @@ func contributeURL() string {
return apiBaseURL() + "/contribute"
}
// printUpdateNotice checks /cli/version and prints a notice if a newer version
// is available. Blocks up to 500ms; silently no-ops on timeout or any error.
func printUpdateNotice() {
// fetchLatestVersion fetches /cli/version and returns the latest version
// string, or "" on any error or after a 500ms timeout.
func fetchLatestVersion() string {
ch := make(chan string, 1)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
@ -72,11 +72,18 @@ func printUpdateNotice() {
}()
select {
case latest := <-ch:
if latest != "" && latest != Version && Version != "dev" {
fmt.Fprintf(os.Stderr, "\n A new version of msdl is available: v%s\n", latest)
fmt.Fprintf(os.Stderr, " Download: https://github.com/starkSV/windows-iso-downloader/releases/latest\n\n")
}
return latest
case <-time.After(500 * time.Millisecond):
return ""
}
}
// printUpdateNotice prints a notice if latest is newer than the running
// binary's version.
func printUpdateNotice(latest string) {
if latest != "" && latest != Version && Version != "dev" {
fmt.Fprintf(os.Stderr, "\n A new version of msdl is available: v%s\n", latest)
fmt.Fprintf(os.Stderr, " Download: https://github.com/starkSV/windows-iso-downloader/releases/latest\n\n")
}
}
@ -199,10 +206,17 @@ More info: https://msdl.tech-latest.com/cli`)
query := strings.Join(fs.Args(), " ")
noContribute := *noContributeFlag || os.Getenv("MSDL_NO_CONTRIBUTE") == "1"
noTelemetry := os.Getenv("MSDL_NO_TELEMETRY") == "1"
isHomepage := *productID == "" && !*evalMode && !*listMode && query == "" && isTerminal()
// Update check — blocks up to 500ms, then continues regardless
// Update check — blocks up to 500ms, then continues regardless. The
// homepage screen folds this into its own footer instead of printing a
// separate notice.
latest := ""
if !noTelemetry {
printUpdateNotice()
latest = fetchLatestVersion()
if !isHomepage {
printUpdateNotice(latest)
}
}
if *listMode {
@ -239,20 +253,37 @@ More info: https://msdl.tech-latest.com/cli`)
telAction = "fetch"
telProductID = *productID
err = runConsumer(*productID, query, *langFlag, noContribute)
case query == "" && isTerminal():
// Pure interactive: combined consumer + eval picker
isEval, product, ep, pickErr := pickCombined()
if pickErr != nil {
return pickErr
case isHomepage:
choice, hpErr := showHomepage(latest)
if hpErr != nil {
return hpErr
}
if isEval {
telAction = "eval"
telEvalSlug = ep.Slug
err = runEval(ep.Slug)
} else {
switch choice.kind {
case homepageKindProduct:
telAction = "fetch"
telProductID = product.ID
err = runConsumer(product.ID, "", *langFlag, noContribute)
telProductID = choice.productID
err = runConsumer(choice.productID, "", *langFlag, noContribute)
case homepageKindEval:
telAction = "eval"
telEvalSlug = choice.evalSlug
err = runEval(choice.evalSlug)
case homepageKindSearch:
telAction = "fetch"
err = runConsumer("", choice.query, *langFlag, noContribute)
case homepageKindList:
isEval, product, ep, pickErr := pickCombined()
if pickErr != nil {
return pickErr
}
if isEval {
telAction = "eval"
telEvalSlug = ep.Slug
err = runEval(ep.Slug)
} else {
telAction = "fetch"
telProductID = product.ID
err = runConsumer(product.ID, "", *langFlag, noContribute)
}
}
default:
err = runConsumer(*productID, query, *langFlag, noContribute)