test: add Rust integration tests for history, latex, claude, and slash_commands

Add 35 integration tests using tempfile for filesystem/git-dependent functions:
- history.rs (15): init, snapshot, list, diff, file_at, restore, labels, tag_map, ensure_excludes
- latex.rs (6): copy_dir_recursive, sync_source_files
- claude.rs (6): extract_first_user_message with JSONL parsing
- slash_commands.rs (6): load_command_from_file, find_markdown_files

Total Rust tests: 100 (65 existing + 35 new)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
delibae 2026-03-02 15:49:36 +09:00
parent 7b9474414d
commit 17dcb3f273
4 changed files with 612 additions and 0 deletions

View file

@ -1128,4 +1128,81 @@ mod tests {
// dots and underscores are non-alphanumeric → replaced with '-'
assert_eq!(dir_name, "-home-user--my-project-v2");
}
// --- extract_first_user_message integration tests ---
#[test]
fn test_extract_first_user_message_string_content() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("session.jsonl");
let line = r#"{"type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"content":"Fix the bibliography"}}"#;
std::fs::write(&path, line).unwrap();
let pb = PathBuf::from(&path);
let (title, ts) = extract_first_user_message(&pb);
assert_eq!(title.unwrap(), "Fix the bibliography");
assert_eq!(ts.unwrap(), "2024-01-01T00:00:00Z");
}
#[test]
fn test_extract_first_user_message_block_array_content() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("session.jsonl");
let line = r#"{"type":"user","timestamp":"2024-02-01T00:00:00Z","message":{"content":[{"type":"text","text":"Rewrite the abstract"}]}}"#;
std::fs::write(&path, line).unwrap();
let pb = PathBuf::from(&path);
let (title, ts) = extract_first_user_message(&pb);
assert_eq!(title.unwrap(), "Rewrite the abstract");
assert_eq!(ts.unwrap(), "2024-02-01T00:00:00Z");
}
#[test]
fn test_extract_first_user_message_empty_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("empty.jsonl");
std::fs::write(&path, "").unwrap();
let pb = PathBuf::from(&path);
let (title, ts) = extract_first_user_message(&pb);
assert!(title.is_none());
assert!(ts.is_none());
}
#[test]
fn test_extract_first_user_message_no_user_messages() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("session.jsonl");
let lines = r#"{"type":"system","subtype":"init","session_id":"abc"}
{"type":"assistant","message":{"content":"Hello"}}"#;
std::fs::write(&path, lines).unwrap();
let pb = PathBuf::from(&path);
let (title, ts) = extract_first_user_message(&pb);
assert!(title.is_none());
assert!(ts.is_none());
}
#[test]
fn test_extract_first_user_message_nonexistent_path() {
let pb = PathBuf::from("/tmp/nonexistent_session_file_12345.jsonl");
let (title, ts) = extract_first_user_message(&pb);
assert!(title.is_none());
assert!(ts.is_none());
}
#[test]
fn test_extract_first_user_message_skips_ide_tags() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("session.jsonl");
// First user message is an IDE tag (should skip), second is real
let lines = r#"{"type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"content":"<ide_context>data"}}
{"type":"user","timestamp":"2024-01-02T00:00:00Z","message":{"content":"Add a new section"}}"#;
std::fs::write(&path, lines).unwrap();
let pb = PathBuf::from(&path);
let (title, ts) = extract_first_user_message(&pb);
assert_eq!(title.unwrap(), "Add a new section");
assert_eq!(ts.unwrap(), "2024-01-02T00:00:00Z");
}
}

View file

