Lines 85.96% 147 / 171
Methods 75.00% 9 / 12
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 useInputStream 100.00% 1 / 1 100.00% 1 / 1 1
 run 82.25% 102 / 124 0.00% 0 / 1 31.07
 planChanges 100.00% 1 / 1 100.00% 1 / 1 1
 declareRenameSuggestions 96.42% 27 / 28 0.00% 0 / 1 13
 isInteractive 66.66% 2 / 3 0.00% 0 / 1 3.33
 canPrompt 100.00% 2 / 2 100.00% 1 / 1 3
 prompt 100.00% 5 / 5 100.00% 1 / 1 1
 readAnswer 100.00% 3 / 3 100.00% 1 / 1 1
 line 100.00% 1 / 1 100.00% 1 / 1 1
 success 100.00% 1 / 1 100.00% 1 / 1 1
 warn 100.00% 1 / 1 100.00% 1 / 1 1
 error 100.00% 1 / 1 100.00% 1 / 1 1
63final class SyncCommand
64{
65    public static string $command = "sync";
66
67    /**
68     * The stream prompts read answers from. Injectable so tests can feed
69     * scripted answers and actually exercise the prompt flows; real runs
70     * use STDIN.
71     */
72    private static mixed $inputStream = null;
73
74    /**
75     * Replace the stream prompts read from (e.g. a memory stream in tests).
76     * Pass null to restore STDIN.
77     */
78    public static function useInputStream(mixed $stream): void
79    {
80        self::$inputStream = $stream;
81    }
82
83    /**
84     * Run the sync.
85     *
86     * @param array<string, mixed> $options CLI options (--filter,
87     *        --exclude-filter, --dir, --force, --dry-run, --no-transactional,
88     *        --no-drop-tables)
89     * @return string Always '' — output is echoed progressively
90     */
91    public function run(array $options = []): string
92    {
93        // ---- Discover models ----
94        $dirs = isset($options['dir'])
95            ? array_map(trim(...), explode(',', (string) $options['dir']))
96            : null;
97
98        $models = (new ModelDiscovery())->discover($dirs);
99
100        // ---- Apply filters (exclude wins) ----
101        try {
102            $filter = new ModelFilter(
103                isset($options['filter']) ? (string) $options['filter'] : null,
104                isset($options['exclude-filter']) ? (string) $options['exclude-filter'] : null,
105            );
106        } catch (\InvalidArgumentException $e) {
107            self::error(ExceptionChain::render($e, ''));
108            return '';
109        }
110
111        ['active' => $active, 'excluded' => $excluded] = $filter->apply($models);
112
113        if ($active === []) {
114            self::warn("No models matched.");
115            return '';
116        }
117
118        // ---- Build the desired state ----
119        $desired = [];
120
121        foreach ($active as $model) {
122            $desired[] = Blueprint::fromMetadata($model);
123        }
124
125        // ---- Protected list: excluded models' tables (best-effort) ----
126        $protected = array_values(MetadataFactory::tables($excluded, skipBroken: true));
127
128        // ---- Transactional apply: default-on when the dialect supports it ----
129        $connection = Database::sqlConnection();
130        $transactional = !isset($options['no-transactional'])
131            && $connection->supportsTransactionalDdl();
132
133        if (!isset($options['no-transactional']) && !$transactional) {
134            self::warn(
135                'This dialect does not support transactional DDL — the apply will not be atomic.'
136            );
137        }
138
139        $dryRun = isset($options['dry-run']);
140        $force = (bool) ($options['force'] ?? false);
141        $dropTables = !isset($options['no-drop-tables']);
142        $synchronizer = new SchemaSynchronizer($connection);
143
144        // The destructive-change gate — shared by the apply under the lock and
145        // by a deferred apply outside it.
146        $confirm = function (SchemaChange $change) use ($force): bool {
147            if ($force) {
148                return true;
149            }
150
151            self::prompt($change->description);
152
153            return self::readAnswer();
154        };
155
156        // One progress bar spans both apply legs: under the lock, and — when
157        // the plan carries a change needing a transaction-free connection —
158        // after it. Created once the plan is known, assigned through the
159        // reference so the deferred leg can finish it.
160        $progress = null;
161        $onChange = function (SchemaChange $change) use (&$progress): void {
162            if ($progress !== null) {
163                $progress->advance();
164            }
165        };
166
167        /** @var list<SchemaChange>|null $deferred The plan must apply outside the lock transaction. */
168        $deferred = null;
169        $applied = [];
170        $total = 0;
171
172        try {
173            // ---- Plan → declare renames → re-plan → display → apply, UNDER the lock ----
174            // The whole flow stays inside the lock transaction whenever the
175            // dialect allows. The plan-aware predicate
176            // (changeRequiresStandaloneTransaction) resolves each change's
177            // FK involvement through the plan itself — an alter in a
178            // rename-led plan reads the rename source's live state — so the
179            // check is answerable BEFORE anything applies and the plan can
180            // be judged as a unit. When any change needs a transaction-free
181            // connection (an FK-involved SQLite table rebuild must toggle
182            // PRAGMA foreign_keys outside any transaction), the whole plan
183            // is handed back and applied after the lock releases — the same
184            // defer SchemaSynchronizer::sync() performs for one-shot
185            // callers. Otherwise it applies here, in the same transaction,
186            // in the differ's executable order (renames already precede the
187            // alters that target the renamed tables).
188            $connection->withLock(function () use (
189                $connection,
190                $synchronizer,
191                $desired,
192                $protected,
193                $force,
194                $dryRun,
195                $dropTables,
196                $transactional,
197                $confirm,
198                $onChange,
199                &$progress,
200                &$deferred,
201                &$applied,
202                &$total,
203            ): void {
204                // Fresh copies — declaring a rename mutates a blueprint
205                // (renamedFrom), and the originals must stay clean.
206                $pristine = array_map(fn(Blueprint $b): Blueprint => clone $b, $desired);
207
208                // ---- Plan → declare renames → re-plan (NO database changes) ----
209                // The loop only converges the PLAN: each flagged create/drop
210                // pair is resolved by declaring the rename on the blueprint,
211                // then re-planning so the differ verifies the declaration and
212                // emits the real RenameTable change — together with any
213                // column shape drift, in one plan. Bounded by the plan size
214                // — each declaration converts one pair.
215                $changes = self::planChanges($synchronizer, $pristine, $protected, $dropTables);
216
217                if ($changes === []) {
218                    self::success("Schema is in sync. Nothing to do.");
219                    return;
220                }
221
222                for ($i = 0, $max = count($changes); $i < $max; $i++) {
223                    if (!self::declareRenameSuggestions($changes, $pristine, $force)) {
224                        break; // no suggestions left — the plan is final
225                    }
226
227                    $changes = self::planChanges($synchronizer, $pristine, $protected, $dropTables);
228
229                    if ($changes === []) {
230                        self::success("Schema is in sync. Nothing to do.");
231                        return;
232                    }
233                }
234
235                if ($changes === []) {
236                    self::success("Schema is in sync. Nothing to do.");
237                    return;
238                }
239
240                // ---- The blueprints are complete — display the final plan ----
241                self::line(ConsoleColors::FG_CYAN . "Planned schema changes:" . ConsoleColors::RESET);
242                foreach ($changes as $change) {
243                    $marker = $change->destructive
244                        ? ConsoleColors::FG_RED . "  ! " . ConsoleColors::RESET
245                        : "    ";
246                    self::line($marker . $change->description);
247                }
248
249                if ($dryRun) {
250                    self::line(ConsoleColors::FG_CYAN . "Dry run — no changes applied." . ConsoleColors::RESET);
251                    return;
252                }
253
254                // ---- Confirm gate ----
255                $destructive = array_filter($changes, fn(SchemaChange $c): bool => $c->destructive);
256
257                // Interactive when a TTY is present OR an input stream is
258                // injected (tests feed scripted answers through the seam —
259                // they must reach the prompt flows, not the fail-fast gate).
260                if ($destructive !== [] && !$force && !self::canPrompt()) {
261                    self::error(
262                        "Destructive changes present and no TTY available — re-run with --force to apply."
263                    );
264                    return;
265                }
266
267                $total = count($changes);
268                $progress = new ProgressBar($total);
269                $progress->setFormat('[{bar}] {percent}% ({current}/{total})');
270
271                // A change the dialect cannot apply inside a transaction (an
272                // FK-involved SQLite table rebuild needs the foreign_keys
273                // PRAGMA toggle outside one) defers the whole plan past the
274                // lock transaction — the plan is a unit, and the alters that
275                // would follow a rebuild in the same transaction would roll
276                // back with it anyway. The race window is fenced by the
277                // rebuild itself: it re-reads the live table per change and
278                // its foreign_key_check gate fails loud on drift it cannot
279                // reconcile.
280                if (array_any(
281                    $changes,
282                    fn (SchemaChange $c): bool => $connection->changeRequiresStandaloneTransaction($c, $changes),
283                )) {
284                    $deferred = $changes;
285                    return;
286                }
287
288                $applied = $synchronizer->apply(
289                    $changes,
290                    confirm: $confirm,
291                    onChange: $onChange,
292                    transactional: $transactional,
293                );
294
295                $progress->finish();
296                self::success(count($applied) . " of " . $total . " planned change(s) applied.");
297            }, 'radiant:schema');
298        } catch (\Throwable $e) {
299            self::error(ExceptionChain::render($e, "Sync failed: "));
300            return '';
301        }
302
303        if ($deferred === null) {
304            return '';
305        }
306
307        // ---- Deferred apply: outside the lock transaction ----
308        try {
309            $applied = $synchronizer->apply(
310                $deferred,
311                confirm: $confirm,
312                onChange: $onChange,
313                transactional: $transactional,
314            );
315
316            $progress?->finish();
317
318            self::success(count($applied) . " of " . $total . " planned change(s) applied.");
319        } catch (\Throwable $e) {
320            self::error(ExceptionChain::render($e, "Sync failed: "));
321        }
322
323        return '';
324    }
325
326    /**
327     * Plan the changes for the given blueprints. Protected tables are
328     * enforced by the differ itself — never dropped, never offered as a
329     * rename target — so no post-plan filtering is needed here. With
330     * $dropTables off the plan is additive-only: undeclared live tables
331     * are left untouched.
332     *
333     * @param list<Blueprint> $blueprints
334     * @param list<string> $protected Tables never offered for drop or rename
335     * @param bool $dropTables Whether undeclared live tables are offered for drop
336     * @return list<SchemaChange>
337     */
338    private static function planChanges(
339        SchemaSynchronizer $synchronizer,
340        array $blueprints,
341        array $protected,
342        bool $dropTables = true,
343    ): array {
344        return $synchronizer->plan($blueprints, protected: $protected, dropTables: $dropTables);
345    }
346
347    /**
348     * Resolve flagged create/drop pairs by DECLARING the rename on the
349     * blueprint — the decision, not a guess.
350     *
351     * When the user confirms, the desired blueprint for the new table gets
352     * a renamedFrom declaration; the caller re-plans so the differ verifies
353     * the declaration and emits the real RenameTable change. Each old table
354     * can only be claimed by one rename (the differ pairs every create with
355     * its best-overlap drop, so multiple creates can claim the same drop —
356     * first claim wins).
357     *
358     * With --force every suggestion is auto-accepted. Without a TTY or an
359     * injected input stream the flagged pair is kept as-is (the plan shows
360     * the POSSIBLE RENAME annotation and the destructive gate handles it)
361     * — never block on stdin we cannot read.
362     *
363     * @param list<SchemaChange> $changes The current plan
364     * @param list<Blueprint> $pristine The desired blueprints (mutated in place)
365     * @param bool $force Auto-accept every suggestion
366     * @return bool Whether any rename was declared (caller must re-plan)
367     */
368    private static function declareRenameSuggestions(array $changes, array &$pristine, bool $force): bool
369    {
370        // Pair up flagged creates with their flagged drops.
371        $pairs = []; // createTable => dropTable
372        foreach ($changes as $change) {
373            if ($change->operation === SchemaOperation::CreateTable && $change->renameOf !== null) {
374                $pairs[$change->table] = $change->renameOf;
375            }
376        }
377
378        if ($pairs === []) {
379            return false;
380        }
381
382        // Nothing to prompt with (no TTY, no injected stream) and no
383        // --force: keep the flagged pair as-is.
384        if (!$force && !self::canPrompt()) {
385            return false;
386        }
387
388        $declared = false;
389        $claimedOldTables = []; // each old table can only be renamed once
390
391        foreach ($pairs as $newTable => $oldTable) {
392            // Only the first claim on an old table becomes a rename.
393            if (in_array($oldTable, $claimedOldTables, true)) {
394                continue;
395            }
396
397            // readAnswer() already normalizes to a bool — a plain falsy
398            // check is the correct gate (a strict string compare would
399            // treat a typed "yes" as a decline and re-prompt every
400            // remaining pair with the same old table).
401            if ($force) {
402                $answer = true;
403            } else {
404                self::prompt(
405                    "POSSIBLE RENAME: [{$oldTable}] → [{$newTable}]. Treat as a rename?"
406                );
407                $answer = self::readAnswer();
408            }
409
410            if (!$answer) {
411                continue;
412            }
413
414            foreach ($pristine as $index => $blueprint) {
415                if ($blueprint->getTable() === $newTable) {
416                    $pristine[$index] = $blueprint->renamedFrom($oldTable);
417                    $claimedOldTables[] = $oldTable;
418                    $declared = true;
419                    break;
420                }
421            }
422        }
423
424        return $declared;
425    }
426
427    /**
428     * Whether the process has an interactive terminal on stdin.
429     *
430     * The LUCENT_NON_INTERACTIVE env var forces the non-interactive path —
431     * used by the test suite and CI runners; a TTY on stdin would otherwise
432     * make the prompts block on fgets() forever. An injected input stream
433     * (useInputStream) also implies non-interactive TTY detection is
434     * irrelevant — prompts are answered from the stream.
435     */
436    private static function isInteractive(): bool
437    {
438        if (getenv('LUCENT_NON_INTERACTIVE') === '1') {
439            return false;
440        }
441
442        return is_resource(STDIN) && stream_isatty(STDIN);
443    }
444
445    /**
446     * Whether prompts can be asked AND answered: a TTY on stdin, or an
447     * injected input stream (tests feed scripted answers through the
448     * seam). The rename suggestions and the destructive confirm gate share
449     * this definition so both prompt flows are reachable under the same
450     * conditions.
451     */
452    private static function canPrompt(): bool
453    {
454        return self::isInteractive()
455            || (self::$inputStream !== null && is_resource(self::$inputStream));
456    }
457
458    /**
459     * Display a prompt question on STDERR.
460     *
461     * Prompts go to STDERR, never STDOUT: they are interaction, not result
462     * output. This keeps them visible when stdout is redirected or captured
463     * (ob_start in test/execute mode would otherwise SWALLOW the question —
464     * the user would see a silent hang) and follows the git/composer/ssh
465     * convention of separating interaction from results.
466     */
467    private static function prompt(string $question): void
468    {
469        fwrite(
470            STDERR,
471            ConsoleColors::FG_YELLOW . $question . ConsoleColors::RESET . "\n"
472            . "Apply? Type yes or no: "
473        );
474    }
475
476    /**
477     * Read a yes/no answer from the injected stream or STDIN.
478     */
479    private static function readAnswer(): bool
480    {
481        $stream = self::$inputStream ?? STDIN;
482        $answer = strtolower(trim((string) fgets($stream)));
483
484        return in_array($answer, ['y', 'yes'], true);
485    }
486
487    private static function line(string $message): void
488    {
489        echo $message . "\n";
490    }
491
492    private static function success(string $message): void
493    {
494        echo ConsoleColors::FG_GREEN . $message . ConsoleColors::RESET . "\n";
495    }
496
497    private static function warn(string $message): void
498    {
499        echo ConsoleColors::FG_YELLOW . $message . ConsoleColors::RESET . "\n";
500    }
501
502    private static function error(string $message): void
503    {
504        echo ConsoleColors::FG_RED . $message . ConsoleColors::RESET . "\n";
505    }
506}