// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt // SPDX-License-Identifier: Apache-2.0 package cli import ( "errors" "fmt" "strings" ) // Validate reports common authoring faults in a command tree. Walk tolerates // all of them, so Validate is optional — but calling it once at startup, or // from a unit test, catches mistakes that otherwise cause silent misdispatch. // It returns all problems found, joined, or nil if the tree is clean. // // It reports: // - a node with more than one slot child (Dynamic != nil): only the first is // ever reachable (FR-1.7); // - a non-root node with an empty Word: it can never be matched or displayed; // - duplicate fixed (non-slot) words among a node's children: the second is // shadowed by the first; // - a node that neither runs nor has children: a dead end that can only print // "". // // Circular slots (a slot that is its own descendant, e.g. a watch-options node) // are traversed once, not looped. func Validate[C any](root *Node[C]) error { var errs []error validateNode(root, "", true, make(map[*Node[C]]bool), &errs) return errors.Join(errs...) } func validateNode[C any](n *Node[C], path string, isRoot bool, seen map[*Node[C]]bool, errs *[]error) { if seen[n] { return } seen[n] = true where := path if where == "" { where = "" } if !isRoot && n.Word == "" { *errs = append(*errs, fmt.Errorf("%s: node has an empty Word", where)) } if !isRoot && n.Run == nil && len(n.Children) == 0 { *errs = append(*errs, fmt.Errorf("%s: node neither runs nor has children (dead end)", where)) } slots := 0 fixedWords := make(map[string]bool) for _, c := range n.Children { if c.Dynamic != nil { slots++ continue } if c.Word != "" { if fixedWords[c.Word] { *errs = append(*errs, fmt.Errorf("%s: duplicate child word %q", where, c.Word)) } fixedWords[c.Word] = true } } if slots > 1 { *errs = append(*errs, fmt.Errorf("%s: %d slot children, but only the first is reachable", where, slots)) } for _, c := range n.Children { cp := c.Word if path != "" { cp = path + " " + c.Word } validateNode(c, strings.TrimSpace(cp), false, seen, errs) } }