diff --git a/src-tauri/src/temporal_versions.rs b/src-tauri/src/temporal_versions.rs index 4b8b79578..8911897bb 100644 --- a/src-tauri/src/temporal_versions.rs +++ b/src-tauri/src/temporal_versions.rs @@ -31,23 +31,93 @@ fn within(path: &str, root: &str) -> bool { path == root || path.starts_with(&format!("{root}/")) } +/// The name a package is matched by within an ecosystem family, as the +/// matcher folds it (`fourda_depengine::matching::package_key`): PyPI per +/// PEP 503 (lowercase, every run of `-`, `_`, `.` one `-`), so "Django" is +/// "django" and "Foo_Bar" is "foo-bar"; Cargo treats `-` and `_` as the same +/// crate; npm names are exact (scopes included); others compare lowercase. +fn folded_name(name: &str, family: &str) -> String { + match family { + "python" => fourda_depengine::matching::pep503_name(name), + "rust" => name.trim().to_lowercase().replace('_', "-"), + "javascript" => name.trim().to_string(), + _ => name.trim().to_lowercase(), + } +} + +/// The spellings of `package` the name index is asked for: as given, its +/// lowercase and folded forms, and (where separators fold) the folded form +/// with `_` / `.` in place of `-`. +fn candidate_names(package: &str, family: &str) -> Vec { + let folded = folded_name(package, family); + let mut out = vec![package.trim().to_string(), package.trim().to_lowercase()]; + match family { + "python" => { + out.push(folded.replace('-', "_")); + out.push(folded.replace('-', ".")); + } + "rust" => out.push(folded.replace('-', "_")), + _ => {} + } + out.push(folded); + out.sort(); + out.dedup(); + out +} + /// Pinned versions of `package` per project path (normalized), same -/// ecosystem family. Exact and lowercase name forms hit the -/// `idx_user_deps_package` index; the case-insensitive match is applied here. +/// ecosystem family, matched by [`folded_name`]. The spellings in +/// [`candidate_names`] hit the `idx_user_deps_package` index; only when +/// none is stored and the family folds separators (a "Foo_Bar" pin for an +/// advisory on "foo-bar") does a separator-insensitive scan run. fn pins_by_project( conn: &rusqlite::Connection, package: &str, family: &str, ) -> BTreeMap> { - let mut out: BTreeMap> = BTreeMap::new(); - let Ok(mut stmt) = conn.prepare_cached( + let want = folded_name(package, family); + let candidates = candidate_names(package, family); + let placeholders = vec!["?"; candidates.len()].join(", "); + let indexed = format!( "SELECT project_path, ecosystem, version, package_name FROM user_dependencies - WHERE package_name IN (?1, ?2) - AND version IS NOT NULL AND version <> ''", - ) else { + WHERE package_name IN ({placeholders}) + AND version IS NOT NULL AND version <> ''" + ); + let mut out = collect_pins(conn, &indexed, &candidates, family, &want); + if out.is_empty() && matches!(family, "python" | "rust") { + // LIKE is ASCII case-insensitive, and `_` matches any one character, + // so every separator spelling and case is a candidate; the fold + // below keeps only the true ones. + let pattern: String = want + .chars() + .map(|c| match c { + '-' | '_' | '.' => '_', + '%' => '_', + c => c, + }) + .collect(); + let scan = "SELECT project_path, ecosystem, version, package_name FROM user_dependencies + WHERE package_name LIKE ?1 + AND version IS NOT NULL AND version <> ''"; + out = collect_pins(conn, scan, &[pattern], family, &want); + } + out +} + +/// The rows `sql` returns for `args`, kept when the name folds to `want` in +/// `family`. +fn collect_pins( + conn: &rusqlite::Connection, + sql: &str, + args: &[String], + family: &str, + want: &str, +) -> BTreeMap> { + let mut out: BTreeMap> = BTreeMap::new(); + let Ok(mut stmt) = conn.prepare_cached(sql) else { return out; }; - let Ok(rows) = stmt.query_map(params![package, package.to_lowercase()], |r| { + let Ok(rows) = stmt.query_map(rusqlite::params_from_iter(args.iter()), |r| { Ok(( r.get::<_, String>(0)?, r.get::<_, String>(1)?, @@ -57,9 +127,8 @@ fn pins_by_project( }) else { return out; }; - let rows: Vec<(String, String, String, String)> = rows.flatten().collect(); - for (path, eco, version, name) in rows { - if name.eq_ignore_ascii_case(package) && ecosystem_family(&eco) == family { + for (path, eco, version, name) in rows.flatten() { + if ecosystem_family(&eco) == family && folded_name(&name, family) == want { out.entry(norm(&path)).or_default().insert(version); } } @@ -180,6 +249,56 @@ mod tests { assert!(versions_for_project(&two, "d:/app", &[], || false).is_empty()); } + /// The pins of `stored` (name, ecosystem as stored) in one project, read + /// for `asked` the way the matcher names it. + fn read(stored: &[(&str, &str)], asked: &str, ecosystem: &str) -> Vec { + let db = crate::test_utils::test_db(); + for (i, (name, eco)) in stored.iter().enumerate() { + db.store_dependency( + "d:/work/app", + name, + Some(&format!("1.0.{i}")), + eco, + false, + None, + ) + .expect("pin"); + } + let conn = db.conn.lock(); + installed_versions_nearby(&conn, asked, ecosystem, &["d:/work/app".to_string()]) + } + + #[test] + fn a_pypi_pin_is_found_by_its_pep503_name() { + assert_eq!(read(&[("Django", "python")], "django", "PyPI"), ["1.0.0"]); + assert_eq!(read(&[("Foo_Bar", "python")], "foo-bar", "PyPI"), ["1.0.0"]); + assert_eq!(read(&[("foo.bar", "python")], "Foo_Bar", "PyPI"), ["1.0.0"]); + assert!(read(&[("foobar", "python")], "foo-bar", "PyPI").is_empty()); + } + + #[test] + fn a_cargo_pin_is_found_across_dash_and_underscore() { + assert_eq!( + read(&[("serde_json", "rust")], "serde-json", "crates.io"), + ["1.0.0"] + ); + assert_eq!( + read(&[("serde-json", "rust")], "serde_json", "crates.io"), + ["1.0.0"] + ); + } + + #[test] + fn npm_names_stay_exact_and_families_never_mix() { + assert_eq!( + read(&[("@scope/pkg", "javascript")], "@scope/pkg", "npm"), + ["1.0.0"] + ); + assert!(read(&[("JSONStream", "javascript")], "jsonstream", "npm").is_empty()); + assert!(read(&[("foo_bar", "javascript")], "foo-bar", "npm").is_empty()); + assert!(read(&[("django", "javascript")], "django", "PyPI").is_empty()); + } + #[test] fn several_copies_in_scope_are_all_returned() { let p = pins(&[("d:/app", "1.0.0"), ("d:/app", "2.0.0")]);