summaryrefslogtreecommitdiff
path: root/src/logs.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/logs.rs')
-rw-r--r--src/logs.rs919
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));
+ }
+}