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_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}