@ -524,3 +524,328 @@ pub fn history_remove_label(project_root: String, label: String) -> Result<(), S
.map_err(|e| format!("Failed to delete label: {}", e))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
/// Create a temp project dir with the given files.
fn setup_project(files: &[(&str, &str)]) -> TempDir {
let dir = TempDir::new().unwrap();
for (name, content) in files {
let path = dir.path().join(name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, content).unwrap();
}
dir
}
fn root(dir: &TempDir) -> String {
dir.path().to_string_lossy().to_string()
}
// ─── history_init ───
#[test]
fn test_history_init_creates_repo() {
let dir = setup_project(&[("main.tex", "\\documentclass{article}")]);
history_init(root(&dir)).unwrap();
let git_dir = dir.path().join(".claudeprism").join("history.git");
assert!(git_dir.exists(), "history.git should be created");
// Should have an initial commit
let repo = Repository::open(&git_dir).unwrap();
let head = repo.head().unwrap();
let commit = head.peel_to_commit().unwrap();
assert!(commit.message().unwrap().contains("[init]"));
}
#[test]
fn test_history_init_idempotent() {
let dir = setup_project(&[("main.tex", "hello")]);
let r = root(&dir);
history_init(r.clone()).unwrap();
// Second call should succeed without error
history_init(r).unwrap();
}
#[test]
fn test_history_init_creates_excludes() {
let dir = setup_project(&[("main.tex", "doc")]);
history_init(root(&dir)).unwrap();
let excludes = dir.path().join(".claudeprism").join("history-exclude");
assert!(excludes.exists());
let content = fs::read_to_string(&excludes).unwrap();
assert!(content.contains("*.aux"));
assert!(content.contains(".claudeprism/"));
assert!(content.contains(".prism/"));
}
// ─── history_snapshot ───
#[test]
fn test_history_snapshot_after_modification() {
let dir = setup_project(&[("main.tex", "v1")]);
let r = root(&dir);
history_init(r.clone()).unwrap();
// Modify a file
fs::write(dir.path().join("main.tex"), "v2").unwrap();
let result = history_snapshot(r, "edited main.tex".into()).unwrap();
assert!(result.is_some());
let snap = result.unwrap();
assert_eq!(snap.message, "edited main.tex");
assert!(snap.changed_files.contains(&"main.tex".to_string()));
}
#[test]
fn test_history_snapshot_no_change_returns_none() {
let dir = setup_project(&[("main.tex", "same")]);
let r = root(&dir);
history_init(r.clone()).unwrap();
// No modification → None
let result = history_snapshot(r, "no-op".into()).unwrap();
assert!(result.is_none());
}
#[test]
fn test_history_snapshot_detects_new_file() {
let dir = setup_project(&[("main.tex", "doc")]);
let r = root(&dir);
history_init(r.clone()).unwrap();
// Add a new file
fs::write(dir.path().join("chapter1.tex"), "new chapter").unwrap();
let snap = history_snapshot(r, "add chapter".into()).unwrap().unwrap();
assert!(snap.changed_files.contains(&"chapter1.tex".to_string()));
}
// ─── history_list ───
#[test]
fn test_history_list_after_snapshots() {
let dir = setup_project(&[("main.tex", "v1")]);
let r = root(&dir);
history_init(r.clone()).unwrap();
fs::write(dir.path().join("main.tex"), "v2").unwrap();
history_snapshot(r.clone(), "snap 1".into()).unwrap();
fs::write(dir.path().join("main.tex"), "v3").unwrap();
history_snapshot(r.clone(), "snap 2".into()).unwrap();
let list = history_list(r, 10, 0).unwrap();
assert_eq!(list.len(), 3); // init + 2 snapshots
let msgs: Vec<&str> = list.iter().map(|s| s.message.as_str()).collect();
assert!(msgs.contains(&"snap 1"));
assert!(msgs.contains(&"snap 2"));
assert!(msgs.iter().any(|m| m.contains("[init]")));
}
#[test]
fn test_history_list_pagination() {
let dir = setup_project(&[("a.tex", "x")]);
let r = root(&dir);
history_init(r.clone()).unwrap();
fs::write(dir.path().join("a.tex"), "y").unwrap();
history_snapshot(r.clone(), "s1".into()).unwrap();
fs::write(dir.path().join("a.tex"), "z").unwrap();
history_snapshot(r.clone(), "s2".into()).unwrap();
// limit=1 → returns exactly 1 entry
let page1 = history_list(r.clone(), 1, 0).unwrap();
assert_eq!(page1.len(), 1);
// offset=1 → returns a different entry
let page2 = history_list(r.clone(), 1, 1).unwrap();
assert_eq!(page2.len(), 1);
assert_ne!(page1[0].id, page2[0].id);
// All 3 entries accessible
let all = history_list(r, 10, 0).unwrap();
assert_eq!(all.len(), 3);
}
// ─── history_diff ───
#[test]
fn test_history_diff_shows_changes() {
let dir = setup_project(&[("main.tex", "old content")]);
let r = root(&dir);
history_init(r.clone()).unwrap();
fs::write(dir.path().join("main.tex"), "new content").unwrap();
let snap = history_snapshot(r.clone(), "update".into()).unwrap().unwrap();
let list = history_list(r.clone(), 10, 0).unwrap();
let from_id = list[1].id.clone(); // init
let to_id = snap.id.clone();
let diffs = history_diff(r, from_id, to_id).unwrap();
assert!(!diffs.is_empty());
let d = diffs.iter().find(|d| d.file_path == "main.tex").unwrap();
assert_eq!(d.status, "modified");
assert_eq!(d.old_content.as_deref(), Some("old content"));
assert_eq!(d.new_content.as_deref(), Some("new content"));
}
#[test]
fn test_history_diff_added_file() {
let dir = setup_project(&[("a.tex", "a")]);
let r = root(&dir);
history_init(r.clone()).unwrap();
fs::write(dir.path().join("b.tex"), "new file").unwrap();
let snap = history_snapshot(r.clone(), "add b".into()).unwrap().unwrap();
let list = history_list(r.clone(), 10, 0).unwrap();
let from_id = list[1].id.clone(); // init
let to_id = snap.id;
let diffs = history_diff(r, from_id, to_id).unwrap();
let d = diffs.iter().find(|d| d.file_path == "b.tex").unwrap();
assert_eq!(d.status, "added");
assert!(d.old_content.is_none());
assert_eq!(d.new_content.as_deref(), Some("new file"));
}
// ─── history_file_at ───
#[test]
fn test_history_file_at_returns_content() {
let dir = setup_project(&[("main.tex", "version one")]);
let r = root(&dir);
history_init(r.clone()).unwrap();
let list = history_list(r.clone(), 1, 0).unwrap();
let init_id = list[0].id.clone();
let content = history_file_at(r, init_id, "main.tex".into()).unwrap();
assert_eq!(content, "version one");
}
#[test]
fn test_history_file_at_nonexistent_file_errors() {
let dir = setup_project(&[("main.tex", "x")]);
let r = root(&dir);
history_init(r.clone()).unwrap();
let list = history_list(r.clone(), 1, 0).unwrap();
let id = list[0].id.clone();
let result = history_file_at(r, id, "nonexistent.tex".into());
assert!(result.is_err());
}
// ─── history_restore ───
#[test]
fn test_history_restore_reverts_content() {
let dir = setup_project(&[("main.tex", "original")]);
let r = root(&dir);
history_init(r.clone()).unwrap();
let list = history_list(r.clone(), 1, 0).unwrap();
let init_id = list[0].id.clone();
// Modify
fs::write(dir.path().join("main.tex"), "modified").unwrap();
history_snapshot(r.clone(), "modify".into()).unwrap();
// Restore to init
let restore_info = history_restore(r.clone(), init_id).unwrap();
assert!(restore_info.message.contains("[restore]"));
// Working directory should have original content
let content = fs::read_to_string(dir.path().join("main.tex")).unwrap();
assert_eq!(content, "original");
}
// ─── labels ───
#[test]
fn test_history_add_and_remove_label() {
let dir = setup_project(&[("main.tex", "doc")]);
let r = root(&dir);
history_init(r.clone()).unwrap();
let list = history_list(r.clone(), 1, 0).unwrap();
let id = list[0].id.clone();
// Add label
history_add_label(r.clone(), id.clone(), "v1.0".into()).unwrap();
// Verify label appears in list
let list = history_list(r.clone(), 1, 0).unwrap();
assert!(list[0].labels.contains(&"v1.0".to_string()));
// Remove label
history_remove_label(r.clone(), "v1.0".into()).unwrap();
// Verify label gone
let list = history_list(r.clone(), 1, 0).unwrap();
assert!(!list[0].labels.contains(&"v1.0".to_string()));
}
#[test]
fn test_history_remove_nonexistent_label_errors() {
let dir = setup_project(&[("main.tex", "x")]);
let r = root(&dir);
history_init(r.clone()).unwrap();
let result = history_remove_label(r, "nope".into());
assert!(result.is_err());
}
// ─── tag_map ───
#[test]
fn test_tag_map_groups_by_oid() {
let dir = setup_project(&[("main.tex", "x")]);
let r = root(&dir);
history_init(r.clone()).unwrap();
let list = history_list(r.clone(), 1, 0).unwrap();
let id = list[0].id.clone();
history_add_label(r.clone(), id.clone(), "alpha".into()).unwrap();
history_add_label(r.clone(), id.clone(), "beta".into()).unwrap();
let repo = open_repo(&r).unwrap();
let map = tag_map(&repo);
let oid = Oid::from_str(&id).unwrap();
let labels = map.get(&oid).unwrap();
assert!(labels.contains(&"alpha".to_string()));
assert!(labels.contains(&"beta".to_string()));
}
// ─── ensure_excludes ───
#[test]
fn test_ensure_excludes_migrates_missing_prism() {
let dir = setup_project(&[("main.tex", "x")]);
let r = root(&dir);
history_init(r.clone()).unwrap();
// Write an excludes file WITHOUT .prism/
let excludes_path = dir.path().join(".claudeprism").join("history-exclude");
fs::write(&excludes_path, "*.aux\n*.log\n.claudeprism/\n").unwrap();
let repo = open_repo(&r).unwrap();
ensure_excludes(&r, &repo);
let content = fs::read_to_string(&excludes_path).unwrap();
assert!(content.contains(".prism/"), "should migrate to include .prism/");
}
}

