use std::path::Path;
use std::process::Command;
use crate::routes::{compile_binary, find_binary};
pub struct ManifestOptions<'a> {
pub package: Option<&'a str>,
pub bin: Option<&'a str>,
pub output: &'a str,
}
fn clear_competing_dump_modes(command: &mut Command) {
command
.env_remove(crate::data_flow::DUMP_ENV)
.env_remove(crate::agents::DUMP_ENV)
.env_remove(crate::graph::DUMP_ENV)
.env_remove(crate::cache_audit::DUMP_ENV);
}
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("AUTUMN_DUMP_JOBS", "1")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit());
clear_competing_dump_modes(&mut command);
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);
}
};
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);
}
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))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ManifestQueues {
Absent,
Present(Vec<String>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ManifestQueuesError {
NotToml(String),
BadQueuesArray(String),
}
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(),
))
}
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`";
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})")),
}
}
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::*;
#[test]
fn clear_competing_dump_modes_removes_every_earlier_mode_var() {
let competing = [
crate::data_flow::DUMP_ENV,
crate::agents::DUMP_ENV,
crate::graph::DUMP_ENV,
crate::cache_audit::DUMP_ENV,
];
let mut command = Command::new("true");
for var in competing {
command.env(var, "1");
}
clear_competing_dump_modes(&mut command);
for var in competing {
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_cache_coherence_dump_cannot_hijack_the_jobs_manifest() {
let mut command = Command::new("true");
command.env(crate::cache_audit::DUMP_ENV, "1");
clear_competing_dump_modes(&mut command);
let survived = command
.get_envs()
.any(|(key, value)| key == crate::cache_audit::DUMP_ENV && value.is_some());
assert!(
!survived,
"an inherited {}=1 must never survive into the jobs-manifest child",
crate::cache_audit::DUMP_ENV
);
}
#[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() {
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() {
assert_eq!(manifest_from_child(false, None, b""), Err(1));
}
#[test]
fn validate_manifest_accepts_well_formed_manifest() {
let stdout = "queues = [\"critical\", \"email\"]\n";
let validated = validate_manifest(stdout).expect("valid manifest should pass");
assert_eq!(validated, stdout);
}
#[test]
fn validate_manifest_accepts_empty_queues_array() {
let stdout = "queues = []\n";
assert_eq!(
validate_manifest(stdout).expect("empty array is valid"),
stdout
);
}
#[test]
fn validate_manifest_rejects_leading_stray_line() {
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() {
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() {
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}"
);
}
#[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() {
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() {
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(_))
));
}
#[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");
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");
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");
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}"
);
}
}