| 1 | package command |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "sort" |
| 7 | "strings" |
| 8 | ) |
| 9 | |
| 10 | // CandidateStatus is the diagnostic disposition of one command file. |
| 11 | type CandidateStatus string |
| 12 | |
| 13 | const ( |
| 14 | CandidateWinner CandidateStatus = "winner" |
| 15 | CandidateShadowed CandidateStatus = "shadowed" |
| 16 | CandidateError CandidateStatus = "error" |
| 17 | ) |
| 18 | |
| 19 | // Candidate is one command file considered during Load, including shadowed |
| 20 | // sources that later directories override. |
| 21 | type Candidate struct { |
| 22 | Name string |
| 23 | Description string |
| 24 | Path string |
| 25 | Root string |
| 26 | Status CandidateStatus |
| 27 | WinnerPath string |
| 28 | Error string |
| 29 | } |
| 30 | |
| 31 | // RootInfo is one scanned commands directory. |
| 32 | type RootInfo struct { |
| 33 | Dir string |
| 34 | Status string // ok | missing | not-directory | unreadable |
| 35 | } |
| 36 | |
| 37 | // Inspection is a read-only snapshot matching command.Load override semantics |
| 38 | // (later dir wins) without changing Load itself. |
| 39 | type Inspection struct { |
| 40 | Roots []RootInfo |
| 41 | Candidates []Candidate |
| 42 | Winners []Command |
| 43 | } |
| 44 | |
| 45 | // Inspect walks dirs in order (same as Load) and records every candidate. |
| 46 | // Missing dirs are listed as missing and produce no warnings. |
| 47 | func Inspect(dirs ...string) Inspection { |
| 48 | var roots []RootInfo |
| 49 | // Per-name list of candidates in scan order; last becomes winner. |
| 50 | byName := map[string][]Candidate{} |
| 51 | winners := map[string]Command{} |
| 52 | |
| 53 | for _, dir := range dirs { |
| 54 | root, err := filepath.Abs(dir) |
| 55 | if err != nil { |
| 56 | roots = append(roots, RootInfo{Dir: dir, Status: "unreadable"}) |
| 57 | continue |
| 58 | } |
| 59 | st := rootStatus(root) |
| 60 | roots = append(roots, RootInfo{Dir: root, Status: st}) |
| 61 | if st != "ok" { |
| 62 | continue |
| 63 | } |
| 64 | visited := map[string]bool{} |
| 65 | if real, err := filepath.EvalSymlinks(root); err == nil { |
| 66 | visited[real] = true |
| 67 | } else { |
| 68 | visited[root] = true |
| 69 | } |
| 70 | walkCommands(root, root, visited, func(path string) { |
| 71 | c, perr := parseFile(root, path) |
| 72 | if perr != nil { |
| 73 | name := guessName(root, path) |
| 74 | byName[name] = append(byName[name], Candidate{ |
| 75 | Name: name, Path: path, Root: root, |
| 76 | Status: CandidateError, Error: perr.Error(), |
| 77 | }) |
| 78 | return |
| 79 | } |
| 80 | byName[c.Name] = append(byName[c.Name], Candidate{ |
| 81 | Name: c.Name, Description: c.Description, Path: path, Root: root, |
| 82 | Status: CandidateWinner, // provisional; finalized below |
| 83 | }) |
| 84 | winners[c.Name] = c |
| 85 | }) |
| 86 | } |
| 87 | |
| 88 | var candidates []Candidate |
| 89 | for name, list := range byName { |
| 90 | // Find last non-error candidate as winner; errors stay as errors. |
| 91 | winIdx := -1 |
| 92 | for i := len(list) - 1; i >= 0; i-- { |
| 93 | if list[i].Status != CandidateError { |
| 94 | winIdx = i |
| 95 | break |
| 96 | } |
| 97 | } |
| 98 | for i, c := range list { |
| 99 | if c.Status == CandidateError { |
| 100 | candidates = append(candidates, c) |
| 101 | continue |
| 102 | } |
| 103 | if i == winIdx { |
| 104 | c.Status = CandidateWinner |
| 105 | c.WinnerPath = "" |
| 106 | } else if winIdx >= 0 { |
| 107 | c.Status = CandidateShadowed |
| 108 | c.WinnerPath = list[winIdx].Path |
| 109 | } |
| 110 | candidates = append(candidates, c) |
| 111 | } |
| 112 | _ = name |
| 113 | } |
| 114 | |
| 115 | cmds := make([]Command, 0, len(winners)) |
| 116 | for _, c := range winners { |
| 117 | cmds = append(cmds, c) |
| 118 | } |
| 119 | sort.Slice(cmds, func(i, j int) bool { return cmds[i].Name < cmds[j].Name }) |
| 120 | sort.SliceStable(candidates, func(i, j int) bool { |
| 121 | if candidates[i].Name != candidates[j].Name { |
| 122 | return candidates[i].Name < candidates[j].Name |
| 123 | } |
| 124 | order := map[CandidateStatus]int{CandidateWinner: 0, CandidateShadowed: 1, CandidateError: 2} |
| 125 | return order[candidates[i].Status] < order[candidates[j].Status] |
| 126 | }) |
| 127 | |
| 128 | return Inspection{Roots: roots, Candidates: candidates, Winners: cmds} |
| 129 | } |
| 130 | |
| 131 | func rootStatus(dir string) string { |
| 132 | info, err := os.Stat(dir) |
| 133 | if err != nil { |
| 134 | if os.IsNotExist(err) { |
| 135 | return "missing" |
| 136 | } |
| 137 | return "unreadable" |
| 138 | } |
| 139 | if !info.IsDir() { |
| 140 | return "not-directory" |
| 141 | } |
| 142 | f, err := os.Open(dir) |
| 143 | if err != nil { |
| 144 | return "unreadable" |
| 145 | } |
| 146 | _ = f.Close() |
| 147 | return "ok" |
| 148 | } |
| 149 | |
| 150 | func guessName(root, path string) string { |
| 151 | rel, err := filepath.Rel(root, path) |
| 152 | if err != nil { |
| 153 | rel = filepath.Base(path) |
| 154 | } |
| 155 | return strings.ReplaceAll(strings.TrimSuffix(filepath.ToSlash(rel), ".md"), "/", ":") |
| 156 | } |
| 157 |