View file

@ -782,4 +782,115 @@ Postamble:
let dir = persistent_build_dir("/project/");
assert_eq!(dir, PathBuf::from("/project/.prism/build"));
}
// --- copy_dir_recursive integration tests ---
#[test]
fn test_copy_dir_recursive_nested() {
let src = tempfile::tempdir().unwrap();
let dst = tempfile::tempdir().unwrap();
// Create nested structure
std::fs::create_dir_all(src.path().join("sub").join("deep")).unwrap();
std::fs::write(src.path().join("top.tex"), "top").unwrap();
std::fs::write(src.path().join("sub").join("mid.tex"), "mid").unwrap();
std::fs::write(src.path().join("sub").join("deep").join("bottom.tex"), "bottom").unwrap();
copy_dir_recursive(src.path(), dst.path()).unwrap();
assert_eq!(std::fs::read_to_string(dst.path().join("top.tex")).unwrap(), "top");
assert_eq!(std::fs::read_to_string(dst.path().join("sub").join("mid.tex")).unwrap(), "mid");
assert_eq!(
std::fs::read_to_string(dst.path().join("sub").join("deep").join("bottom.tex")).unwrap(),
"bottom"
);
}
#[test]
fn test_copy_dir_recursive_skips_hidden_dirs() {
let src = tempfile::tempdir().unwrap();
let dst = tempfile::tempdir().unwrap();
std::fs::create_dir_all(src.path().join(".git")).unwrap();
std::fs::write(src.path().join(".git").join("config"), "secret").unwrap();
std::fs::write(src.path().join("main.tex"), "doc").unwrap();
copy_dir_recursive(src.path(), dst.path()).unwrap();
assert!(dst.path().join("main.tex").exists());
assert!(!dst.path().join(".git").exists(), ".git should be skipped");
}
#[test]
fn test_copy_dir_recursive_empty_subdir() {
let src = tempfile::tempdir().unwrap();
let dst = tempfile::tempdir().unwrap();
std::fs::create_dir_all(src.path().join("empty_sub")).unwrap();
std::fs::write(src.path().join("a.tex"), "a").unwrap();
copy_dir_recursive(src.path(), dst.path()).unwrap();
assert!(dst.path().join("empty_sub").exists());
assert!(dst.path().join("empty_sub").is_dir());
}
// --- sync_source_files integration tests ---
#[test]
fn test_sync_source_files_copies_sources() {
let src = tempfile::tempdir().unwrap();
let dst = tempfile::tempdir().unwrap();
std::fs::write(src.path().join("main.tex"), "doc").unwrap();
std::fs::write(src.path().join("refs.bib"), "bib").unwrap();
std::fs::write(src.path().join("style.sty"), "sty").unwrap();
sync_source_files(src.path(), dst.path()).unwrap();
assert_eq!(std::fs::read_to_string(dst.path().join("main.tex")).unwrap(), "doc");
assert_eq!(std::fs::read_to_string(dst.path().join("refs.bib")).unwrap(), "bib");
assert_eq!(std::fs::read_to_string(dst.path().join("style.sty")).unwrap(), "sty");
}
#[test]
fn test_sync_source_files_skips_artifacts() {
let src = tempfile::tempdir().unwrap();
let dst = tempfile::tempdir().unwrap();
std::fs::write(src.path().join("main.tex"), "doc").unwrap();
std::fs::write(src.path().join("main.aux"), "aux").unwrap();
std::fs::write(src.path().join("main.log"), "log").unwrap();
std::fs::write(src.path().join("main.pdf"), "pdf").unwrap();
std::fs::write(src.path().join("main.synctex.gz"), "sync").unwrap();
sync_source_files(src.path(), dst.path()).unwrap();
assert!(dst.path().join("main.tex").exists());
assert!(!dst.path().join("main.aux").exists());
assert!(!dst.path().join("main.log").exists());
assert!(!dst.path().join("main.pdf").exists());
assert!(!dst.path().join("main.synctex.gz").exists());
}
#[test]
fn test_sync_source_files_recursive_and_skips_hidden() {
let src = tempfile::tempdir().unwrap();
let dst = tempfile::tempdir().unwrap();
std::fs::create_dir_all(src.path().join("chapters")).unwrap();
std::fs::create_dir_all(src.path().join(".claudeprism")).unwrap();
std::fs::write(src.path().join("chapters").join("ch1.tex"), "ch1").unwrap();
std::fs::write(src.path().join("chapters").join("ch1.aux"), "aux").unwrap();
std::fs::write(src.path().join(".claudeprism").join("data"), "data").unwrap();
sync_source_files(src.path(), dst.path()).unwrap();
assert_eq!(
std::fs::read_to_string(dst.path().join("chapters").join("ch1.tex")).unwrap(),
"ch1"
);
assert!(!dst.path().join("chapters").join("ch1.aux").exists());
assert!(!dst.path().join(".claudeprism").exists());
}
}

