autumn-cli 0.9.0

CLI tool for the Autumn web framework
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
//! `autumn jobs manifest` -- emit the effective drained-queue manifest.
//!
//! Compiles the target binary (debug profile), runs it with
//! `AUTUMN_DUMP_JOBS=1`, and writes the TOML `queues = [...]` document from its
//! stdout to the requested output path. This is the ground-truth drained-queue
//! set — the configured `[jobs.queues]` unioned with every
//! `#[job(queue = "…")]`-declared queue — that `autumn doctor` consumes via
//! `[jobs.fleet] manifest = "<path>"`.
//!
//! Emitting from inside the running app is the only sound source: jobs are
//! registered at runtime into the `Vec<JobInfo>` the user passes to
//! `.jobs(jobs![...])`, so the standalone CLI (which links `autumn-web` but never
//! the user's job functions) cannot see the `#[job(queue = …)]` set on its own.

use std::path::Path;
use std::process::Command;

use crate::routes::{clear_other_dump_modes, compile_binary, find_binary};

/// The env var selecting the app binary's jobs-manifest dump mode.
pub const DUMP_ENV: &str = "AUTUMN_DUMP_JOBS";

/// Options controlling `autumn jobs manifest`.
pub struct ManifestOptions<'a> {
    /// Package to inspect (for workspaces).
    pub package: Option<&'a str>,
    /// Binary target to inspect (for packages with multiple bin targets).
    pub bin: Option<&'a str>,
    /// Path the emitted manifest is written to.
    pub output: &'a str,
}

/// Run `autumn jobs manifest`.
pub fn run(opts: &ManifestOptions<'_>) {
    eprintln!("\u{1F342} autumn jobs manifest\n");
    compile_binary(opts.package, opts.bin);
    let binary = find_binary(opts.package, opts.bin);

    let mut command = Command::new(&binary);
    command
        .env(DUMP_ENV, "1")
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::inherit());
    clear_other_dump_modes(&mut command, DUMP_ENV);
    let output = command.output().unwrap_or_else(|e| {
        eprintln!("\u{2717} Failed to run {}: {e}", binary.display());
        std::process::exit(1);
    });

    let stdout = match manifest_from_child(
        output.status.success(),
        output.status.code(),
        &output.stdout,
    ) {
        Ok(stdout) => stdout,
        Err(code) => {
            eprintln!(
                "\u{2717} Binary exited with status {} while dumping jobs manifest",
                output.status
            );
            std::process::exit(code);
        }
    };

    // A clean exit is not enough: the child's stdout must be the expected TOML
    // manifest. If the app prints anything else to stdout (from a custom config
    // or telemetry initializer, say), writing it verbatim would produce a
    // corrupt manifest that `autumn doctor` rejects outright (#2419) — which
    // fails the topology coverage check the manifest exists to guard.
    // Mirror `autumn routes`, which strict-parses the child's stdout and errors
    // on anything unexpected rather than emitting garbage.
    let manifest = match validate_manifest(&stdout) {
        Ok(manifest) => manifest,
        Err(message) => {
            eprintln!("\u{2717} {message}");
            std::process::exit(1);
        }
    };

    if let Err(message) = write_manifest(Path::new(opts.output), &manifest) {
        eprintln!("{message}");
        std::process::exit(1);
    }
    eprintln!("\u{2713} Wrote jobs manifest \u{2192} {}", opts.output);
}

/// Interpret a finished dump child: `Ok(stdout)` when it exited cleanly,
/// `Err(exit_code)` when it failed (the caller reports and propagates the code).
///
/// Returns the child's captured stdout (lossily UTF-8 decoded) without inspecting
/// its contents — validation that it is a well-formed manifest is [`validate_manifest`]'s
/// job. Extracted from [`run`] so the success/failure decision and stdout capture
/// are unit-testable without spawning a real process.
fn manifest_from_child(success: bool, code: Option<i32>, stdout: &[u8]) -> Result<String, i32> {
    if success {
        Ok(String::from_utf8_lossy(stdout).into_owned())
    } else {
        Err(code.unwrap_or(1))
    }
}

/// A strict reading of a jobs manifest's `queues` array, shared by the
/// emitter-side validator ([`validate_manifest`]) and doctor's consumer-side
/// reader — one rule, one implementation, so the two sides cannot drift (#2419).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ManifestQueues {
    /// No `queues` key: the document says nothing about queues.
    Absent,
    /// A well-formed array of strings. Empty is a real answer — the app
    /// stating it declares no `#[job(queue = "…")]` queues — not a failure.
    Present(Vec<String>),
}

