fix(tickets): matching exact du #ref et curseur de pagination opaque/stable pour ticket_list (#20)
Épurement de la dette de ticket_list sur deux axes :
Recherche texte — matching exact du numéro/#ref via parse_issue_search_ref,
court-circuité avant load_issue, sans matching par substring numérique
(« 1 » ne remonte plus « 12 », « 123 »…).
Pagination — curseur opaque et stable anchor-based au lieu d'un offset fragile :
token v1.<base64url-no-pad-json> encodant le tri + l'ancre {number, sortKey},
reprise strictement après l'ancre. Curseur legacy / invalide / de version
inconnue / avec sort divergent rejeté par une erreur explicite « Invalid
cursor ». Le DTO cursor reste String → non-breaking côté UI.
Fichiers : infrastructure/src/issues.rs, app-tauri/src/tickets.rs,
app-tauri/Cargo.toml (+ base64 0.22). Tests : issue_store_text_filter,
ticket_list_ 7/7 (anchor-based + rejets).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -254,6 +254,9 @@ fn filter_matches(row: &IssueIndexEntry, filter: &IssueListFilter) -> bool {
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
if let Some(number) = parse_issue_search_ref(text) {
|
||||
return row.issue_ref.number() == number;
|
||||
}
|
||||
row.title
|
||||
.to_ascii_lowercase()
|
||||
.contains(&text.to_ascii_lowercase())
|
||||
@ -262,6 +265,17 @@ fn filter_matches(row: &IssueIndexEntry, filter: &IssueListFilter) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_issue_search_ref(needle: &str) -> Option<IssueNumber> {
|
||||
let trimmed = needle.trim();
|
||||
let raw = trimmed.strip_prefix('#').unwrap_or(trimmed);
|
||||
if raw.is_empty() || !raw.chars().all(|ch| ch.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
raw.parse::<u64>()
|
||||
.ok()
|
||||
.and_then(|number| IssueNumber::new(number).ok())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl IssueStore for FsIssueStore {
|
||||
async fn create(&self, root: &ProjectPath, issue: &Issue) -> Result<(), IssueStoreError> {
|
||||
@ -309,7 +323,9 @@ impl IssueStore for FsIssueStore {
|
||||
.is_some_and(|text| !text.trim().is_empty())
|
||||
{
|
||||
// Text search may need description/carnet, so fall back to source files.
|
||||
let needle = filter.text.as_ref().unwrap().trim().to_ascii_lowercase();
|
||||
let raw_needle = filter.text.as_ref().unwrap().trim();
|
||||
let ref_needle = parse_issue_search_ref(raw_needle);
|
||||
let needle = raw_needle.to_ascii_lowercase();
|
||||
let base_filter = IssueListFilter {
|
||||
text: None,
|
||||
..filter
|
||||
@ -319,6 +335,12 @@ impl IssueStore for FsIssueStore {
|
||||
if !filter_matches(&row, &base_filter) {
|
||||
continue;
|
||||
}
|
||||
if let Some(number) = ref_needle {
|
||||
if row.issue_ref.number() == number {
|
||||
out.push(row);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if row.title.to_ascii_lowercase().contains(&needle) {
|
||||
out.push(row);
|
||||
continue;
|
||||
|
||||
@ -184,6 +184,132 @@ async fn issue_store_lists_by_index_filters_with_empty_or_and_and_semantics() {
|
||||
assert_eq!(by_status_and_priority[0].title, "Beta");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn issue_store_text_filter_matches_exact_ref_or_number_without_numeric_substring() {
|
||||
let tmp = TempDir::new();
|
||||
let root = tmp.root();
|
||||
let store = FsIssueStore::new();
|
||||
store.create(&root, &issue(&root, 4, "Four")).await.unwrap();
|
||||
store
|
||||
.create(&root, &issue(&root, 40, "Forty"))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.create(&root, &issue(&root, 42, "Forty two"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let by_ref = store
|
||||
.list(
|
||||
&root,
|
||||
IssueListFilter {
|
||||
text: Some("#42".to_owned()),
|
||||
..IssueListFilter::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
by_ref.iter().map(|row| row.issue_ref).collect::<Vec<_>>(),
|
||||
vec![IssueRef::from_str("#42").unwrap()]
|
||||
);
|
||||
|
||||
let by_number = store
|
||||
.list(
|
||||
&root,
|
||||
IssueListFilter {
|
||||
text: Some("42".to_owned()),
|
||||
..IssueListFilter::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
by_number
|
||||
.iter()
|
||||
.map(|row| row.issue_ref)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![IssueRef::from_str("#42").unwrap()]
|
||||
);
|
||||
|
||||
let by_padded_number = store
|
||||
.list(
|
||||
&root,
|
||||
IssueListFilter {
|
||||
text: Some("0042".to_owned()),
|
||||
..IssueListFilter::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
by_padded_number
|
||||
.iter()
|
||||
.map(|row| row.issue_ref)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![IssueRef::from_str("#42").unwrap()]
|
||||
);
|
||||
|
||||
let by_single_digit = store
|
||||
.list(
|
||||
&root,
|
||||
IssueListFilter {
|
||||
text: Some("4".to_owned()),
|
||||
..IssueListFilter::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
by_single_digit
|
||||
.iter()
|
||||
.map(|row| row.issue_ref)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![IssueRef::from_str("#4").unwrap()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn issue_store_text_filter_keeps_classic_title_description_and_carnet_matching() {
|
||||
let tmp = TempDir::new();
|
||||
let root = tmp.root();
|
||||
let store = FsIssueStore::new();
|
||||
let by_title = issue(&root, 1, "Needle in title");
|
||||
let by_description = issue(&root, 2, "Plain title")
|
||||
.mutate(IssueActor::System, 2_000, |i| {
|
||||
i.description = MarkdownDoc::new("Needle in description");
|
||||
})
|
||||
.unwrap();
|
||||
let by_carnet = issue(&root, 3, "Other title")
|
||||
.mutate(IssueActor::System, 2_000, |i| {
|
||||
i.carnet = MarkdownDoc::new("Needle in carnet");
|
||||
})
|
||||
.unwrap();
|
||||
store.create(&root, &by_title).await.unwrap();
|
||||
store.create(&root, &by_description).await.unwrap();
|
||||
store.create(&root, &by_carnet).await.unwrap();
|
||||
|
||||
let rows = store
|
||||
.list(
|
||||
&root,
|
||||
IssueListFilter {
|
||||
text: Some("needle".to_owned()),
|
||||
..IssueListFilter::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
rows.iter().map(|row| row.issue_ref).collect::<Vec<_>>(),
|
||||
vec![
|
||||
IssueRef::from_str("#1").unwrap(),
|
||||
IssueRef::from_str("#2").unwrap(),
|
||||
IssueRef::from_str("#3").unwrap()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn issue_store_persists_and_filters_sprint_membership() {
|
||||
let tmp = TempDir::new();
|
||||
|
||||
Reference in New Issue
Block a user