Skip to content

Commit cbda45a

Browse files
committed
#441: Add '--fuzzy' and '--multiple' options for 'find' command
1 parent 875f2fe commit cbda45a

File tree

9 files changed

+268
-89
lines changed

9 files changed

+268
-89
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
which includes the serialized registry content in the output.
1616
This may be useful if you're consuming the `--api` output to back up with another tool,
1717
but don't have a good way to check the registry keys directly.
18+
* CLI: The `find` command now supports `--fuzzy` and `--multiple` options.
19+
This is also available for the `api` command's `findTitle` request.
1820
* Changed:
1921
* When the game list is filtered,
2022
the summary line (e.g., "1 of 10 games") now reflects the filtered totals.
@@ -32,6 +34,8 @@
3234
Previously, Ludusavi would make a backup folder for the game including the space,
3335
but the OS (namely Windows) would remove the space from the folder title,
3436
causing unpredictable behavior when Ludusavi couldn't find the expected folder name.
37+
* CLI: `find --normalized` now better prioritizes the closest match
38+
when multiple manifest entries have the same normalized title.
3539

3640
## v0.27.0 (2024-11-19)
3741

Cargo.lock

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ sha1 = "0.10.6"
4444
shlex = "1.3.0"
4545
signal-hook = "0.3.17"
4646
steamlocate = "2.0.0"
47+
strsim = "0.11.1"
4748
tokio = { version = "1.40.0", features = ["macros", "time"] }
4849
typed-path = "0.9.2"
4950
unic-langid = "0.9.5"

src/cli.rs

