1use crate::config;
5use crate::store::{self, CliTaskRef, TaskStore};
6use std::path::{Path, PathBuf, MAIN_SEPARATOR_STR};
7
8pub async fn resolve(store: &TaskStore, id: &str) -> anyhow::Result<CliTaskRef> {
10 resolve_in(store, id, current_repo().as_deref()).await
11}
12
13pub async fn resolve_store_id(store: &TaskStore, id: &str) -> anyhow::Result<i64> {
15 resolve(store, id)
16 .await?
17 .store_id
18 .ok_or_else(|| anyhow::anyhow!("task not found in store: {id}"))
19}
20
21pub async fn resolve_in(
23 store: &TaskStore,
24 id: &str,
25 current_repo: Option<&str>,
26) -> anyhow::Result<CliTaskRef> {
27 let spec = expand_repo(store, &normalize(id), current_repo).await?;
28 if let Some(task) = store.resolve_cli_id(&spec, current_repo).await? {
29 return Ok(task);
30 }
31 match store::parse_cli_task_spec(&spec)? {
32 store::CliTaskSpec::Issue { repo, number } => {
33 Ok(store::CliTaskRef::unsynced_issue(repo, number))
34 }
35 store::CliTaskSpec::Bare(number) => {
36 let repo = current_repo
37 .map(str::to_string)
38 .ok_or_else(|| anyhow::anyhow!("task not found: {id}"))?;
39 Ok(store::CliTaskRef::unsynced_issue(repo, number))
40 }
41 store::CliTaskSpec::Internal(_) => anyhow::bail!("internal task '{id}' not found"),
42 }
43}
44
45pub fn state_dir(task: &CliTaskRef) -> anyhow::Result<PathBuf> {
47 Ok(dirs_under(&crate::home::state_dir()?, task).1)
48}
49
50pub fn state_dirs(task: &CliTaskRef) -> anyhow::Result<Vec<PathBuf>> {
52 let (review, own) = dirs_under(&crate::home::state_dir()?, task);
53 Ok(vec![review, own])
54}
55
56fn dirs_under(state: &Path, task: &CliTaskRef) -> (PathBuf, PathBuf) {
59 let tasks = state
60 .join(task.repo.replace('/', MAIN_SEPARATOR_STR))
61 .join("tasks");
62 let name = &task.external_id;
63 (tasks.join(format!("{name}-review")), tasks.join(name))
64}
65
66async fn expand_repo(
69 store: &TaskStore,
70 spec: &str,
71 current_repo: Option<&str>,
72) -> anyhow::Result<String> {
73 let Some((name, number)) = spec.rsplit_once('#') else {
74 return Ok(spec.to_string());
75 };
76 if name.is_empty() || name.contains('/') || number.parse::<i64>().is_err() {
77 return Ok(spec.to_string());
78 }
79
80 if let Some(repo) = current_repo {
81 if repo.rsplit_once('/').is_some_and(|(_, n)| n == name) {
82 return Ok(format!("{repo}#{number}"));
83 }
84 }
85
86 let owners: Vec<String> = store
87 .distinct_repos()
88 .await?
89 .into_iter()
90 .filter(|repo| repo.rsplit_once('/').is_some_and(|(_, n)| n == name))
91 .collect();
92 match owners.as_slice() {
93 [repo] => Ok(format!("{repo}#{number}")),
94 [] => Ok(spec.to_string()),
96 many => anyhow::bail!(
97 "repo '{name}' matches {}, use owner/repo#{number} to pick one",
98 many.join(" and ")
99 ),
100 }
101}
102
103fn normalize(id: &str) -> String {
105 let id = id.trim();
106 match id.strip_prefix("internal-") {
107 Some(rest) if rest.chars().all(|c| c.is_ascii_digit()) => format!("internal:{rest}"),
108 _ => id.to_string(),
109 }
110}
111
112fn current_repo() -> Option<String> {
113 config::get_current_repo()
114 .ok()
115 .filter(|repo| !repo.is_empty())
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121 use crate::store::{NewTask, TaskStore};
122
123 async fn store_with_tasks() -> (TaskStore, i64) {
124 let store = TaskStore::open_memory().await.unwrap();
125 store
126 .create(&NewTask {
127 external_id: Some("5".to_string()),
128 repo: "owner/repo".to_string(),
129 origin: "github".to_string(),
130 title: "GitHub task 5".to_string(),
131 ..Default::default()
132 })
133 .await
134 .unwrap();
135 let internal = store
136 .create_internal("owner/other", "Internal task", "body", "job", "j:1", None)
137 .await
138 .unwrap();
139 (store, internal)
140 }
141
142 #[tokio::test]
143 async fn resolve_accepts_every_id_form() {
144 let (store, internal) = store_with_tasks().await;
145 let forms = [
146 ("owner/repo#5", Some("owner/other")),
147 ("repo#5", Some("owner/other")),
148 ("5", Some("owner/repo")),
149 ("#5", Some("owner/repo")),
150 ];
151 for (id, repo) in forms {
152 let task = resolve_in(&store, id, repo).await.unwrap();
153 assert_eq!(task.display_spec(), "owner/repo#5", "{id}");
154 }
155
156 for id in [
157 format!("internal:{internal}"),
158 format!("internal-{internal}"),
159 ] {
160 let task = resolve_in(&store, &id, None).await.unwrap();
161 assert_eq!(task.display_spec(), format!("internal:{internal}"), "{id}");
162 }
163 }
164
165 #[tokio::test]
166 async fn bare_id_in_a_project_does_not_clash_with_another_project() {
167 let store = TaskStore::open_memory().await.unwrap();
168 store
169 .create(&NewTask {
170 external_id: Some("5".to_string()),
171 repo: "owner/repo".to_string(),
172 origin: "github".to_string(),
173 title: "GitHub task 5".to_string(),
174 ..Default::default()
175 })
176 .await
177 .unwrap();
178 for _ in 0..5 {
180 store
181 .create_internal("owner/other", "Internal", "body", "job", "j:1", None)
182 .await
183 .unwrap();
184 }
185
186 let task = resolve_in(&store, "5", Some("owner/repo")).await.unwrap();
187 assert_eq!(task.display_spec(), "owner/repo#5");
188
189 let err = resolve_in(&store, "5", None).await.unwrap_err().to_string();
191 assert!(
192 err.contains("Use owner/repo#5 for the GitHub task"),
193 "{err}"
194 );
195 assert!(err.contains("or internal:5 for the internal one"), "{err}");
196 }
197
198 #[tokio::test]
199 async fn state_dirs_follow_the_resolved_task() {
200 let (store, internal) = store_with_tasks().await;
201 let state = Path::new("/state");
202
203 let github = resolve_in(&store, "owner/repo#5", None).await.unwrap();
204 assert_eq!(
205 dirs_under(state, &github),
206 (
207 PathBuf::from("/state/owner/repo/tasks/5-review"),
208 PathBuf::from("/state/owner/repo/tasks/5"),
209 )
210 );
211
212 let task = resolve_in(&store, &format!("internal:{internal}"), None)
213 .await
214 .unwrap();
215 assert_eq!(
216 dirs_under(state, &task).1,
217 PathBuf::from(format!("/state/owner/other/tasks/internal:{internal}"))
218 );
219 }
220
221 #[tokio::test]
222 async fn bare_repo_name_needs_an_owner_only_when_two_owners_share_it() {
223 let (store, _) = store_with_tasks().await;
224 for repo in ["other/repo", "owner/repo"] {
225 store
226 .create(&NewTask {
227 external_id: Some("9".to_string()),
228 repo: repo.to_string(),
229 origin: "github".to_string(),
230 title: "Task 9".to_string(),
231 ..Default::default()
232 })
233 .await
234 .unwrap();
235 }
236
237 let err = resolve_in(&store, "repo#9", None)
238 .await
239 .unwrap_err()
240 .to_string();
241 assert!(err.contains("owner/repo#9"), "{err}");
242
243 let task = resolve_in(&store, "repo#9", Some("owner/repo"))
244 .await
245 .unwrap();
246 assert_eq!(task.display_spec(), "owner/repo#9");
247
248 let err = resolve_in(&store, "nosuch#9", None)
250 .await
251 .unwrap_err()
252 .to_string();
253 assert!(err.contains("invalid task id"), "{err}");
254 }
255
256 #[tokio::test]
257 async fn unknown_bare_id_outside_a_project_is_an_error() {
258 let (store, _) = store_with_tasks().await;
259 assert!(resolve_in(&store, "999", None).await.is_err());
260 assert!(resolve_in(&store, "internal:999", None).await.is_err());
261 let task = resolve_in(&store, "999", Some("owner/repo")).await.unwrap();
262 assert_eq!(task.display_spec(), "owner/repo#999");
263 }
264}