use std::io::Write;
use std::path::Path;
pub fn mirror(project_dir: &Path, content: &str) {
if let Err(err) = try_mirror(project_dir, content) {
eprintln!("warning: could not write to run log: {err}");
}
}
pub fn format_dbt_output(
command: &str,
dbt_project_dir: &Path,
stdout: &str,
stderr: &str,
) -> String {
format!(
"$ dbt {command} (in {})\n{stdout}\n{stderr}\n",
dbt_project_dir.display()
)
}
pub fn log_dbt_result(
command: &str,
dbt_project_dir: &Path,
real_project_dir: &Path,
result: Result<
zhao_core::adapters::dbt::DbtCommandOutput,
zhao_core::adapters::dbt::DbtAdapterError,
>,
) -> Result<zhao_core::adapters::dbt::DbtCommandOutput, zhao_core::adapters::dbt::DbtAdapterError> {
use zhao_core::adapters::dbt::DbtAdapterError;
let captured = match &result {
Ok(output) => Some((output.stdout.as_str(), output.stderr.as_str())),
Err(DbtAdapterError::CompileFailed { stdout, stderr, .. })
| Err(DbtAdapterError::DepsFailed { stdout, stderr, .. }) => {
Some((stdout.as_str(), stderr.as_str()))
}
Err(_) => None,
};
if let Some((stdout, stderr)) = captured {
mirror(
real_project_dir,
&format_dbt_output(command, dbt_project_dir, stdout, stderr),
);
}
result
}
fn try_mirror(project_dir: &Path, content: &str) -> std::io::Result<()> {
let dir = project_dir.join("target").join("zhao").join("logs");
std::fs::create_dir_all(&dir)?;
let path = dir.join(format!("{}.log", today()));
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
file.write_all(content.as_bytes())
}
pub fn purge(project_dir: &Path, retention_days: Option<u32>) {
let Some(retention_days) = retention_days else {
return;
};
if let Err(err) = try_purge(project_dir, retention_days) {
eprintln!("warning: could not purge old run logs: {err}");
}
}
fn try_purge(project_dir: &Path, retention_days: u32) -> std::io::Result<()> {
let dir = project_dir.join("target").join("zhao").join("logs");
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(err),
};
let cutoff = today_days() - i64::from(retention_days);
for entry in entries {
let path = entry?.path();
if let Some(file_days) = log_filename_days(&path) {
if file_days < cutoff {
let _ = std::fs::remove_file(&path);
}
}
}
Ok(())
}
fn log_filename_days(path: &Path) -> Option<i64> {
let stem = path.file_stem()?.to_str()?;
if path.extension()?.to_str()? != "log" {
return None;
}
let mut parts = stem.splitn(3, '-');
let y: i64 = parts.next()?.parse().ok()?;
let m: u32 = parts.next()?.parse().ok()?;
let d: u32 = parts.next()?.parse().ok()?;
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return None;
}
Some(days_from_civil(y, m, d))
}
fn today() -> String {
let (y, m, d) = civil_from_days(today_days());
format!("{y:04}-{m:02}-{d:02}")
}
fn today_days() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| (d.as_secs() / 86_400) as i64)
.unwrap_or(0)
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = (y - era * 400) as u64; let mp = if m > 2 { m - 3 } else { m + 9 }; let doy = (153 * mp + 2) / 5 + d - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + u64::from(doy); era * 146_097 + doe as i64 - 719_468
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn civil_from_days_matches_known_dates() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
assert_eq!(civil_from_days(-1), (1969, 12, 31));
assert_eq!(civil_from_days(19_722), (2023, 12, 31));
assert_eq!(civil_from_days(19_723), (2024, 1, 1));
assert_eq!(civil_from_days(19_782), (2024, 2, 29));
assert_eq!(civil_from_days(19_783), (2024, 3, 1));
}
#[test]
fn days_from_civil_is_the_exact_inverse_of_civil_from_days() {
for days in [-1, 0, 1, 19_722, 19_723, 19_782, 19_783] {
let (y, m, d) = civil_from_days(days);
assert_eq!(days_from_civil(y, m, d), days, "{y:04}-{m:02}-{d:02}");
}
}
#[test]
fn log_filename_days_parses_a_valid_log_filename() {
let days = log_filename_days(Path::new("/x/target/zhao/logs/2024-02-29.log"))
.expect("should parse");
assert_eq!(civil_from_days(days), (2024, 2, 29));
}
#[test]
fn log_filename_days_ignores_non_log_files() {
assert_eq!(
log_filename_days(Path::new("/x/target/zhao/logs/2024-02-29.json")),
None
);
assert_eq!(
log_filename_days(Path::new("/x/target/zhao/logs/not-a-date.log")),
None
);
}
#[test]
fn purge_with_no_retention_configured_removes_nothing() {
let dir = tempfile::tempdir().expect("should create temp dir");
let logs_dir = dir.path().join("target").join("zhao").join("logs");
std::fs::create_dir_all(&logs_dir).expect("should create logs dir");
std::fs::write(logs_dir.join("2000-01-01.log"), "old").expect("should write old log");
purge(dir.path(), None);
assert!(logs_dir.join("2000-01-01.log").exists());
}
#[test]
fn purge_removes_only_logs_older_than_the_configured_window() {
let dir = tempfile::tempdir().expect("should create temp dir");
let logs_dir = dir.path().join("target").join("zhao").join("logs");
std::fs::create_dir_all(&logs_dir).expect("should create logs dir");
let (y, m, d) = civil_from_days(today_days());
let today_name = format!("{y:04}-{m:02}-{d:02}.log");
let (oy, om, od) = civil_from_days(today_days() - 100);
let old_name = format!("{oy:04}-{om:02}-{od:02}.log");
std::fs::write(logs_dir.join(&today_name), "today").expect("should write today's log");
std::fs::write(logs_dir.join(&old_name), "old").expect("should write old log");
let sentinel = dir
.path()
.join("target")
.join("zhao")
.join("run-metadata.json");
std::fs::write(&sentinel, "{}").expect("should write sentinel file");
purge(dir.path(), Some(30));
assert!(
logs_dir.join(&today_name).exists(),
"a log within the retention window should survive"
);
assert!(
!logs_dir.join(&old_name).exists(),
"a log older than the retention window should be removed"
);
assert!(
sentinel.exists(),
"purging must never touch other target/zhao/ artifacts"
);
}
#[test]
fn mirror_appends_across_multiple_calls_the_same_day() {
let dir = tempfile::tempdir().expect("should create temp dir");
mirror(dir.path(), "first run\n");
mirror(dir.path(), "second run\n");
let log_path = dir
.path()
.join("target")
.join("zhao")
.join("logs")
.join(format!("{}.log", today()));
let content = std::fs::read_to_string(&log_path).expect("should read log file");
assert_eq!(content, "first run\nsecond run\n");
}
#[test]
fn mirror_never_touches_other_target_zhao_artifacts() {
let dir = tempfile::tempdir().expect("should create temp dir");
std::fs::create_dir_all(dir.path().join("target").join("zhao"))
.expect("should create target/zhao");
std::fs::write(
dir.path()
.join("target")
.join("zhao")
.join("run-metadata.json"),
"{}",
)
.expect("should write sentinel file");
mirror(dir.path(), "a run\n");
let sentinel = dir
.path()
.join("target")
.join("zhao")
.join("run-metadata.json");
assert_eq!(
std::fs::read_to_string(&sentinel).expect("should still exist"),
"{}"
);
}
}