+4
Original file line numberDiff line numberDiff line change
@@ -597,13 +597,15 @@ pub fn run(sub: Subcommand, no_manifest_update: bool, try_manifest_update: bool)
597597
}
598598
Subcommand::Find {
599599
api,
600+
multiple,
600601
path,
601602
backup,
602603
restore,
603604
steam_id,
604605
gog_id,
605606
lutris_id,
606607
normalized,
608+
fuzzy,
607609
disabled,
608610
partial,
609611
names,
@@ -623,11 +625,13 @@ pub fn run(sub: Subcommand, no_manifest_update: bool, try_manifest_update: bool)
623625

624626
let title_finder = TitleFinder::new(&config, &manifest, layout.restorable_game_set());
625627
let found = title_finder.find(TitleQuery {
628+
multiple,
626629
names: names.clone(),
627630
steam_id,
628631
gog_id,
629632
lutris_id,
630633
normalized,
634+
fuzzy,
631635
backup,
632636
restore,
633637
disabled,

src/cli/api.rs

+24-8
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
use std::io::Read;
22

3+
use itertools::Itertools;
4+
35
use crate::{
46
lang::TRANSLATOR,
57
path::StrictPath,
68
prelude::Error,
79
resource::{config::Config, manifest::Manifest},
8-
scan::{layout::BackupLayout, TitleFinder, TitleQuery},
10+
scan::{compare_ranked_titles, layout::BackupLayout, TitleFinder, TitleQuery},
911
};
1012

1113
/// The full input to the `api` command.
@@ -63,14 +65,19 @@ pub mod request {
6365
///
6466
/// Precedence: Steam ID -> GOG ID -> Lutris ID -> exact names -> normalized names.
6567
/// Once a match is found for one of these options,
66-
/// Ludusavi will stop looking and return that match.
68+
/// Ludusavi will stop looking and return that match,
69+
/// unless you set `multiple: true`, in which case,
70+
/// the results will be sorted by how well they match.
6771
///
6872
/// Depending on the options chosen, there may be multiple matches, but the default is a single match.
6973
///
7074
/// Aliases will be resolved to the target title.
7175
#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
7276
#[serde(default, rename_all = "camelCase")]
7377
pub struct FindTitle {
78+
/// Keep looking for all potential matches,
79+
/// instead of stopping at the first match.
80+
pub multiple: bool,
7481
/// Ensure the game is recognized in a backup context.
7582
pub backup: bool,
7683
/// Ensure the game is recognized in a restore context.
@@ -85,6 +92,9 @@ pub mod request {
8592
/// Ignores capitalization, "edition" suffixes, year suffixes, and some special symbols.
8693
/// This may find multiple games for a single input.
8794
pub normalized: bool,
95+
/// Look up games with fuzzy matching.
96+
/// This may find multiple games for a single input.
97+
pub fuzzy: bool,
8898
/// Select games that are disabled.
8999
pub disabled: bool,
90100
/// Select games that have some saves disabled.
@@ -101,8 +111,6 @@ pub mod request {
101111
}
102112

103113
pub mod response {
104-
use std::collections::BTreeSet;
105-
106114
#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
107115
#[serde(default, rename_all = "camelCase")]
108116
pub struct Error {
@@ -114,7 +122,7 @@ pub mod response {
114122
#[serde(default, rename_all = "camelCase")]
115123
pub struct FindTitle {
116124
/// Any matching titles found.
117-
pub titles: BTreeSet<String>,
125+
pub titles: Vec<String>,
118126
}
119127

120128
#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
@@ -185,28 +193,38 @@ pub fn process(input: Option<String>, config: &Config, manifest: &Manifest) -> R
185193
for request in input.requests {
186194
match request {
187195
Request::FindTitle(request::FindTitle {
196+
multiple,
188197
backup,
189198
restore,
190199
steam_id,
191200
gog_id,
192201
lutris_id,
193202
normalized,
203+
fuzzy,
194204
disabled,
195205
partial,
196206
names,
197207
}) => {
198208
let titles = title_finder.find(TitleQuery {
209+
multiple,
199210
names,
200211
steam_id,
201212
gog_id,
202213
lutris_id,
203214
normalized,
215+
fuzzy,
204216
backup,
205217
restore,
206218
disabled,
207219
partial,
208220
});
209221

222+
let titles: Vec<_> = titles
223+
.into_iter()
224+
.sorted_by(compare_ranked_titles)
225+
.map(|(name, _info)| name)
226+
.collect();
227+
210228
responses.push(Response::FindTitle(response::FindTitle { titles }));
211229
}
212230
Request::CheckAppUpdate(request::CheckAppUpdate {}) => match crate::metadata::Release::fetch_sync() {
@@ -230,8 +248,6 @@ pub fn process(input: Option<String>, config: &Config, manifest: &Manifest) -> R
230248

231249
#[cfg(test)]
232250
mod tests {
233-
use std::collections::BTreeSet;
234-
235251
use super::*;
236252
use pretty_assertions::assert_eq;
237253

@@ -270,7 +286,7 @@ mod tests {
270286
pub fn serialize_output() {
271287
let output = Output::Success {
272288
responses: vec![Response::FindTitle(response::FindTitle {
273-
titles: BTreeSet::from(["foo".to_string()]),
289+
titles: vec!["foo".to_string()],
274290
})],
275291
};
276292
let serialized = serde_json::to_string_pretty(&output).unwrap();

src/cli/parse.rs

+19-1
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,9 @@ pub enum Subcommand {
301301
///
302302
/// Precedence: Steam ID -> GOG ID -> Lutris ID -> exact names -> normalized names.
303303
/// Once a match is found for one of these options,
304-
/// Ludusavi will stop looking and return that match.
304+
/// Ludusavi will stop looking and return that match,
305+
/// unless you set `--multiple`, in which case,
306+
/// the results will be sorted by how well they match.
305307
///
306308
/// If there are no matches, Ludusavi will exit with an error.
307309
/// Depending on the options chosen, there may be multiple matches, but the default is a single match.
@@ -315,6 +317,11 @@ pub enum Subcommand {
315317
#[clap(long)]
316318
api: bool,
317319

320+
/// Keep looking for all potential matches,
321+
/// instead of stopping at the first match.
322+
#[clap(long)]
323+
multiple: bool,
324+
318325
/// Directory in which to find backups.
319326
/// When unset, this defaults to the restore path from the config file.
320327
#[clap(long, value_parser = parse_strict_path)]
@@ -346,6 +353,11 @@ pub enum Subcommand {
346353
#[clap(long)]
347354
normalized: bool,
348355

356+
/// Look up games with fuzzy matching.
357+
/// This may find multiple games for a single input.
358+
#[clap(long)]
359+
fuzzy: bool,
360+
349361
/// Select games that are disabled.
350362
#[clap(long)]
351363
disabled: bool,
@@ -1153,13 +1165,15 @@ mod tests {
11531165
try_manifest_update: false,
11541166
sub: Some(Subcommand::Find {
11551167
api: false,
1168+
multiple: false,
11561169
path: None,
11571170
backup: false,
11581171
restore: false,
11591172
steam_id: None,
11601173
gog_id: None,
11611174
lutris_id: None,
11621175
normalized: false,
1176+
fuzzy: false,
11631177
disabled: false,
11641178
partial: false,
11651179
names: vec![],
@@ -1175,6 +1189,7 @@ mod tests {
11751189
"ludusavi",
11761190
"find",
11771191
"--api",
1192+
"--multiple",
11781193
"--path",
11791194
"tests/backup",
11801195
"--backup",
@@ -1186,6 +1201,7 @@ mod tests {
11861201
"--lutris-id",
11871202
"slug",
11881203
"--normalized",
1204+
"--fuzzy",
11891205
"--disabled",
11901206
"--partial",
11911207
"game1",
@@ -1197,13 +1213,15 @@ mod tests {
11971213
try_manifest_update: false,
11981214
sub: Some(Subcommand::Find {
11991215
api: true,
1216+
multiple: true,
12001217
path: Some(StrictPath::relative(s("tests/backup"), Some(repo_raw()))),
12011218
backup: true,
12021219
restore: true,
12031220
steam_id: Some(101),
12041221
gog_id: Some(102),
12051222
lutris_id: Some("slug".to_string()),
12061223
normalized: true,
1224+
fuzzy: true,
12071225
disabled: true,
12081226
partial: true,
12091227
names: vec![s("game1"), s("game2")],

src/cli/report.rs

+15-7
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ use crate::{
88
prelude::StrictPath,
99
resource::manifest::Os,
1010
scan::{
11-
layout::Backup, registry, BackupError, BackupInfo, DuplicateDetector, OperationStatus, OperationStepDecision,
12-
ScanChange, ScanInfo,
11+
compare_ranked_titles_ref, layout::Backup, registry, BackupError, BackupInfo, DuplicateDetector,
12+
OperationStatus, OperationStepDecision, ScanChange, ScanInfo, TitleMatch,
1313
},
1414
};
1515

@@ -177,7 +177,11 @@ enum ApiGame {
177177
backups: Vec<ApiBackup>,
178178
},
179179
/// Used by the `find` command.
180-
Found {},
180+
Found {
181+
/// How well the title matches the query.
182+
/// Range: 0.0 to 1.0 (higher is better).
183+
score: Option<f64>,
184+
},
181185
}
182186

183187
#[derive(Debug, serde::Serialize, schemars::JsonSchema)]
@@ -599,16 +603,20 @@ impl Reporter {
599603
}
600604
}
601605

602-
pub fn add_found_titles(&mut self, names: &BTreeSet<String>) {
606+
pub fn add_found_titles(&mut self, games: &BTreeMap<String, TitleMatch>) {
603607
match self {
604608
Self::Standard { parts, .. } => {
605-
for name in names {
609+
let games: Vec<_> = games.iter().sorted_by(compare_ranked_titles_ref).collect();
610+
611+
for (name, _info) in games {
606612
parts.push(name.to_owned());
607613
}
608614
}
609615
Self::Json { output } => {
610-
for name in names {
611-
output.games.insert(name.to_owned(), ApiGame::Found {});
616+
for (name, info) in games {
617+
output
618+
.games
619+
.insert(name.to_owned(), ApiGame::Found { score: info.score });
612620
}
613621
}
614622
}

src/scan.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ pub use self::{
2626
preview::ScanInfo,
2727
saves::{ScannedFile, ScannedRegistry, ScannedRegistryValue, ScannedRegistryValues},
2828
steam::{SteamShortcut, SteamShortcuts},
29-
title::{TitleFinder, TitleQuery},
29+
title::{compare_ranked_titles, compare_ranked_titles_ref, TitleFinder, TitleMatch, TitleQuery},
3030
};
3131

3232
use crate::{

0 commit comments

Comments
 (0)