use dashmap::DashMap;
use worktrunk::git::Repository;
use super::super::list::model::ListItem;
use super::items::PreviewCacheKey;
use super::preview::PreviewMode;
pub(super) fn render_summary(text: &str, width: usize) -> String {
if text.contains('\x1b') {
return crate::md_help::render_markdown_in_help_with_width(text, Some(width));
}
let markdown = if let Some((subject, body)) = text.split_once('\n') {
format!("#### {subject}\n{body}")
} else {
format!("#### {text}")
};
crate::md_help::render_markdown_in_help_with_width(&markdown, Some(width))
}
pub(super) fn generate_and_cache_summary(
item: &ListItem,
llm_command: &str,
preview_cache: &DashMap<PreviewCacheKey, String>,
repo: &Repository,
) {
let branch = item.branch_name();
let worktree_path = item.worktree_data().map(|d| d.path.as_path());
let summary =
crate::summary::generate_summary(branch, item.head(), worktree_path, llm_command, repo);
preview_cache.insert((branch.to_string(), PreviewMode::Summary), summary);
}
#[cfg(test)]
mod tests {
use insta::assert_snapshot;
use super::*;
use crate::commands::list::model::{ItemKind, WorktreeData};
use std::fs;
use worktrunk::testing::TestRepo;
fn temp_repo() -> (TestRepo, Repository) {
let t = TestRepo::new();
t.repo
.run_command(&["commit", "--allow-empty", "-m", "init"])
.unwrap();
let repo = Repository::at(t.path()).unwrap();
(t, repo)
}
fn temp_repo_configured() -> (TestRepo, Repository, String) {
let t = TestRepo::new();
t.repo
.run_command(&["config", "worktrunk.default-branch", "main"])
.unwrap();
fs::write(t.path().join("README.md"), "# Project\n").unwrap();
t.repo.run_command(&["add", "README.md"]).unwrap();
t.repo
.run_command(&["commit", "-m", "initial commit"])
.unwrap();
let head = t
.repo
.run_command(&["rev-parse", "HEAD"])
.unwrap()
.trim()
.to_string();
let repo = Repository::at(t.path()).unwrap();
(t, repo, head)
}
fn temp_repo_with_feature() -> (TestRepo, Repository, String) {
let (t, repo, _) = temp_repo_configured();
repo.run_command(&["checkout", "-b", "feature"]).unwrap();
fs::write(t.path().join("new.txt"), "new content\n").unwrap();
repo.run_command(&["add", "new.txt"]).unwrap();
repo.run_command(&["commit", "-m", "add new file"]).unwrap();
let head = repo
.run_command(&["rev-parse", "HEAD"])
.unwrap()
.trim()
.to_string();
let repo = Repository::at(t.path()).unwrap();
(t, repo, head)
}
fn feature_item(head: &str, path: &std::path::Path) -> ListItem {
let mut item = ListItem::new_branch(head.to_string(), "feature".to_string());
item.kind = ItemKind::Worktree(Box::new(WorktreeData {
path: path.to_path_buf(),
..Default::default()
}));
item
}
#[test]
fn test_cache_roundtrip_and_prune_on_write() {
use crate::summary::CachedSummary;
let (_t, repo) = temp_repo();
let branch = "feature/test-branch";
assert!(CachedSummary::read(&repo, branch, "deadbeef").is_none());
let first = CachedSummary {
summary: "Add tests\n\nThis adds unit tests for cache.".to_string(),
branch: branch.to_string(),
generated_at: 100,
};
first.write(&repo, "deadbeef");
let loaded = CachedSummary::read(&repo, branch, "deadbeef").unwrap();
assert_eq!(loaded.summary, first.summary);
assert_eq!(loaded.branch, first.branch);
assert_eq!(loaded.generated_at, first.generated_at);
assert!(CachedSummary::read(&repo, branch, "cafebabe").is_none());
let second = CachedSummary {
summary: "Refactor tests".to_string(),
branch: branch.to_string(),
generated_at: 200,
};
second.write(&repo, "cafebabe");
assert!(CachedSummary::read(&repo, branch, "deadbeef").is_none());
assert_eq!(
CachedSummary::read(&repo, branch, "cafebabe")
.unwrap()
.summary,
"Refactor tests"
);
let dir =
CachedSummary::cache_root(&repo).join(worktrunk::path::sanitize_for_filename(branch));
let remaining: Vec<_> = fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(remaining, vec!["cafebabe.json"]);
}
#[test]
fn test_write_handles_unwritable_path() {
use crate::summary::CachedSummary;
let (_t, repo) = temp_repo();
let wt_dir = repo.wt_dir();
fs::create_dir_all(&wt_dir).unwrap();
let cache_parent = wt_dir.join("cache");
fs::write(&cache_parent, "blocker").unwrap();
let cached = CachedSummary {
summary: "test".to_string(),
branch: "main".to_string(),
generated_at: 0,
};
cached.write(&repo, "deadbeef");
assert!(CachedSummary::read(&repo, "main", "deadbeef").is_none());
fs::remove_file(&cache_parent).unwrap();
}
#[cfg(unix)]
#[test]
fn test_write_handles_write_failure() {
use crate::summary::CachedSummary;
use std::os::unix::fs::PermissionsExt;
let (_t, repo) = temp_repo();
let branch_dir = CachedSummary::cache_root(&repo).join("main");
fs::create_dir_all(&branch_dir).unwrap();
fs::set_permissions(&branch_dir, fs::Permissions::from_mode(0o444)).unwrap();
let cached = CachedSummary {
summary: "test".to_string(),
branch: "main".to_string(),
generated_at: 0,
};
cached.write(&repo, "deadbeef");
assert!(CachedSummary::read(&repo, "main", "deadbeef").is_none());
fs::set_permissions(&branch_dir, fs::Permissions::from_mode(0o755)).unwrap();
}
#[test]
fn test_cache_file_uses_sanitized_branch() {
use crate::summary::CachedSummary;
let (_t, repo) = temp_repo();
let path = CachedSummary::cache_file(&repo, "feature/my-branch", "abc123");
let parent = path
.parent()
.unwrap()
.file_name()
.unwrap()
.to_str()
.unwrap();
assert!(parent.starts_with("feature-my-branch-"));
assert_eq!(path.file_name().unwrap().to_str().unwrap(), "abc123.json");
}
#[test]
fn test_cache_root_under_git() {
use crate::summary::CachedSummary;
let (_t, repo) = temp_repo();
let dir = CachedSummary::cache_root(&repo);
assert!(dir.to_str().unwrap().contains("wt"));
assert!(dir.to_str().unwrap().contains("summary"));
}
#[test]
fn test_list_all_returns_freshest_per_branch() {
use crate::summary::CachedSummary;
let (_t, repo) = temp_repo();
CachedSummary {
summary: "a".to_string(),
branch: "feature-a".to_string(),
generated_at: 100,
}
.write(&repo, "aaaa");
CachedSummary {
summary: "b".to_string(),
branch: "feature-b".to_string(),
generated_at: 200,
}
.write(&repo, "bbbb");
let entries = CachedSummary::list_all(&repo);
assert_eq!(entries.len(), 2);
let mut branches: Vec<_> = entries.iter().map(|e| e.branch.clone()).collect();
branches.sort();
assert_eq!(branches, vec!["feature-a", "feature-b"]);
}
#[test]
fn test_clear_all() {
use crate::summary::CachedSummary;
let (_t, repo) = temp_repo();
assert_eq!(CachedSummary::clear_all(&repo).unwrap(), 0);
CachedSummary {
summary: "a".to_string(),
branch: "feature-a".to_string(),
generated_at: 0,
}
.write(&repo, "aaaa");
CachedSummary {
summary: "b".to_string(),
branch: "feature-b".to_string(),
generated_at: 0,
}
.write(&repo, "bbbb");
assert_eq!(CachedSummary::clear_all(&repo).unwrap(), 2);
assert!(CachedSummary::read(&repo, "feature-a", "aaaa").is_none());
assert!(CachedSummary::read(&repo, "feature-b", "bbbb").is_none());
}
#[test]
fn test_clear_all_propagates_non_not_found_read_dir_error() {
use crate::summary::CachedSummary;
let (_t, repo) = temp_repo();
let root = CachedSummary::cache_root(&repo);
fs::create_dir_all(root.parent().unwrap()).unwrap();
fs::write(&root, "not a dir").unwrap();
let err = CachedSummary::clear_all(&repo).unwrap_err();
assert!(
err.to_string().contains("failed to read"),
"expected read-failure context, got: {err}"
);
}
#[test]
fn test_clear_all_propagates_per_file_remove_error() {
use crate::summary::CachedSummary;
let (_t, repo) = temp_repo();
CachedSummary {
summary: "a".to_string(),
branch: "feature".to_string(),
generated_at: 0,
}
.write(&repo, "aaaa");
let branch_dir = CachedSummary::cache_root(&repo).join("feature");
for entry in fs::read_dir(&branch_dir).unwrap().flatten() {
fs::remove_file(entry.path()).unwrap();
}
fs::create_dir(branch_dir.join("bad.json")).unwrap();
let err = CachedSummary::clear_all(&repo).unwrap_err();
assert!(
err.to_string().contains("failed to remove"),
"expected remove-failure context, got: {err}"
);
}
#[test]
fn test_clear_all_skips_non_json_and_non_dir_entries() {
use crate::summary::CachedSummary;
let (_t, repo) = temp_repo();
let root = CachedSummary::cache_root(&repo);
fs::create_dir_all(&root).unwrap();
let branch_dir = root.join("feature");
fs::create_dir_all(&branch_dir).unwrap();
fs::write(branch_dir.join("aaaa.json"), "{}").unwrap();
fs::write(branch_dir.join("README"), "stray").unwrap();
fs::write(root.join("stray.txt"), "noise").unwrap();
let count = CachedSummary::clear_all(&repo).unwrap();
assert_eq!(count, 1, "only the .json inside a branch dir should count");
assert!(!branch_dir.join("aaaa.json").exists());
assert!(branch_dir.join("README").exists());
assert!(root.join("stray.txt").exists());
}
#[test]
fn test_render_prompt() {
use crate::summary::render_prompt;
let prompt = render_prompt("diff content", "1 file changed").unwrap();
assert_snapshot!(prompt, @r#"
<task>Write a summary of this branch's changes as a commit message.</task>
<format>
- Subject line under 50 chars, imperative mood ("Add feature" not "Adds feature")
- Blank line, then a body paragraph or bullet list explaining the key changes
- Output only the message — no quotes, code blocks, or labels
</format>
<diffstat>
1 file changed
</diffstat>
<diff>
diff content
</diff>
"#);
let empty_prompt = render_prompt("", "").unwrap();
assert_snapshot!(empty_prompt, @r#"
<task>Write a summary of this branch's changes as a commit message.</task>
<format>
- Subject line under 50 chars, imperative mood ("Add feature" not "Adds feature")
- Blank line, then a body paragraph or bullet list explaining the key changes
- Output only the message — no quotes, code blocks, or labels
</format>
<diffstat>
</diffstat>
<diff>
</diff>
"#);
}
#[test]
fn test_render_summary() {
assert_snapshot!(
render_summary("Add new feature\n\nSome body text here.", 80),
@"
[1mAdd new feature[0m
Some body text here.
"
);
assert_snapshot!(render_summary("Add new feature", 80), @"[1mAdd new feature[0m");
assert_snapshot!(
render_summary("Subject\n\n- First bullet\n- Second bullet", 80),
@"
[1mSubject[0m
- First bullet
- Second bullet
"
);
assert_snapshot!(
render_summary("\x1b[2mNo changes to summarize.\x1b[0m", 80),
@"[2mNo changes to summarize.[0m"
);
}
#[test]
fn test_render_summary_wraps_body() {
let text = format!("Subject\n\n{}", "word ".repeat(30));
let rendered = render_summary(&text, 40);
assert!(rendered.lines().count() > 3);
}
#[test]
fn test_compute_combined_diff_with_branch_changes() {
use crate::summary::compute_combined_diff;
let (t, repo, head) = temp_repo_with_feature();
let result = compute_combined_diff("feature", &head, Some(t.path()), &repo);
assert!(result.is_some());
let combined = result.unwrap();
assert!(combined.diff.contains("new.txt"));
assert!(combined.stat.contains("new.txt"));
}
#[test]
fn test_compute_combined_diff_default_branch_no_changes() {
use crate::summary::compute_combined_diff;
let (t, repo, head) = temp_repo_configured();
let result = compute_combined_diff("main", &head, Some(t.path()), &repo);
assert!(result.is_none());
}
#[test]
fn test_compute_combined_diff_with_uncommitted_changes() {
use crate::summary::compute_combined_diff;
let (t, repo, head) = temp_repo_with_feature();
fs::write(t.path().join("uncommitted.txt"), "wip\n").unwrap();
repo.run_command(&["add", "uncommitted.txt"]).unwrap();
let result = compute_combined_diff("feature", &head, Some(t.path()), &repo);
assert!(result.is_some());
let combined = result.unwrap();
assert!(combined.diff.contains("new.txt"));
assert!(combined.diff.contains("uncommitted.txt"));
}
#[test]
fn test_compute_combined_diff_branch_only_no_worktree() {
use crate::summary::compute_combined_diff;
let (_t, repo, head) = temp_repo_with_feature();
let result = compute_combined_diff("feature", &head, None, &repo);
assert!(result.is_some());
let combined = result.unwrap();
assert!(combined.diff.contains("new.txt"));
}
#[test]
fn test_compute_combined_diff_no_default_branch_with_worktree_changes() {
use crate::summary::compute_combined_diff;
let t = TestRepo::new();
t.commit("initial commit");
t.run_git(&["branch", "-m", "main", "init-branch"]);
t.run_git(&["checkout", "-b", "feature"]);
t.run_git(&["commit", "--allow-empty", "-m", "feature commit"]);
fs::write(t.path().join("wip.txt"), "work in progress\n").unwrap();
t.repo.run_command(&["add", "wip.txt"]).unwrap();
let head = t
.repo
.run_command(&["rev-parse", "HEAD"])
.unwrap()
.trim()
.to_string();
let repo = Repository::at(t.path()).unwrap();
assert!(
repo.default_branch().is_none(),
"expected no default branch with exotic branch names"
);
let result = compute_combined_diff("feature", &head, Some(t.path()), &repo);
assert!(
result.is_some(),
"should include working tree diff even without default branch"
);
let combined = result.unwrap();
assert!(combined.diff.contains("wip.txt"));
}
#[test]
fn test_generate_summary_calls_llm() {
let (t, repo, head) = temp_repo_with_feature();
let summary = crate::summary::generate_summary(
"feature",
&head,
Some(t.path()),
"cat >/dev/null && echo 'Add new file'",
&repo,
);
assert_eq!(summary, "Add new file");
}
#[test]
fn test_generate_summary_caches_result() {
let (t, repo, head) = temp_repo_with_feature();
let summary1 = crate::summary::generate_summary(
"feature",
&head,
Some(t.path()),
"cat >/dev/null && echo 'Add new file'",
&repo,
);
assert_eq!(summary1, "Add new file");
let summary2 = crate::summary::generate_summary(
"feature",
&head,
Some(t.path()),
"cat >/dev/null && echo 'Different output'",
&repo,
);
assert_eq!(summary2, "Add new file");
}
#[test]
fn test_generate_summary_no_changes() {
let (t, repo, head) = temp_repo_configured();
let summary = crate::summary::generate_summary(
"main",
&head,
Some(t.path()),
"echo 'should not run'",
&repo,
);
assert_snapshot!(summary, @"[2mâ—‹[22m[0m [1mmain[22m[0m has no changes to summarize");
}
#[test]
fn test_generate_summary_llm_error() {
let (t, repo, head) = temp_repo_with_feature();
let summary = crate::summary::generate_summary(
"feature",
&head,
Some(t.path()),
"cat >/dev/null && echo 'fail' >&2 && exit 1",
&repo,
);
assert!(summary.starts_with("Error:"));
}
#[test]
fn test_generate_and_cache_summary_populates_cache() {
let (t, repo, head) = temp_repo_with_feature();
let item = feature_item(&head, t.path());
let cache: DashMap<PreviewCacheKey, String> = DashMap::new();
generate_and_cache_summary(
&item,
"cat >/dev/null && echo 'Add new file'",
&cache,
&repo,
);
let key = ("feature".to_string(), PreviewMode::Summary);
assert!(cache.contains_key(&key));
assert_eq!(cache.get(&key).unwrap().value(), "Add new file");
}
}