From d857c48740cfdbef7994ed615865833f81a68c13 Mon Sep 17 00:00:00 2001 From: "seungju24.choi" Date: Tue, 18 Aug 2026 15:09:07 +0900 Subject: [PATCH 1/3] match display refs whose project prefix carries digits Search's ref lane only treated a leading token as a project prefix when it was entirely alphabetic, but prefix_base builds prefixes from the first character of each key word, so a key like 00-main yields 0M and 03-lge yields 0L2. Those display refs fell through to the suffix-only branch, where 0M-XYMT collapses to 0MXYMT and never matches the task id XYMT..., so searching them returned nothing. That surfaced in the TUI: accepting a search result re-runs the search against the result's display ref, so pressing enter on any task in a digit-prefixed project landed on the empty "no search results" view. Treat any leading group of at most four characters as a prefix candidate (unique_project_prefix mints at most a three-character base plus a collision counter), which keeps ordinary hyphenated prose out of the ref lane. Cover the parser shapes and the preview-then-accept round trip. --- crates/aven-core/src/query/search/parser.rs | 30 ++++++++++- crates/aven-core/src/query/search_tests.rs | 58 +++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/crates/aven-core/src/query/search/parser.rs b/crates/aven-core/src/query/search/parser.rs index 06f6be7e..887d854c 100644 --- a/crates/aven-core/src/query/search/parser.rs +++ b/crates/aven-core/src/query/search/parser.rs @@ -1,3 +1,7 @@ +/// Longest prefix `unique_project_prefix` can mint: a three-character base plus +/// a collision counter. +const MAX_PROJECT_PREFIX_LEN: usize = 4; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ParsedTaskSearchQuery { pub trimmed: String, @@ -165,7 +169,7 @@ fn parse_ref_query(input: &str) -> Option { if groups.is_empty() { return None; } - if groups.len() >= 2 && groups[0].chars().all(|c| c.is_ascii_alphabetic()) { + if groups.len() >= 2 && is_project_prefix_group(groups[0]) { let suffix = groups[1..].join(""); if suffix.len() >= 3 && (!has_whitespace || has_ref_marker || suffix_has_digit(&suffix)) { return Some(ParsedRefSearchQuery { @@ -187,6 +191,13 @@ fn parse_ref_query(input: &str) -> Option { None } +/// Project prefixes are derived from project keys, so they can carry digits +/// (`00-main` yields `0M`) as well as a disambiguating counter (`0L2`). Only the +/// length rules a leading group out as a prefix candidate. +fn is_project_prefix_group(group: &str) -> bool { + group.len() <= MAX_PROJECT_PREFIX_LEN +} + fn suffix_has_digit(input: &str) -> bool { input.chars().any(|ch| ch.is_ascii_digit()) } @@ -267,6 +278,23 @@ mod tests { assert_eq!(parse_task_search_query("release cleanup").ref_query, None); } + #[test] + fn task_search_parser_identifies_ref_shapes_for_numeric_project_prefixes() { + let numeric = parse_task_search_query("0M-XYMT").ref_query.unwrap(); + assert_eq!(numeric.normalized_prefix.as_deref(), Some("0M")); + assert_eq!(numeric.normalized_suffix, "XYMT"); + + let counted = parse_task_search_query("/0L2-7OKI").ref_query.unwrap(); + assert_eq!(counted.normalized_prefix.as_deref(), Some("012")); + assert_eq!(counted.normalized_suffix, "70K1"); + + let long_leading_group = parse_task_search_query("release-cleanup") + .ref_query + .unwrap(); + assert_eq!(long_leading_group.normalized_prefix, None); + assert_eq!(long_leading_group.normalized_suffix, "RE1EASEC1EANUP"); + } + #[test] fn task_search_parser_identifies_punctuation_insensitive_ref_shapes() { let qualified = parse_task_search_query("/APP.7OKI").ref_query.unwrap(); diff --git a/crates/aven-core/src/query/search_tests.rs b/crates/aven-core/src/query/search_tests.rs index 301a3582..38d479dd 100644 --- a/crates/aven-core/src/query/search_tests.rs +++ b/crates/aven-core/src/query/search_tests.rs @@ -1404,3 +1404,61 @@ async fn task_search_applies_project_scope_to_every_candidate_lane_before_limiti ); assert_eq!(attachments[0].matched_field, SearchMatchedField::Attachment); } + +#[tokio::test] +async fn task_search_resolves_display_refs_for_numeric_project_prefixes() { + let (_temp, mut conn) = test_conn().await; + sqlx::query( + "INSERT INTO projects(id, key, name, prefix, created_at, updated_at) + VALUES ('0000000000000002', '00-main', '00. Main', '0M', 't', 't')", + ) + .execute(conn.as_mut()) + .await + .unwrap(); + sqlx::query( + "INSERT INTO tasks(id, title, description, project_id, status, priority, + created_at, updated_at, queue_activity_at) + VALUES ('XYMTB8W8T3NRZDXY', 'FinceptTerminal', '', '0000000000000002', 'todo', 'none', 't', 't', 't')", + ) + .execute(conn.as_mut()) + .await + .unwrap(); + + let workspace_id = crate::workspaces::default_workspace_id(); + let preview = search_task_preview_set_in_workspace( + &mut conn, + &workspace_id, + TaskSearchQuery { + metadata: Vec::new(), + has_metadata: Vec::new(), + missing_metadata: Vec::new(), + text: "fin".to_string(), + project: None, + include_deleted: false, + limit: 10, + }, + ) + .await + .unwrap(); + assert_eq!(preview.items[0].display_ref, "0M-XYMT"); + + // Accepting a preview row re-runs the search against its display ref, so the + // ref lane has to recognize a prefix that carries digits. + let accepted = search_task_items_in_workspace( + &mut conn, + &workspace_id, + TaskSearchQuery { + metadata: Vec::new(), + has_metadata: Vec::new(), + missing_metadata: Vec::new(), + text: preview.items[0].display_ref.clone(), + project: None, + include_deleted: false, + limit: 10, + }, + ) + .await + .unwrap(); + assert_eq!(accepted[0].item.task.id.as_str(), "XYMTB8W8T3NRZDXY"); + assert_eq!(accepted[0].matched_field, SearchMatchedField::Ref); +} From 04ee9842c3460d48c5327818941328489d567335 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Tue, 18 Aug 2026 13:14:06 +0300 Subject: [PATCH 2/3] preserve long project prefix search The numeric-prefix parser used a four-character ceiling that excluded valid custom prefixes and changed how longer alphabetic prefixes were parsed. Those refs could not resolve after selecting their tasks from TUI search. Use the explicit project-prefix length limit for numeric groups and preserve alphabetic prefix parsing regardless of length. Share the limit with project validation and cover longer numeric and alphabetic refs in parser tests. --- crates/aven-core/src/operations/projects.rs | 4 ++- crates/aven-core/src/projects.rs | 2 ++ crates/aven-core/src/query/search/parser.rs | 28 +++++++++++++-------- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/crates/aven-core/src/operations/projects.rs b/crates/aven-core/src/operations/projects.rs index 7fcdb967..8fa368cd 100644 --- a/crates/aven-core/src/operations/projects.rs +++ b/crates/aven-core/src/operations/projects.rs @@ -806,7 +806,9 @@ async fn project_prefix_exists( fn normalize_prefix(prefix: &str) -> Result { let prefix = prefix.trim().to_ascii_uppercase(); - if (2..=8).contains(&prefix.len()) && prefix.chars().all(|ch| ch.is_ascii_alphanumeric()) { + if (2..=crate::projects::MAX_PROJECT_PREFIX_LEN).contains(&prefix.len()) + && prefix.chars().all(|ch| ch.is_ascii_alphanumeric()) + { Ok(prefix) } else { bail!("error invalid-project-prefix prefix={prefix:?}") diff --git a/crates/aven-core/src/projects.rs b/crates/aven-core/src/projects.rs index 90cbb117..52dae6e1 100644 --- a/crates/aven-core/src/projects.rs +++ b/crates/aven-core/src/projects.rs @@ -434,6 +434,8 @@ async fn restore_deleted_project( ))) } +pub(crate) const MAX_PROJECT_PREFIX_LEN: usize = 8; + async fn unique_project_prefix( conn: &mut SqliteConnection, workspace_id: &WorkspaceId, diff --git a/crates/aven-core/src/query/search/parser.rs b/crates/aven-core/src/query/search/parser.rs index 887d854c..536efdc8 100644 --- a/crates/aven-core/src/query/search/parser.rs +++ b/crates/aven-core/src/query/search/parser.rs @@ -1,6 +1,4 @@ -/// Longest prefix `unique_project_prefix` can mint: a three-character base plus -/// a collision counter. -const MAX_PROJECT_PREFIX_LEN: usize = 4; +use crate::projects::MAX_PROJECT_PREFIX_LEN; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ParsedTaskSearchQuery { @@ -191,11 +189,11 @@ fn parse_ref_query(input: &str) -> Option { None } -/// Project prefixes are derived from project keys, so they can carry digits -/// (`00-main` yields `0M`) as well as a disambiguating counter (`0L2`). Only the -/// length rules a leading group out as a prefix candidate. +/// Alphabetic prefixes are valid regardless of length. Prefixes containing +/// digits follow the length constraint used for explicit project prefixes. fn is_project_prefix_group(group: &str) -> bool { - group.len() <= MAX_PROJECT_PREFIX_LEN + group.chars().all(|ch| ch.is_ascii_alphabetic()) + || (group.len() <= MAX_PROJECT_PREFIX_LEN && group.chars().any(|ch| ch.is_ascii_digit())) } fn suffix_has_digit(input: &str) -> bool { @@ -279,7 +277,7 @@ mod tests { } #[test] - fn task_search_parser_identifies_ref_shapes_for_numeric_project_prefixes() { + fn task_search_parser_supports_numeric_and_long_alphabetic_project_prefixes() { let numeric = parse_task_search_query("0M-XYMT").ref_query.unwrap(); assert_eq!(numeric.normalized_prefix.as_deref(), Some("0M")); assert_eq!(numeric.normalized_suffix, "XYMT"); @@ -288,11 +286,19 @@ mod tests { assert_eq!(counted.normalized_prefix.as_deref(), Some("012")); assert_eq!(counted.normalized_suffix, "70K1"); - let long_leading_group = parse_task_search_query("release-cleanup") + let long_numeric = parse_task_search_query("2FAST-7OKI").ref_query.unwrap(); + assert_eq!(long_numeric.normalized_prefix.as_deref(), Some("2FAST")); + assert_eq!(long_numeric.normalized_suffix, "70K1"); + + let long_alphabetic = parse_task_search_query("BRAVO-7OKI").ref_query.unwrap(); + assert_eq!(long_alphabetic.normalized_prefix.as_deref(), Some("BRAV0")); + assert_eq!(long_alphabetic.normalized_suffix, "70K1"); + + let hyphenated = parse_task_search_query("release-cleanup") .ref_query .unwrap(); - assert_eq!(long_leading_group.normalized_prefix, None); - assert_eq!(long_leading_group.normalized_suffix, "RE1EASEC1EANUP"); + assert_eq!(hyphenated.normalized_prefix.as_deref(), Some("RE1EASE")); + assert_eq!(hyphenated.normalized_suffix, "C1EANUP"); } #[test] From 465682998569181eb15d9ddde74af7ee172f838a Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Wed, 19 Aug 2026 23:11:32 +0300 Subject: [PATCH 3/3] clarify project prefix parsing invariants The shared prefix limit applies to explicitly assigned prefixes, while the search parser intentionally preserves alphabetic prefix candidates of any length. The previous constant name and parser comment obscured that distinction. Name the explicit-prefix limit precisely and separate numeric and alphabetic parser coverage so failures identify the affected behavior directly. --- crates/aven-core/src/operations/projects.rs | 2 +- crates/aven-core/src/projects.rs | 2 +- crates/aven-core/src/query/search/parser.rs | 26 ++++++++++----------- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/crates/aven-core/src/operations/projects.rs b/crates/aven-core/src/operations/projects.rs index 8fa368cd..00ba8012 100644 --- a/crates/aven-core/src/operations/projects.rs +++ b/crates/aven-core/src/operations/projects.rs @@ -806,7 +806,7 @@ async fn project_prefix_exists( fn normalize_prefix(prefix: &str) -> Result { let prefix = prefix.trim().to_ascii_uppercase(); - if (2..=crate::projects::MAX_PROJECT_PREFIX_LEN).contains(&prefix.len()) + if (2..=crate::projects::MAX_EXPLICIT_PROJECT_PREFIX_LEN).contains(&prefix.len()) && prefix.chars().all(|ch| ch.is_ascii_alphanumeric()) { Ok(prefix) diff --git a/crates/aven-core/src/projects.rs b/crates/aven-core/src/projects.rs index 52dae6e1..82119ab3 100644 --- a/crates/aven-core/src/projects.rs +++ b/crates/aven-core/src/projects.rs @@ -434,7 +434,7 @@ async fn restore_deleted_project( ))) } -pub(crate) const MAX_PROJECT_PREFIX_LEN: usize = 8; +pub(crate) const MAX_EXPLICIT_PROJECT_PREFIX_LEN: usize = 8; async fn unique_project_prefix( conn: &mut SqliteConnection, diff --git a/crates/aven-core/src/query/search/parser.rs b/crates/aven-core/src/query/search/parser.rs index 536efdc8..7969126c 100644 --- a/crates/aven-core/src/query/search/parser.rs +++ b/crates/aven-core/src/query/search/parser.rs @@ -1,4 +1,4 @@ -use crate::projects::MAX_PROJECT_PREFIX_LEN; +use crate::projects::MAX_EXPLICIT_PROJECT_PREFIX_LEN; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ParsedTaskSearchQuery { @@ -189,11 +189,12 @@ fn parse_ref_query(input: &str) -> Option { None } -/// Alphabetic prefixes are valid regardless of length. Prefixes containing -/// digits follow the length constraint used for explicit project prefixes. +/// Alphabetic groups are prefix candidates regardless of length. Groups +/// containing digits follow the length constraint for explicit project prefixes. fn is_project_prefix_group(group: &str) -> bool { group.chars().all(|ch| ch.is_ascii_alphabetic()) - || (group.len() <= MAX_PROJECT_PREFIX_LEN && group.chars().any(|ch| ch.is_ascii_digit())) + || (group.len() <= MAX_EXPLICIT_PROJECT_PREFIX_LEN + && group.chars().any(|ch| ch.is_ascii_digit())) } fn suffix_has_digit(input: &str) -> bool { @@ -277,7 +278,7 @@ mod tests { } #[test] - fn task_search_parser_supports_numeric_and_long_alphabetic_project_prefixes() { + fn task_search_parser_supports_numeric_project_prefixes() { let numeric = parse_task_search_query("0M-XYMT").ref_query.unwrap(); assert_eq!(numeric.normalized_prefix.as_deref(), Some("0M")); assert_eq!(numeric.normalized_suffix, "XYMT"); @@ -289,16 +290,13 @@ mod tests { let long_numeric = parse_task_search_query("2FAST-7OKI").ref_query.unwrap(); assert_eq!(long_numeric.normalized_prefix.as_deref(), Some("2FAST")); assert_eq!(long_numeric.normalized_suffix, "70K1"); + } - let long_alphabetic = parse_task_search_query("BRAVO-7OKI").ref_query.unwrap(); - assert_eq!(long_alphabetic.normalized_prefix.as_deref(), Some("BRAV0")); - assert_eq!(long_alphabetic.normalized_suffix, "70K1"); - - let hyphenated = parse_task_search_query("release-cleanup") - .ref_query - .unwrap(); - assert_eq!(hyphenated.normalized_prefix.as_deref(), Some("RE1EASE")); - assert_eq!(hyphenated.normalized_suffix, "C1EANUP"); + #[test] + fn task_search_parser_supports_long_alphabetic_project_prefixes() { + let parsed = parse_task_search_query("BRAVO-7OKI").ref_query.unwrap(); + assert_eq!(parsed.normalized_prefix.as_deref(), Some("BRAV0")); + assert_eq!(parsed.normalized_suffix, "70K1"); } #[test]