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