use std::io::Write;
use std::path::PathBuf;
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
pub const RETENTION_DAYS: i64 = 90;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpsLogEntry {
pub timestamp: DateTime<Utc>,
pub cli_version: String,
pub args: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub environment: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service: Option<String>,
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub duration_ms: u64,
}
pub fn log_path() -> Option<PathBuf> {
if let Ok(path) = std::env::var("RAILWAY_POSTGRES_OPS_LOG")
&& !path.is_empty()
{
return Some(PathBuf::from(path));
}
dirs::home_dir().map(|home| home.join(".railway").join("postgres-ops.jsonl"))
}
pub fn record(entry: &OpsLogEntry) {
let Some(path) = log_path() else { return };
let _ = record_at(&path, entry);
}
fn record_at(path: &PathBuf, entry: &OpsLogEntry) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let cutoff = Utc::now() - Duration::days(RETENTION_DAYS);
let mut lines: Vec<String> = match std::fs::read_to_string(path) {
Ok(existing) => existing
.lines()
.filter(|line| {
serde_json::from_str::<OpsLogEntry>(line)
.map(|parsed| parsed.timestamp >= cutoff)
.unwrap_or(true)
})
.map(String::from)
.collect(),
Err(_) => Vec::new(),
};
lines.push(serde_json::to_string(entry)?);
let mut file = std::fs::File::create(path)?;
for line in &lines {
writeln!(file, "{line}")?;
}
Ok(())
}
pub fn read_entries() -> Vec<OpsLogEntry> {
let Some(path) = log_path() else {
return Vec::new();
};
match std::fs::read_to_string(path) {
Ok(contents) => contents
.lines()
.filter_map(|line| serde_json::from_str(line).ok())
.collect(),
Err(_) => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(ts: DateTime<Utc>, args: &[&str]) -> OpsLogEntry {
OpsLogEntry {
timestamp: ts,
cli_version: "0.0.0-test".to_string(),
args: args.iter().map(|s| s.to_string()).collect(),
project: Some("proj-1".to_string()),
environment: Some("env-1".to_string()),
service: Some("svc-1".to_string()),
success: true,
error: None,
duration_ms: 42,
}
}
#[test]
fn appends_and_reads_back_in_order() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ops.jsonl");
record_at(&path, &entry(Utc::now(), &["postgres", "pitr", "enable"])).unwrap();
record_at(&path, &entry(Utc::now(), &["postgres", "ha", "convert"])).unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
let parsed: Vec<OpsLogEntry> = contents
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].args[1], "pitr");
assert_eq!(parsed[1].args[1], "ha");
}
#[test]
fn prunes_entries_past_retention_on_append() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ops.jsonl");
let stale = entry(
Utc::now() - Duration::days(RETENTION_DAYS + 1),
&["postgres", "pitr", "enable"],
);
let fresh = entry(Utc::now(), &["postgres", "pitr", "disable"]);
record_at(&path, &stale).unwrap();
record_at(&path, &fresh).unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
let parsed: Vec<OpsLogEntry> = contents
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(parsed.len(), 1, "stale entry pruned");
assert_eq!(parsed[0].args[2], "disable");
}
#[test]
fn unparseable_lines_survive_pruning() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ops.jsonl");
std::fs::write(&path, "not json at all\n").unwrap();
record_at(&path, &entry(Utc::now(), &["postgres", "pitr", "status"])).unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.lines().count() == 2);
assert!(contents.starts_with("not json at all"));
}
}