1import * as Effect from "effect/Effect";
2import { createPhysicalName } from "../../PhysicalName.ts";
3import {
4 alchemyLabelKeys,
5 createInternalLabels,
6 hasAlchemyLabels,
7} from "../Labels.ts";
8
9export const DEFAULT_PRODUCT = "IP";
10export const MAX_ID_LENGTH = 63;
11export const MAX_DISPLAY_NAME_LENGTH = 255;
12export const MAX_DESCRIPTION_LENGTH = 8000;
13export const MAX_TEMPLATE_DESCRIPTION_LENGTH = 255;
14
15export const lastSegment = (value: string) => {
16 const trimmed = value.replace(/\/+$/, "");
17 const parts = trimmed.split("/");
18 return parts[parts.length - 1] || trimmed;
19};
20
21export const locationOf = (name: string, fallback: string) => {
22 const parts = name.split("/");
23 const index = parts.indexOf("locations");
24 return index >= 0 ? (parts[index + 1] ?? fallback) : fallback;
25};
26
27export const projectOf = (name: string) => {
28 const parts = name.split("/");
29 const index = parts.indexOf("projects");
30 return index >= 0 ? (parts[index + 1] ?? "") : "";
31};
32
33// Integrations returns names keyed by project number, which later get calls
34// reject ("Found zero or more than one active project"); key them by id.
35export const withProjectId = (name: string, project: string) =>
36 name.replace(/^projects\/[^/]+/, `projects/${project}`);
37
38export const productOf = (name: string, fallback = DEFAULT_PRODUCT) => {
39 const parts = name.split("/");
40 const index = parts.indexOf("products");
41 return index >= 0 ? (parts[index + 1] ?? fallback) : fallback;
42};
43
44export const parentOf = (name: string) => {
45 const parts = name.split("/").filter((part) => part.length > 0);
46 return parts.slice(0, -2).join("/");
47};
48
49export const locationParent = (project: string, location: string) =>
50 `projects/${project}/locations/${location}`;
51
52export const expandSfdcInstance = (
53 value: string,
54 project: string,
55 location: string,
56) =>
57 value.includes("/")
58 ? value
59 : `${locationParent(project, location)}/sfdcInstances/${value}`;
60
61export const isDeleted = (resource: { deleteTime?: string }) =>
62 (resource.deleteTime ?? "").length > 0;
63
64export const productParent = (
65 project: string,
66 location: string,
67 product: string,
68) => `${locationParent(project, location)}/products/${product}`;
69
70export const normalizeLocation = (
71 location: string | undefined,
72 defaultLocation: string,
73) => lastSegment(location ?? defaultLocation).toLowerCase();
74
75export const normalizeProduct = (product: string | undefined) =>
76 lastSegment(product ?? DEFAULT_PRODUCT);
77
78export const sameText = (left: string | undefined, right: string | undefined) =>
79 (left ?? "") === (right ?? "");
80
81export const canonical = (value: unknown): unknown => {
82 if (value === undefined || value === null) return undefined;
83 if (typeof value === "boolean" || typeof value === "number") return value;
84 if (typeof value === "string") {
85 return value.length === 0 ? undefined : value;
86 }
87 if (Array.isArray(value)) {
88 const items = value.map(canonical).filter((item) => item !== undefined);
89 return items.length === 0 ? undefined : items;
90 }
91 if (typeof value === "object") {
92 const entries = Object.entries(value as Record<string, unknown>)
93 .map(([key, item]) => [key, canonical(item)] as const)
94 .filter(([, item]) => item !== undefined)
95 .sort(([left], [right]) => left.localeCompare(right));
96 if (entries.length === 0) return undefined;
97 return Object.fromEntries(entries);
98 }
99 return undefined;
100};
101
102export const fingerprint = (value: unknown): string =>
103 JSON.stringify(canonical(value) ?? null);
104
105export const jsonEqual = (left: unknown, right: unknown) =>
106 fingerprint(left) === fingerprint(right);
107
108export const replaceOnIdentity = (changed: boolean) =>
109 changed
110 ? ({ action: "replace" as const, deleteFirst: false } as const)
111 : undefined;
112
113export const updateMaskOf = (...fields: Array<string | undefined>) =>
114 fields.filter((field): field is string => field !== undefined).join(",");
115
116export const toResourceId = (
117 id: string,
118 requested: string | undefined,
119 existing: string | undefined,
120 maxLength = MAX_ID_LENGTH,
121) =>
122 Effect.gen(function* () {
123 if (requested !== undefined) return requested;
124 if (existing !== undefined) return existing;
125 const generated = yield* createPhysicalName({
126 id,
127 maxLength,
128 lowercase: true,
129 });
130 const next = /^[a-z]/.test(generated)
131 ? generated
132 : `i${generated}`.slice(0, maxLength);
133 return next.length >= 4 ? next : `${next}xxxx`.slice(0, maxLength);
134 });
135
136const markerOf = (
137 labels: Record<string, string>,
138 stack: string,
139 stage: string,
140 id: string,
141) =>
142 `[alchemy ${alchemyLabelKeys.stack}=${stack} ${alchemyLabelKeys.stage}=${stage} ${alchemyLabelKeys.id}=${id}]`;
143
144const fitMarker = (labels: Record<string, string>, maxLength: number) => {
145 let stack = labels[alchemyLabelKeys.stack] ?? "x";
146 let stage = labels[alchemyLabelKeys.stage] ?? "x";
147 let id = labels[alchemyLabelKeys.id] ?? "x";
148 let marker = markerOf(labels, stack, stage, id);
149 while (
150 marker.length > maxLength &&
151 (stack.length > 1 || stage.length > 1 || id.length > 1)
152 ) {
153 if (stack.length >= stage.length && stack.length >= id.length) {
154 stack = stack.slice(0, -1);
155 } else if (stage.length >= id.length) {
156 stage = stage.slice(0, -1);
157 } else {
158 id = id.slice(0, -1);
159 }
160 marker = markerOf(labels, stack, stage, id);
161 }
162 return marker.slice(0, maxLength);
163};
164
165export const encodeOwnership = (
166 labels: Record<string, string>,
167 text: string | undefined,
168 maxLength = MAX_DESCRIPTION_LENGTH,
169): string => {
170 const marker = fitMarker(labels, maxLength);
171 const trimmed = text?.trim();
172 if (!trimmed) return marker.slice(0, maxLength);
173 return `${marker}\n${trimmed}`.slice(0, maxLength);
174};
175
176export const encodeOwnershipLine = (
177 labels: Record<string, string>,
178 text: string | undefined,
179 maxLength = MAX_DISPLAY_NAME_LENGTH,
180): string => {
181 const trimmed = text?.replace(/[\r\n]+/g, " ").trim();
182 if (!trimmed) return fitMarker(labels, maxLength);
183 const minMarker = 24;
184 const reserved = Math.min(
185 trimmed.length + 1,
186 Math.max(0, maxLength - minMarker),
187 );
188 const marker = fitMarker(labels, maxLength - reserved);
189 return `${marker} ${trimmed}`.slice(0, maxLength);
190};
191
192export const parseOwnership = (
193 text: string | undefined,
194): {
195 labels: Record<string, string>;
196 text: string | undefined;
197} => {
198 if (!text?.startsWith("[alchemy ")) {
199 return { labels: {}, text };
200 }
201 const end = text.indexOf("]");
202 if (end < 0) return { labels: {}, text };
203 const labels: Record<string, string> = {};
204 for (const part of text.slice("[alchemy ".length, end).split(/\s+/)) {
205 const eq = part.indexOf("=");
206 if (eq > 0) {
207 labels[part.slice(0, eq)] = part.slice(eq + 1);
208 }
209 }
210 const rest = text.slice(end + 1).replace(/^[\s\n]+/, "");
211 return { labels, text: rest.length > 0 ? rest : undefined };
212};
213
214export const hasOwnershipMarker = (text: string | undefined) =>
215 Object.keys(parseOwnership(text).labels).some((key) =>
216 key.startsWith("alchemy-"),
217 );
218
219const prefixMatch = (expected: string, observed: string) =>
220 expected === observed ||
221 expected.startsWith(observed) ||
222 observed.startsWith(expected);
223
224export const ownedByAlchemy = (id: string, text: string | undefined) =>
225 Effect.gen(function* () {
226 const expected = yield* createInternalLabels(id);
227 const { labels } = parseOwnership(text);
228 if (!hasOwnershipMarker(text)) return false;
229 const exact = yield* hasAlchemyLabels(id, labels);
230 if (exact) return true;
231 return (
232 prefixMatch(
233 expected[alchemyLabelKeys.stack] ?? "",
234 labels[alchemyLabelKeys.stack] ?? "",
235 ) &&
236 prefixMatch(
237 expected[alchemyLabelKeys.stage] ?? "",
238 labels[alchemyLabelKeys.stage] ?? "",
239 ) &&
240 prefixMatch(
241 expected[alchemyLabelKeys.id] ?? "",
242 labels[alchemyLabelKeys.id] ?? "",
243 )
244 );
245 });