use anyhow::{Context as _, Result}; use std::path::{Path, PathBuf}; use std::time::Duration; use tokio::io::AsyncWriteExt as _; use tokio::process::Command; use tracing::{debug, warn}; use crate::hook::HookResult; /// Exit status of a build operation. #[derive(Debug)] pub enum BuildExitStatus { Success, Failed { exit_code: Option, error: String, }, } impl std::fmt::Display for BuildExitStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Success => write!(f, "success"), Self::Failed { exit_code, error } => { if let Some(code) = exit_code { write!(f, "failed (exit code: {code}): {error}") } else { write!(f, "failed: {error}") } } } } } /// Metadata about a build for logging purposes. #[derive(Debug)] pub struct BuildLogMeta { pub site_name: String, pub timestamp: String, pub git_commit: Option, pub container_image: String, pub duration: Duration, pub exit_status: BuildExitStatus, } /// Save build log to disk via streaming composition. /// /// Writes the metadata header to the log file, then streams stdout and stderr /// content from temporary files via `tokio::io::copy` (O(1) memory). /// Deletes the temporary files after successful composition. /// /// Creates a log file at `{log_dir}/{site_name}/{timestamp}.log`. /// /// # Errors /// /// Returns an error if the log directory cannot be created, the log file /// cannot be written, or the temp files cannot be read. pub async fn save_build_log( log_dir: &Path, meta: &BuildLogMeta, stdout_file: &Path, stderr_file: &Path, ) -> Result { let site_log_dir = log_dir.join(&meta.site_name); let log_file = site_log_dir.join(format!("{}.log", meta.timestamp)); // Create logs directory if it doesn't exist tokio::fs::create_dir_all(&site_log_dir) .await .with_context(|| { format!( "failed to create logs directory: {}", site_log_dir.display() ) })?; // Write header + stream content from temp files let mut log_writer = tokio::io::BufWriter::new( tokio::fs::File::create(&log_file) .await .with_context(|| format!("failed to create log file: {}", log_file.display()))?, ); let header = format_log_header(meta); log_writer.write_all(header.as_bytes()).await?; // Append stdout section log_writer.write_all(b"\n=== STDOUT ===\n").await?; let mut stdout_reader = tokio::fs::File::open(stdout_file) .await .with_context(|| format!("failed to open {}", stdout_file.display()))?; tokio::io::copy(&mut stdout_reader, &mut log_writer).await?; // Append stderr section log_writer.write_all(b"\n\n=== STDERR ===\n").await?; let mut stderr_reader = tokio::fs::File::open(stderr_file) .await .with_context(|| format!("failed to open {}", stderr_file.display()))?; tokio::io::copy(&mut stderr_reader, &mut log_writer).await?; log_writer.write_all(b"\n").await?; log_writer.flush().await?; drop(log_writer); // Delete temp files (best-effort) let _ = tokio::fs::remove_file(stdout_file).await; let _ = tokio::fs::remove_file(stderr_file).await; debug!( path = %log_file.display(), site = %meta.site_name, "build log saved" ); Ok(log_file) } /// Format a duration as a human-readable string (e.g., "45s" or "2m 30s"). #[must_use] pub fn format_duration(d: Duration) -> String { let secs = d.as_secs(); if secs >= 60 { format!("{}m {}s", secs / 60, secs % 60) } else { format!("{secs}s") } } /// Format the metadata header for a build log (without output sections). fn format_log_header(meta: &BuildLogMeta) -> String { let git_commit = meta.git_commit.as_deref().unwrap_or("unknown"); let duration_str = format_duration(meta.duration); format!( "=== BUILD LOG ===\n\ Site: {}\n\ Timestamp: {}\n\ Git Commit: {}\n\ Image: {}\n\ Duration: {}\n\ Status: {}", meta.site_name, meta.timestamp, git_commit, meta.container_image, duration_str, meta.exit_status, ) } /// Get the current git commit hash from a repository. /// /// Returns the short (7 character) commit hash, or None if the repository /// is not a valid git repository or the command fails. pub async fn get_git_commit(clone_dir: &Path) -> Option { let mut cmd = Command::new("git"); cmd.env_remove("GIT_DIR") .env_remove("GIT_WORK_TREE") .env_remove("GIT_INDEX_FILE"); let output = cmd .args(["rev-parse", "--short", "HEAD"]) .current_dir(clone_dir) .output() .await .ok()?; if !output.status.success() { return None; } let commit = String::from_utf8_lossy(&output.stdout).trim().to_owned(); if commit.is_empty() { None } else { Some(commit) } } /// Save hook log to disk via streaming composition. /// /// Writes the metadata header to the log file, then streams stdout and stderr /// content from temporary files via `tokio::io::copy` (O(1) memory). /// Deletes the temporary files after successful composition. /// /// Creates a log file at `{log_dir}/{site_name}/{timestamp}-hook.log`. /// A log is written for every hook invocation regardless of outcome. /// /// # Errors /// /// Returns an error if the log directory cannot be created or the log file /// cannot be written. pub async fn save_hook_log( log_dir: &Path, site_name: &str, timestamp: &str, hook_result: &HookResult, ) -> Result { let site_log_dir = log_dir.join(site_name); let log_file = site_log_dir.join(format!("{timestamp}-hook.log")); tokio::fs::create_dir_all(&site_log_dir) .await .with_context(|| { format!( "failed to create logs directory: {}", site_log_dir.display() ) })?; let mut log_writer = tokio::io::BufWriter::new( tokio::fs::File::create(&log_file) .await .with_context(|| format!("failed to create hook log file: {}", log_file.display()))?, ); let header = format_hook_log_header(site_name, timestamp, hook_result); log_writer.write_all(header.as_bytes()).await?; // Append stdout section log_writer.write_all(b"\n=== STDOUT ===\n").await?; let mut stdout_reader = tokio::fs::File::open(&hook_result.stdout_file) .await .with_context(|| format!("failed to open {}", hook_result.stdout_file.display()))?; tokio::io::copy(&mut stdout_reader, &mut log_writer).await?; // Append stderr section log_writer.write_all(b"\n\n=== STDERR ===\n").await?; let mut stderr_reader = tokio::fs::File::open(&hook_result.stderr_file) .await .with_context(|| format!("failed to open {}", hook_result.stderr_file.display()))?; tokio::io::copy(&mut stderr_reader, &mut log_writer).await?; log_writer.write_all(b"\n").await?; log_writer.flush().await?; drop(log_writer); // Delete temp files (best-effort) let _ = tokio::fs::remove_file(&hook_result.stdout_file).await; let _ = tokio::fs::remove_file(&hook_result.stderr_file).await; debug!( path = %log_file.display(), site = %site_name, "hook log saved" ); Ok(log_file) } /// Format the metadata header for a hook log (without output sections). fn format_hook_log_header(site_name: &str, timestamp: &str, result: &HookResult) -> String { let command_str = result.command.join(" "); let duration_str = format_duration(result.duration); let status_str = if result.success { "success".to_owned() } else if let Some(code) = result.exit_code { format!("failed (exit code {code})") } else { "failed (signal)".to_owned() }; format!( "=== HOOK LOG ===\n\ Site: {site_name}\n\ Timestamp: {timestamp}\n\ Command: {command_str}\n\ Duration: {duration_str}\n\ Status: {status_str}" ) } /// Parsed header from a build log file. #[derive(Debug, Clone, serde::Serialize)] pub struct ParsedLogHeader { pub site_name: String, pub timestamp: String, pub git_commit: String, pub image: String, pub duration: String, pub status: String, } /// Combined deployment status (build + optional hook). #[derive(Debug, Clone, serde::Serialize)] pub struct DeploymentStatus { pub site_name: String, pub timestamp: String, pub git_commit: String, pub duration: String, pub status: String, pub log: String, } /// Parse the header section of a build log file. /// /// Expects lines like: /// ```text /// === BUILD LOG === /// Site: my-site /// Timestamp: 20260126-143000-123456 /// Git Commit: abc123d /// Image: node:20-alpine /// Duration: 45s /// Status: success /// ``` /// /// Returns `None` if the header is malformed. #[must_use] pub fn parse_log_header(content: &str) -> Option { let mut site_name = None; let mut timestamp = None; let mut git_commit = None; let mut image = None; let mut duration = None; let mut status = None; for line in content.lines().take(10) { if let Some(val) = line.strip_prefix("Site: ") { site_name = Some(val.to_owned()); } else if let Some(val) = line.strip_prefix("Timestamp: ") { timestamp = Some(val.to_owned()); } else if let Some(val) = line.strip_prefix("Git Commit: ") { git_commit = Some(val.to_owned()); } else if let Some(val) = line.strip_prefix("Image: ") { image = Some(val.to_owned()); } else if let Some(val) = line.strip_prefix("Duration: ") { duration = Some(val.to_owned()); } else if let Some(val) = line.strip_prefix("Status: ") { status = Some(val.to_owned()); } } Some(ParsedLogHeader { site_name: site_name?, timestamp: timestamp?, git_commit: git_commit.unwrap_or_else(|| "unknown".to_owned()), image: image.unwrap_or_else(|| "unknown".to_owned()), duration: duration?, status: status?, }) } /// Parse the status line from a hook log. /// /// Returns `Some(true)` for success, `Some(false)` for failure, /// `None` if the content cannot be parsed. #[must_use] pub fn parse_hook_status(content: &str) -> Option { for line in content.lines().take(10) { if let Some(val) = line.strip_prefix("Status: ") { return Some(val == "success"); } } None } /// List build log files for a site, sorted newest-first. /// /// Returns `(timestamp, path)` pairs. Excludes `*-hook.log` and `*.tmp` files. /// /// # Errors /// /// Returns an error if the directory cannot be read (except for not-found, /// which returns an empty list). pub async fn list_site_logs(log_dir: &Path, site_name: &str) -> Result> { let site_log_dir = log_dir.join(site_name); if !site_log_dir.is_dir() { return Ok(Vec::new()); } let mut entries = tokio::fs::read_dir(&site_log_dir) .await .with_context(|| format!("failed to read log directory: {}", site_log_dir.display()))?; let mut logs = Vec::new(); while let Some(entry) = entries.next_entry().await? { let name = entry.file_name(); let name_str = name.to_string_lossy(); // Skip hook logs and temp files if name_str.ends_with("-hook.log") || name_str.ends_with(".tmp") { continue; } if let Some(timestamp) = name_str.strip_suffix(".log") { logs.push((timestamp.to_owned(), entry.path())); } } // Sort descending (newest first) — timestamps are lexicographically sortable logs.sort_by(|a, b| b.0.cmp(&a.0)); Ok(logs) } /// Get the deployment status for a single build log. /// /// Reads the build log header and checks for an accompanying hook log /// to determine overall deployment status. /// /// # Errors /// /// Returns an error if the build log cannot be read. pub async fn get_deployment_status( log_dir: &Path, site_name: &str, timestamp: &str, log_path: &Path, ) -> Result { let content = tokio::fs::read_to_string(log_path) .await .with_context(|| format!("failed to read build log: {}", log_path.display()))?; let header = parse_log_header(&content); let (git_commit, duration, build_status) = match &header { Some(h) => (h.git_commit.clone(), h.duration.clone(), h.status.clone()), None => { warn!(path = %log_path.display(), "malformed build log header"); ( "unknown".to_owned(), "-".to_owned(), "(parse error)".to_owned(), ) } }; // Check for accompanying hook log let hook_log_path = log_dir .join(site_name) .join(format!("{timestamp}-hook.log")); let status = if hook_log_path.is_file() { match tokio::fs::read_to_string(&hook_log_path).await { Ok(hook_content) => match parse_hook_status(&hook_content) { Some(true) => { if build_status.starts_with("failed") { build_status } else { "success".to_owned() } } Some(false) => { if build_status.starts_with("failed") { build_status } else { "hook failed".to_owned() } } None => build_status, }, Err(_) => build_status, } } else { build_status }; Ok(DeploymentStatus { site_name: site_name.to_owned(), timestamp: timestamp.to_owned(), git_commit, duration, status, log: log_path.to_string_lossy().to_string(), }) } #[cfg(test)] #[allow(clippy::unwrap_used, clippy::indexing_slicing)] mod tests { use super::*; use crate::test_support::{cleanup, temp_dir}; use tokio::fs; /// Create a git Command isolated from parent git environment. /// Prevents interference when tests run inside git hooks /// (e.g., pre-commit hook running `cargo test`). fn git_cmd() -> Command { let mut cmd = Command::new("git"); cmd.env_remove("GIT_DIR") .env_remove("GIT_WORK_TREE") .env_remove("GIT_INDEX_FILE"); cmd } #[tokio::test] async fn save_build_log_creates_file_with_correct_content() { let base_dir = temp_dir("logs-test").await; let log_dir = base_dir.join("logs"); let meta = BuildLogMeta { site_name: "test-site".to_owned(), timestamp: "20260126-143000-123456".to_owned(), git_commit: Some("abc123d".to_owned()), container_image: "node:20-alpine".to_owned(), duration: Duration::from_secs(45), exit_status: BuildExitStatus::Success, }; // Create temp files with content let stdout_tmp = base_dir.join("stdout.tmp"); let stderr_tmp = base_dir.join("stderr.tmp"); fs::write(&stdout_tmp, "build output").await.unwrap(); fs::write(&stderr_tmp, "warning message").await.unwrap(); let result = save_build_log(&log_dir, &meta, &stdout_tmp, &stderr_tmp).await; assert!(result.is_ok(), "save_build_log should succeed: {result:?}"); let log_path = result.unwrap(); // Verify file exists at expected path assert_eq!( log_path, log_dir.join("test-site/20260126-143000-123456.log") ); assert!(log_path.exists(), "log file should exist"); // Verify content let content = fs::read_to_string(&log_path).await.unwrap(); assert!(content.contains("=== BUILD LOG ===")); assert!(content.contains("Site: test-site")); assert!(content.contains("Timestamp: 20260126-143000-123456")); assert!(content.contains("Git Commit: abc123d")); assert!(content.contains("Image: node:20-alpine")); assert!(content.contains("Duration: 45s")); assert!(content.contains("Status: success")); assert!(content.contains("=== STDOUT ===")); assert!(content.contains("build output")); assert!(content.contains("=== STDERR ===")); assert!(content.contains("warning message")); // Verify temp files were deleted assert!(!stdout_tmp.exists(), "stdout temp file should be deleted"); assert!(!stderr_tmp.exists(), "stderr temp file should be deleted"); cleanup(&base_dir).await; } #[tokio::test] async fn save_build_log_handles_empty_output() { let base_dir = temp_dir("logs-test").await; let log_dir = base_dir.join("logs"); let meta = BuildLogMeta { site_name: "empty-site".to_owned(), timestamp: "20260126-150000-000000".to_owned(), git_commit: None, container_image: "alpine:latest".to_owned(), duration: Duration::from_secs(5), exit_status: BuildExitStatus::Success, }; let stdout_tmp = base_dir.join("stdout.tmp"); let stderr_tmp = base_dir.join("stderr.tmp"); fs::write(&stdout_tmp, "").await.unwrap(); fs::write(&stderr_tmp, "").await.unwrap(); let result = save_build_log(&log_dir, &meta, &stdout_tmp, &stderr_tmp).await; assert!(result.is_ok(), "save_build_log should succeed: {result:?}"); let log_path = result.unwrap(); let content = fs::read_to_string(&log_path).await.unwrap(); assert!(content.contains("Git Commit: unknown")); assert!(content.contains("=== STDOUT ===\n\n")); assert!(content.contains("=== STDERR ===\n\n")); cleanup(&base_dir).await; } #[tokio::test] async fn save_build_log_failed_status() { let base_dir = temp_dir("logs-test").await; let log_dir = base_dir.join("logs"); let meta = BuildLogMeta { site_name: "failed-site".to_owned(), timestamp: "20260126-160000-000000".to_owned(), git_commit: Some("def456".to_owned()), container_image: "node:18".to_owned(), duration: Duration::from_secs(120), exit_status: BuildExitStatus::Failed { exit_code: Some(1), error: "npm install failed".to_owned(), }, }; let stdout_tmp = base_dir.join("stdout.tmp"); let stderr_tmp = base_dir.join("stderr.tmp"); fs::write(&stdout_tmp, "").await.unwrap(); fs::write(&stderr_tmp, "Error: ENOENT").await.unwrap(); let result = save_build_log(&log_dir, &meta, &stdout_tmp, &stderr_tmp).await; assert!(result.is_ok()); let log_path = result.unwrap(); let content = fs::read_to_string(&log_path).await.unwrap(); assert!(content.contains("Duration: 2m 0s")); assert!(content.contains("Status: failed (exit code: 1): npm install failed")); cleanup(&base_dir).await; } #[tokio::test] async fn save_build_log_deletes_temp_files() { let base_dir = temp_dir("logs-test").await; let log_dir = base_dir.join("logs"); let meta = BuildLogMeta { site_name: "temp-test".to_owned(), timestamp: "20260126-170000-000000".to_owned(), git_commit: None, container_image: "alpine:latest".to_owned(), duration: Duration::from_secs(1), exit_status: BuildExitStatus::Success, }; let stdout_tmp = base_dir.join("stdout.tmp"); let stderr_tmp = base_dir.join("stderr.tmp"); fs::write(&stdout_tmp, "some output").await.unwrap(); fs::write(&stderr_tmp, "some errors").await.unwrap(); assert!(stdout_tmp.exists()); assert!(stderr_tmp.exists()); let result = save_build_log(&log_dir, &meta, &stdout_tmp, &stderr_tmp).await; assert!(result.is_ok()); // Temp files must be gone assert!(!stdout_tmp.exists(), "stdout temp file should be deleted"); assert!(!stderr_tmp.exists(), "stderr temp file should be deleted"); cleanup(&base_dir).await; } #[tokio::test] async fn get_git_commit_returns_short_hash() { let temp = temp_dir("logs-test").await; // Initialize a git repo git_cmd() .args(["init"]) .current_dir(&temp) .output() .await .unwrap(); // Configure git user for commit git_cmd() .args(["config", "user.email", "test@test.com"]) .current_dir(&temp) .output() .await .unwrap(); git_cmd() .args(["config", "user.name", "Test"]) .current_dir(&temp) .output() .await .unwrap(); // Create a file and commit fs::write(temp.join("file.txt"), "content").await.unwrap(); git_cmd() .args(["add", "."]) .current_dir(&temp) .output() .await .unwrap(); git_cmd() .args(["commit", "-m", "initial"]) .current_dir(&temp) .output() .await .unwrap(); let commit = get_git_commit(&temp).await; assert!(commit.is_some(), "should return commit hash"); let hash = commit.unwrap(); assert!(!hash.is_empty(), "hash should not be empty"); assert!(hash.len() >= 7, "short hash should be at least 7 chars"); cleanup(&temp).await; } #[tokio::test] async fn get_git_commit_returns_none_for_non_repo() { let temp = temp_dir("logs-test").await; // No git init - just an empty directory let commit = get_git_commit(&temp).await; assert!(commit.is_none(), "should return None for non-git directory"); cleanup(&temp).await; } #[tokio::test] async fn save_hook_log_creates_file_with_correct_content() { let base_dir = temp_dir("logs-test").await; let log_dir = base_dir.join("logs"); let stdout_tmp = base_dir.join("hook-stdout.tmp"); let stderr_tmp = base_dir.join("hook-stderr.tmp"); fs::write(&stdout_tmp, "hook output").await.unwrap(); fs::write(&stderr_tmp, "").await.unwrap(); let hook_result = HookResult { command: vec!["touch".to_owned(), "marker".to_owned()], stdout_file: stdout_tmp.clone(), stderr_file: stderr_tmp.clone(), last_stderr: String::new(), exit_code: Some(0), duration: Duration::from_secs(1), success: true, }; let result = save_hook_log( &log_dir, "test-site", "20260202-120000-000000", &hook_result, ) .await; assert!(result.is_ok()); let log_path = result.unwrap(); assert_eq!( log_path, log_dir.join("test-site/20260202-120000-000000-hook.log") ); assert!(log_path.exists()); let content = fs::read_to_string(&log_path).await.unwrap(); assert!(content.contains("=== HOOK LOG ===")); assert!(content.contains("Site: test-site")); assert!(content.contains("Command: touch marker")); assert!(content.contains("Status: success")); assert!(content.contains("=== STDOUT ===")); assert!(content.contains("hook output")); // Temp files should be deleted assert!(!stdout_tmp.exists()); assert!(!stderr_tmp.exists()); cleanup(&base_dir).await; } #[tokio::test] async fn save_hook_log_failure_status() { let base_dir = temp_dir("logs-test").await; let log_dir = base_dir.join("logs"); let stdout_tmp = base_dir.join("hook-stdout.tmp"); let stderr_tmp = base_dir.join("hook-stderr.tmp"); fs::write(&stdout_tmp, "").await.unwrap(); fs::write(&stderr_tmp, "error output").await.unwrap(); let hook_result = HookResult { command: vec!["false".to_owned()], stdout_file: stdout_tmp, stderr_file: stderr_tmp, last_stderr: "error output".to_owned(), exit_code: Some(1), duration: Duration::from_secs(0), success: false, }; let result = save_hook_log( &log_dir, "test-site", "20260202-120000-000000", &hook_result, ) .await; assert!(result.is_ok()); let log_path = result.unwrap(); let content = fs::read_to_string(&log_path).await.unwrap(); assert!(content.contains("Status: failed (exit code 1)")); assert!(content.contains("error output")); cleanup(&base_dir).await; } #[tokio::test] async fn save_hook_log_signal_status() { let base_dir = temp_dir("logs-test").await; let log_dir = base_dir.join("logs"); let stdout_tmp = base_dir.join("hook-stdout.tmp"); let stderr_tmp = base_dir.join("hook-stderr.tmp"); fs::write(&stdout_tmp, "").await.unwrap(); fs::write(&stderr_tmp, "post-deploy hook timed out after 30s") .await .unwrap(); let hook_result = HookResult { command: vec!["sleep".to_owned(), "100".to_owned()], stdout_file: stdout_tmp, stderr_file: stderr_tmp, last_stderr: String::new(), exit_code: None, duration: Duration::from_secs(30), success: false, }; let result = save_hook_log( &log_dir, "test-site", "20260202-120000-000000", &hook_result, ) .await; assert!(result.is_ok()); let log_path = result.unwrap(); let content = fs::read_to_string(&log_path).await.unwrap(); assert!(content.contains("Status: failed (signal)")); assert!(content.contains("timed out")); cleanup(&base_dir).await; } // --- parse_log_header tests --- #[test] fn parse_log_header_success() { let content = "\ === BUILD LOG === Site: my-site Timestamp: 20260126-143000-123456 Git Commit: abc123d Image: node:20-alpine Duration: 45s Status: success === STDOUT === build output "; let header = parse_log_header(content).unwrap(); assert_eq!(header.site_name, "my-site"); assert_eq!(header.timestamp, "20260126-143000-123456"); assert_eq!(header.git_commit, "abc123d"); assert_eq!(header.image, "node:20-alpine"); assert_eq!(header.duration, "45s"); assert_eq!(header.status, "success"); } #[test] fn parse_log_header_failed_build() { let content = "\ === BUILD LOG === Site: fail-site Timestamp: 20260126-160000-000000 Git Commit: def456 Image: node:18 Duration: 2m 0s Status: failed (exit code: 42): build error "; let header = parse_log_header(content).unwrap(); assert_eq!(header.status, "failed (exit code: 42): build error"); assert_eq!(header.duration, "2m 0s"); } #[test] fn parse_log_header_unknown_commit() { let content = "\ === BUILD LOG === Site: test-site Timestamp: 20260126-150000-000000 Git Commit: unknown Image: alpine:latest Duration: 5s Status: success "; let header = parse_log_header(content).unwrap(); assert_eq!(header.git_commit, "unknown"); } #[test] fn parse_log_header_malformed() { let content = "This is not a valid log file\nSome random text\n"; let header = parse_log_header(content); assert!(header.is_none()); } #[test] fn parse_hook_status_success() { let content = "\ === HOOK LOG === Site: test-site Timestamp: 20260202-120000-000000 Command: touch marker Duration: 1s Status: success "; assert_eq!(parse_hook_status(content), Some(true)); } #[test] fn parse_hook_status_failed() { let content = "\ === HOOK LOG === Site: test-site Timestamp: 20260202-120000-000000 Command: false Duration: 0s Status: failed (exit code 1) "; assert_eq!(parse_hook_status(content), Some(false)); } }