aion-cli 0.13.3

The `aion` command line: operate Aion durable workflows over gRPC and run the Aion server.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
//! Local `awl` subcommand group: the rev-2 AWL authoring loop.
//!
//! `aion awl check` parses and typechecks a `.awl` document, printing one
//! compiler-style `<file>:<line>:<column>: error: <message>` diagnostic per
//! error to stderr and exiting non-zero when any is found. `aion awl fmt`
//! rewrites the document in place with the canonical printer — the printer IS
//! the formatter, one rendering — so there is deliberately no `--check` mode.
//! `aion awl emit` lowers a document to Gleam source, but only past a clean
//! typecheck — generated code quality depends on it, so a parse error, a
//! typecheck error, and an emit error all report the same way and exit
//! non-zero. `aion awl schema` derives draft 2020-12 JSON Schema from the same
//! checked document through the public `aion-awl` derivation. Schema imports
//! (`type X = schema("file")`) resolve relative to the document's directory.
//! `aion awl scaffold` generates a compiling Rust worker crate for the
//! document's declared worker(s) from the same compiled contract the deploy
//! path admits against (see [`crate::awl_scaffold`]).
//!
//! Every one of these commands runs entirely locally and owns its own
//! compiler-style reporting contract (diagnostics to stderr, a one-line
//! summary to stdout) instead of the client commands' JSON rendering.

use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;

use aion_awl::{CompileError, CompiledWorkflow, Span};
use aion_package::CanonicalJson;
use clap::{Subcommand, ValueEnum};

