use std::fs;
use std::process::Command;
fn mkit_bin() -> &'static str {
env!("CARGO_BIN_EXE_mkit")
}
fn run_in(cwd: &std::path::Path, args: &[&str]) -> std::process::Output {
let xdg = tempfile::tempdir().expect("xdg tempdir");
let out = Command::new(mkit_bin())
.args(args)
.current_dir(cwd)
.env("XDG_CONFIG_HOME", xdg.path())
.output()
.expect("spawn mkit");
drop(xdg);
out
}
fn init_with_commits(commits: &[(&str, &[u8], &str)]) -> tempfile::TempDir {
let td = tempfile::tempdir().unwrap();
assert!(run_in(td.path(), &["init"]).status.success());
assert!(run_in(td.path(), &["keygen"]).status.success());
for (name, content, msg) in commits {
fs::write(td.path().join(name), content).unwrap();
assert!(run_in(td.path(), &["add", name]).status.success());
assert!(
run_in(td.path(), &["commit", "-m", msg]).status.success(),
"commit '{msg}' failed"
);
}
td
}
#[derive(Debug, Default)]
struct LogEntry {
hash: String,
parents: Vec<String>,
tree: String,
author: String,
timestamp: i64,
title: String,
message: String,
}
fn parse_log_line(line: &str) -> LogEntry {
LogEntry {
hash: extract_string(line, "\"hash\":\""),
parents: extract_string_array(line, "\"parents\":["),
tree: extract_string(line, "\"tree\":\""),
author: extract_string(line, "\"author\":\""),
timestamp: extract_number(line, "\"timestamp\":"),
title: extract_string(line, "\"title\":\""),
message: extract_string(line, "\"message\":\""),
}
}
fn extract_string(line: &str, prefix: &str) -> String {
let start = line.find(prefix).expect(prefix) + prefix.len();
let rest = &line[start..];
let mut end = 0;
let mut iter = rest.char_indices();
while let Some((i, c)) = iter.next() {
if c == '\\' {
iter.next();
continue;
}
if c == '"' {
end = i;
break;
}
}
rest[..end].to_string()
}
fn extract_number(line: &str, prefix: &str) -> i64 {
let start = line.find(prefix).expect(prefix) + prefix.len();
let rest = &line[start..];
let end = rest
.find(|c: char| !c.is_ascii_digit() && c != '-')
.unwrap_or(rest.len());
rest[..end].parse().unwrap_or(0)
}
fn extract_string_array(line: &str, prefix: &str) -> Vec<String> {
let start = line.find(prefix).expect(prefix) + prefix.len();
let rest = &line[start..];
let end = rest.find(']').expect("]");
let body = &rest[..end];
if body.is_empty() {
return vec![];
}
body.split(',')
.map(|s| s.trim_matches('"').to_string())
.collect()
}
#[test]
fn single_commit_emits_one_jsonl_record() {
let td = init_with_commits(&[("a.txt", b"hello", "first commit")]);
let out = run_in(td.path(), &["log", "--format=json"]);
assert!(out.status.success(), "log --format=json failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
let lines: Vec<_> = stdout.lines().collect();
assert_eq!(lines.len(), 1, "expected exactly 1 line, got: {stdout:?}");
let e = parse_log_line(lines[0]);
assert_eq!(e.hash.len(), 64, "hash should be 64 hex chars");
assert_eq!(e.tree.len(), 64, "tree should be 64 hex chars");
assert!(e.parents.is_empty(), "first commit has no parents");
assert!(
e.author.starts_with("ed25519:") || e.author.starts_with("mid:"),
"author '{}' should be ed25519:<hex> or mid:<n>",
e.author
);
assert!(e.timestamp > 0, "timestamp should be positive");
assert_eq!(e.title, "first commit");
assert!(e.message.starts_with("first commit"));
}
#[test]
fn multi_commit_emits_newest_first() {
let td = init_with_commits(&[
("a.txt", b"v1", "first"),
("b.txt", b"v2", "second"),
("c.txt", b"v3", "third"),
]);
let out = run_in(td.path(), &["log", "--format=json"]);
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).unwrap();
let lines: Vec<_> = stdout.lines().collect();
assert_eq!(lines.len(), 3, "expected 3 lines, got: {stdout:?}");
let titles: Vec<_> = lines.iter().map(|l| parse_log_line(l).title).collect();
assert_eq!(titles, vec!["third", "second", "first"]);
let entries: Vec<_> = lines.iter().map(|l| parse_log_line(l)).collect();
assert_eq!(entries[0].parents[0], entries[1].hash);
assert_eq!(entries[1].parents[0], entries[2].hash);
assert!(entries[2].parents.is_empty(), "root has no parents");
}
#[test]
fn no_commits_emits_empty_stdout() {
let td = tempfile::tempdir().unwrap();
assert!(run_in(td.path(), &["init"]).status.success());
let out = run_in(td.path(), &["log", "--format=json"]);
assert!(out.status.success(), "log on empty repo should succeed");
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.is_empty(),
"expected empty stdout for empty repo, got: {stdout:?}"
);
}
#[test]
fn multi_line_message_is_json_escaped() {
let td = tempfile::tempdir().unwrap();
assert!(run_in(td.path(), &["init"]).status.success());
assert!(run_in(td.path(), &["keygen"]).status.success());
fs::write(td.path().join("a.txt"), b"x").unwrap();
assert!(run_in(td.path(), &["add", "a.txt"]).status.success());
let msg = r#"a "quoted" \ title"#;
assert!(run_in(td.path(), &["commit", "-m", msg]).status.success());
let out = run_in(td.path(), &["log", "--format=json"]);
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.contains(r#"\"quoted\""#),
"expected escaped quotes in JSON: {stdout:?}"
);
assert!(
stdout.contains(r"\\"),
"expected escaped backslash in JSON: {stdout:?}"
);
let line = stdout.lines().next().unwrap();
let e = parse_log_line(line);
assert_eq!(e.title, r#"a \"quoted\" \\ title"#);
}
#[test]
fn limit_caps_output() {
let td = init_with_commits(&[
("a.txt", b"v1", "first"),
("b.txt", b"v2", "second"),
("c.txt", b"v3", "third"),
]);
let out = run_in(td.path(), &["log", "--format=json", "-n", "2"]);
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).unwrap();
assert_eq!(stdout.lines().count(), 2);
}