1use crate::ids::{TaskId, WorkspaceId};
2use std::collections::HashMap;
3
4use anyhow::{Result, bail};
5use sqlx::SqliteConnection;
6
7use crate::db::Database;
8use crate::types::Task;
9use crate::workspaces::Workspace;
10
11const DISPLAY_SUFFIX_FLOOR: usize = 4;
12
13fn quote(input: &str) -> String {
14 serde_json::to_string(input).unwrap_or_else(|_| "\"\"".to_string())
15}
16
17impl Database {
18 pub async fn display_ref_context(
19 &self,
20 workspace_id: &WorkspaceId,
21 ) -> Result<DisplayRefContext> {
22 let mut conn = self.acquire().await?;
23 DisplayRefContext::for_workspace(&mut conn, workspace_id).await
24 }
25
26 pub async fn resolve_task_ref(&self, workspace: &Workspace, input: &str) -> Result<Task> {
27 let mut conn = self.acquire().await?;
28 resolve_task_ref_in_workspace(&mut conn, workspace, input).await
29 }
30}
31
32pub struct DisplayRefContext {
33 task_ids_by_workspace: HashMap<WorkspaceId, Vec<TaskId>>,
34}
35
36impl DisplayRefContext {
37 pub(crate) async fn for_workspace(
38 conn: &mut SqliteConnection,
39 workspace_id: &WorkspaceId,
40 ) -> Result<Self> {
41 Self::load(conn, std::slice::from_ref(workspace_id)).await
42 }
43
44 async fn load(conn: &mut SqliteConnection, workspace_ids: &[WorkspaceId]) -> Result<Self> {
45 let mut task_ids_by_workspace = HashMap::with_capacity(workspace_ids.len());
46 for workspace_id in workspace_ids {
47 task_ids_by_workspace.insert(workspace_id.clone(), task_ids(conn, workspace_id).await?);
48 }
49 Ok(Self {
50 task_ids_by_workspace,
51 })
52 }
53
54 pub fn display_ref(&self, task: &Task) -> String {
55 self.display_ref_for_id(&task.workspace_id, &task.project_prefix, &task.id)
56 }
57
58 pub fn display_ref_for_id(
59 &self,
60 workspace_id: &WorkspaceId,
61 project_prefix: &str,
62 id: &TaskId,
63 ) -> String {
64 format!(
65 "{}-{}",
66 project_prefix,
67 self.display_suffix(workspace_id, id)
68 )
69 }
70
71 pub fn display_suffix(&self, workspace_id: &WorkspaceId, id: &TaskId) -> String {
72 let ids = self
73 .task_ids_by_workspace
74 .get(workspace_id)
75 .map(Vec::as_slice)
76 .unwrap_or_default();
77 display_suffix_for_id(id, ids)
78 }
79}
80
81#[allow(dead_code)]
82pub(crate) async fn get_task_in_workspace(
83 conn: &mut SqliteConnection,
84 workspace: &Workspace,
85 id: &TaskId,
86) -> Result<Task> {
87 get_task_scoped(conn, &workspace.id, id).await
88}
89
90async fn get_task_scoped(
91 conn: &mut SqliteConnection,
92 workspace_id: &WorkspaceId,
93 id: &TaskId,
94) -> Result<Task> {
95 let row = sqlx::query(
96 "SELECT t.id, t.workspace_id, t.title, t.description, t.project_id,
97 p.key AS project_key, p.prefix AS project_prefix, t.status, t.priority, t.created_at, t.updated_at,
98 t.queue_activity_at, t.available_at, t.due_on, t.deleted, t.is_epic
99 FROM tasks t JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
100 WHERE t.workspace_id = ? AND t.id = ?",
101 )
102 .bind(workspace_id)
103 .bind(id)
104 .fetch_one(&mut *conn)
105 .await?;
106 crate::db::task_from_row(&row)
107}
108
109#[allow(dead_code)]
110pub(crate) async fn resolve_task_ref_in_workspace(
111 conn: &mut SqliteConnection,
112 workspace: &Workspace,
113 input: &str,
114) -> Result<Task> {
115 resolve_task_ref_scoped(conn, &workspace.id, input).await
116}
117
118async fn resolve_task_ref_scoped(
119 conn: &mut SqliteConnection,
120 workspace_id: &WorkspaceId,
121 input: &str,
122) -> Result<Task> {
123 let (hint, suffix) = split_ref(input);
124 if suffix.len() < 3 {
125 bail!("error ref-too-short input={} minimum=3", input);
126 }
127 let suffix = suffix.to_ascii_uppercase();
128 let rows = sqlx::query(
129 "SELECT t.id, t.workspace_id, t.title, t.description, t.project_id,
130 p.key AS project_key, p.prefix AS project_prefix, t.status, t.priority, t.created_at, t.updated_at,
131 t.queue_activity_at, t.available_at, t.due_on, t.deleted, t.is_epic
132 FROM tasks t JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
133 WHERE t.workspace_id = ? AND t.id LIKE ? || '%'
134 ORDER BY t.id",
135 )
136 .bind(workspace_id)
137 .bind(suffix)
138 .fetch_all(&mut *conn)
139 .await?;
140 let matches = rows
141 .into_iter()
142 .map(|row| crate::db::task_from_row(&row))
143 .collect::<Result<Vec<_>>>()?;
144 if matches.is_empty() {
145 bail!("error unknown-ref input={}", input);
146 }
147 if let Some(hint) = hint {
148 let hinted: Vec<Task> = matches
149 .iter()
150 .filter(|task| task.project_prefix.eq_ignore_ascii_case(&hint))
151 .cloned()
152 .collect();
153 if hinted.len() == 1 {
154 return Ok(hinted[0].clone());
155 }
156 }
157 if matches.len() == 1 {
158 return Ok(matches[0].clone());
159 }
160 let display_refs = DisplayRefContext::for_workspace(conn, workspace_id).await?;
161 println!("error ambiguous-ref input={}", input);
162 for task in matches {
163 println!(
164 "match {} title={}",
165 display_refs.display_ref(&task),
166 quote(&task.title)
167 );
168 }
169 println!("hint \"retry with longer ref\"");
170 bail!("ambiguous ref");
171}
172
173fn split_ref(input: &str) -> (Option<String>, String) {
174 if let Some((prefix, suffix)) = input.split_once('-') {
175 (Some(prefix.to_string()), normalize_ref(suffix))
176 } else {
177 (None, normalize_ref(input))
178 }
179}
180
181fn normalize_ref(input: &str) -> String {
182 input
183 .chars()
184 .filter(|ch| ch.is_ascii_alphanumeric())
185 .map(|ch| match ch.to_ascii_uppercase() {
186 'O' => '0',
187 'I' | 'L' => '1',
188 ch => ch,
189 })
190 .collect()
191}
192
193async fn task_ids(conn: &mut SqliteConnection, workspace_id: &WorkspaceId) -> Result<Vec<TaskId>> {
194 Ok(
195 sqlx::query_scalar::<_, TaskId>("SELECT id FROM tasks WHERE workspace_id = ? ORDER BY id")
196 .bind(workspace_id)
197 .fetch_all(&mut *conn)
198 .await?,
199 )
200}
201
202fn display_suffix_for_id(id: &TaskId, ids: &[TaskId]) -> String {
203 let len = display_suffix_len(id, ids);
204 id.as_str()[..len].to_string()
205}
206
207fn display_suffix_len(id: &TaskId, ids: &[TaskId]) -> usize {
208 let id = id.as_str();
209 let floor = DISPLAY_SUFFIX_FLOOR.min(id.len());
210 let index = ids.partition_point(|candidate| candidate.as_str() < id);
211 let exact_index = ids
212 .get(index)
213 .filter(|candidate| candidate.as_str() == id)
214 .map(|_| index);
215 let insertion_index = exact_index.unwrap_or(index);
216 let previous = insertion_index
217 .checked_sub(1)
218 .and_then(|previous| ids.get(previous));
219 let next = ids.get(insertion_index + usize::from(exact_index.is_some()));
220 let shared = previous
221 .into_iter()
222 .chain(next)
223 .map(|candidate| common_prefix_len(id, candidate))
224 .max()
225 .unwrap_or(0);
226 floor.max(shared.saturating_add(1).min(id.len()))
227}
228
229fn common_prefix_len(left: &str, right: &str) -> usize {
230 left.bytes()
231 .zip(right.bytes())
232 .take_while(|(left, right)| left == right)
233 .count()
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 #[tokio::test]
241 async fn context_reuses_loaded_workspace_ids() {
242 let (_temp, mut conn) = crate::test_support::test_conn().await;
243 let workspace_id = crate::workspaces::default_workspace_id();
244 let project_id = "PROJECTREFCACHE1";
245 sqlx::query(
246 "INSERT INTO projects(
247 workspace_id, id, key, name, prefix, created_at, updated_at
248 ) VALUES (?, ?, 'refs', 'Refs', 'REF', 't', 't')",
249 )
250 .bind(&workspace_id)
251 .bind(project_id)
252 .execute(&mut *conn)
253 .await
254 .unwrap();
255 sqlx::query(
256 "INSERT INTO tasks(
257 workspace_id, id, title, description, project_id, status, priority,
258 created_at, updated_at, queue_activity_at
259 ) VALUES (?, 'ABCD000000000000', 'original', '', ?, 'todo', 'none', 't', 't', 't')",
260 )
261 .bind(&workspace_id)
262 .bind(project_id)
263 .execute(&mut *conn)
264 .await
265 .unwrap();
266
267 let display_refs = DisplayRefContext::for_workspace(&mut conn, &workspace_id)
268 .await
269 .unwrap();
270 sqlx::query(
271 "INSERT INTO tasks(
272 workspace_id, id, title, description, project_id, status, priority,
273 created_at, updated_at, queue_activity_at
274 ) VALUES (?, 'ABCD100000000000', 'collider', '', ?, 'todo', 'none', 't', 't', 't')",
275 )
276 .bind(&workspace_id)
277 .bind(project_id)
278 .execute(&mut *conn)
279 .await
280 .unwrap();
281
282 assert_eq!(
283 display_refs.display_suffix(&workspace_id, &"ABCD000000000000".parse().unwrap(),),
284 "ABCD"
285 );
286 let refreshed = DisplayRefContext::for_workspace(&mut conn, &workspace_id)
287 .await
288 .unwrap();
289 assert_eq!(
290 refreshed.display_suffix(&workspace_id, &"ABCD000000000000".parse().unwrap(),),
291 "ABCD0"
292 );
293 }
294
295 #[test]
296 fn sorted_neighbors_determine_unique_display_suffix() {
297 let ids: Vec<TaskId> = ["ABCD000000000000", "ABCD100000000000", "ABCE000000000000"]
298 .into_iter()
299 .map(|id| id.parse().unwrap())
300 .collect();
301
302 assert_eq!(display_suffix_for_id(&ids[0], &ids), "ABCD0");
303 assert_eq!(display_suffix_for_id(&ids[1], &ids), "ABCD1");
304 assert_eq!(display_suffix_for_id(&ids[2], &ids), "ABCE");
305 }
306}