preview_executor 0.1.0
preview_executor: ^0.1.0 copied to clipboard
Execute an ordered list of steps, each of which states what it would do before anything runs — and is held to it. Transport-neutral engine behind CLI plan/apply and HTTP dry-run.
/// Opening a requisition: four steps, previewed and then performed.
///
/// It writes into a temporary directory and uses a stand-in for GitHub, so it
/// runs offline and cleans up after itself.
///
/// ```bash
/// dart run example/example.dart
/// ```
library;
import 'dart:io';
import 'package:preview_executor/preview_executor.dart';
Future<void> main() async {
final workspace = Directory.systemTemp.createTempSync('preview_executor_');
const executor = PreviewExecutor();
// The steps are built once, here. Whatever they need — the template's
// contents, the resolved paths — is settled now, so previewing and performing
// describe the same change instead of deriving it twice.
final publish = PublishIssue(title: 'Recurring payments');
final steps = <Step>[
WriteFile('${workspace.path}/requisition.md', '# Recurring payments\n'),
WriteFile('${workspace.path}/issue.yaml', 'title: Recurring payments\n'),
publish,
RecordIssueNumber('${workspace.path}/issue.yaml', publish),
];
// ── What would happen ─────────────────────────────────────────────────────
stdout.writeln('Plan\n');
for (final preview in executor.preview(steps)) {
stdout.writeln(' ${_render(preview)}');
}
// ── The decision belongs to the host ──────────────────────────────────────
//
// A CLI reads --plan / --apply and may prompt. An HTTP handler reads a
// request parameter. The engine has no opinion, and is given the answer.
stdout.writeln('\n(approved)\n');
// ── Doing it ──────────────────────────────────────────────────────────────
final execution = await executor.perform(steps);
stdout.writeln('Applied\n');
for (final outcome in execution.outcomes) {
stdout.writeln(' ${outcome.verb.padRight(8)} ${outcome.target}');
}
stdout.writeln('\n complete: ${execution.isComplete}');
stdout.writeln(' faithful: ${execution.isFaithful}');
for (final discrepancy in execution.discrepancies) {
stdout.writeln(' ! ${discrepancy.message}');
}
workspace.deleteSync(recursive: true);
}
/// `create docs/requisition.md`, with the pending values spelled out.
String _render(Preview preview) {
final line = '${preview.verb.padRight(8)} ${preview.target}';
final notes = [
if (preview.detail != null) preview.detail!,
for (final name in preview.pending) '$name: known once this runs',
];
return notes.isEmpty ? line : '$line (${notes.join('; ')})';
}
// ─── Steps ──────────────────────────────────────────────────────────────────
/// Writes a file, or keeps the one that is already there.
class WriteFile implements Step {
WriteFile(this.path, this.contents);
final String path;
final String contents;
@override
Preview preview() => File(path).existsSync()
? Preview(verb: 'keep', target: path, detail: 'already exists')
: Preview(verb: 'create', target: path);
@override
Future<Outcome> perform(StepContext context) async {
if (File(path).existsSync()) {
return Outcome(verb: 'keep', target: path);
}
File(path).writeAsStringSync(contents);
return Outcome(verb: 'create', target: path);
}
}
/// Creates the issue. Its number does not exist until it has been created, so
/// the preview says so rather than staying silent.
class PublishIssue implements Step {
PublishIssue({required this.title});
final String title;
@override
Preview preview() => Preview(
verb: 'create',
target: 'issue "$title" in ccisnedev/macss',
pending: const ['number'],
);
@override
Future<Outcome> perform(StepContext context) async {
// Stand-in for `gh issue create`.
const number = 41;
return Outcome(
verb: 'create',
target: 'issue "$title" in ccisnedev/macss',
values: const {'number': number},
);
}
}
/// Writes the issue number beside the requisition — reading it from the step
/// that produced it, rather than asking GitHub a second time.
class RecordIssueNumber implements Step {
RecordIssueNumber(this.path, this.source);
final String path;
final Step source;
@override
Preview preview() =>
Preview(verb: 'record', target: path, pending: const ['number']);
@override
Future<Outcome> perform(StepContext context) async {
final number = context.outcomeOf(source).values['number'];
File(path).writeAsStringSync('issue: $number\n', mode: FileMode.append);
return Outcome(
verb: 'record',
target: path,
values: {'number': number},
);
}
}