1use crate::config::Config;
8use crate::error::CliError;
9use crate::Cli;
10use regex::Regex;
11use std::process::Command;
12use std::sync::OnceLock;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct ResolvedTask {
17 pub id: String,
21 pub raw: String,
24 pub is_custom: bool,
27 pub source: TaskSource,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum TaskSource {
33 Explicit,
34 Env,
35 Branch(String),
37}
38
39const STRIPPED_PREFIXES: &[&str] = &[
42 "feature/",
43 "feat/",
44 "fix/",
45 "hotfix/",
46 "bugfix/",
47 "release/",
48 "chore/",
49 "docs/",
50 "refactor/",
51 "test/",
52 "ci/",
53 "perf/",
54 "build/",
55 "style/",
56];
57
58const EXCLUDED_CUSTOM_PREFIXES: &[&str] = &[
62 "FEATURE", "FEAT", "BUGFIX", "BUG", "FIX", "HOTFIX", "RELEASE", "CHORE", "DOCS", "DOC",
63 "REFACTOR", "TEST", "CI", "PERF", "BUILD", "STYLE", "WIP", "TMP",
64];
65
66fn cu_regex() -> &'static Regex {
67 static RE: OnceLock<Regex> = OnceLock::new();
68 RE.get_or_init(|| Regex::new(r"(?i)\bCU-([0-9a-z]+)").unwrap())
69}
70
71fn custom_regex() -> &'static Regex {
72 static RE: OnceLock<Regex> = OnceLock::new();
73 RE.get_or_init(|| Regex::new(r"\b([A-Z][A-Z0-9]+-\d+)\b").unwrap())
74}
75
76pub fn current_branch() -> Option<String> {
80 let out = Command::new("git")
81 .args(["rev-parse", "--abbrev-ref", "HEAD"])
82 .output()
83 .ok()?;
84 if !out.status.success() {
85 return None;
86 }
87 let name = String::from_utf8(out.stdout).ok()?.trim().to_string();
88 if name.is_empty() || name == "HEAD" {
89 return None;
90 }
91 Some(name)
92}
93
94fn strip_prefix(branch: &str) -> &str {
98 let lower = branch.to_ascii_lowercase();
99 for p in STRIPPED_PREFIXES {
100 if lower.starts_with(p) {
101 return &branch[p.len()..];
102 }
103 }
104 branch
105}
106
107pub fn extract_task_id(branch: &str) -> Option<ResolvedTask> {
110 let stripped = strip_prefix(branch);
111
112 if let Some(m) = cu_regex().captures(stripped) {
113 let raw = m.get(0).unwrap().as_str().to_string();
114 let id = m.get(1).unwrap().as_str().to_string();
115 return Some(ResolvedTask {
116 id,
117 raw,
118 is_custom: false,
119 source: TaskSource::Branch(branch.to_string()),
120 });
121 }
122
123 for m in custom_regex().captures_iter(stripped) {
124 let matched = m.get(1).unwrap().as_str();
125 let prefix = matched.split('-').next().unwrap_or("");
126 if EXCLUDED_CUSTOM_PREFIXES.contains(&prefix) {
127 continue;
128 }
129 return Some(ResolvedTask {
130 id: matched.to_string(),
131 raw: matched.to_string(),
132 is_custom: true,
133 source: TaskSource::Branch(branch.to_string()),
134 });
135 }
136
137 None
138}
139
140pub fn parse_task_id(arg: &str) -> ResolvedTask {
144 let arg = arg.trim();
145
146 if let Some(m) = cu_regex().captures(arg) {
147 if m.get(0).unwrap().as_str().len() == arg.len() {
149 let id = m.get(1).unwrap().as_str().to_string();
150 return ResolvedTask {
151 id,
152 raw: arg.to_string(),
153 is_custom: false,
154 source: TaskSource::Explicit,
155 };
156 }
157 }
158
159 if let Some(m) = custom_regex().captures(arg) {
160 let matched = m.get(1).unwrap().as_str();
161 let prefix = matched.split('-').next().unwrap_or("");
162 if matched.len() == arg.len() && !EXCLUDED_CUSTOM_PREFIXES.contains(&prefix) {
163 return ResolvedTask {
164 id: arg.to_string(),
165 raw: arg.to_string(),
166 is_custom: true,
167 source: TaskSource::Explicit,
168 };
169 }
170 }
171
172 ResolvedTask {
174 id: arg.to_string(),
175 raw: arg.to_string(),
176 is_custom: false,
177 source: TaskSource::Explicit,
178 }
179}
180
181fn detect_enabled() -> bool {
184 if let Ok(v) = std::env::var("CLICKUP_GIT_DETECT") {
185 if v == "0" || v.eq_ignore_ascii_case("false") {
186 return false;
187 }
188 }
189 let cfg = Config::load().unwrap_or_default();
190 cfg.git.enabled.unwrap_or(true)
191}
192
193fn verbose_enabled() -> bool {
194 let cfg = Config::load().unwrap_or_default();
195 cfg.git.verbose.unwrap_or(true)
196}
197
198fn maybe_print_breadcrumb(cli: &Cli, task: &ResolvedTask) {
201 if cli.quiet || cli.output != "table" {
202 return;
203 }
204 if !verbose_enabled() {
205 return;
206 }
207 if let TaskSource::Branch(branch) = &task.source {
208 eprintln!("resolved task {} from branch {}", task.raw, branch);
209 }
210}
211
212pub fn resolve_task(
219 cli: &Cli,
220 explicit: Option<&str>,
221 allow_branch: bool,
222) -> Result<Option<ResolvedTask>, CliError> {
223 if let Some(arg) = explicit {
224 let t = parse_task_id(arg);
225 return Ok(Some(t));
226 }
227
228 if let Ok(v) = std::env::var("CLICKUP_TASK_ID") {
229 if !v.is_empty() {
230 let mut t = parse_task_id(&v);
231 t.source = TaskSource::Env;
232 return Ok(Some(t));
233 }
234 }
235
236 if !allow_branch || !detect_enabled() {
237 return Ok(None);
238 }
239
240 let branch = match current_branch() {
241 Some(b) => b,
242 None => return Ok(None),
243 };
244 let resolved = extract_task_id(&branch);
245 if let Some(t) = &resolved {
246 maybe_print_breadcrumb(cli, t);
247 }
248 Ok(resolved)
249}
250
251pub fn require_task(
253 cli: &Cli,
254 explicit: Option<&str>,
255 allow_branch: bool,
256) -> Result<ResolvedTask, CliError> {
257 match resolve_task(cli, explicit, allow_branch)? {
258 Some(t) => Ok(t),
259 None => Err(no_task_id_error(allow_branch)),
260 }
261}
262
263fn no_task_id_error(allow_branch: bool) -> CliError {
264 if !allow_branch {
265 return CliError::BranchDetect {
266 message: "No task ID provided. This command does not auto-detect from branch.".into(),
267 hint: "Pass the task ID explicitly.".into(),
268 };
269 }
270 match current_branch() {
271 Some(b) => CliError::BranchDetect {
272 message: format!(
273 "No task ID on the command line and none detected in branch \"{}\".",
274 b
275 ),
276 hint: "Name your branch like feat/CU-abc123-... or PROJ-42-..., or pass the ID \
277 explicitly."
278 .into(),
279 },
280 None => CliError::BranchDetect {
281 message: "No task ID provided and not inside a git repository.".into(),
282 hint: "Pass the task ID explicitly, or run from a repo whose branch contains a \
283 task ID (e.g. feat/CU-abc123-...)."
284 .into(),
285 },
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 fn extract(b: &str) -> Option<(String, bool)> {
294 extract_task_id(b).map(|t| (t.id, t.is_custom))
295 }
296
297 #[test]
298 fn cu_plain_branch() {
299 assert_eq!(extract("CU-abc123-foo"), Some(("abc123".into(), false)));
300 }
301
302 #[test]
303 fn cu_with_feat_prefix() {
304 assert_eq!(
305 extract("feat/CU-abc123-foo"),
306 Some(("abc123".into(), false))
307 );
308 }
309
310 #[test]
311 fn cu_lowercase() {
312 assert_eq!(extract("cu-dead01-test"), Some(("dead01".into(), false)));
313 }
314
315 #[test]
316 fn cu_mixed_case_prefix() {
317 assert_eq!(extract("Feature/Cu-Abc123"), Some(("Abc123".into(), false)));
318 }
319
320 #[test]
321 fn cu_with_underscore_after_id() {
322 assert_eq!(
324 extract("CU-86d1u2bz4_React-Native-Pois-gone"),
325 Some(("86d1u2bz4".into(), false))
326 );
327 }
328
329 #[test]
330 fn cu_with_feature_prefix_and_underscore() {
331 assert_eq!(
332 extract("feature/CU-86d1u2bz4_something"),
333 Some(("86d1u2bz4".into(), false))
334 );
335 }
336
337 #[test]
338 fn custom_id_plain() {
339 assert_eq!(extract("PROJ-42-add-login"), Some(("PROJ-42".into(), true)));
340 }
341
342 #[test]
343 fn custom_id_with_fix_prefix() {
344 assert_eq!(
345 extract("fix/ENG-1234-auth"),
346 Some(("ENG-1234".into(), true))
347 );
348 }
349
350 #[test]
351 fn excluded_prefix_feature() {
352 assert_eq!(extract("FEATURE-123-something"), None);
353 }
354
355 #[test]
356 fn excluded_prefix_bugfix() {
357 assert_eq!(extract("BUGFIX-456-foo"), None);
358 }
359
360 #[test]
361 fn excluded_prefix_wip() {
362 assert_eq!(extract("WIP-1-in-progress"), None);
363 }
364
365 #[test]
366 fn no_match_main() {
367 assert_eq!(extract("main"), None);
368 }
369
370 #[test]
371 fn no_match_draft_work() {
372 assert_eq!(extract("draft-work"), None);
373 }
374
375 #[test]
376 fn no_match_head_literal() {
377 assert_eq!(extract("HEAD"), None);
379 }
380
381 #[test]
382 fn cu_first_match_wins() {
383 assert_eq!(extract("CU-aaa-refs-CU-bbb"), Some(("aaa".into(), false)));
384 }
385
386 #[test]
387 fn cu_wins_over_custom() {
388 assert_eq!(
389 extract("feat/CU-abc123-refs-PROJ-42-foo"),
390 Some(("abc123".into(), false))
391 );
392 }
393
394 #[test]
395 fn does_not_match_mid_word() {
396 assert_eq!(extract("xyzCU-abc"), None);
398 }
399
400 #[test]
401 fn empty_branch() {
402 assert_eq!(extract(""), None);
403 }
404
405 #[test]
406 fn parse_explicit_cu_stripped() {
407 let t = parse_task_id("CU-abc123");
408 assert_eq!(t.id, "abc123");
409 assert!(!t.is_custom);
410 assert_eq!(t.source, TaskSource::Explicit);
411 }
412
413 #[test]
414 fn parse_explicit_custom_flagged() {
415 let t = parse_task_id("PROJ-42");
416 assert_eq!(t.id, "PROJ-42");
417 assert!(t.is_custom);
418 }
419
420 #[test]
421 fn parse_explicit_plain() {
422 let t = parse_task_id("abc123");
423 assert_eq!(t.id, "abc123");
424 assert!(!t.is_custom);
425 }
426
427 #[test]
428 fn parse_explicit_excluded_prefix_not_custom() {
429 let t = parse_task_id("FEATURE-123");
431 assert_eq!(t.id, "FEATURE-123");
432 assert!(!t.is_custom);
433 }
434
435 #[test]
436 fn parse_explicit_trims_whitespace() {
437 let t = parse_task_id(" CU-abc123 ");
438 assert_eq!(t.id, "abc123");
439 }
440}