| 1 | package workspacestate |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/json" |
| 6 | "maps" |
| 7 | "path/filepath" |
| 8 | "slices" |
| 9 | "strings" |
| 10 | |
| 11 | "reasonix/internal/pathidentity" |
| 12 | ) |
| 13 | |
| 14 | // WorkspaceIndex resolves every persisted workspace root once and reuses the |
| 15 | // result for the lifetime of one read projection. Sidebar snapshots commonly |
| 16 | // resolve the same roots several times; doing that through ResolveWorkspaceID |
| 17 | // would walk every registered root for every sidebar row. |
| 18 | type WorkspaceIndex struct { |
| 19 | exact map[string]string |
| 20 | physical map[string]string |
| 21 | resolved map[string]string |
| 22 | paths map[string]workspacePathResult |
| 23 | } |
| 24 | |
| 25 | type workspacePathResult struct { |
| 26 | key string |
| 27 | err error |
| 28 | } |
| 29 | |
| 30 | func (i *WorkspaceIndex) pathKey(root string) (string, error) { |
| 31 | root = strings.TrimSpace(root) |
| 32 | if result, ok := i.paths[root]; ok { |
| 33 | return result.key, result.err |
| 34 | } |
| 35 | identity, err := pathidentity.Resolve(root, pathidentity.Options{FollowLeaf: true}) |
| 36 | i.paths[root] = workspacePathResult{identity.Key, err} |
| 37 | return identity.Key, err |
| 38 | } |
| 39 | |
| 40 | // SameRoot compares physical roots even before either has a registry owner. |
| 41 | // Unknown owners must not compare equal just because both IDs are empty. |
| 42 | func (i *WorkspaceIndex) SameRoot(a, b string) bool { |
| 43 | left, err := i.pathKey(a) |
| 44 | if err != nil || left == "" { |
| 45 | return false |
| 46 | } |
| 47 | right, err := i.pathKey(b) |
| 48 | return err == nil && left == right |
| 49 | } |
| 50 | |
| 51 | func NewWorkspaceIndex(state State) *WorkspaceIndex { |
| 52 | index := &WorkspaceIndex{ |
| 53 | exact: map[string]string{}, physical: map[string]string{}, resolved: map[string]string{}, paths: map[string]workspacePathResult{}, |
| 54 | } |
| 55 | groups := map[string][]string{} |
| 56 | keys := map[string]string{} |
| 57 | for id, workspace := range state.Workspaces { |
| 58 | root := strings.TrimSpace(workspace.Root) |
| 59 | if root == "" { |
| 60 | continue |
| 61 | } |
| 62 | key, err := index.pathKey(root) |
| 63 | if err != nil || key == "" { |
| 64 | continue |
| 65 | } |
| 66 | groups[key] = append(groups[key], id) |
| 67 | keys[id] = key |
| 68 | } |
| 69 | for key, ids := range groups { |
| 70 | slices.Sort(ids) |
| 71 | index.physical[key] = canonicalWorkspaceOwner(state, ids) |
| 72 | } |
| 73 | for id, workspace := range state.Workspaces { |
| 74 | if owner := index.physical[keys[id]]; owner != "" { |
| 75 | index.exact[filepath.Clean(strings.TrimSpace(workspace.Root))] = owner |
| 76 | } |
| 77 | } |
| 78 | return index |
| 79 | } |
| 80 | |
| 81 | // Resolve returns the persisted owner for root without rescanning registered |
| 82 | // roots. Exact persisted spellings are filesystem-free; aliases are resolved |
| 83 | // once and cached for the rest of the snapshot. |
| 84 | func (i *WorkspaceIndex) Resolve(root string) (string, bool, error) { |
| 85 | root = strings.TrimSpace(root) |
| 86 | if root == "" { |
| 87 | return "", false, nil |
| 88 | } |
| 89 | clean := filepath.Clean(root) |
| 90 | if id := i.exact[clean]; id != "" { |
| 91 | return id, true, nil |
| 92 | } |
| 93 | if id, ok := i.resolved[clean]; ok { |
| 94 | return id, id != "", nil |
| 95 | } |
| 96 | key, err := i.pathKey(root) |
| 97 | if err != nil { |
| 98 | return "", false, err |
| 99 | } |
| 100 | id := i.physical[key] |
| 101 | i.resolved[clean] = id |
| 102 | return id, id != "", nil |
| 103 | } |
| 104 | |
| 105 | // FindWorkspace admits the read-only path only for an unambiguous physical |
| 106 | // owner. Missing or duplicate roots go through the registration transaction. |
| 107 | func FindWorkspace(state State, root string) (string, bool) { |
| 108 | if strings.TrimSpace(root) == "" { |
| 109 | return "", false |
| 110 | } |
| 111 | identity, err := pathidentity.Resolve(root, pathidentity.Options{FollowLeaf: true}) |
| 112 | if err != nil { |
| 113 | return "", false |
| 114 | } |
| 115 | matches := matchingWorkspaceIDs(state, identity) |
| 116 | if len(matches) != 1 { |
| 117 | return "", false |
| 118 | } |
| 119 | return matches[0], true |
| 120 | } |
| 121 | |
| 122 | // Clone preserves unknown fields while isolating every mutable organization |
| 123 | // field. Read-side import planning must never edit a published snapshot. |
| 124 | func (o Organization) Clone() Organization { |
| 125 | o.Order = slices.Clone(o.Order) |
| 126 | o.Imported = maps.Clone(o.Imported) |
| 127 | o.extra = cloneUnknownFields(o.extra) |
| 128 | o.Groups = slices.Clone(o.Groups) |
| 129 | for i := range o.Groups { |
| 130 | o.Groups[i].Members = slices.Clone(o.Groups[i].Members) |
| 131 | o.Groups[i].extra = cloneUnknownFields(o.Groups[i].extra) |
| 132 | } |
| 133 | return o |
| 134 | } |
| 135 | |
| 136 | func cloneUnknownFields(fields map[string]json.RawMessage) map[string]json.RawMessage { |
| 137 | if fields == nil { |
| 138 | return nil |
| 139 | } |
| 140 | clone := make(map[string]json.RawMessage, len(fields)) |
| 141 | for key, value := range fields { |
| 142 | clone[key] = bytes.Clone(value) |
| 143 | } |
| 144 | return clone |
| 145 | } |
| 146 |