use std::io::Write;
use std::path::Path;
use std::process::ExitCode;
use crate::git::reader::RepoReader;
use crate::git::refs::RefRange;
use crate::git::refs::parse_range;
use crate::shim::classify::Classification;
use crate::tools::size::estimate_response_tokens;
use crate::tools::types::{
CommitSignature, ShowCommitDetail, ShowDiffstat, ShowFileEntry, ShowManifestResponse,
};
use crate::tools::{
ContextOptions, ManifestOptions, SnapshotOptions, build_function_context_with_options,
build_snapshots, collect_all_history_pages, collect_all_manifest_pages,
collect_all_worktree_manifest_pages,
};
pub(crate) fn handle<W: Write>(
classification: &Classification<'_>,
repo_path: &Path,
out: &mut W,
) -> ExitCode {
match dispatch(classification, repo_path, out) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("git-prism shim: handler error: {e}");
ExitCode::FAILURE
}
}
}
fn dispatch<W: Write>(
classification: &Classification<'_>,
repo_path: &Path,
out: &mut W,
) -> anyhow::Result<()> {
match classification {
Classification::Manifest { range } => handle_manifest(range, repo_path, out),
Classification::History { range } => handle_history(range, repo_path, out),
Classification::FunctionContext {
range,
pickaxe_term,
} => handle_function_context(*range, pickaxe_term, repo_path, out),
Classification::ShowSnapshot { sha } => handle_show_snapshot(sha, repo_path, out),
Classification::BlameSnapshot { path } => handle_blame_snapshot(path, repo_path, out),
Classification::GhPrDiff { pr_number } => handle_gh_pr_diff(pr_number, repo_path, out),
Classification::Passthrough => {
anyhow::bail!("dispatch called with Passthrough — caller bug")
}
}
}
fn handle_manifest<W: Write>(range: &str, repo_path: &Path, out: &mut W) -> anyhow::Result<()> {
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: false,
max_response_tokens: Some(8192),
};
let result = match parse_range(range) {
RefRange::CommitRange { base, head } => {
collect_all_manifest_pages(repo_path, base, head, &options, 500)?
}
RefRange::WorktreeCompare { base } => {
collect_all_worktree_manifest_pages(repo_path, base, &options, 500)?
}
};
serde_json::to_writer_pretty(out, &result)?;
Ok(())
}
fn handle_history<W: Write>(range: &str, repo_path: &Path, out: &mut W) -> anyhow::Result<()> {
let (base, head) = match parse_range(range) {
RefRange::CommitRange { base, head } => (base, head),
RefRange::WorktreeCompare { .. } => {
anyhow::bail!("history requires a commit range")
}
};
let options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: true,
max_response_tokens: None,
};
let result = collect_all_history_pages(repo_path, base, head, &options, 500)?;
serde_json::to_writer_pretty(out, &result)?;
Ok(())
}
fn handle_function_context<W: Write>(
range: Option<&str>,
_pickaxe_term: &str,
repo_path: &Path,
out: &mut W,
) -> anyhow::Result<()> {
let effective_range = range.unwrap_or("HEAD~1..HEAD");
let (base, head) = match parse_range(effective_range) {
RefRange::CommitRange { base, head } => (base, head),
RefRange::WorktreeCompare { .. } => {
anyhow::bail!("function context requires a commit range")
}
};
let options = ContextOptions {
cursor: None,
page_size: 25,
function_names: None,
max_response_tokens: Some(8192),
};
let result = build_function_context_with_options(repo_path, base, head, &options)?;
serde_json::to_writer_pretty(out, &result)?;
Ok(())
}
fn handle_show_snapshot<W: Write>(sha: &str, repo_path: &Path, out: &mut W) -> anyhow::Result<()> {
let commit = build_show_commit_detail(repo_path, sha)?;
let files: Vec<ShowFileEntry> = if commit.parents.is_empty() {
let reader =
RepoReader::open(repo_path).map_err(|e| anyhow::anyhow!("failed to open repo: {e}"))?;
let diff = reader
.diff_root_commit(sha)
.map_err(|e| anyhow::anyhow!("failed to diff root commit: {e}"))?;
diff.files
.into_iter()
.map(file_change_to_show_entry)
.collect()
} else {
let manifest_options = ManifestOptions {
include_patterns: vec![],
exclude_patterns: vec![],
include_function_analysis: false,
max_response_tokens: None,
};
let base_ref = format!("{}^", commit.sha);
let manifest =
collect_all_manifest_pages(repo_path, &base_ref, &commit.sha, &manifest_options, 500)?;
manifest
.files
.into_iter()
.map(|f| ShowFileEntry {
path: f.path,
old_path: f.old_path,
change_type: f.change_type,
additions: f.lines_added,
deletions: f.lines_removed,
is_binary: f.is_binary,
})
.collect()
};
let insertions: usize = files.iter().map(|f| f.additions).sum();
let deletions: usize = files.iter().map(|f| f.deletions).sum();
let diffstat = ShowDiffstat {
files_changed: files.len(),
insertions,
deletions,
};
let mut result = ShowManifestResponse {
commit,
diffstat,
files,
token_estimate: 0,
};
result.token_estimate = estimate_response_tokens(&result);
serde_json::to_writer_pretty(out, &result)?;
Ok(())
}
fn file_change_to_show_entry(f: crate::git::diff::FileChange) -> ShowFileEntry {
ShowFileEntry {
path: f.path,
old_path: f.old_path,
change_type: f.change_type,
additions: f.lines_added,
deletions: f.lines_removed,
is_binary: f.is_binary,
}
}
fn build_show_commit_detail(repo_path: &Path, sha: &str) -> anyhow::Result<ShowCommitDetail> {
let reader =
RepoReader::open(repo_path).map_err(|e| anyhow::anyhow!("failed to open repo: {e}"))?;
let commit = reader
.peel_to_commit(sha)
.map_err(|e| anyhow::anyhow!("failed to resolve commit {sha}: {e}"))?;
let full_sha = commit.id().to_string();
let short_sha = full_sha.chars().take(8).collect::<String>();
let parents = commit
.parent_ids()
.map(|id| id.to_string())
.collect::<Vec<_>>();
let author_sig = commit
.author()
.map_err(|e| anyhow::anyhow!("failed to read author: {e}"))?;
let committer_sig = commit
.committer()
.map_err(|e| anyhow::anyhow!("failed to read committer: {e}"))?;
let author = signature_to_commit_signature(&author_sig)?;
let committer = signature_to_commit_signature(&committer_sig)?;
let raw_message = commit
.message_raw()
.map_err(|e| anyhow::anyhow!("failed to read message: {e}"))?;
let message = std::str::from_utf8(raw_message.as_ref())
.map_err(|e| anyhow::anyhow!("commit message not UTF-8: {e}"))?;
let (subject, body) = split_commit_message(message);
Ok(ShowCommitDetail {
sha: full_sha,
short_sha,
parents,
author,
committer,
subject,
body,
})
}
fn signature_to_commit_signature(
sig: &gix::actor::SignatureRef<'_>,
) -> anyhow::Result<CommitSignature> {
let gix_time = sig
.time()
.map_err(|e| anyhow::anyhow!("failed to parse commit time for {}: {e}", sig.email))?;
let epoch = gix_time.seconds;
let offset_seconds = gix_time.offset;
let naive = chrono::DateTime::from_timestamp(epoch, 0)
.ok_or_else(|| anyhow::anyhow!("commit timestamp {epoch} out of representable range"))?
.naive_utc();
let offset = chrono::FixedOffset::east_opt(offset_seconds)
.ok_or_else(|| anyhow::anyhow!("commit tz offset {offset_seconds}s out of range"))?;
let dt = chrono::DateTime::<chrono::FixedOffset>::from_naive_utc_and_offset(naive, offset);
Ok(CommitSignature {
name: sig.name.to_string(),
email: sig.email.to_string(),
date_iso: dt.to_rfc3339(),
date_epoch: epoch,
})
}
fn split_commit_message(message: &str) -> (String, Option<String>) {
let mut lines = message.lines();
let subject = lines.next().unwrap_or("").trim().to_string();
let body_lines: Vec<&str> = lines.skip_while(|l| l.trim().is_empty()).collect();
let body = if body_lines.is_empty() {
None
} else {
Some(body_lines.join("\n").trim().to_string())
};
(subject, body)
}
fn handle_gh_pr_diff<W: Write>(
pr_number: &str,
repo_path: &Path,
out: &mut W,
) -> anyhow::Result<()> {
let range = resolve_pr_range(pr_number)?;
let git_root = discover_git_root(repo_path)?;
handle_manifest(&range, &git_root, out)
}
fn discover_git_root(start: &Path) -> anyhow::Result<std::path::PathBuf> {
let mut current = start.to_path_buf();
loop {
if current.join(".git").exists() {
return Ok(current);
}
match current.parent() {
Some(parent) => current = parent.to_path_buf(),
None => anyhow::bail!(
"could not find git repository root from {}",
start.display()
),
}
}
}
fn resolve_pr_range(pr_number: &str) -> anyhow::Result<String> {
let output = std::process::Command::new("gh")
.args(["pr", "view", pr_number, "--json", "baseRefOid,headRefOid"])
.output()
.map_err(|e| anyhow::anyhow!("failed to run gh pr view: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"gh pr view {pr_number} failed with status {}: {stderr}",
output.status
);
}
let json: serde_json::Value = serde_json::from_slice(&output.stdout)
.map_err(|e| anyhow::anyhow!("gh pr view returned invalid JSON: {e}"))?;
let base_sha = json["baseRefOid"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("gh pr view JSON missing baseRefOid"))?;
let head_sha = json["headRefOid"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("gh pr view JSON missing headRefOid"))?;
Ok(format!("{base_sha}..{head_sha}"))
}
fn handle_blame_snapshot<W: Write>(
path: &str,
repo_path: &Path,
out: &mut W,
) -> anyhow::Result<()> {
let options = SnapshotOptions {
include_before: false,
include_after: true,
max_file_size_bytes: 100_000,
line_range: None,
include_diff_hunks: false,
};
let result = build_snapshots(repo_path, "HEAD^", "HEAD", &[path.to_string()], &options)?;
serde_json::to_writer_pretty(out, &result)?;
Ok(())
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::process::Command;
use tempfile::TempDir;
use super::*;
fn init_repo_with_two_commits() -> (TempDir, PathBuf) {
let dir = TempDir::new().unwrap();
let path = dir.path().to_path_buf();
let run = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(&path)
.output()
.unwrap()
};
run(&["init", "-b", "main"]);
run(&["config", "user.email", "test@test.com"]);
run(&["config", "user.name", "Test"]);
std::fs::write(path.join("hello.txt"), "hello\n").unwrap();
run(&["add", "hello.txt"]);
run(&["commit", "-m", "first commit"]);
std::fs::write(path.join("world.txt"), "world\n").unwrap();
run(&["add", "world.txt"]);
run(&["commit", "-m", "second commit"]);
(dir, path)
}
fn head_sha(repo: &Path) -> String {
let out = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo)
.output()
.unwrap();
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
#[test]
fn it_handles_manifest_and_returns_files_array() {
let (_dir, path) = init_repo_with_two_commits();
let classification = Classification::Manifest {
range: "HEAD~1..HEAD",
};
let mut out = Vec::new();
let code = handle(&classification, &path, &mut out);
assert_eq!(code, ExitCode::SUCCESS);
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert!(
json.get("files").and_then(|f| f.as_array()).is_some(),
"expected 'files' array in manifest output"
);
}
#[test]
fn it_handles_history_and_returns_commits_array() {
let (_dir, path) = init_repo_with_two_commits();
let classification = Classification::History {
range: "HEAD~1..HEAD",
};
let mut out = Vec::new();
let code = handle(&classification, &path, &mut out);
assert_eq!(code, ExitCode::SUCCESS);
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert!(
json.get("commits").and_then(|c| c.as_array()).is_some(),
"expected 'commits' array in history output"
);
}
#[test]
fn it_handles_show_snapshot_and_returns_snapshots_key() {
let (_dir, path) = init_repo_with_two_commits();
let sha = head_sha(&path);
let classification = Classification::ShowSnapshot { sha: &sha };
let mut out = Vec::new();
let code = handle(&classification, &path, &mut out);
assert_eq!(code, ExitCode::SUCCESS);
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert!(
json.get("files").and_then(|f| f.as_array()).is_some(),
"expected 'files' array in show output, got: {json}"
);
}
#[test]
fn it_handles_show_snapshot_and_returns_commit_metadata() {
let (_dir, path) = init_repo_with_two_commits();
let sha = head_sha(&path);
let classification = Classification::ShowSnapshot { sha: &sha };
let mut out = Vec::new();
let code = handle(&classification, &path, &mut out);
assert_eq!(code, ExitCode::SUCCESS);
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
let commit = json
.get("commit")
.expect("expected 'commit' key in show output");
assert_eq!(
commit["author"]["name"].as_str().unwrap(),
"Test",
"commit.author.name must be 'Test'"
);
assert_eq!(
commit["author"]["email"].as_str().unwrap(),
"test@test.com",
"commit.author.email must be 'test@test.com'"
);
let diffstat = json
.get("diffstat")
.expect("expected 'diffstat' key in show output");
assert_eq!(
diffstat["files_changed"].as_u64().unwrap(),
1,
"diffstat.files_changed must be 1"
);
assert_eq!(
diffstat["insertions"].as_u64().unwrap(),
1,
"diffstat.insertions must be 1"
);
assert_eq!(
diffstat["deletions"].as_u64().unwrap(),
0,
"diffstat.deletions must be 0"
);
}
#[test]
fn it_handles_show_snapshot_commit_sha_is_full_40_chars() {
let (_dir, path) = init_repo_with_two_commits();
let sha = head_sha(&path);
let classification = Classification::ShowSnapshot { sha: &sha };
let mut out = Vec::new();
handle(&classification, &path, &mut out);
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
let commit_sha = json["commit"]["sha"].as_str().unwrap();
assert_eq!(commit_sha.len(), 40, "sha must be 40 hex chars");
let short_sha = json["commit"]["short_sha"].as_str().unwrap();
assert_eq!(short_sha.len(), 8, "short_sha must be 8 hex chars");
assert!(
commit_sha.starts_with(short_sha),
"short_sha must be a prefix of sha"
);
}
#[test]
fn it_handles_show_snapshot_subject_matches_commit_message() {
let (_dir, path) = init_repo_with_two_commits();
let sha = head_sha(&path);
let classification = Classification::ShowSnapshot { sha: &sha };
let mut out = Vec::new();
handle(&classification, &path, &mut out);
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(json["commit"]["subject"].as_str().unwrap(), "second commit");
assert!(
json["commit"]["body"].is_null(),
"single-line commit must have null body"
);
}
#[test]
fn it_handles_show_snapshot_parents_array_has_one_entry_for_normal_commit() {
let (_dir, path) = init_repo_with_two_commits();
let sha = head_sha(&path);
let classification = Classification::ShowSnapshot { sha: &sha };
let mut out = Vec::new();
handle(&classification, &path, &mut out);
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
let parents = json["commit"]["parents"].as_array().unwrap();
assert_eq!(
parents.len(),
1,
"non-root commit must have exactly one parent"
);
assert_eq!(
parents[0].as_str().unwrap().len(),
40,
"parent sha must be 40 chars"
);
}
#[test]
fn it_handles_show_snapshot_author_epoch_is_positive_integer() {
let (_dir, path) = init_repo_with_two_commits();
let sha = head_sha(&path);
let classification = Classification::ShowSnapshot { sha: &sha };
let mut out = Vec::new();
handle(&classification, &path, &mut out);
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
let epoch = json["commit"]["author"]["date_epoch"].as_i64().unwrap();
assert!(epoch > 0, "date_epoch must be a positive unix timestamp");
}
#[test]
fn it_handles_show_snapshot_committer_fields_present() {
let (_dir, path) = init_repo_with_two_commits();
let sha = head_sha(&path);
let classification = Classification::ShowSnapshot { sha: &sha };
let mut out = Vec::new();
handle(&classification, &path, &mut out);
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
let committer = &json["commit"]["committer"];
assert_eq!(committer["name"].as_str().unwrap(), "Test");
assert_eq!(committer["email"].as_str().unwrap(), "test@test.com");
let date_iso = committer["date_iso"]
.as_str()
.expect("date_iso must be a string");
chrono::DateTime::parse_from_rfc3339(date_iso)
.unwrap_or_else(|e| panic!("date_iso '{date_iso}' must parse as RFC-3339: {e}"));
assert!(
committer["date_epoch"].as_i64().unwrap() > 0,
"date_epoch must be a positive unix timestamp"
);
}
#[test]
fn it_handles_show_snapshot_diffstat_files_changed_matches_files_array() {
let (_dir, path) = init_repo_with_two_commits();
let sha = head_sha(&path);
let classification = Classification::ShowSnapshot { sha: &sha };
let mut out = Vec::new();
handle(&classification, &path, &mut out);
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
let files_changed = json["diffstat"]["files_changed"].as_u64().unwrap();
let files_count = json["files"].as_array().unwrap().len() as u64;
assert_eq!(
files_changed, files_count,
"diffstat.files_changed must equal files array length"
);
}
#[test]
fn it_handles_blame_snapshot_and_returns_files_array() {
let (_dir, path) = init_repo_with_two_commits();
let classification = Classification::BlameSnapshot { path: "hello.txt" };
let mut out = Vec::new();
let code = handle(&classification, &path, &mut out);
assert_eq!(code, ExitCode::SUCCESS);
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert!(
json.get("files").and_then(|f| f.as_array()).is_some(),
"expected 'files' array in blame output, got: {json}"
);
}
#[test]
fn split_commit_message_single_line_has_no_body() {
let (subject, body) = split_commit_message("fix: correct the thing");
assert_eq!(subject, "fix: correct the thing");
assert!(body.is_none());
}
#[test]
fn split_commit_message_subject_and_single_paragraph_body() {
let (subject, body) = split_commit_message("feat: add widget\n\nThis adds the widget.");
assert_eq!(subject, "feat: add widget");
assert_eq!(body.as_deref(), Some("This adds the widget."));
}
#[test]
fn split_commit_message_multi_paragraph_body_preserves_internal_blank_line() {
let msg = "feat: multi\n\nParagraph one.\n\nParagraph two.";
let (subject, body) = split_commit_message(msg);
assert_eq!(subject, "feat: multi");
let b = body.expect("body must be Some");
assert!(b.contains("Paragraph one."), "body must contain first para");
assert!(
b.contains("Paragraph two."),
"body must contain second para"
);
}
#[test]
fn split_commit_message_trailing_blank_lines_only_gives_no_body() {
let (subject, body) = split_commit_message("fix: thing\n\n \n ");
assert_eq!(subject, "fix: thing");
assert!(body.is_none(), "trailing blanks only must yield None body");
}
#[test]
fn split_commit_message_empty_string_gives_empty_subject_no_body() {
let (subject, body) = split_commit_message("");
assert_eq!(subject, "");
assert!(body.is_none());
}
#[test]
fn split_commit_message_trims_subject_whitespace() {
let (subject, _) = split_commit_message(" padded subject \n\nbody");
assert_eq!(subject, "padded subject");
}
#[test]
fn it_handles_show_snapshot_multi_paragraph_body_is_present_and_not_in_subject() {
let dir = TempDir::new().unwrap();
let path = dir.path().to_path_buf();
let run = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(&path)
.output()
.unwrap()
};
run(&["init", "-b", "main"]);
run(&["config", "user.email", "test@test.com"]);
run(&["config", "user.name", "Test"]);
std::fs::write(path.join("a.txt"), "a\n").unwrap();
run(&["add", "a.txt"]);
run(&[
"commit",
"-m",
"feat: the subject",
"-m",
"First paragraph of body.",
"-m",
"Second paragraph of body.",
]);
let sha = head_sha(&path);
let classification = Classification::ShowSnapshot { sha: &sha };
let mut out = Vec::new();
let code = handle(&classification, &path, &mut out);
assert_eq!(code, ExitCode::SUCCESS);
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(
json["commit"]["subject"].as_str().unwrap(),
"feat: the subject",
"subject must not contain body paragraphs"
);
let body = json["commit"]["body"]
.as_str()
.expect("body must be non-null for multi-paragraph commit");
assert!(
body.contains("First paragraph"),
"body must contain first paragraph"
);
assert!(
body.contains("Second paragraph"),
"body must contain second paragraph"
);
assert!(
!body.contains("feat: the subject"),
"subject must not leak into body"
);
}
#[test]
fn it_handles_function_context_and_returns_functions_key() {
let (_dir, path) = init_repo_with_two_commits();
let classification = Classification::FunctionContext {
range: Some("HEAD~1..HEAD"),
pickaxe_term: "hello",
};
let mut out = Vec::new();
let code = handle(&classification, &path, &mut out);
assert_eq!(code, ExitCode::SUCCESS);
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert!(
json.get("functions").is_some(),
"expected 'functions' key in function_context output, got: {json}"
);
}
#[test]
fn it_handles_show_snapshot_for_annotated_tag_without_error() {
let (_dir, path) = init_repo_with_two_commits();
Command::new("git")
.args(["tag", "-a", "v1.0", "-m", "release v1.0"])
.current_dir(&path)
.output()
.unwrap();
let classification = Classification::ShowSnapshot { sha: "v1.0" };
let mut out = Vec::new();
let code = handle(&classification, &path, &mut out);
assert_eq!(
code,
ExitCode::SUCCESS,
"handle should exit SUCCESS for annotated tag, got non-zero"
);
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert!(
json.get("files").and_then(|f| f.as_array()).is_some(),
"expected 'files' array in show output for annotated tag, got: {json}"
);
}
#[test]
fn qa_show_annotated_tag_output_equals_target_sha_output() {
let (_dir, path) = init_repo_with_two_commits();
Command::new("git")
.args(["tag", "-a", "v1.0", "-m", "release v1.0"])
.current_dir(&path)
.output()
.unwrap();
let target_sha = head_sha(&path);
let mut out_tag = Vec::new();
assert_eq!(
handle(
&Classification::ShowSnapshot { sha: "v1.0" },
&path,
&mut out_tag
),
ExitCode::SUCCESS
);
let mut out_sha = Vec::new();
assert_eq!(
handle(
&Classification::ShowSnapshot { sha: &target_sha },
&path,
&mut out_sha
),
ExitCode::SUCCESS
);
let json_tag: serde_json::Value = serde_json::from_slice(&out_tag).unwrap();
let json_sha: serde_json::Value = serde_json::from_slice(&out_sha).unwrap();
assert_eq!(
json_tag.get("files"),
json_sha.get("files"),
"annotated-tag show must produce the same files as target-sha show"
);
}
#[test]
fn qa_manifest_over_annotated_tag_range_returns_changed_files() {
let (dir, path) = init_repo_with_two_commits();
let git = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(&path)
.output()
.unwrap()
};
git(&["tag", "-a", "v0.1", "-m", "v0.1 release", "HEAD~1"]);
git(&["tag", "-a", "v0.2", "-m", "v0.2 release", "HEAD"]);
let classification = Classification::Manifest {
range: "v0.1..v0.2",
};
let mut out = Vec::new();
let code = handle(&classification, &path, &mut out);
assert_eq!(
code,
ExitCode::SUCCESS,
"manifest over annotated tag range must exit SUCCESS"
);
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
let files = json
.get("files")
.and_then(|f| f.as_array())
.expect("expected 'files' array in manifest output");
assert!(
!files.is_empty(),
"manifest over v0.1..v0.2 must be non-empty (expected world.txt)"
);
let paths: Vec<&str> = files
.iter()
.filter_map(|f| f.get("path").and_then(|p| p.as_str()))
.collect();
assert!(
paths.contains(&"world.txt"),
"expected world.txt in manifest files, got: {paths:?}"
);
drop(dir);
}
}