Skip to main content

aven_core/
refs.rs

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}