diff --git a/apps/desktop/src-tauri/src/history.rs b/apps/desktop/src-tauri/src/history.rs index 29395e9..570e158 100644 --- a/apps/desktop/src-tauri/src/history.rs +++ b/apps/desktop/src-tauri/src/history.rs @@ -848,4 +848,95 @@ mod tests { let content = fs::read_to_string(&excludes_path).unwrap(); assert!(content.contains(".prism/"), "should migrate to include .prism/"); } + + // ─── edge cases ─── + + #[test] + fn test_history_snapshot_deleted_file() { + let dir = setup_project(&[("a.tex", "aaa"), ("b.tex", "bbb")]); + let r = root(&dir); + history_init(r.clone()).unwrap(); + + // Delete a file + fs::remove_file(dir.path().join("b.tex")).unwrap(); + + let snap = history_snapshot(r.clone(), "delete b".into()).unwrap().unwrap(); + assert!(!snap.changed_files.is_empty()); + } + + #[test] + fn test_history_diff_deleted_file() { + let dir = setup_project(&[("a.tex", "keep"), ("b.tex", "remove me")]); + 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(); + + fs::remove_file(dir.path().join("b.tex")).unwrap(); + let snap = history_snapshot(r.clone(), "delete b".into()).unwrap().unwrap(); + + let diffs = history_diff(r, init_id, snap.id).unwrap(); + let d = diffs.iter().find(|d| d.file_path == "b.tex").unwrap(); + assert_eq!(d.status, "deleted"); + assert_eq!(d.old_content.as_deref(), Some("remove me")); + assert!(d.new_content.is_none()); + } + + #[test] + fn test_history_diff_nonadjacent_snapshots() { + let dir = setup_project(&[("a.tex", "v1")]); + let r = root(&dir); + history_init(r.clone()).unwrap(); + + let list0 = history_list(r.clone(), 1, 0).unwrap(); + let init_id = list0[0].id.clone(); + + fs::write(dir.path().join("a.tex"), "v2").unwrap(); + history_snapshot(r.clone(), "s1".into()).unwrap(); + + fs::write(dir.path().join("a.tex"), "v3").unwrap(); + let snap3 = history_snapshot(r.clone(), "s2".into()).unwrap().unwrap(); + + // Diff from init directly to s2 (skipping s1) + let diffs = history_diff(r, init_id, snap3.id).unwrap(); + let d = diffs.iter().find(|d| d.file_path == "a.tex").unwrap(); + assert_eq!(d.old_content.as_deref(), Some("v1")); + assert_eq!(d.new_content.as_deref(), Some("v3")); + } + + #[test] + fn test_history_add_duplicate_label_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(); + + history_add_label(r.clone(), id.clone(), "dup".into()).unwrap(); + // Adding same label again should error + let result = history_add_label(r, id, "dup".into()); + assert!(result.is_err()); + } + + #[test] + fn test_history_restore_creates_restore_commit() { + let dir = setup_project(&[("main.tex", "original")]); + let r = root(&dir); + history_init(r.clone()).unwrap(); + + let init_list = history_list(r.clone(), 1, 0).unwrap(); + let init_id = init_list[0].id.clone(); + + fs::write(dir.path().join("main.tex"), "changed").unwrap(); + history_snapshot(r.clone(), "change".into()).unwrap(); + + history_restore(r.clone(), init_id).unwrap(); + + // Should now have 4 entries: init, change, restore + let list = history_list(r, 10, 0).unwrap(); + assert_eq!(list.len(), 3); + assert!(list.iter().any(|s| s.message.contains("[restore]"))); + } } diff --git a/apps/desktop/src-tauri/src/slash_commands.rs b/apps/desktop/src-tauri/src/slash_commands.rs index 06ab1b4..b606233 100644 --- a/apps/desktop/src-tauri/src/slash_commands.rs +++ b/apps/desktop/src-tauri/src/slash_commands.rs @@ -533,4 +533,173 @@ mod tests { assert!(names.contains(&"top.md".to_string())); assert!(names.contains(&"nested.md".to_string())); } + + // --- remove_empty_dirs integration tests --- + + #[test] + fn test_remove_empty_dirs_removes_nested_empty() { + let dir = tempfile::tempdir().unwrap(); + let deep = dir.path().join("a").join("b").join("c"); + fs::create_dir_all(&deep).unwrap(); + + remove_empty_dirs(&deep); + + assert!(!dir.path().join("a").exists(), "entire empty chain should be removed"); + } + + #[test] + fn test_remove_empty_dirs_stops_at_nonempty() { + let dir = tempfile::tempdir().unwrap(); + let parent = dir.path().join("a"); + let child = parent.join("b"); + fs::create_dir_all(&child).unwrap(); + fs::write(parent.join("keep.txt"), "x").unwrap(); + + remove_empty_dirs(&child); + + assert!(!child.exists(), "empty child should be removed"); + assert!(parent.exists(), "parent with file should be kept"); + } + + #[test] + fn test_remove_empty_dirs_nonexistent_is_noop() { + let dir = tempfile::tempdir().unwrap(); + let fake = dir.path().join("nonexistent"); + // Should not panic + remove_empty_dirs(&fake); + } + + // --- create_default_commands --- + + #[test] + fn test_create_default_commands_structure() { + let cmds = create_default_commands(); + assert_eq!(cmds.len(), 3); + let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect(); + assert!(names.contains(&"add-dir")); + assert!(names.contains(&"init")); + assert!(names.contains(&"review")); + for cmd in &cmds { + assert_eq!(cmd.scope, "default"); + assert!(cmd.full_command.starts_with('/')); + } + } + + // --- slash_command_save integration tests --- + + #[tokio::test] + async fn test_slash_command_save_project_scope() { + let dir = tempfile::tempdir().unwrap(); + let project_path = dir.path().to_string_lossy().to_string(); + + let result = slash_command_save( + "project".into(), + "test-cmd".into(), + None, + "Do something".into(), + None, + vec![], + Some(project_path.clone()), + ) + .await; + + assert!(result.is_ok()); + let cmd = result.unwrap(); + assert_eq!(cmd.name, "test-cmd"); + assert_eq!(cmd.full_command, "/test-cmd"); + assert_eq!(cmd.content, "Do something"); + + // Verify file was created + let file = dir.path().join(".claude").join("commands").join("test-cmd.md"); + assert!(file.exists()); + assert_eq!(fs::read_to_string(&file).unwrap(), "Do something"); + } + + #[tokio::test] + async fn test_slash_command_save_with_frontmatter() { + let dir = tempfile::tempdir().unwrap(); + let project_path = dir.path().to_string_lossy().to_string(); + + let cmd = slash_command_save( + "project".into(), + "lint".into(), + None, + "Run the linter".into(), + Some("Lint all files".into()), + vec!["Bash".into(), "Read".into()], + Some(project_path), + ) + .await + .unwrap(); + + assert_eq!(cmd.description.unwrap(), "Lint all files"); + assert_eq!(cmd.allowed_tools, vec!["Bash", "Read"]); + + // Verify frontmatter in file + let file = dir.path().join(".claude").join("commands").join("lint.md"); + let content = fs::read_to_string(&file).unwrap(); + assert!(content.starts_with("---\n")); + assert!(content.contains("description: Lint all files")); + assert!(content.contains("- Bash")); + } + + #[tokio::test] + async fn test_slash_command_save_with_namespace() { + let dir = tempfile::tempdir().unwrap(); + let project_path = dir.path().to_string_lossy().to_string(); + + let cmd = slash_command_save( + "project".into(), + "clippy".into(), + Some("tools:rust".into()), + "Run clippy".into(), + None, + vec![], + Some(project_path), + ) + .await + .unwrap(); + + assert_eq!(cmd.name, "clippy"); + assert_eq!(cmd.namespace.unwrap(), "tools:rust"); + assert_eq!(cmd.full_command, "/tools:rust:clippy"); + + // Verify nested directory structure + let file = dir.path().join(".claude").join("commands").join("tools").join("rust").join("clippy.md"); + assert!(file.exists()); + } + + #[tokio::test] + async fn test_slash_command_save_empty_name_errors() { + let result = slash_command_save( + "project".into(), + "".into(), + None, + "content".into(), + None, + vec![], + Some("/tmp".into()), + ) + .await; + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("empty")); + } + + #[tokio::test] + async fn test_slash_command_save_invalid_scope_errors() { + let result = slash_command_save( + "global".into(), + "test".into(), + None, + "content".into(), + None, + vec![], + None, + ) + .await; + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Invalid scope")); + } }