use dashmap::DashMap;
use worktrunk::git::Repository;
use super::super::list::model::ListItem;
use super::items::PreviewCacheKey;
use super::preview::PreviewMode;
use crate::summary::LLM_SEMAPHORE;
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 _permit = LLM_SEMAPHORE.acquire();
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, set_test_identity};
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() {
use crate::summary::{CachedSummary, read_cache, write_cache};
let (_t, repo) = temp_repo();
let branch = "feature/test-branch";
let cached = CachedSummary {
summary: "Add tests\n\nThis adds unit tests for cache.".to_string(),
diff_hash: 12345,
branch: branch.to_string(),
};
assert!(read_cache(&repo, branch).is_none());
write_cache(&repo, branch, &cached);
let loaded = read_cache(&repo, branch).unwrap();
assert_eq!(loaded.summary, cached.summary);
assert_eq!(loaded.diff_hash, cached.diff_hash);
assert_eq!(loaded.branch, cached.branch);
}
#[test]
fn test_write_cache_handles_unwritable_path() {
use crate::summary::{CachedSummary, read_cache, write_cache};
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(),
diff_hash: 1,
branch: "main".to_string(),
};
write_cache(&repo, "main", &cached);
assert!(read_cache(&repo, "main").is_none());
fs::remove_file(&cache_parent).unwrap();
}
#[cfg(unix)]
#[test]
fn test_write_cache_handles_write_failure() {
use crate::summary::{CachedSummary, cache_dir, read_cache, write_cache};
use std::os::unix::fs::PermissionsExt;
let (_t, repo) = temp_repo();
let cache_path = cache_dir(&repo);
fs::create_dir_all(&cache_path).unwrap();
fs::set_permissions(&cache_path, fs::Permissions::from_mode(0o444)).unwrap();
let cached = CachedSummary {
summary: "test".to_string(),
diff_hash: 1,
branch: "main".to_string(),
};
write_cache(&repo, "main", &cached);
assert!(read_cache(&repo, "main").is_none());
fs::set_permissions(&cache_path, fs::Permissions::from_mode(0o755)).unwrap();
}
#[test]
fn test_cache_invalidation_by_hash() {
use crate::summary::{CachedSummary, read_cache, write_cache};
let (_t, repo) = temp_repo();
let branch = "main";
let cached = CachedSummary {
summary: "Old summary".to_string(),
diff_hash: 111,
branch: branch.to_string(),
};
write_cache(&repo, branch, &cached);
let loaded = read_cache(&repo, branch).unwrap();
assert_ne!(loaded.diff_hash, 222);
}
#[test]
fn test_cache_file_uses_sanitized_branch() {
use crate::summary::cache_file;
let (_t, repo) = temp_repo();
let path = cache_file(&repo, "feature/my-branch");
let filename = path.file_name().unwrap().to_str().unwrap();
assert!(filename.starts_with("feature-my-branch-"));
assert!(filename.ends_with(".json"));
}
#[test]
fn test_cache_dir_under_git() {
use crate::summary::cache_dir;
let (_t, repo) = temp_repo();
let dir = cache_dir(&repo);
assert!(dir.to_str().unwrap().contains("wt"));
assert!(dir.to_str().unwrap().contains("summaries"));
}
#[test]
fn test_hash_diff_deterministic() {
use crate::summary::hash_diff;
let hash1 = hash_diff("some diff content");
let hash2 = hash_diff("some diff content");
assert_eq!(hash1, hash2);
}
#[test]
fn test_hash_diff_different_inputs() {
use crate::summary::hash_diff;
let hash1 = hash_diff("diff A");
let hash2 = hash_diff("diff B");
assert_ne!(hash1, hash2);
}
#[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 dir = tempfile::tempdir().unwrap();
worktrunk::shell_exec::Cmd::new("git")
.args(["init", "--initial-branch=init-branch"])
.current_dir(dir.path())
.run()
.unwrap();
let setup_repo = Repository::at(dir.path()).unwrap();
set_test_identity(&setup_repo);
fs::write(dir.path().join("README.md"), "# Project\n").unwrap();
setup_repo.run_command(&["add", "README.md"]).unwrap();
setup_repo
.run_command(&["commit", "-m", "initial commit"])
.unwrap();
setup_repo
.run_command(&["checkout", "-b", "feature"])
.unwrap();
setup_repo
.run_command(&["commit", "--allow-empty", "-m", "feature commit"])
.unwrap();
fs::write(dir.path().join("wip.txt"), "work in progress\n").unwrap();
setup_repo.run_command(&["add", "wip.txt"]).unwrap();
let head = setup_repo
.run_command(&["rev-parse", "HEAD"])
.unwrap()
.trim()
.to_string();
let repo = Repository::at(dir.path()).unwrap();
assert!(
repo.default_branch().is_none(),
"expected no default branch with exotic branch names"
);
let result = compute_combined_diff("feature", &head, Some(dir.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, @"[2mNo changes to summarize on main.[22m");
}
#[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");
}
}