diff options
Diffstat (limited to 'src/logs.rs')
| -rw-r--r-- | src/logs.rs | 919 |
1 files changed, 919 insertions, 0 deletions
diff --git a/src/logs.rs b/src/logs.rs new file mode 100644 index 0000000..bddcc9d --- /dev/null +++ b/src/logs.rs @@ -0,0 +1,919 @@ +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<i32>, + 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<String>, + 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<PathBuf> { + 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<String> { + 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<PathBuf> { + 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<ParsedLogHeader> { + 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<bool> { + 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<Vec<(String, PathBuf)>> { + 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<DeploymentStatus> { + 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)); + } +} |
