use std::process::Command;
use autumn_web::config::{AutumnConfig, Env, OsEnv};
pub struct ListOptions;
pub struct GetOptions {
pub key: String,
}
pub struct SetOptions {
pub key: String,
pub value: String,
pub actor: Option<String>,
}
pub struct UnsetOptions {
pub key: String,
pub actor: Option<String>,
}
pub struct HistoryOptions {
pub key: String,
pub limit: usize,
}
const CONFIG_SET_SQL: &str = "BEGIN; \
SELECT pg_advisory_xact_lock(1, hashtext(:'key')); \
WITH \
prior AS ( \
SELECT raw_value \
FROM autumn_runtime_config_values \
WHERE key = :'key' \
), \
upsert AS ( \
INSERT INTO autumn_runtime_config_values (key, raw_value, updated_at) \
VALUES (:'key', :'value', NOW()) \
ON CONFLICT (key) DO UPDATE \
SET raw_value = EXCLUDED.raw_value, \
updated_at = EXCLUDED.updated_at \
) \
INSERT INTO autumn_runtime_config_changes (key, old_value, new_value, actor) \
VALUES ( \
:'key', \
(SELECT raw_value FROM prior), \
:'value', \
:'actor' \
); \
COMMIT;";
const CONFIG_UNSET_SQL: &str = "BEGIN; \
SELECT pg_advisory_xact_lock(1, hashtext(:'key')); \
WITH \
removed AS ( \
DELETE FROM autumn_runtime_config_values \
WHERE key = :'key' \
RETURNING raw_value \
) \
INSERT INTO autumn_runtime_config_changes (key, old_value, new_value, actor) \
SELECT :'key', raw_value, NULL, :'actor' \
FROM removed; \
COMMIT;";
pub fn run_list(_opts: &ListOptions) {
let url = resolve_database_url();
check_psql();
run_psql_or_die(
&url,
"SELECT key, raw_value, updated_at \
FROM autumn_runtime_config_values \
ORDER BY key;",
);
}
pub fn run_get(opts: &GetOptions) {
let url = resolve_database_url();
check_psql();
let mut probe = Command::new("psql");
probe.arg(&url).args(["-t", "-A"]);
probe.args(["-v", &format!("key={}", opts.key)]).args([
"-c",
"SELECT 1 FROM autumn_runtime_config_values WHERE key = :'key';",
]);
match probe.output() {
Ok(out) if out.status.success() => {
if String::from_utf8_lossy(&out.stdout).trim().is_empty() {
eprintln!("\u{2717} Key '{}' has no active override.", opts.key);
std::process::exit(1);
}
}
Ok(_) => {
eprintln!("\u{2717} psql probe failed. Check the output above.");
std::process::exit(1);
}
Err(e) => {
eprintln!("\u{2717} Failed to run psql: {e}");
std::process::exit(1);
}
}
run_psql_with_vars_or_die(
&url,
"SELECT key, raw_value, updated_at \
FROM autumn_runtime_config_values \
WHERE key = :'key';",
&[("key", &opts.key)],
);
}
pub fn run_set(opts: &SetOptions) {
let url = resolve_database_url();
check_psql();
let actor = opts.actor.as_deref().unwrap_or("cli");
run_psql_with_vars_or_die(
&url,
CONFIG_SET_SQL,
&[("key", &opts.key), ("value", &opts.value), ("actor", actor)],
);
eprintln!(
"\u{2713} Set '{key}' = '{value}'",
key = opts.key,
value = opts.value
);
}
pub fn run_unset(opts: &UnsetOptions) {
let url = resolve_database_url();
check_psql();
let actor = opts.actor.as_deref().unwrap_or("cli");
run_psql_with_vars_or_die(
&url,
CONFIG_UNSET_SQL,
&[("key", &opts.key), ("actor", actor)],
);
eprintln!(
"\u{2713} Unset '{key}' (reverted to compile-time default)",
key = opts.key
);
}
pub fn run_history(opts: &HistoryOptions) {
let url = resolve_database_url();
check_psql();
let limit = opts.limit.to_string();
run_psql_with_vars_or_die(
&url,
"SELECT id, key, old_value, new_value, actor, changed_at \
FROM autumn_runtime_config_changes \
WHERE key = :'key' \
ORDER BY changed_at DESC \
LIMIT :'limit'::int;",
&[("key", &opts.key), ("limit", &limit)],
);
}
pub fn resolve_database_url() -> String {
if let Some(url) = resolve_primary_database_url_with_env(&OsEnv) {
return url;
}
eprintln!("\u{2717} No database URL found.");
eprintln!(
" Set database.primary_url (or database.url) in autumn.toml, autumn-<profile>.toml, \
or set AUTUMN_DATABASE__PRIMARY_URL / AUTUMN_DATABASE__URL / DATABASE_URL."
);
std::process::exit(1);
}
pub fn resolve_primary_database_url_with_env(env: &dyn Env) -> Option<String> {
resolve_primary_database_url_from_env_var(|key| env.var(key)).or_else(|| {
AutumnConfig::load_with_env(env).ok().and_then(|config| {
config
.database
.effective_primary_url()
.map(ToOwned::to_owned)
})
})
}
fn resolve_primary_database_url_from_env_var<F>(env_var: F) -> Option<String>
where
F: Fn(&str) -> Result<String, std::env::VarError>,
{
for var in [
"AUTUMN_DATABASE__PRIMARY_URL",
"AUTUMN_DATABASE__URL",
"DATABASE_URL",
] {
if let Ok(url) = env_var(var)
&& !url.is_empty()
{
return Some(url);
}
}
None
}
#[cfg(test)]
fn resolve_primary_database_url_from_sources<F>(
env_var: F,
table: Option<&toml::Table>,
) -> Option<String>
where
F: Fn(&str) -> Result<String, std::env::VarError>,
{
if let Some(url) = resolve_primary_database_url_from_env_var(env_var) {
return Some(url);
}
let database = table?.get("database").and_then(toml::Value::as_table)?;
for key in ["primary_url", "url"] {
if let Some(url) = database
.get(key)
.and_then(toml::Value::as_str)
.filter(|url| !url.is_empty())
{
return Some(url.to_owned());
}
}
None
}
fn check_psql() {
match Command::new("psql").arg("--version").output() {
Ok(out) if out.status.success() => {
let v = String::from_utf8_lossy(&out.stdout);
eprintln!(" Using {}", v.trim());
}
_ => {
eprintln!("\u{2717} psql not found on PATH.");
eprintln!(" Install PostgreSQL client tools (e.g. `apt install postgresql-client`).");
std::process::exit(1);
}
}
}
fn run_psql_or_die(database_url: &str, sql: &str) {
let status = Command::new("psql")
.args([database_url, "-v", "ON_ERROR_STOP=on", "-c", sql])
.status();
match status {
Ok(s) if s.success() => {}
Ok(_) => {
eprintln!("\u{2717} psql command failed.");
std::process::exit(1);
}
Err(e) => {
eprintln!("\u{2717} Failed to run psql: {e}");
std::process::exit(1);
}
}
}
fn run_psql_with_vars_or_die(database_url: &str, sql: &str, vars: &[(&str, &str)]) {
let mut cmd = Command::new("psql");
cmd.arg(database_url);
cmd.args(["-v", "ON_ERROR_STOP=on"]);
for (name, value) in vars {
cmd.args(["-v", &format!("{name}={value}")]);
}
cmd.args(["-c", sql]);
match cmd.status() {
Ok(s) if s.success() => {}
Ok(_) => {
eprintln!("\u{2717} psql command failed. Check the output above.");
std::process::exit(1);
}
Err(e) => {
eprintln!("\u{2717} Failed to run psql: {e}");
std::process::exit(1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unset_sql_takes_key_advisory_lock_before_delete() {
let lock = CONFIG_UNSET_SQL
.find("pg_advisory_xact_lock(1, hashtext(:'key'))")
.expect("unset should acquire the per-key advisory lock");
let delete = CONFIG_UNSET_SQL
.find("DELETE FROM autumn_runtime_config_values")
.expect("unset should delete the override row");
assert!(lock < delete, "unset must take the key lock before DELETE");
}
#[test]
fn resolve_prefers_primary_url_env_var() {
let env = |key: &str| match key {
"AUTUMN_DATABASE__PRIMARY_URL" => Ok("postgres://primary".to_owned()),
"AUTUMN_DATABASE__URL" => Ok("postgres://legacy".to_owned()),
"DATABASE_URL" => Ok("postgres://fallback".to_owned()),
_ => Err(std::env::VarError::NotPresent),
};
let url = resolve_primary_database_url_from_sources(env, None).unwrap();
assert_eq!(url, "postgres://primary");
}
#[test]
fn resolve_falls_back_to_legacy_env_var() {
let env = |key: &str| match key {
"AUTUMN_DATABASE__URL" => Ok("postgres://legacy".to_owned()),
_ => Err(std::env::VarError::NotPresent),
};
let url = resolve_primary_database_url_from_sources(env, None).unwrap();
assert_eq!(url, "postgres://legacy");
}
#[test]
fn resolve_falls_back_to_database_url_env_var() {
let env = |key: &str| match key {
"DATABASE_URL" => Ok("postgres://fallback".to_owned()),
_ => Err(std::env::VarError::NotPresent),
};
let url = resolve_primary_database_url_from_sources(env, None).unwrap();
assert_eq!(url, "postgres://fallback");
}
#[test]
fn resolve_reads_primary_url_from_toml() {
let table = toml::from_str::<toml::Table>(
r#"
[database]
primary_url = "postgres://from-toml"
"#,
)
.unwrap();
let env = |_: &str| Err(std::env::VarError::NotPresent);
let url = resolve_primary_database_url_from_sources(env, Some(&table)).unwrap();
assert_eq!(url, "postgres://from-toml");
}
#[test]
fn resolve_reads_url_from_toml_when_primary_url_absent() {
let table = toml::from_str::<toml::Table>(
r#"
[database]
url = "postgres://legacy-toml"
"#,
)
.unwrap();
let env = |_: &str| Err(std::env::VarError::NotPresent);
let url = resolve_primary_database_url_from_sources(env, Some(&table)).unwrap();
assert_eq!(url, "postgres://legacy-toml");
}
#[test]
fn resolve_reads_url_from_active_profile_file() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("autumn.toml"), "").unwrap();
std::fs::write(
tmp.path().join("autumn-dev.toml"),
r#"
[database]
url = "postgres://profile-file"
"#,
)
.unwrap();
let env = autumn_web::config::MockEnv::new()
.with("AUTUMN_MANIFEST_DIR", tmp.path().to_str().unwrap())
.with("AUTUMN_ENV", "dev");
let url = resolve_primary_database_url_with_env(&env).unwrap();
assert_eq!(url, "postgres://profile-file");
}
#[test]
fn resolve_returns_none_when_no_source_found() {
let env = |_: &str| Err(std::env::VarError::NotPresent);
let url = resolve_primary_database_url_from_sources(env, None);
assert!(url.is_none());
}
}