use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
const REPORT_PREFIX: &str = "AUTUMN_DATA_CAPSULE_REPORT=";
const MODE_ENV: &str = "AUTUMN_DATA_CAPSULE";
const SUBJECT_ENV: &str = "AUTUMN_DATA_CAPSULE_SUBJECT";
const PATH_ENV: &str = "AUTUMN_DATA_CAPSULE_PATH";
const ONE_SHOT_ENV: [&str; 3] = [MODE_ENV, SUBJECT_ENV, PATH_ENV];
pub fn clear_inherited_one_shot_env(command: &mut Command) {
for var in ONE_SHOT_ENV {
command.env_remove(var);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CapsuleAction {
Export {
subject: String,
},
Import,
Verify,
}
impl CapsuleAction {
const fn env_value(&self) -> &'static str {
match self {
Self::Export { .. } => "export",
Self::Import => "import",
Self::Verify => "verify",
}
}
}
pub struct CapsuleOptions<'a> {
pub package: Option<&'a str>,
pub bin: Option<&'a str>,
pub profile: &'a str,
pub action: CapsuleAction,
pub path: &'a str,
pub force: bool,
pub json: bool,
}
pub fn run(opts: &CapsuleOptions<'_>) {
eprintln!(
"\u{1F342} autumn data capsule {}\n",
opts.action.env_value()
);
if let Some(refusal) = production_refusal(opts) {
eprintln!("\u{2717} {refusal}");
std::process::exit(1);
}
let path = std::path::absolute(opts.path).unwrap_or_else(|error| {
eprintln!("\u{2717} Bad path {}: {error}", opts.path);
std::process::exit(1);
});
crate::routes::compile_binary(opts.package, opts.bin);
let binary = crate::routes::find_binary(opts.package, opts.bin);
let manifest_dir = opts.package.and_then(crate::dev::find_manifest_dir);
let mut command = build_command(&binary, opts, &path, manifest_dir.as_deref());
crate::task::apply_managed_pg_env(&mut command, opts.package);
let output = command.output().unwrap_or_else(|error| {
eprintln!("\u{2717} Failed to run {}: {error}", binary.display());
std::process::exit(1);
});
let stdout = String::from_utf8_lossy(&output.stdout);
let Some(json) = extract_report_json(&stdout) else {
eprintln!("Failed to find the capsule report in the binary's output.");
std::process::exit(output.status.code().filter(|code| *code != 0).unwrap_or(1));
};
if opts.json {
println!("{json}");
} else {
println!("{}", format_report(json, &path));
}
if !output.status.success() {
std::process::exit(output.status.code().unwrap_or(1));
}
}
fn production_refusal(opts: &CapsuleOptions<'_>) -> Option<String> {
if opts.action != CapsuleAction::Import || opts.force {
return None;
}
let profile = crate::migrate::canonical_profile(opts.profile);
if matches!(profile.as_str(), "dev" | "test") {
return None;
}
Some(format!(
"Refusing to import a capsule into the {profile:?} profile.\n \
Import writes records. Re-run with --force if you mean it."
))
}
fn build_command(
binary: &Path,
opts: &CapsuleOptions<'_>,
path: &Path,
manifest_dir: Option<&Path>,
) -> Command {
let mut command = Command::new(binary);
if let Some(dir) = manifest_dir {
command.current_dir(dir).env("AUTUMN_MANIFEST_DIR", dir);
}
clear_competing_one_shot_env(&mut command);
command
.env(MODE_ENV, opts.action.env_value())
.env(PATH_ENV, path)
.env("AUTUMN_ENV", opts.profile)
.env("AUTUMN_PROFILE", opts.profile)
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
match &opts.action {
CapsuleAction::Export { subject } => command.env(SUBJECT_ENV, subject),
_ => command.env_remove(SUBJECT_ENV),
};
command
}
fn clear_competing_one_shot_env(command: &mut Command) {
for var in [
"AUTUMN_BUILD_STATIC",
"AUTUMN_DUMP_ROUTES",
"AUTUMN_DUMP_OPENAPI",
"AUTUMN_DUMP_CACHE_COHERENCE",
"AUTUMN_DUMP_DATA_FLOW",
"AUTUMN_DUMP_AGENT_AUTHORITY",
"AUTUMN_DUMP_GRAPH",
"AUTUMN_DUMP_JOBS",
"AUTUMN_LIST_TASKS",
"AUTUMN_RUN_TASK",
"AUTUMN_MIGRATE",
"AUTUMN_RETENTION_DRY_RUN",
"AUTUMN_DB_RETENTION",
"AUTUMN_DB_RETENTION_DATASET",
] {
command.env_remove(var);
}
}
fn extract_report_json(stdout: &str) -> Option<&str> {
stdout
.lines()
.rev()
.find_map(|line| line.strip_prefix(REPORT_PREFIX))
}
fn format_report(json: &str, path: &Path) -> String {
let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
return json.to_owned();
};
if value["ok"] != true {
let error = value["error"].as_str().unwrap_or("unknown error");
return format!("\u{2717} {error}");
}
let report = &value["report"];
let path = PathBuf::from(path);
match value["mode"].as_str() {
Some("export") => format!(
"\u{2713} Exported subject {} ({} records, {} blobs) to {}\n \
Open {} in a browser to view it.",
report["subject"].as_str().unwrap_or("?"),
report["records"],
report["blobs"],
path.display(),
path.join("viewer").join("index.html").display()
),
Some("import") => format!(
"\u{2713} Imported {} records from {}",
report["records"],
path.display()
),
_ => format!(
"\u{2713} Capsule {} is intact ({} files, {} records, subject {})",
path.display(),
report["files_checked"],
report["records"],
report["subject"].as_str().unwrap_or("?")
),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn opts(action: CapsuleAction, profile: &str, force: bool) -> CapsuleOptions<'static> {
CapsuleOptions {
package: None,
bin: None,
profile: Box::leak(profile.to_owned().into_boxed_str()),
action,
path: "capsule",
force,
json: false,
}
}
#[derive(Debug, PartialEq, Eq)]
enum Env {
Inherited,
Removed,
Set(String),
}
fn env_of(command: &Command, var: &str) -> Env {
match command
.get_envs()
.find(|(k, _)| *k == std::ffi::OsStr::new(var))
{
None => Env::Inherited,
Some((_, None)) => Env::Removed,
Some((_, Some(v))) => Env::Set(v.to_string_lossy().into_owned()),
}
}
#[test]
fn import_needs_force_outside_dev_and_test() {
assert!(production_refusal(&opts(CapsuleAction::Import, "prod", false)).is_some());
assert!(production_refusal(&opts(CapsuleAction::Import, "prod", true)).is_none());
assert!(production_refusal(&opts(CapsuleAction::Import, "dev", false)).is_none());
assert!(production_refusal(&opts(CapsuleAction::Import, "development", false)).is_none());
assert!(production_refusal(&opts(CapsuleAction::Verify, "prod", false)).is_none());
let export = CapsuleAction::Export {
subject: "1".into(),
};
assert!(production_refusal(&opts(export, "prod", false)).is_none());
}
#[test]
fn export_command_carries_mode_subject_and_path() {
let o = opts(
CapsuleAction::Export {
subject: "42".into(),
},
"dev",
false,
);
let cmd = build_command(Path::new("/bin/true"), &o, Path::new("/tmp/c"), None);
assert_eq!(env_of(&cmd, MODE_ENV), Env::Set("export".into()));
assert_eq!(env_of(&cmd, SUBJECT_ENV), Env::Set("42".into()));
assert_eq!(env_of(&cmd, PATH_ENV), Env::Set("/tmp/c".into()));
assert_eq!(env_of(&cmd, "AUTUMN_DB_RETENTION"), Env::Removed);
assert_eq!(env_of(&cmd, "AUTUMN_RUN_TASK"), Env::Removed);
assert_eq!(env_of(&cmd, "AUTUMN_DUMP_OPENAPI"), Env::Removed);
}
#[test]
fn a_member_runs_from_its_manifest_dir() {
let o = opts(CapsuleAction::Verify, "dev", false);
let member = Path::new("/ws/apps/shop");
let cmd = build_command(
Path::new("/bin/true"),
&o,
Path::new("/tmp/c"),
Some(member),
);
assert_eq!(cmd.get_current_dir(), Some(member));
assert_eq!(
env_of(&cmd, "AUTUMN_MANIFEST_DIR"),
Env::Set("/ws/apps/shop".into())
);
let cmd = build_command(Path::new("/bin/true"), &o, Path::new("/tmp/c"), None);
assert_eq!(cmd.get_current_dir(), None);
}
#[test]
fn import_command_removes_an_inherited_subject() {
let o = opts(CapsuleAction::Import, "dev", false);
let cmd = build_command(Path::new("/bin/true"), &o, Path::new("/tmp/c"), None);
assert_eq!(env_of(&cmd, MODE_ENV), Env::Set("import".into()));
assert_eq!(env_of(&cmd, SUBJECT_ENV), Env::Removed);
}
#[test]
fn clear_inherited_removes_every_protocol_var() {
let mut cmd = Command::new("/bin/true");
clear_inherited_one_shot_env(&mut cmd);
for var in ONE_SHOT_ENV {
assert_eq!(env_of(&cmd, var), Env::Removed, "{var}");
}
}
#[test]
fn report_json_is_the_last_prefixed_line() {
let stdout = "log line\nAUTUMN_DATA_CAPSULE_REPORT={\"a\":1}\nmore\n\
AUTUMN_DATA_CAPSULE_REPORT={\"a\":2}\n";
assert_eq!(extract_report_json(stdout), Some("{\"a\":2}"));
assert_eq!(extract_report_json("nothing"), None);
}
#[test]
fn report_text_names_the_viewer_after_export() {
let json = r#"{"ok":true,"mode":"export","report":{"subject":"42","records":3,"blobs":1}}"#;
let text = format_report(json, Path::new("/tmp/c"));
assert!(text.contains("subject 42"), "{text}");
assert!(text.contains("3 records"), "{text}");
assert!(text.contains("viewer"), "{text}");
}
#[test]
fn report_text_shows_the_error() {
let json = r#"{"ok":false,"mode":"verify","error":"capsule integrity check failed: x"}"#;
assert!(format_report(json, Path::new("c")).contains("integrity check failed"));
}
}