/// Why a jobs manifest's `queues` reading failed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ManifestQueuesError {
    /// The document did not parse as TOML at all (carries the parse error).
    NotToml(String),
    /// `queues` is present but is not an array of strings (carries the reason).
    /// This is the shape [`validate_manifest`] refuses to *write*, so readers
    /// must refuse to *trust* it too.
    BadQueuesArray(String),
}

/// Strictly parse the `queues` array out of a jobs-manifest TOML document,
/// enforcing the emitter's contract: a top-level `queues` array of strings.
///
/// `Absent` is the document saying nothing (doctor falls through to
/// `declared_queues`); `BadQueuesArray` is corruption the emitter would have
/// refused to write, so the caller must fail loudly rather than silently
/// narrowing the declared set.
pub fn parse_manifest_queues(contents: &str) -> Result<ManifestQueues, ManifestQueuesError> {
    let value: toml::Value = toml::from_str(contents)
        .map_err(|e| ManifestQueuesError::NotToml(format!("did not parse as TOML: {e}")))?;
    let Some(queues) = value.get("queues") else {
        return Ok(ManifestQueues::Absent);
    };
    let Some(array) = queues.as_array() else {
        return Err(ManifestQueuesError::BadQueuesArray(
            "`queues` is not an array".to_string(),
        ));
    };
    if !array.iter().all(toml::Value::is_str) {
        return Err(ManifestQueuesError::BadQueuesArray(
            "`queues` is not an array of strings".to_string(),
        ));
    }
    Ok(ManifestQueues::Present(
        array
            .iter()
            .filter_map(toml::Value::as_str)
            .map(str::to_owned)
            .collect(),
    ))
}

/// Validate that `stdout` is the expected jobs manifest before it is written.
///
/// A clean child exit does not guarantee the captured stdout is the manifest: any
/// bytes the app writes to stdout during boot (from a custom config loader or
/// telemetry initializer, or a stray `println!`) land here too, and writing them
/// verbatim produces a corrupt file. `autumn doctor` rejects a manifest whose
/// `queues` array is not an array of strings outright (#2419), so a corrupt
/// manifest fails the topology coverage check rather than false-passing without
/// the app-declared queues.
///
/// Mirrors `autumn routes`, which strict-parses the child's stdout (as JSON there,
/// TOML here) and errors rather than accepting unexpected output. Requires the
/// stdout to parse as TOML with a top-level `queues` array of strings; on success
/// returns the original `stdout` unchanged so the on-disk bytes are byte-identical
/// to what the app emitted (preserving the highest-priority-first ordering).
fn validate_manifest(stdout: &str) -> Result<String, String> {
    let hint = "app did not emit a valid jobs manifest on stdout; \
                ensure nothing else writes to stdout during `autumn jobs manifest`";

    // Return the stdout unchanged so the on-disk bytes are byte-identical to what
    // the app emitted (preserving the highest-priority-first ordering).
    match parse_manifest_queues(stdout) {
        Ok(ManifestQueues::Present(_)) => Ok(stdout.to_owned()),
        Ok(ManifestQueues::Absent) => Err(format!("{hint} (no top-level `queues` array found)")),
        Err(ManifestQueuesError::NotToml(parse_error)) => {
            Err(format!("{hint} (stdout {parse_error})"))
        }
        Err(ManifestQueuesError::BadQueuesArray(reason)) => Err(format!("{hint} ({reason})")),
    }
}

