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