1import * as Effect from "effect/Effect";
2import * as Redacted from "effect/Redacted";
3import { havePropsChanged } from "../Diff.ts";
4import * as Output from "../Output.ts";
5import { isPlainData } from "../Util/data.ts";
6import { stringify } from "yaml";
7
8export type YamlDisplayValue =
9 | string
10 | number
11 | boolean
12 | null
13 | YamlDisplayValue[]
14 | { readonly [key: string]: YamlDisplayValue };
15
16export interface DeclaredPropertyYaml {
17 readonly kind: "create" | "change" | "drift";
18 readonly lines: ReadonlyArray<string>;
19}
20
21const unifiedDriftLines = (
22 expected: YamlDisplayValue,
23 actual: YamlDisplayValue,
24 depth = 0,
25): string[] => {
26 const padding = " ".repeat(depth);
27 if (JSON.stringify(expected) === JSON.stringify(actual)) {
28 return indent(formatYamlLines(expected), depth);
29 }
30 if (
31 expected !== null &&
32 actual !== null &&
33 !Array.isArray(expected) &&
34 !Array.isArray(actual) &&
35 typeof expected === "object" &&
36 typeof actual === "object"
37 ) {
38 const lines: string[] = [];
39 for (const key of [
40 ...new Set([...Object.keys(expected), ...Object.keys(actual)]),
41 ].sort((a, b) => a.localeCompare(b))) {
42 const hasExpected = Object.hasOwn(expected, key);
43 const hasActual = Object.hasOwn(actual, key);
44 const expectedValue = hasExpected ? expected[key]! : UNDEFINED;
45 const actualValue = hasActual ? actual[key]! : UNDEFINED;
46 if (
47 hasExpected &&
48 hasActual &&
49 expectedValue !== null &&
50 actualValue !== null &&
51 !Array.isArray(expectedValue) &&
52 !Array.isArray(actualValue) &&
53 typeof expectedValue === "object" &&
54 typeof actualValue === "object" &&
55 JSON.stringify(expectedValue) !== JSON.stringify(actualValue)
56 ) {
57 lines.push(`${padding}${key}:`);
58 lines.push(...unifiedDriftLines(expectedValue, actualValue, depth + 2));
59 } else if (
60 JSON.stringify(expectedValue) === JSON.stringify(actualValue)
61 ) {
62 lines.push(...indent(formatYamlLines({ [key]: expectedValue }), depth));
63 } else {
64 if (hasExpected) {
65 lines.push(
66 ...indent(formatYamlLines({ [key]: expectedValue }), depth).map(
67 mark("-"),
68 ),
69 );
70 }
71 if (hasActual) {
72 lines.push(
73 ...indent(formatYamlLines({ [key]: actualValue }), depth).map(
74 mark("+"),
75 ),
76 );
77 }
78 }
79 }
80 return lines;
81 }
82 return [
83 ...indent(formatYamlLines(expected), depth).map(mark("-")),
84 ...indent(formatYamlLines(actual), depth).map(mark("+")),
85 ];
86};
87
88const mark = (marker: "-" | "+") => (line: string) => {
89 return `${marker} ${line}`;
90};
91
92export interface YamlChangeMatch {
93 readonly marker: "-" | "+";
94 readonly content: string;
95}
96
97/** Match a `- `/`+ ` change marker emitted by {@link unifiedDriftLines}. */
98export const matchYamlChange = (line: string): YamlChangeMatch | undefined => {
99 const match = line.match(/^([+-]) (.*)$/);
100 return match === null
101 ? undefined
102 : { marker: match[1] as "-" | "+", content: match[2]! };
103};
104
105export interface YamlKeyMatch {
106 readonly indent: string;
107 /** The key including its trailing colon. */
108 readonly key: string;
109 readonly value: string;
110}
111
112/** Match an `indent` + `key:` + rest display-YAML line for colorizing. */
113export const matchYamlKey = (line: string): YamlKeyMatch | undefined => {
114 const match = line.match(/^(\s*)([A-Za-z_][\w .-]*:)(.*)$/);
115 return match === null
116 ? undefined
117 : { indent: match[1]!, key: match[2]!, value: match[3]! };
118};
119
120/** Format the changed cloud attributes carried by a drift-repair plan. */
121export const formatDriftPropertyYaml = (
122 expected: unknown,
123 actual: unknown,
124 missing = false,
125): DeclaredPropertyYaml => {
126 const expectedValue = toYamlDisplayValue(expected);
127 const actualValue = missing ? "(missing)" : toYamlDisplayValue(actual);
128 return {
129 kind: "drift",
130 lines: unifiedDriftLines(expectedValue, actualValue),
131 };
132};
133
134const REDACTED = "(redacted)";
135const KNOWN_AFTER_APPLY = "(known after apply)";
136const COMPUTED = "(computed)";
137const UNDEFINED = "(undefined)";
138const OPAQUE = "(opaque)";
139const CIRCULAR = "(circular)";
140
141/**
142 * Turn arbitrary declared inputs or persisted state into safe, deterministic
143 * plain data for terminal display. Deferred values are described, never run,
144 * and Redacted values are replaced before their contents can reach YAML.
145 */
146export const toYamlDisplayValue = (
147 value: unknown,
148 ancestors: WeakSet<object> = new WeakSet(),
149): YamlDisplayValue => {
150 // Output expressions are Effects too, so this order is security-sensitive.
151 if (Redacted.isRedacted(value)) return REDACTED;
152 if (Output.isExpr(value)) return KNOWN_AFTER_APPLY;
153 if (Effect.isEffect(value) || typeof value === "function") return COMPUTED;
154 if (value === undefined) return UNDEFINED;
155 if (value === null || typeof value === "string") return value;
156 if (typeof value === "boolean" || typeof value === "number") return value;
157 if (typeof value === "bigint") return `${value}n`;
158 if (!isPlainData(value)) return OPAQUE;
159 if (ancestors.has(value)) return CIRCULAR;
160
161 ancestors.add(value);
162 try {
163 if (Array.isArray(value)) {
164 return value.map((item) => toYamlDisplayValue(item, ancestors));
165 }
166 return Object.fromEntries(
167 Object.keys(value)
168 .sort((a, b) => a.localeCompare(b))
169 .map((key) => [
170 key,
171 toYamlDisplayValue(
172 (value as Record<string, unknown>)[key],
173 ancestors,
174 ),
175 ]),
176 );
177 } finally {
178 ancestors.delete(value);
179 }
180};
181
182/** Format a value as stable, unadorned YAML lines for terminal renderers. */
183export const formatYamlLines = (value: unknown): string[] => {
184 const yaml = stringify(toYamlDisplayValue(value), {
185 lineWidth: 0,
186 sortMapEntries: true,
187 });
188 return (yaml || `${UNDEFINED}\n`).trimEnd().split("\n");
189};
190
191/**
192 * Build the detailed property document for a plan resource. This compares
193 * persisted declared props to desired declared props; it is not cloud drift.
194 */
195export const formatDeclaredPropertyYaml = (
196 oldProps: unknown,
197 newProps: unknown,
198 action: "create" | "update" | "replace",
199): DeclaredPropertyYaml | undefined => {
200 const desired = newProps ?? {};
201 if (action === "create") {
202 return {
203 kind: "create",
204 lines: ["properties:", ...indent(formatYamlLines(desired))],
205 };
206 }
207 if (
208 !havePropsChanged(
209 (oldProps ?? {}) as Record<string, unknown>,
210 desired as Record<string, unknown>,
211 )
212 ) {
213 return undefined;
214 }
215 return {
216 kind: "change",
217 lines: [
218 "properties:",
219 ...unifiedDriftLines(
220 toYamlDisplayValue(oldProps ?? {}),
221 toYamlDisplayValue(desired),
222 2,
223 ),
224 ],
225 };
226};
227
228const indent = (lines: ReadonlyArray<string>, spaces = 2): string[] =>
229 lines.map((line) => `${" ".repeat(spaces)}${line}`);