/// The `aion awl` authoring subcommands.
#[derive(Debug, Subcommand)]
pub(crate) enum AwlCommand {
    /// Parse and typecheck a rev-2 AWL workflow document: declarations,
    /// binding flow along the step graph, `after`/route targets, outcome
    /// exhaustiveness, and both schema doors.
    ///
    /// Prints one `<file>:<line>:<column>: error: <message>` diagnostic per
    /// error to stderr and exits non-zero when any is found; prints a
    /// one-line summary to stdout when clean: `ok: <file> (N steps)` for a
    /// workflow document, `ok: <file> (worker `queue`, N actions)` for a
    /// worker document, whose family has no steps to count.
    Check {
        /// Path to the `.awl` document.
        file: PathBuf,
    },
    /// Reformat an AWL workflow document in place.
    ///
    /// Parses the document and writes the canonical rendering back to the
    /// file — the printer is the single formatter, one rendering, so there is
    /// no `--check` mode. On a parse error the diagnostic prints to stderr,
    /// the file is left untouched, and the exit code is non-zero.
    Fmt {
        /// Path to the `.awl` document.
        file: PathBuf,
    },
    /// Emit an executable artifact from an AWL document.
    ///
    /// `--target gleam` (the default) lowers the document to Gleam source (the
    /// stopgap execution target) and writes it to `--output`, or to stdout when
    /// `--output` is omitted. `--target beam` compiles the document to direct
    /// BEAM bytecode through the SAME `aion_awl::compile` seam the server's
    /// `POST /awl/deploy` path uses, so CLI output can never diverge from a
    /// console deploy.
    ///
    /// Parses and typechecks the document first — emission requires a clean
    /// typecheck, since generated code quality depends on it. A parse error, a
    /// typecheck error, or a compile/emit error all print
    /// `<file>:<line>:<column>: error: <message>` diagnostics to stderr and
    /// exit non-zero.
    ///
    /// File layout: the direct compiler emits exactly ONE BEAM module (implicit
    /// per-item `distribute` children are additional workflow entry points
    /// compiled INTO that same module, recorded in the sidecar — never separate
    /// modules), so `--target beam` writes a single `.beam` file at `--output`
    /// and a `<output>.json` beam-shaped sidecar (workflow name, contracts,
    /// action requirements, synthesized entries, timeout). Binary bytes are
    /// never written to stdout: `--target beam` requires `--output`.
    Emit {
        /// Path to the `.awl` document.
        file: PathBuf,
        /// Path to write the emitted artifact. Optional for `--target gleam`
        /// (defaults to stdout); REQUIRED for `--target beam`.
        #[arg(short, long)]
        output: Option<PathBuf>,
        /// Output form: `gleam` source (default) or `beam` bytecode.
        #[arg(long, value_enum, default_value_t = EmitTarget::Gleam)]
        target: EmitTarget,
    },
    /// Generate a compiling Rust worker crate for the document's declared
    /// worker(s).
    ///
    /// The document is compiled through the SAME `aion_awl::compile` seam the
    /// deploy path uses, and every wire descriptor the generated worker
    /// advertises is taken from the contract that compile returns — the very
    /// bytes the server deploys and admits against. Deriving those schemas
    /// from the handlers' Rust types instead would be a second, independent
    /// rendering of the same shapes, which is exactly the drift the server's
    /// contract gate exists to catch.
    ///
    /// The emitted crate opens ONE CONNECTION PER NODE (the server routes by
    /// namespace × task queue × node and never by activity type), registers a
    /// descriptor with every handler, serves no action that carries a
    /// declarative `body` (the server executes those itself), and fails every
    /// un-implemented activity loudly.
    ///
    /// One crate is written per `worker` block, at `<out>/<queue>-worker`.
    /// `src/handlers.rs` is yours from the moment it exists: re-running
    /// rewrites the generated files and never that one.
    Scaffold {
        /// Path to the `.awl` document.
        file: PathBuf,
        /// Directory the generated crate(s) are written into.
        #[arg(long)]
        out: PathBuf,
        /// Scaffold only this `worker` block; omitted, every block is
        /// scaffolded.
        #[arg(long)]
        worker: Option<String>,
        /// Path to a local aion checkout's `crates/` directory. Given, the
        /// generated crate depends on the aion SDK BY PATH; omitted, it
        /// depends on the published crates at this binary's own version.
        #[arg(long)]
        aion_crates: Option<PathBuf>,
    },
    /// Emit JSON Schema draft 2020-12 for a declared AWL type.
    Schema {
        /// Path to the `.awl` document.
        file: PathBuf,
        /// Declared type to derive; omitted, emits the workflow's start contract (its inputs).
        #[arg(long, conflicts_with = "queries")]
        r#type: Option<String>,
        /// Emit the read-only surface instead: every declared `query` name
        /// with the schema of the answer an operator receives.
        #[arg(long, conflicts_with = "type")]
        queries: bool,
    },
    /// Print what a word of AWL means, with a checked example and where it is
    /// explained in full.
    ///
    /// With one word, prints its meaning, a complete example document that
    /// passes `aion awl check`, and the reference section that explains it.
    /// With no argument, prints every word of the language grouped by the
    /// section of the AWL reference that explains it, above the paths of the
    /// documents an author needs: `docs/authoring/AWL.md` for the tour,
    /// `examples/assistant/resources/AWL-AUTHORING.md` for the path from an
    /// objective to a checked document,
    /// `examples/assistant/resources/WORKERS.md` for what actually serves an
    /// action, `examples/assistant/resources/COMMANDS.md` for the CLI loop,
    /// and `docs/workers/DECLARED-COMMANDS.md` before any `run "…"` body.
    ///
    /// `--reference` prints the full language reference itself, embedded in
    /// this binary — no repository checkout required.
    ///
    /// Every sentence printed comes from the same table the language server
    /// hovers and the ops console renders, so the three surfaces cannot
    /// disagree about what a word does. `--json` emits that table.
    Guide {
        /// The word to explain; omitted, every word of the language is
        /// printed.
        word: Option<String>,
        /// Emit the glossary as JSON instead of as text.
        #[arg(long)]
        json: bool,
        /// Print the full embedded AWL language reference and exit.
        #[arg(long, conflicts_with_all = ["word", "json"])]
        reference: bool,
    },
    /// Run the AWL language server over stdio for editor integration.
    Lsp,
}

/// The output form of `aion awl emit`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
pub(crate) enum EmitTarget {
    /// Gleam source (the stopgap execution target until AWL bytecode is the
    /// only path). Writes to `--output` or stdout.
    #[default]
    Gleam,
    /// Direct BEAM bytecode via `aion_awl::compile` — the same seam the ops
    /// console's deploy path uses. Requires `--output`.
    Beam,
}

