use anyhow::{Context as _, Result}; use log::debug; use std::path::{Path, PathBuf}; use std::time::Duration; use tokio::io::AsyncWriteExt as _; use tokio::process::Command; 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!( "[{}] build log saved: {}", meta.site_name, log_file.display() ); 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!("[{site_name}] hook log saved: {}", log_file.display()); 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}" ) } /// 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, pub current_build: 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; } }