/// Write `contents` to `path`, creating any missing parent directories.
///
/// Returns a formatted, user-facing error message on failure instead of exiting,
/// so both the parent-directory-creation and the write branches are unit-testable
/// (the caller in [`run`] prints the message and exits non-zero).
fn write_manifest(path: &Path, contents: &str) -> Result<(), String> {
    if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty())
        && let Err(e) = std::fs::create_dir_all(parent)
    {
        return Err(format!(
            "\u{2717} Failed to create {}: {e}",
            parent.display()
        ));
    }
    if let Err(e) = std::fs::write(path, contents) {
        return Err(format!("\u{2717} Failed to write {}: {e}", path.display()));
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    // ── clear_other_dump_modes(_, DUMP_ENV) ─────────────────────────────────

    #[test]
    fn clear_other_dump_modes_removes_every_other_mode_var() {
        // Every one of these is dispatched before AUTUMN_DUMP_JOBS (the LAST
        // mode in AppBuilder::run's dispatch chain), so any one left in the
        // CLI's own environment would hijack `autumn jobs manifest` into a
        // different dump (AUTUMN_DUMP_CACHE_COHERENCE in particular — issue
        // #2370). Previously only 4 of these 7 were cleared.
        let other_modes: Vec<&str> = crate::routes::ALL_DUMP_MODE_ENVS
            .iter()
            .copied()
            .filter(|&mode| mode != DUMP_ENV)
            .collect();
        let mut command = Command::new("true");
        for var in &other_modes {
            command.env(var, "1");
        }

        clear_other_dump_modes(&mut command, DUMP_ENV);

        for var in &other_modes {
            let value = command
                .get_envs()
                .find(|(key, _)| *key == std::ffi::OsStr::new(var));
            assert_eq!(
                value,
                Some((std::ffi::OsStr::new(var), None)),
                "{var} must be explicitly removed: {value:?}"
            );
        }
    }

    #[test]
    fn an_inherited_openapi_dump_cannot_hijack_the_jobs_manifest() {
        // AUTUMN_DUMP_OPENAPI is checked before AUTUMN_DUMP_JOBS in
        // AppBuilder::run, so an ambient `=1` would have answered `autumn jobs
        // manifest` with the OpenAPI document instead of the TOML queues one
        // (issue #2370) — the one entry every prior ad hoc list omitted.
        let mut command = Command::new("true");
        command.env(crate::openapi::DUMP_ENV, "1");
        clear_other_dump_modes(&mut command, DUMP_ENV);
        let survived = command
            .get_envs()
            .any(|(key, value)| key == crate::openapi::DUMP_ENV && value.is_some());
        assert!(
            !survived,
            "an inherited {}=1 must never survive into the jobs-manifest child",
            crate::openapi::DUMP_ENV
        );
    }

    // ── manifest_from_child ─────────────────────────────────────────────────

    #[test]
    fn manifest_from_child_returns_stdout_on_success() {
        let manifest =
            manifest_from_child(true, Some(0), b"queues = [\"default\"]\n").expect("clean exit");
        assert_eq!(manifest, "queues = [\"default\"]\n");
    }

    #[test]
    fn manifest_from_child_lossily_decodes_non_utf8_stdout() {
        // A clean exit with invalid UTF-8 bytes must not panic — it is decoded
        // lossily, mirroring the previous `String::from_utf8_lossy` behaviour.
        let manifest = manifest_from_child(true, Some(0), &[0xff, 0xfe, b'x']).expect("clean exit");
        assert!(manifest.ends_with('x'));
        assert!(manifest.contains('\u{FFFD}'), "invalid bytes become U+FFFD");
    }

    #[test]
    fn manifest_from_child_propagates_child_exit_code() {
        assert_eq!(manifest_from_child(false, Some(2), b""), Err(2));
    }

    #[test]
    fn manifest_from_child_defaults_missing_code_to_one() {
        // A signal-terminated child has no exit code; default to 1 so callers
        // still exit non-zero.
        assert_eq!(manifest_from_child(false, None, b""), Err(1));
    }

    // ── validate_manifest ───────────────────────────────────────────────────

    #[test]
    fn validate_manifest_accepts_well_formed_manifest() {
        let stdout = "queues = [\"critical\", \"email\"]\n";
        let validated = validate_manifest(stdout).expect("valid manifest should pass");
        // The stdout is returned byte-for-byte so the on-disk manifest is exactly
        // what the app emitted (preserving highest-priority-first ordering).
        assert_eq!(validated, stdout);
    }

    #[test]
    fn validate_manifest_accepts_empty_queues_array() {
        // A configured-but-empty queue set is a legitimate manifest, distinct
        // from missing output.
        let stdout = "queues = []\n";
        assert_eq!(
            validate_manifest(stdout).expect("empty array is valid"),
            stdout
        );
    }

    #[test]
    fn validate_manifest_rejects_leading_stray_line() {
        // A stray log/telemetry line before the manifest makes the whole payload
        // fail to parse as TOML — we must error and refuse to write, not silently
        // persist a corrupt file (strict-parse, mirroring `autumn routes`).
        let stdout = "some log line\nqueues = [\"critical\"]\n";
        let err = validate_manifest(stdout).expect_err("stray line must be rejected");
        assert!(
            err.contains("did not emit a valid jobs manifest"),
            "expected manifest-validation error, got: {err}"
        );
    }

    #[test]
    fn validate_manifest_rejects_empty_stdout() {
        let err = validate_manifest("").expect_err("empty stdout must be rejected");
        assert!(
            err.contains("did not emit a valid jobs manifest"),
            "expected manifest-validation error, got: {err}"
        );
    }

    #[test]
    fn validate_manifest_rejects_toml_without_queues_key() {
        // Valid TOML that lacks the `queues` array must be rejected, and the
        // message should name the missing key.
        let stdout = "other = [\"critical\"]\n";
        let err = validate_manifest(stdout).expect_err("missing queues key must be rejected");
        assert!(
            err.contains("no top-level `queues` array"),
            "expected missing-key error, got: {err}"
        );
    }

    #[test]
    fn validate_manifest_rejects_non_string_queue_elements() {
        // `queues` present but not an array of strings must be rejected.
        let stdout = "queues = [1, 2]\n";
        let err = validate_manifest(stdout).expect_err("non-string elements must be rejected");
        assert!(
            err.contains("not an array of strings"),
            "expected element-type error, got: {err}"
        );
    }

    // ── parse_manifest_queues (shared with doctor, #2419) ────────────────

    #[test]
    fn parse_manifest_queues_accepts_a_well_formed_array() {
        assert_eq!(
            parse_manifest_queues("queues = [\"critical\", \"email\"]\n"),
            Ok(ManifestQueues::Present(vec![
                "critical".to_string(),
                "email".to_string()
            ])),
        );
    }

    #[test]
    fn parse_manifest_queues_empty_array_is_present_not_absent() {
        // Empty is the app answering "no job-declared queues", a real answer
        // rather than a missing one.
        assert_eq!(
            parse_manifest_queues("queues = []\n"),
            Ok(ManifestQueues::Present(Vec::new())),
        );
    }

    #[test]
    fn parse_manifest_queues_absent_key_is_absent() {
        assert_eq!(
            parse_manifest_queues("other = 1\n"),
            Ok(ManifestQueues::Absent),
        );
    }

    #[test]
    fn parse_manifest_queues_distinguishes_parse_failure_from_shape_failure() {
        // Unparseable TOML and a present-but-malformed `queues` array are
        // different defects: the reader falls through on the former and fails
        // loudly on the latter.
        assert!(matches!(
            parse_manifest_queues("this is not toml ="),
            Err(ManifestQueuesError::NotToml(_))
        ));
        assert!(matches!(
            parse_manifest_queues("queues = [1]\n"),
            Err(ManifestQueuesError::BadQueuesArray(_))
        ));
        assert!(matches!(
            parse_manifest_queues("queues = \"critical\"\n"),
            Err(ManifestQueuesError::BadQueuesArray(_))
        ));
    }

    // ── write_manifest ──────────────────────────────────────────────────────

    #[test]
    fn write_manifest_writes_contents_to_path() {
        let dir = tempfile::tempdir().expect("tempdir");
        let path = dir.path().join("jobs-manifest.toml");
        write_manifest(&path, "queues = [\"a\"]\n").expect("write should succeed");
        let written = std::fs::read_to_string(&path).expect("read back");
        assert_eq!(written, "queues = [\"a\"]\n");
    }

    #[test]
    fn write_manifest_creates_missing_parent_directories() {
        let dir = tempfile::tempdir().expect("tempdir");
        // Nested parents that do not yet exist must be created.
        let path = dir.path().join("nested/deeper/jobs-manifest.toml");
        write_manifest(&path, "queues = []\n").expect("nested write should succeed");
        assert!(path.exists(), "manifest file should exist");
        assert_eq!(
            std::fs::read_to_string(&path).expect("read back"),
            "queues = []\n"
        );
    }

    #[test]
    fn write_manifest_errors_when_parent_creation_fails() {
        let dir = tempfile::tempdir().expect("tempdir");
        // Create a regular file, then try to treat it as a parent directory:
        // `create_dir_all` must fail because a path component is a file.
        let blocker = dir.path().join("iam-a-file");
        std::fs::write(&blocker, "x").expect("create blocker file");
        let path = blocker.join("sub/jobs-manifest.toml");
        let err = write_manifest(&path, "queues = []\n").expect_err("parent creation must fail");
        assert!(
            err.contains("Failed to create"),
            "expected parent-creation error, got: {err}"
        );
    }

    #[test]
    fn write_manifest_errors_when_write_fails() {
        let dir = tempfile::tempdir().expect("tempdir");
        // The target path is an existing directory, so the write itself fails
        // even though the parent exists.
        let path = dir.path().to_path_buf();
        let err = write_manifest(&path, "queues = []\n").expect_err("writing to a dir must fail");
        assert!(
            err.contains("Failed to write"),
            "expected write error, got: {err}"
        );
    }
}