use super::*; use crate::core::db::{create_connection, run_migrations}; use std::path::Path; fn setup_test_db() -> Connection { let conn = create_connection(Path::new(":memory:")).unwrap(); run_migrations(&conn).unwrap(); conn } fn seed_project(conn: &Connection, id: i64) { conn.execute( "INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at) VALUES (?1, ?1, 'group/repo', 'https://gl.example.com/group/repo', 1000, 2000)", rusqlite::params![id], ) .unwrap(); } fn seed_mr(conn: &Connection, mr_id: i64, project_id: i64) { conn.execute( "INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, \ created_at, updated_at, last_seen_at, source_branch, target_branch) VALUES (?1, ?1, ?1, ?2, 'MR', 'merged', 1000, 2000, 2000, 'feat', 'main')", rusqlite::params![mr_id, project_id], ) .unwrap(); } fn seed_file_change(conn: &Connection, mr_id: i64, project_id: i64, path: &str) { conn.execute( "INSERT INTO mr_file_changes (merge_request_id, project_id, new_path, change_type) VALUES (?1, ?2, ?3, 'modified')", rusqlite::params![mr_id, project_id, path], ) .unwrap(); } fn seed_diffnote(conn: &Connection, id: i64, project_id: i64, path: &str) { // Need a discussion first (MergeRequest type, linked to mr_id=1) conn.execute( "INSERT OR IGNORE INTO discussions (id, gitlab_discussion_id, project_id, \ merge_request_id, noteable_type, resolvable, resolved, last_seen_at, last_note_at) VALUES (?1, ?2, ?3, 1, 'MergeRequest', 1, 0, 2000, 2000)", rusqlite::params![id, format!("disc-{id}"), project_id], ) .unwrap(); conn.execute( "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, note_type, is_system, \ author_username, body, created_at, updated_at, last_seen_at, position_new_path) VALUES (?1, ?1, ?1, ?2, 'DiffNote', 0, 'user', 'note', 1000, 2000, 2000, ?3)", rusqlite::params![id, project_id, path], ) .unwrap(); } // ─── escape_like ───────────────────────────────────────────────────────────── #[test] fn test_escape_like() { assert_eq!(escape_like("normal/path"), "normal/path"); assert_eq!(escape_like("has_underscore"), "has\\_underscore"); assert_eq!(escape_like("has%percent"), "has\\%percent"); assert_eq!(escape_like("has\\backslash"), "has\\\\backslash"); } // ─── normalize_repo_path ───────────────────────────────────────────────────── #[test] fn test_normalize_repo_path() { assert_eq!(normalize_repo_path("./src/foo/"), "src/foo/"); assert_eq!(normalize_repo_path("/src/foo/"), "src/foo/"); assert_eq!(normalize_repo_path("././src/foo"), "src/foo"); assert_eq!(normalize_repo_path("src\\foo\\bar.rs"), "src/foo/bar.rs"); assert_eq!(normalize_repo_path("src/foo\\bar"), "src/foo\\bar"); assert_eq!(normalize_repo_path("src//foo//bar/"), "src/foo/bar/"); assert_eq!(normalize_repo_path(" src/foo/ "), "src/foo/"); assert_eq!(normalize_repo_path("src/foo/bar.rs"), "src/foo/bar.rs"); assert_eq!(normalize_repo_path(""), ""); } // ─── build_path_query heuristics (no DB data) ────────────────────────────── #[test] fn test_trailing_slash_is_prefix() { let conn = setup_test_db(); let pq = build_path_query(&conn, "src/auth/", None).unwrap(); assert_eq!(pq.value, "src/auth/%"); assert!(pq.is_prefix); } #[test] fn test_no_dot_in_last_segment_is_prefix() { let conn = setup_test_db(); let pq = build_path_query(&conn, "src/auth", None).unwrap(); assert_eq!(pq.value, "src/auth/%"); assert!(pq.is_prefix); } #[test] fn test_file_extension_is_exact() { let conn = setup_test_db(); let pq = build_path_query(&conn, "src/auth/login.rs", None).unwrap(); assert_eq!(pq.value, "src/auth/login.rs"); assert!(!pq.is_prefix); } #[test] fn test_root_file_is_exact() { let conn = setup_test_db(); let pq = build_path_query(&conn, "README.md", None).unwrap(); assert_eq!(pq.value, "README.md"); assert!(!pq.is_prefix); } #[test] fn test_dotless_root_file_is_exact() { let conn = setup_test_db(); let pq = build_path_query(&conn, "Makefile", None).unwrap(); assert_eq!(pq.value, "Makefile"); assert!(!pq.is_prefix); let pq = build_path_query(&conn, "LICENSE", None).unwrap(); assert_eq!(pq.value, "LICENSE"); assert!(!pq.is_prefix); } #[test] fn test_metacharacters_escaped_in_prefix() { let conn = setup_test_db(); let pq = build_path_query(&conn, "src/test_files/", None).unwrap(); assert_eq!(pq.value, "src/test\\_files/%"); assert!(pq.is_prefix); } #[test] fn test_exact_value_not_escaped() { let conn = setup_test_db(); let pq = build_path_query(&conn, "README_with_underscore.md", None).unwrap(); assert_eq!(pq.value, "README_with_underscore.md"); assert!(!pq.is_prefix); } // ─── build_path_query DB probes ───────────────────────────────────────────── #[test] fn test_db_probe_detects_dotless_file() { // "src/Dockerfile" has no dot in last segment -> normally prefix. // DB probe detects it's actually a file. let conn = setup_test_db(); seed_project(&conn, 1); seed_mr(&conn, 1, 1); seed_diffnote(&conn, 1, 1, "src/Dockerfile"); let pq = build_path_query(&conn, "src/Dockerfile", None).unwrap(); assert_eq!(pq.value, "src/Dockerfile"); assert!(!pq.is_prefix); // Without DB data -> falls through to prefix let empty = setup_test_db(); let pq2 = build_path_query(&empty, "src/Dockerfile", None).unwrap(); assert!(pq2.is_prefix); } #[test] fn test_db_probe_via_file_changes() { // Exact match via mr_file_changes even without notes let conn = setup_test_db(); seed_project(&conn, 1); seed_mr(&conn, 1, 1); seed_file_change(&conn, 1, 1, "src/Dockerfile"); let pq = build_path_query(&conn, "src/Dockerfile", None).unwrap(); assert_eq!(pq.value, "src/Dockerfile"); assert!(!pq.is_prefix); } #[test] fn test_db_probe_project_scoped() { let conn = setup_test_db(); seed_project(&conn, 1); seed_project(&conn, 2); seed_mr(&conn, 1, 1); seed_diffnote(&conn, 1, 1, "infra/Makefile"); // Unscoped: finds it assert!( !build_path_query(&conn, "infra/Makefile", None) .unwrap() .is_prefix ); // Scoped to project 1: finds it assert!( !build_path_query(&conn, "infra/Makefile", Some(1)) .unwrap() .is_prefix ); // Scoped to project 2: no data -> prefix assert!( build_path_query(&conn, "infra/Makefile", Some(2)) .unwrap() .is_prefix ); } // ─── suffix resolution ────────────────────────────────────────────────────── #[test] fn test_suffix_resolves_bare_filename() { let conn = setup_test_db(); seed_project(&conn, 1); seed_mr(&conn, 1, 1); seed_file_change(&conn, 1, 1, "src/auth/login.rs"); let pq = build_path_query(&conn, "login.rs", None).unwrap(); assert_eq!(pq.value, "src/auth/login.rs"); assert!(!pq.is_prefix); } #[test] fn test_suffix_resolves_partial_path() { let conn = setup_test_db(); seed_project(&conn, 1); seed_mr(&conn, 1, 1); seed_file_change(&conn, 1, 1, "src/auth/login.rs"); let pq = build_path_query(&conn, "auth/login.rs", None).unwrap(); assert_eq!(pq.value, "src/auth/login.rs"); assert!(!pq.is_prefix); } #[test] fn test_suffix_ambiguous_returns_error() { let conn = setup_test_db(); seed_project(&conn, 1); seed_mr(&conn, 1, 1); seed_file_change(&conn, 1, 1, "src/auth/utils.rs"); seed_file_change(&conn, 1, 1, "src/db/utils.rs"); let err = build_path_query(&conn, "utils.rs", None).unwrap_err(); let msg = err.to_string(); assert!(msg.contains("src/auth/utils.rs"), "candidates: {msg}"); assert!(msg.contains("src/db/utils.rs"), "candidates: {msg}"); } #[test] fn test_suffix_scoped_to_project() { let conn = setup_test_db(); seed_project(&conn, 1); seed_project(&conn, 2); seed_mr(&conn, 1, 1); seed_mr(&conn, 2, 2); seed_file_change(&conn, 1, 1, "src/utils.rs"); seed_file_change(&conn, 2, 2, "lib/utils.rs"); // Unscoped: ambiguous assert!(build_path_query(&conn, "utils.rs", None).is_err()); // Scoped to project 1: resolves let pq = build_path_query(&conn, "utils.rs", Some(1)).unwrap(); assert_eq!(pq.value, "src/utils.rs"); } #[test] fn test_suffix_deduplicates_across_sources() { // Same path in notes AND file_changes -> single match, not ambiguous let conn = setup_test_db(); seed_project(&conn, 1); seed_mr(&conn, 1, 1); seed_file_change(&conn, 1, 1, "src/auth/login.rs"); seed_diffnote(&conn, 1, 1, "src/auth/login.rs"); let pq = build_path_query(&conn, "login.rs", None).unwrap(); assert_eq!(pq.value, "src/auth/login.rs"); assert!(!pq.is_prefix); } #[test] fn test_exact_match_preferred_over_suffix() { let conn = setup_test_db(); seed_project(&conn, 1); seed_mr(&conn, 1, 1); seed_file_change(&conn, 1, 1, "README.md"); seed_file_change(&conn, 1, 1, "docs/README.md"); // "README.md" exists as exact match -> no ambiguity let pq = build_path_query(&conn, "README.md", None).unwrap(); assert_eq!(pq.value, "README.md"); assert!(!pq.is_prefix); } fn seed_rename(conn: &Connection, mr_id: i64, project_id: i64, old_path: &str, new_path: &str) { conn.execute( "INSERT INTO mr_file_changes (merge_request_id, project_id, old_path, new_path, change_type) VALUES (?1, ?2, ?3, ?4, 'renamed')", rusqlite::params![mr_id, project_id, old_path, new_path], ) .unwrap(); } // ─── rename-aware ambiguity resolution ────────────────────────────────────── #[test] fn test_ambiguity_resolved_by_rename_chain() { let conn = setup_test_db(); seed_project(&conn, 1); seed_mr(&conn, 1, 1); seed_mr(&conn, 2, 1); // File was at src/old/operators.ts, then renamed to src/new/operators.ts seed_file_change(&conn, 1, 1, "src/old/operators.ts"); seed_rename(&conn, 2, 1, "src/old/operators.ts", "src/new/operators.ts"); // Bare "operators.ts" matches both paths via suffix probe, but they're // connected by a rename — should auto-resolve to the newest path. let pq = build_path_query(&conn, "operators.ts", Some(1)).unwrap(); assert_eq!(pq.value, "src/new/operators.ts"); assert!(!pq.is_prefix); } #[test] fn test_ambiguity_not_resolved_when_genuinely_different_files() { let conn = setup_test_db(); seed_project(&conn, 1); seed_mr(&conn, 1, 1); // Two genuinely different files with the same name (no rename connecting them) seed_file_change(&conn, 1, 1, "src/utils/helpers.ts"); seed_file_change(&conn, 1, 1, "tests/utils/helpers.ts"); let err = build_path_query(&conn, "helpers.ts", Some(1)).unwrap_err(); assert!(err.to_string().contains("matches multiple paths")); } #[test] fn test_ambiguity_rename_chain_with_three_hops() { let conn = setup_test_db(); seed_project(&conn, 1); seed_mr(&conn, 1, 1); seed_mr(&conn, 2, 1); seed_mr(&conn, 3, 1); // File named "config.ts" moved twice: lib/ -> src/ -> src/core/ seed_file_change(&conn, 1, 1, "lib/config.ts"); seed_rename(&conn, 2, 1, "lib/config.ts", "src/config.ts"); seed_rename(&conn, 3, 1, "src/config.ts", "src/core/config.ts"); // "config.ts" matches lib/config.ts, src/config.ts, src/core/config.ts via suffix let pq = build_path_query(&conn, "config.ts", Some(1)).unwrap(); assert_eq!(pq.value, "src/core/config.ts"); assert!(!pq.is_prefix); } #[test] fn test_ambiguity_rename_without_project_id_stays_ambiguous() { let conn = setup_test_db(); seed_project(&conn, 1); seed_mr(&conn, 1, 1); seed_mr(&conn, 2, 1); seed_file_change(&conn, 1, 1, "src/old/utils.ts"); seed_rename(&conn, 2, 1, "src/old/utils.ts", "src/new/utils.ts"); // Without project_id, rename resolution is skipped → stays ambiguous let err = build_path_query(&conn, "utils.ts", None).unwrap_err(); assert!(err.to_string().contains("matches multiple paths")); }