/// Runs an `aion awl` subcommand.
pub(crate) fn run(command: &AwlCommand) -> ExitCode {
    match command {
        AwlCommand::Check { file } => check_command(file),
        AwlCommand::Fmt { file } => fmt_command(file),
        AwlCommand::Emit {
            file,
            output,
            target,
        } => emit_command(file, output.as_deref(), *target),
        AwlCommand::Scaffold {
            file,
            out,
            worker,
            aion_crates,
        } => crate::awl_scaffold::run(file, out, worker.as_deref(), aion_crates.as_deref()),
        AwlCommand::Schema {
            file,
            r#type,
            queries,
        } => schema_command(file, r#type.as_deref(), *queries),
        AwlCommand::Guide {
            word,
            json,
            reference,
        } => crate::awl_guide::run(word.as_deref(), *json, *reference),
        AwlCommand::Lsp => match aion_awl_lsp::run_stdio() {
            Ok(()) => ExitCode::SUCCESS,
            Err(error) => {
                eprintln!("error: AWL language server failed: {error}");
                ExitCode::FAILURE
            }
        },
    }
}

fn check_command(file: &Path) -> ExitCode {
    let Some(source) = read_source(file) else {
        return ExitCode::FAILURE;
    };
    match check_source(file, &source) {
        Ok(summary) => {
            println!("ok: {} ({summary})", file.display());
            ExitCode::SUCCESS
        }
        Err(diagnostics) => report(&diagnostics),
    }
}

fn fmt_command(file: &Path) -> ExitCode {
    let Some(source) = read_source(file) else {
        return ExitCode::FAILURE;
    };
    match format_source(file, &source) {
        Ok(formatted) => {
            if let Err(error) = fs::write(file, formatted) {
                eprintln!("error: failed to write {}: {error}", file.display());
                return ExitCode::FAILURE;
            }
            println!("formatted: {}", file.display());
            ExitCode::SUCCESS
        }
        Err(diagnostics) => report(&diagnostics),
    }
}

/// Dispatches `aion awl emit` on its target: Gleam source (default) or direct
/// BEAM bytecode. The two targets never share output bytes — `beam` reuses the
/// server's `aion_awl::compile` seam, `gleam` the legacy `emit_artifact_in`
/// path — so the gleam behaviour is byte-identical to before this target split.
fn emit_command(file: &Path, output: Option<&Path>, target: EmitTarget) -> ExitCode {
    match target {
        EmitTarget::Gleam => emit_gleam_command(file, output),
        EmitTarget::Beam => emit_beam_command(file, output),
    }
}

/// Emits Gleam source, unchanged from the pre-`--target` behaviour: writes to
/// `output` (with the Gleam-project entry sidecar) or prints to stdout.
fn emit_gleam_command(file: &Path, output: Option<&Path>) -> ExitCode {
    let Some(source) = read_source(file) else {
        return ExitCode::FAILURE;
    };
    match emit_artifact_source(file, &source) {
        Ok(artifact) => {
            if let Some(output) = output {
                if let Err(error) = fs::write(output, &artifact.source) {
                    eprintln!("error: failed to write {}: {error}", output.display());
                    return ExitCode::FAILURE;
                }
                if let Err(error) = write_entry_sidecar(output, &artifact) {
                    eprintln!("error: failed to write generated entry metadata: {error}");
                    return ExitCode::FAILURE;
                }
                println!("emitted: {}", output.display());
            } else {
                print!("{}", artifact.source);
            }
            ExitCode::SUCCESS
        }
        Err(diagnostics) => report(&diagnostics),
    }
}

/// Emits direct BEAM bytecode through the shared `aion_awl::compile` seam. The
/// binary is never written to stdout, so `output` is required; the schema root
/// is the document's own directory, matching the server's staged-imports root
/// so `schema("…")` resolution is identical on both surfaces.
fn emit_beam_command(file: &Path, output: Option<&Path>) -> ExitCode {
    let Some(output) = output else {
        eprintln!(
            "error: `--target beam` requires `--output` \
             (BEAM bytes are never written to stdout)"
        );
        return ExitCode::FAILURE;
    };
    let Some(source) = read_source(file) else {
        return ExitCode::FAILURE;
    };
    let compiled = match aion_awl::compile(&source, document_root(file)) {
        Ok(compiled) => compiled,
        Err(error) => return report(&compile_diagnostics(file, &error)),
    };
    if let Err(error) = write_beam_artifact(output, &compiled) {
        eprintln!("error: failed to write {}: {error}", output.display());
        return ExitCode::FAILURE;
    }
    println!("emitted: {}", output.display());
    ExitCode::SUCCESS
}

/// Writes the compiled workflow's single BEAM module to `output` and its
/// beam-shaped sidecar alongside. The direct compiler produces exactly one
/// module (its entry function plus every synthesized `distribute` child entry),
/// so a single file carries the whole compilation; the sidecar records the
/// derived contracts, action requirements, synthesized entries, and timeout.
fn write_beam_artifact(
    output: &Path,
    compiled: &CompiledWorkflow,
) -> Result<(), Box<dyn std::error::Error>> {
    fs::write(output, &compiled.beam_bytes)?;
    let mut sidecar_path = OsString::from(output.as_os_str());
    sidecar_path.push(".json");
    fs::write(
        sidecar_path,
        serde_json::to_vec_pretty(&beam_sidecar(compiled))?,
    )?;
    Ok(())
}

/// The beam-shaped emit sidecar, derived from [`CompiledWorkflow`]. Distinct by
/// construction from the Gleam `project_metadata` shape: it carries the derived
/// start/outcome contracts, the effective action requirements, the synthesized
/// child workflow entries, and the document-declared timeout.
///
/// Canonically encoded, like the Gleam sidecar: the same document emits the
/// same bytes whatever `serde_json` map representation the build resolved.
fn beam_sidecar(compiled: &CompiledWorkflow) -> CanonicalJson {
    let actions = compiled
        .actions
        .iter()
        .map(|action| {
            serde_json::json!({
                "task_queue": action.task_queue,
                "action": action.action,
                "node": action.node,
            })
        })
        .collect::<Vec<_>>();
    let synthesized = compiled
        .synthesized_workflows
        .iter()
        .map(|entry| {
            serde_json::json!({
                "workflow_type": entry.workflow_type,
                "entry_module": entry.entry_module,
                "entry_function": entry.entry_function,
                "timeout_seconds": entry.timeout.map(|timeout| timeout.as_secs()),
                "input_schema": entry.input_schema,
                "output_schema": entry.output_schema,
                "internal": entry.internal,
            })
        })
        .collect::<Vec<_>>();
    CanonicalJson::new(serde_json::json!({
        "target": "beam",
        "workflow_name": compiled.workflow_name,
        "timeout_seconds": compiled.timeout.map(|timeout| timeout.as_secs()),
        "input_schema": compiled.input_schema,
        "output_schema": compiled.output_schema,
        "actions": actions,
        "synthesized_workflows": synthesized,
    }))
}

/// Renders a [`CompileError`] into the same `<file>:<line>:<column>: error:
/// <message>` diagnostic lines the emit surface uses. Span-anchored stages
/// (parse, check, schema, unsupported, lower) carry a source position; the
/// span-free planning and backend stages render as a file-level diagnostic.
pub(crate) fn compile_diagnostics(file: &Path, error: &CompileError) -> Vec<String> {
    match error {
        CompileError::Parse(parse) => vec![diagnostic(file, parse.span, &parse.message)],
        CompileError::Check(errors) => errors
            .iter()
            .map(|check| diagnostic(file, check.span, &check.message))
            .collect(),
        CompileError::Schema(schema) => vec![diagnostic(file, schema.span(), &schema.to_string())],
        CompileError::Unsupported { shape, span } => {
            vec![diagnostic(
                file,
                *span,
                &format!("does not yet lower {shape}"),
            )]
        }
        CompileError::Family { message, span } | CompileError::Lower { message, span } => {
            vec![diagnostic(file, *span, message)]
        }
        CompileError::Planning { message } | CompileError::Backend { message } => {
            vec![format!("{}: error: {message}", file.display())]
        }
    }
}

fn schema_command(file: &Path, type_name: Option<&str>, queries: bool) -> ExitCode {
    let Some(source) = read_source(file) else {
        return ExitCode::FAILURE;
    };
    match schema_source(file, &source, type_name, queries) {
        Ok(schema) => {
            print!("{schema}");
            ExitCode::SUCCESS
        }
        Err(diagnostics) => report(&diagnostics),
    }
}

/// Parses and typechecks `source`, returning the workflow step count on a
/// clean pass or the rendered diagnostic lines otherwise. A parse error
/// yields the same diagnostic shape as a typecheck error.
/// The one-line summary a clean check prints, counting whichever thing the
/// document's FAMILY is made of.
///
/// A worker document has no steps, so "0 steps" would be a true sentence that
/// reads as an empty file. It serves actions, and that is what its summary
/// counts.
fn check_summary(document: &aion_awl::Document) -> String {
    match document.family {
        aion_awl::DocumentFamily::Workflow => {
            let steps = document.steps.len();
            let noun = if steps == 1 { "step" } else { "steps" };
            format!("{steps} {noun}")
        }
        aion_awl::DocumentFamily::Worker => {
            let actions: usize = document
                .workers
                .iter()
                .map(|worker| worker.actions.len())
                .sum();
            let noun = if actions == 1 { "action" } else { "actions" };
            format!("worker `{}`, {actions} {noun}", document.name)
        }
    }
}

fn check_source(file: &Path, source: &str) -> Result<String, Vec<String>> {
    let document = aion_awl::parse(source)
        .map_err(|error| vec![diagnostic(file, error.span, &error.message)])?;
    let errors = aion_awl::check_in(&document, document_root(file));
    if errors.is_empty() {
        Ok(check_summary(&document))
    } else {
        Err(errors
            .iter()
            .map(|error| diagnostic(file, error.span, &error.message))
            .collect())
    }
}

/// Parses `source` and returns the canonical rendering, or the parse
/// diagnostic. Formatting deliberately never runs the typechecker: a
/// well-formed document with type errors still deserves a canonical layout.
fn format_source(file: &Path, source: &str) -> Result<String, Vec<String>> {
    let document = aion_awl::parse(source)
        .map_err(|error| vec![diagnostic(file, error.span, &error.message)])?;
    Ok(aion_awl::print(&document))
}

/// Parses, typechecks, and emits `source` as Gleam source. Emission
/// deliberately requires a clean typecheck — unlike `format_source` — since
/// the generated code's quality depends on it: a parse error, any typecheck
/// error, or an emit error all yield the same diagnostic shape.
#[cfg(test)]
fn emit_source(file: &Path, source: &str) -> Result<String, Vec<String>> {
    Ok(emit_artifact_source(file, source)?.source)
}

fn emit_artifact_source(
    file: &Path,
    source: &str,
) -> Result<aion_awl::EmittedArtifact, Vec<String>> {
    let document = aion_awl::parse(source)
        .map_err(|error| vec![diagnostic(file, error.span, &error.message)])?;
    let root = document_root(file);
    let errors = aion_awl::check_in(&document, root);
    if !errors.is_empty() {
        return Err(errors
            .iter()
            .map(|error| diagnostic(file, error.span, &error.message))
            .collect());
    }
    aion_awl::emit_artifact_in(&document, root)
        .map_err(|error| vec![diagnostic(file, error.span, &error.message)])
}

fn write_entry_sidecar(
    output: &Path,
    artifact: &aion_awl::EmittedArtifact,
) -> Result<(), Box<dyn std::error::Error>> {
    let path = output.with_extension("awl.json");
    if artifact.synthesized_workflows.is_empty() {
        match fs::remove_file(path) {
            Ok(()) => {}
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
            Err(error) => return Err(error.into()),
        }
        return Ok(());
    }
    fs::write(
        path,
        serde_json::to_vec_pretty(&artifact.project_metadata())?,
    )?;
    Ok(())
}

/// The directory schema imports resolve against: the document's own.
pub(crate) fn document_root(file: &Path) -> &Path {
    match file.parent() {
        Some(parent) if !parent.as_os_str().is_empty() => parent,
        _ => Path::new("."),
    }
}

fn schema_source(
    file: &Path,
    source: &str,
    requested_type: Option<&str>,
    queries: bool,
) -> Result<String, Vec<String>> {
    let document = aion_awl::parse(source)
        .map_err(|error| vec![diagnostic(file, error.span, &error.message)])?;
    let root = document_root(file);
    let errors = aion_awl::check_in(&document, root);
    if !errors.is_empty() {
        return Err(errors
            .iter()
            .map(|error| diagnostic(file, error.span, &error.message))
            .collect());
    }
    let derived = match (requested_type, queries) {
        (Some(name), _) => aion_awl::schema_for_type_in(&document, root, name),
        (None, true) => aion_awl::schema_for_queries_in(&document, root),
        (None, false) => aion_awl::schema_for_workflow_in(&document, root),
    };
    let schema =
        derived.map_err(|error| vec![diagnostic(file, error.span(), &error.to_string())])?;
    // Canonically encoded: `aion awl schema` output is an artifact operators
    // and agents diff and commit, so its key order is the document's, sorted,
    // and never the build's map representation.
    serde_json::to_string_pretty(&CanonicalJson::new(schema))
        .map(|json| format!("{json}\n"))
        .map_err(|error| vec![diagnostic(file, document.span, &error.to_string())])
}

/// Renders one compiler-style diagnostic line from a diagnostic's span.
fn diagnostic(file: &Path, span: Span, message: &str) -> String {
    format!(
        "{}:{}:{}: error: {message}",
        file.display(),
        span.line,
        span.column
    )
}

pub(crate) fn report(diagnostics: &[String]) -> ExitCode {
    for line in diagnostics {
        eprintln!("{line}");
    }
    ExitCode::FAILURE
}

pub(crate) fn read_source(file: &Path) -> Option<String> {
    match fs::read_to_string(file) {
        Ok(source) => Some(source),
        Err(error) => {
            eprintln!("error: failed to read {}: {error}", file.display());
            None
        }
    }
}

#[cfg(test)]
#[path = "awl_tests.rs"]
mod tests;