diff --git a/apps/desktop/src-tauri/src/claude.rs b/apps/desktop/src-tauri/src/claude.rs index 2b8e5cd..e31239c 100644 --- a/apps/desktop/src-tauri/src/claude.rs +++ b/apps/desktop/src-tauri/src/claude.rs @@ -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":"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"); + } } diff --git a/apps/desktop/src-tauri/src/history.rs b/apps/desktop/src-tauri/src/history.rs index a1e9b34..29395e9 100644 --- a/apps/desktop/src-tauri/src/history.rs +++ b/apps/desktop/src-tauri/src/history.rs @@ -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/"); + } +} diff --git a/apps/desktop/src-tauri/src/latex.rs b/apps/desktop/src-tauri/src/latex.rs index 3771ba4..afb0b53 100644 --- a/apps/desktop/src-tauri/src/latex.rs +++ b/apps/desktop/src-tauri/src/latex.rs @@ -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()); + } } diff --git a/apps/desktop/src-tauri/src/slash_commands.rs b/apps/desktop/src-tauri/src/slash_commands.rs index a757faa..06ab1b4 100644 --- a/apps/desktop/src-tauri/src/slash_commands.rs +++ b/apps/desktop/src-tauri/src/slash_commands.rs @@ -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 = 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 = 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())); + } }