refactor(core): extract path_resolver module, fix old_path matching in who
Extract shared path resolution logic from who.rs into a new core::path_resolver module for cross-module reuse. Functions moved: escape_like, normalize_repo_path, PathQuery, SuffixResult, build_path_query, suffix_probe. Duplicate escape_like copies removed from list.rs, project.rs, and filters.rs — all now import from path_resolver. Additionally fixes two bugs in query_expert_details() and query_overlap() where only position_new_path was checked (missing old_path matches for renamed files) and state filter excluded 'closed' MRs despite the main scoring query including them with a decay multiplier. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
290
src/core/path_resolver_tests.rs
Normal file
290
src/core/path_resolver_tests.rs
Normal file
@@ -0,0 +1,290 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user