Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion crates/aven-core/src/operations/projects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -806,7 +806,9 @@ async fn project_prefix_exists(

fn normalize_prefix(prefix: &str) -> Result<String> {
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:?}")
Expand Down
2 changes: 2 additions & 0 deletions crates/aven-core/src/projects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
34 changes: 33 additions & 1 deletion crates/aven-core/src/query/search/parser.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use crate::projects::MAX_EXPLICIT_PROJECT_PREFIX_LEN;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedTaskSearchQuery {
pub trimmed: String,
Expand Down Expand Up @@ -165,7 +167,7 @@ fn parse_ref_query(input: &str) -> Option<ParsedRefSearchQuery> {
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 {
Expand All @@ -187,6 +189,14 @@ fn parse_ref_query(input: &str) -> Option<ParsedRefSearchQuery> {
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())
}
Expand Down Expand Up @@ -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();
Expand Down
58 changes: 58 additions & 0 deletions crates/aven-core/src/query/search_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}