View file

@ -434,4 +434,103 @@ mod tests {
let (name, _) = extract_command_info(file, base).unwrap();
assert_eq!(name, "my-command");
}
// --- load_command_from_file integration tests ---
#[test]
fn test_load_command_from_file_simple() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("greet.md");
fs::write(&file, "Hello $ARGUMENTS, run !`echo hi` and check @main.tex").unwrap();
let cmd = load_command_from_file(&file, dir.path(), "project").unwrap();
assert_eq!(cmd.name, "greet");
assert!(cmd.namespace.is_none());
assert_eq!(cmd.full_command, "/greet");
assert_eq!(cmd.scope, "project");
assert!(cmd.accepts_arguments);
assert!(cmd.has_bash_commands);
assert!(cmd.has_file_references);
assert!(cmd.description.is_none());
assert!(cmd.allowed_tools.is_empty());
}
#[test]
fn test_load_command_from_file_with_frontmatter() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("lint.md");
let content = "---\ndescription: Run linter\nallowed-tools:\n - Bash\n - Read\n---\nLint the project";
fs::write(&file, content).unwrap();
let cmd = load_command_from_file(&file, dir.path(), "user").unwrap();
assert_eq!(cmd.name, "lint");
assert_eq!(cmd.description.unwrap(), "Run linter");
assert_eq!(cmd.allowed_tools, vec!["Bash", "Read"]);
assert_eq!(cmd.content, "Lint the project");
}
#[test]
fn test_load_command_from_file_nested_namespace() {
let dir = tempfile::tempdir().unwrap();
let ns_dir = dir.path().join("tools").join("rust");
fs::create_dir_all(&ns_dir).unwrap();
let file = ns_dir.join("clippy.md");
fs::write(&file, "Run clippy").unwrap();
let cmd = load_command_from_file(&file, dir.path(), "project").unwrap();
assert_eq!(cmd.name, "clippy");
assert_eq!(cmd.namespace.unwrap(), "tools:rust");
assert_eq!(cmd.full_command, "/tools:rust:clippy");
}
// --- find_markdown_files integration tests ---
#[test]
fn test_find_markdown_files_collects_md_only() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("cmd1.md"), "a").unwrap();
fs::write(dir.path().join("cmd2.md"), "b").unwrap();
fs::write(dir.path().join("notes.txt"), "c").unwrap();
fs::write(dir.path().join("data.json"), "d").unwrap();
let mut files = Vec::new();
find_markdown_files(dir.path(), &mut files);
assert_eq!(files.len(), 2);
let names: Vec<String> = files.iter().map(|f| f.file_name().unwrap().to_string_lossy().to_string()).collect();
assert!(names.contains(&"cmd1.md".to_string()));
assert!(names.contains(&"cmd2.md".to_string()));
}
#[test]
fn test_find_markdown_files_skips_hidden_dirs() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("visible.md"), "ok").unwrap();
let hidden = dir.path().join(".hidden");
fs::create_dir_all(&hidden).unwrap();
fs::write(hidden.join("secret.md"), "hidden").unwrap();
let mut files = Vec::new();
find_markdown_files(dir.path(), &mut files);
assert_eq!(files.len(), 1);
assert_eq!(files[0].file_name().unwrap().to_str().unwrap(), "visible.md");
}
#[test]
fn test_find_markdown_files_recursive() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("top.md"), "top").unwrap();
let sub = dir.path().join("sub");
fs::create_dir_all(&sub).unwrap();
fs::write(sub.join("nested.md"), "nested").unwrap();
let mut files = Vec::new();
find_markdown_files(dir.path(), &mut files);
assert_eq!(files.len(), 2);
let names: Vec<String> = files.iter().map(|f| f.file_name().unwrap().to_string_lossy().to_string()).collect();
assert!(names.contains(&"top.md".to_string()));
assert!(names.contains(&"nested.md".to_string()));
}
}