1import { toPath } from "../FQN.ts";
2import type {
3 BindingAction,
4 CRUD,
5 ActionApply,
6 ActionDelete,
7 Plan,
8} from "../Plan.ts";
9import type { ProviderMode } from "../ProviderMode.ts";
10import {
11 formatDeclaredPropertyYaml,
12 formatDriftPropertyYaml,
13 type DeclaredPropertyYaml,
14} from "./PropertyDiff.ts";
15
16export type ActionTreeItem = ActionApply | ActionDelete;
17export type ActionVerb = ActionTreeItem["action"]; // "run" | "noop" | "delete"
18
19/** No-op actions are dependency markers, not work the user needs to review. */
20export const actionHasPlannedWork = (item: ActionTreeItem): boolean =>
21 item.action !== "noop";
22
23export interface PlanSummaryCounts {
24 readonly counts: Record<
25 | "create"
26 | "update"
27 | "adopted"
28 | "delete"
29 | "orphaned"
30 | "replace"
31 | "noop",
32 number
33 >;
34 readonly taskCounts: Record<"run" | "delete" | "noop", number>;
35 readonly bindingChanges: number;
36}
37
38/** Count every resource and task, plus binding changes, for the Plan summary. */
39export const buildPlanSummary = (plan: Plan): PlanSummaryCounts => {
40 const allItems = [
41 ...Object.values(plan.resources),
42 ...Object.values(plan.deletions),
43 ].filter((item): item is CRUD => item !== undefined);
44 const counts = {
45 create: 0,
46 update: 0,
47 adopted: 0,
48 delete: 0,
49 orphaned: 0,
50 noop: 0,
51 replace: 0,
52 };
53 for (const item of allItems) {
54 counts[item.action]++;
55 }
56 const taskCounts = { run: 0, noop: 0, delete: 0 };
57 for (const item of [
58 ...Object.values(plan.actions ?? {}),
59 ...Object.values(plan.actionDeletions ?? {}),
60 ].filter((task): task is ActionTreeItem => task !== undefined)) {
61 taskCounts[item.action]++;
62 }
63 const bindingChanges = allItems.reduce(
64 (count, item) =>
65 count +
66 item.bindings.filter((binding) => binding.action !== "noop").length,
67 0,
68 );
69 return { counts, taskCounts, bindingChanges };
70};
71
72/**
73 * A tree node representing a namespace.
74 * Resources and tasks live directly inside the namespace where they were
75 * created.
76 */
77export interface TreeNode {
78 id: string;
79 path: string[];
80 children: Map<string, TreeNode>;
81 resources: CRUD[];
82 actions: ActionTreeItem[];
83}
84
85export type DerivedAction =
86 | "create"
87 | "update"
88 | "adopted"
89 | "delete"
90 | "orphaned"
91 | "replace"
92 | "noop"
93 | "mixed";
94
95export function buildNamespaceTree(
96 items: CRUD[],
97 actions: ReadonlyArray<ActionTreeItem> = [],
98): TreeNode {
99 const root: TreeNode = {
100 id: "",
101 path: [],
102 children: new Map(),
103 resources: [],
104 actions: [],
105 };
106
107 const getNode = (path: string[]) => {
108 let current = root;
109 for (let i = 0; i < path.length; i++) {
110 const segment = path[i];
111 let child = current.children.get(segment);
112 if (!child) {
113 child = {
114 id: segment,
115 path: path.slice(0, i + 1),
116 children: new Map(),
117 resources: [],
118 actions: [],
119 };
120 current.children.set(segment, child);
121 }
122 current = child;
123 }
124 return current;
125 };
126
127 for (const item of items) {
128 getNode(toPath(item.resource.Namespace)).resources.push(item);
129 }
130 for (const action of actions) {
131 getNode(toPath(action.def.Namespace)).actions.push(action);
132 }
133
134 return root;
135}
136
137function deriveNamespaceAction(node: TreeNode): DerivedAction {
138 const actions = new Set<BindingAction | CRUD["action"] | DerivedAction>();
139
140 for (const resource of node.resources) {
141 actions.add(deriveResourceChildrenAction(resource, node));
142 }
143 for (const action of node.actions) {
144 // Map task actions onto the resource action space for the rollup:
145 // run → create, delete → delete, noop → noop.
146 actions.add(
147 action.action === "run"
148 ? "create"
149 : action.action === "delete"
150 ? "delete"
151 : "noop",
152 );
153 }
154 for (const child of node.children.values()) {
155 const childAction = deriveNamespaceAction(child);
156 if (childAction === "mixed") {
157 return "mixed";
158 }
159 actions.add(childAction);
160 }
161
162 return deriveAction(actions);
163}
164
165export interface FlattenedItem {
166 type: "namespace" | "resource" | "binding" | "action";
167 depth: number;
168 id: string;
169 path: string[];
170 action: CRUD["action"] | BindingAction | DerivedAction | ActionVerb;
171 resourceType?: string;
172 bindingSid?: string;
173 bindingCount?: number;
174 hasChildren?: boolean;
175 /** For task items, the Task's Type (e.g. "Sync"). */
176 actionType?: string;
177 /**
178 * For resource items, the {@link ProviderMode} the node's provider was
179 * resolved for. `undefined` for mode-agnostic providers.
180 */
181 providerMode?: ProviderMode;
182 /**
183 * For resource items planned as a mode-switch replacement, the mode the
184 * old generation was created with (always differs from `providerMode`).
185 */
186 fromProviderMode?: ProviderMode;
187 /** Safe YAML detail attached only when the caller opts into detailed view. */
188 propertyYaml?: DeclaredPropertyYaml;
189}
190
191export interface FlattenTreeOptions {
192 includePropertyYaml?: boolean;
193}
194
195export function flattenTree(
196 node: TreeNode,
197 options: FlattenTreeOptions = {},
198): FlattenedItem[] {
199 const result: FlattenedItem[] = [];
200 flattenNamespace(node, 0, result, options);
201 return result;
202}
203
204const flattenNamespace = (
205 node: TreeNode,
206 depth: number,
207 result: FlattenedItem[],
208 options: FlattenTreeOptions,
209) => {
210 const sortedResources = [...node.resources].sort((a, b) =>
211 a.resource.LogicalId.localeCompare(b.resource.LogicalId),
212 );
213 const sortedChildren = Array.from(node.children.entries()).sort(([a], [b]) =>
214 a.localeCompare(b),
215 );
216 const resourceIds = new Set(
217 sortedResources.map((resource) => resource.resource.LogicalId),
218 );
219
220 for (const [id, child] of sortedChildren) {
221 if (resourceIds.has(id) || isEmpty(child)) {
222 continue;
223 }
224 result.push({
225 type: "namespace",
226 depth,
227 id,
228 path: child.path,
229 action: deriveNamespaceAction(child),
230 hasChildren: true,
231 });
232 flattenNamespace(child, depth + 1, result, options);
233 }
234
235 for (const resource of sortedResources) {
236 const childNamespace = node.children.get(resource.resource.LogicalId);
237 result.push({
238 type: "resource",
239 depth,
240 id: resource.resource.LogicalId,
241 path: [...node.path, resource.resource.LogicalId],
242 action: resource.action,
243 resourceType: resource.resource.Type,
244 bindingCount: resource.bindings.length,
245 providerMode: resource.mode,
246 fromProviderMode:
247 resource.action === "replace" &&
248 resource.mode !== undefined &&
249 resource.state.providerMode !== undefined &&
250 resource.state.providerMode !== resource.mode
251 ? resource.state.providerMode
252 : undefined,
253 propertyYaml:
254 "drift" in resource && resource.drift !== undefined
255 ? formatDriftPropertyYaml(
256 resource.drift.expected,
257 resource.drift.actual,
258 resource.drift.missing,
259 )
260 : options.includePropertyYaml &&
261 (resource.action === "create" ||
262 resource.action === "update" ||
263 resource.action === "adopted" ||
264 resource.action === "replace")
265 ? formatDeclaredPropertyYaml(
266 resource.action === "create" ? {} : resource.state.props,
267 resource.props,
268 resource.action === "adopted" ? "update" : resource.action,
269 )
270 : undefined,
271 });
272 for (const binding of [...resource.bindings].sort((a, b) =>
273 a.sid.localeCompare(b.sid),
274 )) {
275 result.push({
276 type: "binding",
277 depth: depth + 1,
278 id: binding.sid,
279 path: [...node.path, resource.resource.LogicalId, binding.sid],
280 action: binding.action,
281 bindingSid: binding.sid,
282 });
283 }
284 if (childNamespace) {
285 flattenNamespace(childNamespace, depth + 1, result, options);
286 }
287 }
288
289 // Actions are listed after resources at the same depth.
290 const sortedActions = [...node.actions].sort((a, b) =>
291 a.def.LogicalId.localeCompare(b.def.LogicalId),
292 );
293 for (const action of sortedActions) {
294 result.push({
295 type: "action",
296 depth,
297 id: action.def.LogicalId,
298 path: [...node.path, action.def.LogicalId],
299 action: action.action,
300 actionType: action.def.Type,
301 });
302 }
303};
304
305const isEmpty = (node: TreeNode) =>
306 node.resources.length === 0 &&
307 node.actions.length === 0 &&
308 Array.from(node.children.values()).every(isEmpty);
309
310const deriveResourceChildrenAction = (
311 resource: CRUD,
312 node: TreeNode,
313): DerivedAction => {
314 const actions = new Set<BindingAction | CRUD["action"] | DerivedAction>([
315 resource.action,
316 ]);
317 for (const binding of resource.bindings) {
318 actions.add(binding.action);
319 }
320 const childNamespace = node.children.get(resource.resource.LogicalId);
321 if (childNamespace) {
322 actions.add(deriveNamespaceAction(childNamespace));
323 }
324 return deriveAction(actions);
325};
326
327const deriveAction = (
328 actions: Set<BindingAction | CRUD["action"] | DerivedAction>,
329): DerivedAction => {
330 if (actions.size === 0) return "noop";
331 if (actions.has("replace")) return actions.size === 1 ? "replace" : "mixed";
332 if (actions.has("orphaned")) return actions.size === 1 ? "orphaned" : "mixed";
333 if (actions.has("delete")) return actions.size === 1 ? "delete" : "mixed";
334 if (actions.has("create")) return actions.size === 1 ? "create" : "mixed";
335 if (actions.has("adopted")) return actions.size === 1 ? "adopted" : "mixed";
336 if (actions.has("update")) return actions.size === 1 ? "update" : "mixed";
337 return "noop";
338};