package vendor import ( "fmt" "sort" "strings" "github.com/m3tam3re/agent-lib/internal/discovery" "github.com/m3tam3re/agent-lib/internal/lockfile" ) // CollisionError names both parties of a deployed-name clash. type CollisionError struct { Type string `json:"type"` DeployedID string `json:"deployed_id"` Source string `json:"source"` OtherOwner string `json:"other_owner"` OtherID string `json:"other_id"` } func (e *CollisionError) Error() string { return fmt.Sprintf("source %q: %s %q collides with %s %s %q", e.Source, e.Type, e.DeployedID, e.OtherOwner, e.Type, e.OtherID) } // deployedIndex maps type -> deployed name -> owner description, built from // the work repository's own items and every lockfile source except skip. type deployedIndex map[string]map[string]owner type owner struct { source string id string } func ownDeployed(workDir string) (deployedIndex, error) { items, err := discovery.Scan(&discovery.FsTree{Root: workDir}, discovery.Config{}) if err != nil { return nil, err } idx := deployedIndex{} for _, it := range items { if it.Skipped { continue } idx.add(it.Type, it.UpstreamID, owner{source: "own", id: it.UpstreamID}) } return idx, nil } func lockfileDeployed(lf *lockfile.Lockfile, skip string) deployedIndex { idx := deployedIndex{} for name, src := range lf.Sources { if name == skip { continue } for typ, ids := range src.Inventory { for _, id := range ids { idx.add(typ, deployedName(id, src.Renames), owner{source: name, id: id}) } } } return idx } func (d deployedIndex) add(typ, deployed string, o owner) { if d[typ] == nil { d[typ] = map[string]owner{} } d[typ][deployed] = o } func (d deployedIndex) find(typ, deployed string) (owner, bool) { o, ok := d[typ][deployed] return o, ok } func mergeIndex(a, b deployedIndex) deployedIndex { out := deployedIndex{} for typ, m := range a { for dep, o := range m { out.add(typ, dep, o) } } for typ, m := range b { for dep, o := range m { out.add(typ, dep, o) } } return out } // checkCollisions verifies that every selected item of source deploys into // the flat namespace without clashing — against own items, against other // sources, and within the source itself (renames included). It performs no // filesystem mutation. func checkCollisions(workDir string, lf *lockfile.Lockfile, source string, selected []discovery.Item, renames map[string]string) error { own, err := ownDeployed(workDir) if err != nil { return fmt.Errorf("scanning own items: %w", err) } existing := mergeIndex(own, lockfileDeployed(lf, source)) seen := map[string]string{} for _, it := range selected { deployed := deployedName(it.UpstreamID, renames) if deployed == "" { return fmt.Errorf("source %q: rename of %q maps to an empty name", source, it.UpstreamID) } if prev, dup := seen[deployed]; dup { return &CollisionError{ Type: it.Type, DeployedID: deployed, Source: source, OtherOwner: "source " + source, OtherID: prev, } } seen[deployed] = it.UpstreamID if o, clash := existing.find(it.Type, deployed); clash { return &CollisionError{ Type: it.Type, DeployedID: deployed, Source: source, OtherOwner: o.source, OtherID: o.id, } } } return nil } // validateCollisions re-checks the deployed namespace across own items and // all lockfile sources — the offline equivalent used by validate. func validateCollisions(workDir string, lf *lockfile.Lockfile) []ValidationError { own, err := ownDeployed(workDir) if err != nil { return []ValidationError{{Rule: "collision", Message: fmt.Sprintf("scanning own items: %v", err)}} } idx := mergeIndex(own, deployedIndex{}) var errs []ValidationError for _, name := range sortedSourceNames(lf) { src := lf.Sources[name] for typ, ids := range src.Inventory { for _, id := range ids { deployed := deployedName(id, src.Renames) if prev, dup := idx.find(typ, deployed); dup { errs = append(errs, ValidationError{ Source: name, Rule: "collision", Message: fmt.Sprintf("source %q: %s %q (upstream %q) collides with %s %q", name, typ, deployed, id, prev.source, prev.id), }) continue } idx.add(typ, deployed, owner{source: name, id: id}) } } } sort.Slice(errs, func(i, j int) bool { return errs[i].Message < errs[j].Message }) return errs } // ParseRenames converts --rename upstream-id=deployed-name flag values. func ParseRenames(pairs []string) (map[string]string, error) { if len(pairs) == 0 { return nil, nil } out := map[string]string{} for _, p := range pairs { upstream, deployed, ok := strings.Cut(p, "=") if !ok || strings.TrimSpace(upstream) == "" || strings.TrimSpace(deployed) == "" { return nil, fmt.Errorf("invalid --rename %q (want upstream-id=deployed-name)", p) } out[strings.TrimSpace(upstream)] = strings.TrimSpace(deployed) } return out, nil }