use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::commands::job::shared_wizard::ReportBucketInput;
use crate::commands::keyring::bucket::store::BucketConfig;
use crate::commands::keyring::store::Store;
use crate::core::rclone::RcloneCommand as _;
use crate::core::rclone::command::CopyTo;
use crate::core::rclone::config::RemoteTarget;
use crate::core::wizard::WizardInput;
use crate::observability::{self, transcript::Transcript};
pub(crate) fn resolve(
flag: Option<String>,
store: &Store,
non_interactive: bool,
) -> Result<BucketConfig, String> {
let alias = (ReportBucketInput { flag, store }).resolve(non_interactive)?;
let bucket_config = store
.bucket_configs()
.find(|bucket_config| bucket_config.alias == alias)
.ok_or_else(|| format!("no bucket-config named '{alias}'"))?
.clone();
Ok(bucket_config)
}
pub(crate) fn generate_run_id() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
let run_id = format!(
"{nanos:x}-{}-{:x}",
observability::instance(),
std::process::id()
);
observability::record_run_id(&run_id);
run_id
}
pub(crate) fn run_prefix(job_name: &str, run_id: &str) -> String {
let now = time::OffsetDateTime::now_utc();
format!(
"{:04}-{:02}-{:02}-{job_name}-{run_id}",
now.year(),
u8::from(now.month()),
now.day()
)
}
pub(crate) fn write_summary_report(
local_output: &Path,
job_name: &str,
summary: &impl std::fmt::Debug,
) -> Result<PathBuf, String> {
let path = local_output.join(format!("{job_name}-report.txt"));
fs::write(&path, format!("{summary:#?}\n"))
.map_err(|err| format!("failed to write {}: {err}", path.display()))?;
Ok(path)
}
pub(crate) fn new_transcript(local_output: &Path) -> Result<(Transcript, PathBuf), String> {
let path = local_output.join("transcript.txt");
let transcript = Transcript::create(&path)?;
Ok((transcript, path))
}
pub(crate) fn say(transcript: &Transcript, message: impl AsRef<str>) {
let message = message.as_ref();
println!("{message}");
transcript.line(message);
}
pub(crate) fn say_error(transcript: &Transcript, err: impl std::fmt::Display) {
say(transcript, format!("Error: {err}"));
}
pub(crate) fn log_run_outcome(exit_code: i32) {
if exit_code == 0 {
tracing::info!(exit_code, "job run outcome before artifact upload: success");
} else {
tracing::error!(exit_code, "job run outcome before artifact upload: failure");
}
}
pub(crate) async fn upload_run_artifacts(
bucket_config: &BucketConfig,
prefix: &str,
run_id: &str,
report_path: &Path,
transcript_path: &Path,
) {
let destination = RemoteTarget::bucket_root(&bucket_config.alias).address();
let log_dir = report_path.parent().unwrap_or(Path::new("."));
let uploads: [(&str, &Path); 2] = [
("report.txt", report_path),
("transcript.txt", transcript_path),
];
for (name, path) in uploads {
upload_one(&destination, &format!("{prefix}/{name}"), path, log_dir).await;
}
match write_run_log_extract(run_id, log_dir) {
Ok(Some(extract_path)) => {
upload_one(
&destination,
&format!("{prefix}/pigeon.jsonl"),
&extract_path,
log_dir,
)
.await;
let _ = std::fs::remove_file(&extract_path);
}
Ok(None) => {
tracing::warn!(
run_id,
"no log lines carried this run's id; skipping the log artifact"
);
}
Err(err) => {
tracing::warn!(error = %err, "failed to build this run's log extract");
}
}
}
fn write_run_log_extract(run_id: &str, log_dir: &Path) -> Result<Option<PathBuf>, String> {
use std::io::Write as _;
let kept = extract_matching_lines(&observability::log_extract_sources()?, run_id);
if kept.is_empty() {
return Ok(None);
}
let extract_path = log_dir.join(format!("run-log-{run_id}.jsonl"));
let output = std::fs::File::create(&extract_path)
.map_err(|err| format!("failed to create {}: {err}", extract_path.display()))?;
let mut writer = std::io::BufWriter::new(output);
for line in &kept {
writeln!(writer, "{line}")
.map_err(|err| format!("failed to write {}: {err}", extract_path.display()))?;
}
writer
.flush()
.map_err(|err| format!("failed to write {}: {err}", extract_path.display()))?;
Ok(Some(extract_path))
}
fn extract_matching_lines(sources: &[PathBuf], run_id: &str) -> Vec<String> {
use std::io::BufRead as _;
let needle = format!("\"run_id\":\"{run_id}\"");
let mut kept = Vec::new();
for source in sources {
let Ok(file) = std::fs::File::open(source) else {
continue;
};
for line in std::io::BufReader::new(file).lines().map_while(Result::ok) {
if line.contains(&needle) {
kept.push(line);
}
}
}
kept
}
async fn upload_one(destination: &str, key: &str, path: &Path, log_dir: &Path) {
let flattened: String = key
.chars()
.map(|ch| if ch == '/' { '-' } else { ch })
.collect();
let result = (CopyTo {
source: path.display().to_string(),
destination: format!("{destination}{key}"),
log_path: log_dir.join(format!("artifact-{flattened}.jsonl")),
job: "report-upload",
step: "artifact",
emit_upload_metrics: false,
bytes: 0,
})
.run()
.await;
if let Err(err) = result {
tracing::warn!(key, error = %err, "failed to upload run artifact");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_run_log_extract_keeps_only_this_runs_lines() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("mixed.jsonl");
std::fs::write(
&log,
concat!(
"{\"run_id\":\"aaaa\",\"msg\":\"theirs\"}\n",
"{\"run_id\":\"bbbb\",\"msg\":\"mine one\"}\n",
"{\"msg\":\"no run id at all\"}\n",
"{\"run_id\":\"bbbb\",\"msg\":\"mine two\"}\n",
),
)
.unwrap();
let kept = extract_matching_lines(&[log], "bbbb");
assert_eq!(kept.len(), 2);
assert!(kept.iter().all(|line| line.contains("\"run_id\":\"bbbb\"")));
assert!(kept.iter().any(|line| line.contains("mine one")));
assert!(kept.iter().any(|line| line.contains("mine two")));
}
#[test]
fn a_run_log_extract_spans_rotated_generations() {
let dir = tempfile::tempdir().unwrap();
let older = dir.path().join("pigeon.2026-10-13.jsonl");
let newer = dir.path().join("pigeon.2026-10-14.jsonl");
std::fs::write(
&older,
"{\"run_id\":\"cccc\",\"msg\":\"before midnight\"}\n",
)
.unwrap();
std::fs::write(&newer, "{\"run_id\":\"cccc\",\"msg\":\"after midnight\"}\n").unwrap();
let kept = extract_matching_lines(&[older, newer], "cccc");
assert_eq!(kept.len(), 2);
}
#[test]
fn a_run_log_extract_is_empty_when_no_line_carries_the_id() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("other.jsonl");
std::fs::write(&log, "{\"run_id\":\"zzzz\",\"msg\":\"not mine\"}\n").unwrap();
assert!(extract_matching_lines(&[log], "bbbb").is_empty());
}
#[test]
fn a_run_log_extract_tolerates_a_vanished_generation() {
let dir = tempfile::tempdir().unwrap();
let present = dir.path().join("present.jsonl");
std::fs::write(&present, "{\"run_id\":\"dddd\",\"msg\":\"here\"}\n").unwrap();
let kept = extract_matching_lines(&[dir.path().join("gone.jsonl"), present], "dddd");
assert_eq!(kept.len(), 1);
}
#[test]
fn generate_run_id_is_not_empty_and_varies_across_calls() {
let first = generate_run_id();
let second = generate_run_id();
assert!(!first.is_empty());
assert_ne!(first, second);
}
#[test]
fn run_prefix_has_the_expected_shape() {
let prefix = run_prefix("deduplicate", "abc123");
assert!(prefix.ends_with("-deduplicate-abc123"));
assert_eq!(
prefix.len(),
"YYYY-MM-DD".len() + "-deduplicate-abc123".len()
);
}
#[test]
fn write_summary_report_debug_formats_the_summary() {
#[derive(Debug)]
#[allow(dead_code)]
struct Summary {
processed: usize,
}
let dir = tempfile::tempdir().unwrap();
let path =
write_summary_report(dir.path(), "deduplicate", &Summary { processed: 3 }).unwrap();
let contents = fs::read_to_string(&path).unwrap();
assert!(contents.contains("processed: 3"));
assert_eq!(path.file_name().unwrap(), "deduplicate-report.txt");
}
#[test]
fn write_summary_report_also_works_for_an_error_string() {
let dir = tempfile::tempdir().unwrap();
let path = write_summary_report(dir.path(), "transform", &"boom".to_string()).unwrap();
let contents = fs::read_to_string(&path).unwrap();
assert!(contents.contains("boom"));
}
#[test]
fn say_error_prefixes_and_records_the_message() {
let dir = tempfile::tempdir().unwrap();
let (transcript, path) = new_transcript(dir.path()).unwrap();
say_error(&transcript, "boom");
assert_eq!(fs::read_to_string(&path).unwrap(), "Error: boom\n");
}
#[derive(Clone, Default)]
struct CapturedEvents(
std::sync::Arc<std::sync::Mutex<Vec<std::collections::HashMap<String, String>>>>,
);
struct CaptureLayer(CapturedEvents);
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CaptureLayer {
fn on_event(
&self,
event: &tracing::Event<'_>,
_ctx: tracing_subscriber::layer::Context<'_, S>,
) {
struct Visitor(std::collections::HashMap<String, String>);
impl tracing::field::Visit for Visitor {
fn record_debug(
&mut self,
field: &tracing::field::Field,
value: &dyn std::fmt::Debug,
) {
self.0
.insert(field.name().to_string(), format!("{value:?}"));
}
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
self.0.insert(field.name().to_string(), value.to_string());
}
}
let mut visitor = Visitor(std::collections::HashMap::new());
event.record(&mut visitor);
self.0.0.lock().unwrap().push(visitor.0);
}
}
fn capture_events(run: impl FnOnce()) -> Vec<std::collections::HashMap<String, String>> {
use tracing_subscriber::layer::SubscriberExt as _;
let events = CapturedEvents::default();
let subscriber = tracing_subscriber::registry().with(CaptureLayer(events.clone()));
tracing::subscriber::with_default(subscriber, run);
events.0.lock().unwrap().clone()
}
#[test]
fn log_run_outcome_logs_an_info_line_with_exit_code_zero_on_success() {
let events = capture_events(|| log_run_outcome(0));
assert_eq!(events.len(), 1);
let fields = &events[0];
assert_eq!(
fields.get("message").map(String::as_str),
Some("job run outcome before artifact upload: success")
);
assert_eq!(fields.get("exit_code").map(String::as_str), Some("0"));
}
#[test]
fn log_run_outcome_logs_an_error_line_with_the_nonzero_exit_code_on_failure() {
let events = capture_events(|| log_run_outcome(1));
assert_eq!(events.len(), 1);
let fields = &events[0];
assert_eq!(
fields.get("message").map(String::as_str),
Some("job run outcome before artifact upload: failure")
);
assert_eq!(fields.get("exit_code").map(String::as_str), Some("1"));
}
}