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:
2026-07-14 19:20:40 +02:00
parent 83e4df7626
commit bd5d8d864a
5 changed files with 401 additions and 35 deletions

1
Cargo.lock generated
View File

@ -73,6 +73,7 @@ version = "0.3.0"
dependencies = [ dependencies = [
"application", "application",
"async-trait", "async-trait",
"base64 0.22.1",
"domain", "domain",
"infrastructure", "infrastructure",
"interprocess", "interprocess",

View File

@ -32,6 +32,7 @@ serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
base64 = "0.22"
# `AppAgentResumer` implements the application's async `AgentResumer` port (LS7). # `AppAgentResumer` implements the application's async `AgentResumer` port (LS7).
async-trait = { workspace = true } async-trait = { workspace = true }
# Cross-OS local IPC for the MCP loopback transport (M5a): Unix domain socket # Cross-OS local IPC for the MCP loopback transport (M5a): Unix domain socket

View File

@ -4,10 +4,12 @@
#![allow(missing_docs)] #![allow(missing_docs)]
use std::cmp::Ordering;
use std::str::FromStr; use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use tauri::State; use tauri::State;
@ -208,7 +210,7 @@ pub struct TicketListRequestDto {
} }
/// List sort request. /// List sort request.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct TicketListSortDto { pub struct TicketListSortDto {
pub field: TicketListSortFieldDto, pub field: TicketListSortFieldDto,
@ -216,7 +218,7 @@ pub struct TicketListSortDto {
} }
/// List sort field. /// List sort field.
#[derive(Debug, Clone, Copy, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum TicketListSortFieldDto { pub enum TicketListSortFieldDto {
Number, Number,
@ -226,7 +228,7 @@ pub enum TicketListSortFieldDto {
} }
/// List sort direction. /// List sort direction.
#[derive(Debug, Clone, Copy, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum TicketListSortDirectionDto { pub enum TicketListSortDirectionDto {
Asc, Asc,
@ -455,7 +457,9 @@ impl TicketToolProvider for AppTicketToolProvider {
.await .await
.map_err(ticket_error)? .map_err(ticket_error)?
.sprints; .sprints;
json!(paginate_with_sprints(rows, req.limit, req.cursor, &sprints)) let page = paginate_with_sprints(rows, req.limit, req.cursor, req.sort, &sprints)
.map_err(|e| TicketToolError::new("invalid", e.message))?;
json!(page)
} }
"idea_sprint_list" => { "idea_sprint_list" => {
let rows = self let rows = self
@ -722,7 +726,7 @@ pub async fn ticket_list(
.map_err(ErrorDto::from)? .map_err(ErrorDto::from)?
.issues; .issues;
sort_ticket_rows(&mut rows, page.sort); sort_ticket_rows(&mut rows, page.sort);
Ok(paginate(rows, page.limit, page.cursor)) paginate(rows, page.limit, page.cursor, page.sort)
} }
#[tauri::command] #[tauri::command]
@ -1219,6 +1223,30 @@ fn status_rank(status: IssueStatus) -> u8 {
} }
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TicketCursorToken {
v: u8,
sort: Option<TicketListSortDto>,
anchor: TicketCursorAnchor,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TicketCursorAnchor {
number: u64,
sort_key: TicketCursorSortKey,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
enum TicketCursorSortKey {
Number(u64),
Priority(u8),
Status(u8),
Title { lower: String, raw: String },
}
async fn resolve_project(state: &AppState, project_id: &str) -> Result<Project, ErrorDto> { async fn resolve_project(state: &AppState, project_id: &str) -> Result<Project, ErrorDto> {
let id = ProjectId::from_uuid( let id = ProjectId::from_uuid(
Uuid::parse_str(project_id) Uuid::parse_str(project_id)
@ -1305,44 +1333,149 @@ fn parse_link_request(link: TicketLinkRequestDto) -> Result<IssueLink, ErrorDto>
}) })
} }
fn paginate(rows: Vec<IssueIndexEntry>, limit: usize, cursor: Option<String>) -> TicketListDto { fn paginate(
let start = cursor rows: Vec<IssueIndexEntry>,
.as_deref() limit: usize,
.and_then(|raw| raw.parse::<usize>().ok()) cursor: Option<String>,
.unwrap_or(0); sort: Option<TicketListSortDto>,
let total = rows.len(); ) -> Result<TicketListDto, ErrorDto> {
let end = total.min(start.saturating_add(limit)); paginate_rows(rows, limit, cursor, sort, TicketSummaryDto::from)
TicketListDto {
items: rows
.into_iter()
.skip(start)
.take(limit)
.map(TicketSummaryDto::from)
.collect(),
next_cursor: (end < total).then(|| end.to_string()),
}
} }
fn paginate_with_sprints( fn paginate_with_sprints(
rows: Vec<IssueIndexEntry>, rows: Vec<IssueIndexEntry>,
limit: usize, limit: usize,
cursor: Option<String>, cursor: Option<String>,
sort: Option<TicketListSortDto>,
sprints: &[application::SprintListEntry], sprints: &[application::SprintListEntry],
) -> TicketListDto { ) -> Result<TicketListDto, ErrorDto> {
let start = cursor paginate_rows(rows, limit, cursor, sort, |row| {
.as_deref() TicketSummaryDto::from_row_with_sprints(row, sprints)
.and_then(|raw| raw.parse::<usize>().ok()) })
.unwrap_or(0); }
fn paginate_rows(
rows: Vec<IssueIndexEntry>,
limit: usize,
cursor: Option<String>,
sort: Option<TicketListSortDto>,
map_row: impl Fn(IssueIndexEntry) -> TicketSummaryDto,
) -> Result<TicketListDto, ErrorDto> {
let start = cursor_start(&rows, cursor.as_deref(), sort)?;
let total = rows.len(); let total = rows.len();
let end = total.min(start.saturating_add(limit)); let end = total.min(start.saturating_add(limit));
TicketListDto { let next_cursor = if end < total && end > start {
Some(encode_ticket_cursor(&rows[end - 1], sort)?)
} else {
None
};
Ok(TicketListDto {
items: rows items: rows
.into_iter() .into_iter()
.skip(start) .skip(start)
.take(limit) .take(limit)
.map(|row| TicketSummaryDto::from_row_with_sprints(row, sprints)) .map(map_row)
.collect(), .collect(),
next_cursor: (end < total).then(|| end.to_string()), next_cursor,
})
}
fn cursor_start(
rows: &[IssueIndexEntry],
cursor: Option<&str>,
sort: Option<TicketListSortDto>,
) -> Result<usize, ErrorDto> {
let Some(raw) = cursor else {
return Ok(0);
};
let token = decode_ticket_cursor(raw)?;
if token.sort != sort {
return Err(ErrorDto::invalid("Invalid cursor: sort mismatch"));
}
Ok(rows
.iter()
.position(|row| compare_row_to_anchor(row, &token.anchor, sort) == Ordering::Greater)
.unwrap_or(rows.len()))
}
fn encode_ticket_cursor(
row: &IssueIndexEntry,
sort: Option<TicketListSortDto>,
) -> Result<String, ErrorDto> {
let token = TicketCursorToken {
v: 1,
sort,
anchor: row_cursor_anchor(row, sort),
};
let json = serde_json::to_vec(&token)
.map_err(|err| ErrorDto::invalid(format!("Invalid cursor: {err}")))?;
Ok(format!("v1.{}", URL_SAFE_NO_PAD.encode(json)))
}
fn decode_ticket_cursor(raw: &str) -> Result<TicketCursorToken, ErrorDto> {
let encoded = raw
.strip_prefix("v1.")
.ok_or_else(|| ErrorDto::invalid("Invalid cursor: unknown version"))?;
let bytes = URL_SAFE_NO_PAD
.decode(encoded)
.map_err(|err| ErrorDto::invalid(format!("Invalid cursor: {err}")))?;
let token: TicketCursorToken = serde_json::from_slice(&bytes)
.map_err(|err| ErrorDto::invalid(format!("Invalid cursor: {err}")))?;
if token.v != 1 {
return Err(ErrorDto::invalid("Invalid cursor: unknown version"));
}
Ok(token)
}
fn row_cursor_anchor(row: &IssueIndexEntry, sort: Option<TicketListSortDto>) -> TicketCursorAnchor {
TicketCursorAnchor {
number: row.issue_ref.number().get(),
sort_key: row_sort_key(row, sort),
}
}
fn row_sort_key(row: &IssueIndexEntry, sort: Option<TicketListSortDto>) -> TicketCursorSortKey {
match sort.map(|sort| sort.field) {
None | Some(TicketListSortFieldDto::Number) => {
TicketCursorSortKey::Number(row.issue_ref.number().get())
}
Some(TicketListSortFieldDto::Priority) => {
TicketCursorSortKey::Priority(priority_rank(row.priority))
}
Some(TicketListSortFieldDto::Status) => {
TicketCursorSortKey::Status(status_rank(row.status))
}
Some(TicketListSortFieldDto::Title) => TicketCursorSortKey::Title {
lower: row.title.to_lowercase(),
raw: row.title.clone(),
},
}
}
fn compare_row_to_anchor(
row: &IssueIndexEntry,
anchor: &TicketCursorAnchor,
sort: Option<TicketListSortDto>,
) -> Ordering {
let row_key = row_sort_key(row, sort);
let field_order = compare_sort_key(&row_key, &anchor.sort_key);
let directed = match sort.map(|sort| sort.direction) {
Some(TicketListSortDirectionDto::Desc) => field_order.reverse(),
None | Some(TicketListSortDirectionDto::Asc) => field_order,
};
directed.then_with(|| row.issue_ref.number().get().cmp(&anchor.number))
}
fn compare_sort_key(a: &TicketCursorSortKey, b: &TicketCursorSortKey) -> Ordering {
match (a, b) {
(TicketCursorSortKey::Number(a), TicketCursorSortKey::Number(b)) => a.cmp(b),
(TicketCursorSortKey::Priority(a), TicketCursorSortKey::Priority(b))
| (TicketCursorSortKey::Status(a), TicketCursorSortKey::Status(b)) => a.cmp(b),
(
TicketCursorSortKey::Title { lower: al, raw: ar },
TicketCursorSortKey::Title { lower: bl, raw: br },
) => al.cmp(bl).then_with(|| ar.cmp(br)),
_ => Ordering::Equal,
} }
} }
@ -1679,7 +1812,7 @@ mod tests {
text: None, text: None,
sort: None, sort: None,
limit: Some(1), limit: Some(1),
cursor: Some("1".into()), cursor: None,
}) })
.unwrap(); .unwrap();
let rows = vec![ let rows = vec![
@ -1693,10 +1826,13 @@ mod tests {
vec![IssueStatus::Open, IssueStatus::Qa] vec![IssueStatus::Open, IssueStatus::Qa]
); );
assert_eq!(page.filter.priorities, vec![IssuePriority::High]); assert_eq!(page.filter.priorities, vec![IssuePriority::High]);
let out = paginate(rows, page.limit, page.cursor); let out = paginate(rows, page.limit, page.cursor, page.sort).unwrap();
assert_eq!(out.items.len(), 1); assert_eq!(out.items.len(), 1);
assert_eq!(out.items[0].r#ref, "#2"); assert_eq!(out.items[0].r#ref, "#1");
assert_eq!(out.next_cursor, Some("2".to_owned())); assert!(out
.next_cursor
.as_deref()
.is_some_and(|cursor| cursor.starts_with("v1.")));
} }
#[test] #[test]
@ -1724,7 +1860,7 @@ mod tests {
]; ];
sort_ticket_rows(&mut rows, page.sort); sort_ticket_rows(&mut rows, page.sort);
let out = paginate(rows, page.limit, page.cursor); let out = paginate(rows, page.limit, page.cursor, page.sort).unwrap();
assert_eq!( assert_eq!(
out.items out.items
@ -1735,6 +1871,82 @@ mod tests {
); );
} }
#[test]
fn ticket_list_cursor_is_anchor_based_when_items_are_inserted_or_removed_before_anchor() {
let rows = vec![
issue_row(10, IssueStatus::Open, IssuePriority::High, "Alpha"),
issue_row(20, IssueStatus::Open, IssuePriority::High, "Beta"),
issue_row(30, IssueStatus::Open, IssuePriority::High, "Gamma"),
issue_row(40, IssueStatus::Open, IssuePriority::High, "Delta"),
];
let first = paginate(rows, 2, None, None).unwrap();
let cursor = first.next_cursor.clone().expect("next cursor");
assert_eq!(refs(&first), vec!["#10", "#20"]);
let with_insert_before_anchor = vec![
issue_row(10, IssueStatus::Open, IssuePriority::High, "Alpha"),
issue_row(15, IssueStatus::Open, IssuePriority::High, "Inserted"),
issue_row(20, IssueStatus::Open, IssuePriority::High, "Beta"),
issue_row(30, IssueStatus::Open, IssuePriority::High, "Gamma"),
issue_row(40, IssueStatus::Open, IssuePriority::High, "Delta"),
];
let second = paginate(with_insert_before_anchor, 2, Some(cursor.clone()), None).unwrap();
assert_eq!(refs(&second), vec!["#30", "#40"]);
let with_removed_before_anchor = vec![
issue_row(20, IssueStatus::Open, IssuePriority::High, "Beta"),
issue_row(30, IssueStatus::Open, IssuePriority::High, "Gamma"),
issue_row(40, IssueStatus::Open, IssuePriority::High, "Delta"),
];
let second = paginate(with_removed_before_anchor, 2, Some(cursor.clone()), None).unwrap();
assert_eq!(refs(&second), vec!["#30", "#40"]);
let with_removed_anchor = vec![
issue_row(10, IssueStatus::Open, IssuePriority::High, "Alpha"),
issue_row(30, IssueStatus::Open, IssuePriority::High, "Gamma"),
issue_row(40, IssueStatus::Open, IssuePriority::High, "Delta"),
];
let second = paginate(with_removed_anchor, 2, Some(cursor), None).unwrap();
assert_eq!(refs(&second), vec!["#30", "#40"]);
}
#[test]
fn ticket_list_cursor_rejects_legacy_or_invalid_tokens() {
let rows = vec![issue_row(
1,
IssueStatus::Open,
IssuePriority::High,
"Alpha",
)];
for cursor in ["2", "v1.not-base64", "v2.abc"] {
let err = paginate(rows.clone(), 1, Some(cursor.to_owned()), None).unwrap_err();
assert_eq!(err.code, "INVALID");
assert!(err.message.contains("Invalid cursor"));
}
}
#[test]
fn ticket_list_cursor_rejects_sort_mismatch() {
let mut rows = vec![
issue_row(1, IssueStatus::Open, IssuePriority::Low, "Alpha"),
issue_row(2, IssueStatus::Open, IssuePriority::Critical, "Beta"),
issue_row(3, IssueStatus::Open, IssuePriority::High, "Gamma"),
];
let priority_sort = Some(TicketListSortDto {
field: TicketListSortFieldDto::Priority,
direction: TicketListSortDirectionDto::Desc,
});
sort_ticket_rows(&mut rows, priority_sort);
let first = paginate(rows.clone(), 1, None, priority_sort).unwrap();
let cursor = first.next_cursor.expect("next cursor");
let err = paginate(rows, 1, Some(cursor), None).unwrap_err();
assert_eq!(err.code, "INVALID");
assert!(err.message.contains("sort mismatch"));
}
fn issue_row( fn issue_row(
number: u64, number: u64,
status: IssueStatus, status: IssueStatus,
@ -1752,4 +1964,8 @@ mod tests {
updated_at: number, updated_at: number,
} }
} }
fn refs(out: &TicketListDto) -> Vec<String> {
out.items.iter().map(|item| item.r#ref.clone()).collect()
}
} }

