use std::io::IsTerminal;
use std::path::Path;
use base64::prelude::*;
use chrono::{DateTime, SecondsFormat, TimeZone, Utc};
use serde::Serialize;
use void_core::{
cid,
crypto::{CommitReader, EncryptedCommit, KeyVault},
diff::{diff_commits, DiffKind},
metadata::Commit,
store::{FsStore, ObjectStoreExt},
};
use void_core::support::ToVoidCid;
use void_core::VoidContext;
use void_core::crypto::CommitCid;
use crate::context::{build_void_context, resolve_ref, void_err_to_cli};
use crate::output::{run_command, CliError, CliOptions};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum SignatureStatus {
Verified,
Unsigned,
Invalid,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileChange {
pub status: String,
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub from: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub similarity: Option<u8>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CommitShowOutput {
pub cid: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
pub date: String,
pub message: String,
pub files: Vec<FileChange>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
pub signature_status: SignatureStatus,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileShowOutput {
pub commit: String,
pub path: String,
pub content: String,
pub size: u64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum ShowOutput {
Commit(CommitShowOutput),
File(FileShowOutput),
}
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, commit_cid: &CommitCid) -> Result<Commit, CliError> {
let cid_obj =
cid::from_bytes(commit_cid.as_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_plaintext, _reader) = CommitReader::open_with_vault(vault, &encrypted)
.map_err(|e| CliError::internal(format!("failed to open commit: {e}")))?;
let commit = commit_plaintext.parse()
.map_err(|e| CliError::internal(format!("failed to parse commit: {e}")))?;
Ok(commit)
}
fn show_file_from_commit(
void_ctx: &VoidContext,
store: &FsStore,
commit_cid: &CommitCid,
file_path: &str,
) -> Result<Vec<u8>, CliError> {
let cid_obj = cid::from_bytes(commit_cid.as_bytes())
.map_err(|e| CliError::internal(format!("invalid CID: {e}")))?;
let (commit, reader) = void_ctx.load_commit(store, &cid_obj)
.map_err(void_err_to_cli)?;
let ancestor_keys =
void_core::crypto::collect_ancestor_content_keys_vault(&void_ctx.crypto.vault, store, &commit);
void_ctx
.read_file_from_commit(store, &commit, &reader, &ancestor_keys, file_path)
.map(Into::into)
.map_err(void_err_to_cli)
}
mod colors {
pub const YELLOW: &str = "\x1b[33m";
pub const GREEN: &str = "\x1b[32m";
pub const RED: &str = "\x1b[31m";
pub const CYAN: &str = "\x1b[36m";
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 cyan(use_colors: bool) -> &'static str {
if use_colors {
CYAN
} else {
""
}
}
pub fn reset(use_colors: bool) -> &'static str {
if use_colors {
RESET
} else {
""
}
}
}
pub fn run(cwd: &Path, target: &str, verify: bool, opts: &CliOptions) -> Result<(), CliError> {
run_command("show", opts, |ctx| {
ctx.progress("Loading repository...");
let void_ctx = build_void_context(cwd)?;
let store = void_ctx.open_store().map_err(void_err_to_cli)?;
let (commit_ref, file_path) = if let Some(colon_pos) = target.find(':') {
let commit_part = &target[..colon_pos];
let path_part = &target[colon_pos + 1..];
if path_part.is_empty() {
return Err(CliError::invalid_args(
"file path cannot be empty after ':'",
));
}
(commit_part, Some(path_part))
} else {
(target, None)
};
ctx.verbose(format!("Resolving reference: {}", commit_ref));
let cid_bytes = resolve_ref(&void_ctx.paths.void_dir, commit_ref)?;
let cid_str = cid::from_bytes(cid_bytes.as_bytes())
.map(|c| c.to_string())
.unwrap_or_else(|_| hex::encode(cid_bytes.as_bytes()));
ctx.verbose(format!(
"Loading commit: {}",
&cid_str[..12.min(cid_str.len())]
));
let commit = read_commit(&store, &void_ctx.crypto.vault, &cid_bytes)?;
let use_colors = std::io::stderr().is_terminal();
if let Some(path) = file_path {
ctx.progress(format!("Reading file: {}", path));
let content = show_file_from_commit(&void_ctx, &store, &cid_bytes, path)?;
let size = content.len() as u64;
let encoded = BASE64_STANDARD.encode(&content);
if !ctx.use_json() {
ctx.info(format!("commit {}", cid_str));
ctx.info(format!("path {}", path));
ctx.info(format!("size {} bytes", size));
ctx.info("");
if let Ok(text) = String::from_utf8(content.clone()) {
for line in text.lines() {
ctx.info(line);
}
} else {
ctx.info("(binary content, use --json for base64 output)");
}
}
Ok(ShowOutput::File(FileShowOutput {
commit: cid_str,
path: path.to_string(),
content: encoded,
size,
}))
} else {
ctx.progress("Loading commit details...");
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()))
});
let (author, signature_status) = if verify {
match commit.verify() {
Ok(true) => (
commit.author.map(|a| format!("ed25519:{}", a.to_hex())),
SignatureStatus::Verified,
),
Ok(false) => (None, SignatureStatus::Unsigned),
Err(_) => (
commit.author.map(|a| format!("ed25519:{}", a.to_hex())),
SignatureStatus::Invalid,
),
}
} else {
(
commit.author.map(|a| format!("ed25519:{}", a.to_hex())),
if commit.signature.is_some() {
SignatureStatus::Verified } else {
SignatureStatus::Unsigned
},
)
};
ctx.verbose("Computing file changes...");
let cid_obj = cid::from_bytes(cid_bytes.as_bytes())
.map_err(|e| CliError::internal(format!("invalid CID: {e}")))?;
let parent_cid_obj = match commit.first_parent() {
Some(p) => Some(
p.to_void_cid()
.map_err(|e| CliError::internal(format!("invalid parent CID: {e}")))?,
),
None => None,
};
let diff = diff_commits(&store, &void_ctx.crypto.vault, parent_cid_obj.as_ref(), &cid_obj)
.map_err(void_err_to_cli)?;
let files: Vec<FileChange> = diff
.files
.into_iter()
.map(|f| {
let (status, from, similarity) = match &f.kind {
DiffKind::Added => ("A".to_string(), None, None),
DiffKind::Modified => ("M".to_string(), None, None),
DiffKind::Deleted => ("D".to_string(), None, None),
DiffKind::Renamed { from, similarity } => {
("R".to_string(), Some(from.clone()), Some(*similarity))
}
};
FileChange {
status,
path: f.path,
from,
similarity,
}
})
.collect();
if !ctx.use_json() {
ctx.info(format!(
"{}commit {}{}",
colors::yellow(use_colors),
cid_str,
colors::reset(use_colors)
));
if let Some(ref p) = parent_cid {
ctx.info(format!("Parent: {}", p));
}
if verify {
let author_display = match &author {
Some(key) => key.clone(),
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("");
if !files.is_empty() {
ctx.info("Files:");
for file in &files {
let color = match file.status.as_str() {
"A" => colors::green(use_colors),
"D" => colors::red(use_colors),
"M" => colors::cyan(use_colors),
"R" => colors::yellow(use_colors),
_ => "",
};
if let Some(ref from) = file.from {
ctx.info(format!(
" {}{}{} {} (from {})",
color,
file.status,
colors::reset(use_colors),
file.path,
from
));
} else {
ctx.info(format!(
" {}{}{} {}",
color,
file.status,
colors::reset(use_colors),
file.path
));
}
}
}
}
Ok(ShowOutput::Commit(CommitShowOutput {
cid: cid_str,
parent: parent_cid,
date: format_timestamp_iso(commit.timestamp),
message: commit.message.clone(),
files,
author,
signature_status,
}))
}
})
}
#[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_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_file_change_serialization() {
let change = FileChange {
status: "A".to_string(),
path: "src/main.rs".to_string(),
from: None,
similarity: None,
};
let json = serde_json::to_string(&change).unwrap();
assert!(json.contains("\"status\":\"A\""));
assert!(json.contains("\"path\":\"src/main.rs\""));
assert!(!json.contains("\"from\"")); }
#[test]
fn test_file_change_renamed_serialization() {
let change = FileChange {
status: "R".to_string(),
path: "new_name.rs".to_string(),
from: Some("old_name.rs".to_string()),
similarity: Some(95),
};
let json = serde_json::to_string(&change).unwrap();
assert!(json.contains("\"status\":\"R\""));
assert!(json.contains("\"path\":\"new_name.rs\""));
assert!(json.contains("\"from\":\"old_name.rs\""));
assert!(json.contains("\"similarity\":95"));
}
#[test]
fn test_commit_show_output_serialization() {
let output = CommitShowOutput {
cid: "bafytest123".to_string(),
parent: Some("bafyparent456".to_string()),
date: "2024-01-01T00:00:00Z".to_string(),
message: "test commit".to_string(),
files: vec![FileChange {
status: "A".to_string(),
path: "README.md".to_string(),
from: None,
similarity: None,
}],
author: Some("ed25519:abcd1234".to_string()),
signature_status: SignatureStatus::Verified,
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"cid\":\"bafytest123\""));
assert!(json.contains("\"parent\":\"bafyparent456\""));
assert!(json.contains("\"date\":\"2024-01-01T00:00:00Z\""));
assert!(json.contains("\"message\":\"test commit\""));
assert!(json.contains("\"signatureStatus\":\"verified\""));
}
#[test]
fn test_file_show_output_serialization() {
let output = FileShowOutput {
commit: "bafytest123".to_string(),
path: "src/main.rs".to_string(),
content: "SGVsbG8gV29ybGQ=".to_string(), size: 11,
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"commit\":\"bafytest123\""));
assert!(json.contains("\"path\":\"src/main.rs\""));
assert!(json.contains("\"content\":\"SGVsbG8gV29ybGQ=\""));
assert!(json.contains("\"size\":11"));
}
#[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::cyan(true), "\x1b[36m");
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::cyan(false), "");
assert_eq!(colors::reset(false), "");
}
}