use std::io::IsTerminal;
use std::path::Path;
use chrono::{DateTime, SecondsFormat, TimeZone, Utc};
use serde::Serialize;
use void_core::{
cid,
cid::ToVoidCid,
crypto::{CommitReader, EncryptedCommit, KeyVault},
metadata::Commit,
refs,
store::{FsStore, ObjectStoreExt},
};
use crate::context::build_void_context;
use crate::output::{run_command, CliError, CliOptions};
mod colors {
pub const YELLOW: &str = "\x1b[33m";
pub const GREEN: &str = "\x1b[32m";
pub const RED: &str = "\x1b[31m";
pub const RESET: &str = "\x1b[0m";
pub fn yellow(use_colors: bool) -> &'static str {
if use_colors {
YELLOW
} else {
""
}
}
pub fn green(use_colors: bool) -> &'static str {
if use_colors {
GREEN
} else {
""
}
}
pub fn red(use_colors: bool) -> &'static str {
if use_colors {
RED
} else {
""
}
}
pub fn reset(use_colors: bool) -> &'static str {
if use_colors {
RESET
} else {
""
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum SignatureStatus {
Verified,
Unsigned,
Invalid,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CommitEntry {
pub cid: String,
pub message: String,
pub timestamp: String,
#[serde(rename = "parent")]
pub parent: Option<String>,
pub author: Option<String>,
pub author_verified: bool,
pub signature_status: SignatureStatus,
}
#[derive(Debug, Clone, Serialize)]
pub struct LogOutput {
pub commits: Vec<CommitEntry>,
}
fn format_timestamp_iso(timestamp_ms: u64) -> String {
let secs = (timestamp_ms / 1000) as i64;
let datetime: DateTime<Utc> = Utc.timestamp_opt(secs, 0).single().unwrap_or_else(Utc::now);
datetime.to_rfc3339_opts(SecondsFormat::Secs, true)
}
fn read_commit(store: &FsStore, vault: &KeyVault, cid_bytes: &[u8]) -> Result<Commit, CliError> {
let cid_obj =
cid::from_bytes(cid_bytes).map_err(|e| CliError::internal(format!("invalid CID: {e}")))?;
let encrypted: EncryptedCommit = store
.get_blob(&cid_obj)
.map_err(|e| CliError::not_found(format!("commit not found: {e}")))?;
let (commit_bytes, _reader) = CommitReader::open_with_vault(vault, &encrypted)
.map_err(|e| CliError::internal(format!("failed to open commit: {e}")))?;
let commit = commit_bytes.parse()
.map_err(|e| CliError::internal(format!("failed to parse commit: {e}")))?;
Ok(commit)
}
pub fn run(cwd: &Path, number: usize, opts: &CliOptions, verify: bool) -> Result<(), CliError> {
run_command("log", opts, |ctx| {
ctx.progress("Loading commit history...");
ctx.verbose("Reading repository context...");
let void_ctx = build_void_context(cwd)?;
let void_dir = void_ctx.paths.void_dir.clone();
ctx.verbose("Resolving HEAD...");
let head_cid = refs::resolve_head(&void_dir)
.map_err(|e| CliError::internal(format!("failed to read HEAD: {e}")))?;
let head_cid = match head_cid {
Some(cid) => cid,
None => {
return Err(CliError::not_found(
"No commits found. Run 'void commit' first.",
));
}
};
let objects_dir = void_dir.join("objects");
let store = FsStore::new(&objects_dir)
.map_err(|e| CliError::internal(format!("failed to open object store: {e}")))?;
ctx.verbose("Walking commit history...");
let use_colors = std::io::stderr().is_terminal();
let mut commits = Vec::new();
let mut current_cid: Option<Vec<u8>> = Some(head_cid.into_bytes());
while let Some(cid_bytes) = current_cid.take() {
if commits.len() >= number {
break;
}
let cid_str = cid::from_bytes(&cid_bytes)
.map(|c| c.to_string())
.unwrap_or_else(|_| hex::encode(&cid_bytes));
ctx.verbose(format!(
"Reading commit {}",
&cid_str[..12.min(cid_str.len())]
));
let commit = match read_commit(&store, &void_ctx.crypto.vault, &cid_bytes) {
Ok(c) => c,
Err(_) if !commits.is_empty() => {
if !ctx.use_json() {
ctx.info(format!(
" (parent {} from foreign repository)",
cid_str
));
}
break;
}
Err(e) => return Err(e),
};
let (author, author_verified, signature_status) = if verify {
match commit.verify() {
Ok(true) => (
commit.author.map(|a| a.to_hex()),
true,
SignatureStatus::Verified,
),
Ok(false) => (None, false, SignatureStatus::Unsigned),
Err(_) => (
commit.author.map(|a| a.to_hex()),
false,
SignatureStatus::Invalid,
),
}
} else {
(None, false, SignatureStatus::Unsigned)
};
let parent_cid = commit.first_parent().map(|p| {
p.to_void_cid()
.map(|c| c.to_string())
.unwrap_or_else(|_| hex::encode(p.as_bytes()))
});
if !ctx.use_json() {
ctx.info(format!(
"{}commit {}{}",
colors::yellow(use_colors),
cid_str,
colors::reset(use_colors)
));
if verify {
let author_display = match &author {
Some(hex_key) => format!("ed25519:{}...", &hex_key[..8]),
None => "(unsigned)".to_string(),
};
let badge = match signature_status {
SignatureStatus::Verified => format!(
"{}[✓ verified]{}",
colors::green(use_colors),
colors::reset(use_colors)
),
SignatureStatus::Unsigned => format!(
"{}[no signature]{}",
colors::yellow(use_colors),
colors::reset(use_colors)
),
SignatureStatus::Invalid => format!(
"{}[✗ invalid]{}",
colors::red(use_colors),
colors::reset(use_colors)
),
};
ctx.info(format!("Author: {} {}", author_display, badge));
}
ctx.info(format!(
"Date: {}",
format_timestamp_iso(commit.timestamp)
));
ctx.info("");
for line in commit.message.lines() {
ctx.info(format!(" {}", line));
}
ctx.info("");
}
commits.push(CommitEntry {
cid: cid_str,
message: commit.message.clone(),
timestamp: format_timestamp_iso(commit.timestamp),
parent: parent_cid.clone(),
author: author.clone(),
author_verified,
signature_status: signature_status.clone(),
});
current_cid = commit.first_parent().map(|p| p.as_bytes().to_vec());
}
ctx.progress(format!("Found {} commits", commits.len()));
ctx.verbose(format!("Found {} commits", commits.len()));
Ok(LogOutput { commits })
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_timestamp_iso() {
let ts_ms = 1704067200000; let formatted = format_timestamp_iso(ts_ms);
assert_eq!(formatted, "2024-01-01T00:00:00Z");
}
#[test]
fn test_commit_entry_serialization() {
let entry = CommitEntry {
cid: "bafytest123".to_string(),
message: "test commit".to_string(),
timestamp: "2024-01-01T00:00:00Z".to_string(),
parent: Some("bafyparent456".to_string()),
author: Some("abcd1234abcd1234".to_string()),
author_verified: true,
signature_status: SignatureStatus::Verified,
};
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("\"cid\":\"bafytest123\""));
assert!(json.contains("\"message\":\"test commit\""));
assert!(json.contains("\"timestamp\":\"2024-01-01T00:00:00Z\""));
assert!(json.contains("\"parent\":\"bafyparent456\""));
assert!(json.contains("\"author\":\"abcd1234abcd1234\""));
assert!(json.contains("\"authorVerified\":true"));
assert!(json.contains("\"signatureStatus\":\"verified\""));
}
#[test]
fn test_commit_entry_without_parent() {
let entry = CommitEntry {
cid: "bafytest123".to_string(),
message: "initial commit".to_string(),
timestamp: "2024-01-01T00:00:00Z".to_string(),
parent: None,
author: None,
author_verified: false,
signature_status: SignatureStatus::Unsigned,
};
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("\"parent\":null")); assert!(json.contains("\"author\":null")); assert!(json.contains("\"signatureStatus\":\"unsigned\""));
}
#[test]
fn test_log_output_serialization() {
let output = LogOutput {
commits: vec![CommitEntry {
cid: "bafytest123".to_string(),
message: "test".to_string(),
timestamp: "2024-01-01T00:00:00Z".to_string(),
parent: None,
author: None,
author_verified: false,
signature_status: SignatureStatus::Unsigned,
}],
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"commits\""));
}
#[test]
fn test_signature_status_serialization() {
let verified = serde_json::to_string(&SignatureStatus::Verified).unwrap();
let unsigned = serde_json::to_string(&SignatureStatus::Unsigned).unwrap();
let invalid = serde_json::to_string(&SignatureStatus::Invalid).unwrap();
assert_eq!(verified, "\"verified\"");
assert_eq!(unsigned, "\"unsigned\"");
assert_eq!(invalid, "\"invalid\"");
}
#[test]
fn test_colors_with_tty() {
assert_eq!(colors::yellow(true), "\x1b[33m");
assert_eq!(colors::green(true), "\x1b[32m");
assert_eq!(colors::red(true), "\x1b[31m");
assert_eq!(colors::reset(true), "\x1b[0m");
}
#[test]
fn test_colors_without_tty() {
assert_eq!(colors::yellow(false), "");
assert_eq!(colors::green(false), "");
assert_eq!(colors::red(false), "");
assert_eq!(colors::reset(false), "");
}
}