View File

@ -254,6 +254,9 @@ fn filter_matches(row: &IssueIndexEntry, filter: &IssueListFilter) -> bool {
.map(|s| s.trim()) .map(|s| s.trim())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
{ {
if let Some(number) = parse_issue_search_ref(text) {
return row.issue_ref.number() == number;
}
row.title row.title
.to_ascii_lowercase() .to_ascii_lowercase()
.contains(&text.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] #[async_trait]
impl IssueStore for FsIssueStore { impl IssueStore for FsIssueStore {
async fn create(&self, root: &ProjectPath, issue: &Issue) -> Result<(), IssueStoreError> { 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()) .is_some_and(|text| !text.trim().is_empty())
{ {
// Text search may need description/carnet, so fall back to source files. // 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 { let base_filter = IssueListFilter {
text: None, text: None,
..filter ..filter
@ -319,6 +335,12 @@ impl IssueStore for FsIssueStore {
if !filter_matches(&row, &base_filter) { if !filter_matches(&row, &base_filter) {
continue; 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) { if row.title.to_ascii_lowercase().contains(&needle) {
out.push(row); out.push(row);
continue; continue;

View File

@ -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"); 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] #[tokio::test]
async fn issue_store_persists_and_filters_sprint_membership() { async fn issue_store_persists_and_filters_sprint_membership() {
let tmp = TempDir::new(); let tmp = TempDir::new();