Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 130 additions & 11 deletions src-tauri/src/temporal_versions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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<String, BTreeSet<String>> {
let mut out: BTreeMap<String, BTreeSet<String>> = 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<String, BTreeSet<String>> {
let mut out: BTreeMap<String, BTreeSet<String>> = 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)?,
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -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<String> {
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")]);
Expand Down
Loading