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:
parent
b1ee80f9b6
commit
3994a82d6d
5 changed files with 391 additions and 23 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
114
cli/homepage.go
Normal 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
166
cli/homepage_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
73
cli/main.go
73
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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue