From 3994a82d6dd16d4da30714f7109588c212b85c68 Mon Sep 17 00:00:00 2001 From: Shekhar Vaidya Date: Mon, 13 Jul 2026 12:24:43 +0530 Subject: [PATCH] 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 --- cli/catalog.go | 27 ++++++- cli/catalog_test.go | 34 ++++++++- cli/homepage.go | 114 +++++++++++++++++++++++++++++ cli/homepage_test.go | 166 +++++++++++++++++++++++++++++++++++++++++++ cli/main.go | 73 +++++++++++++------ 5 files changed, 391 insertions(+), 23 deletions(-) create mode 100644 cli/homepage.go create mode 100644 cli/homepage_test.go diff --git a/cli/catalog.go b/cli/catalog.go index 9c949c2..ffa8188 100644 --- a/cli/catalog.go +++ b/cli/catalog.go @@ -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 { diff --git a/cli/catalog_test.go b/cli/catalog_test.go index fa316dc..09be26d 100644 --- a/cli/catalog_test.go +++ b/cli/catalog_test.go @@ -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 { diff --git a/cli/homepage.go b/cli/homepage.go new file mode 100644 index 0000000..e57b006 --- /dev/null +++ b/cli/homepage.go @@ -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 +} diff --git a/cli/homepage_test.go b/cli/homepage_test.go new file mode 100644 index 0000000..a3c1fbc --- /dev/null +++ b/cli/homepage_test.go @@ -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) + } +} diff --git a/cli/main.go b/cli/main.go index 75289f2..8e47e0e 100644 --- a/cli/main.go +++ b/cli/main.go @@ -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)