diff --git a/crates/aven-core/src/operations/projects.rs b/crates/aven-core/src/operations/projects.rs index 7fcdb967..00ba8012 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_EXPLICIT_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..82119ab3 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_EXPLICIT_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 06f6be7e..7969126c 100644 --- a/crates/aven-core/src/query/search/parser.rs +++ b/crates/aven-core/src/query/search/parser.rs @@ -1,3 +1,5 @@ +use crate::projects::MAX_EXPLICIT_PROJECT_PREFIX_LEN; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ParsedTaskSearchQuery { pub trimmed: String, @@ -165,7 +167,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 +189,14 @@ fn parse_ref_query(input: &str) -> Option { None } +/// 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_EXPLICIT_PROJECT_PREFIX_LEN + && group.chars().any(|ch| ch.is_ascii_digit())) +} + fn suffix_has_digit(input: &str) -> bool { input.chars().any(|ch| ch.is_ascii_digit()) } @@ -267,6 +277,28 @@ mod tests { assert_eq!(parse_task_search_query("release cleanup").ref_query, None); } + #[test] + 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"); + + 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_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"); + } + + #[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] 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); +}