use crate::config::Config;
use crate::error::CliError;
use crate::Cli;
use regex::Regex;
use std::process::Command;
use std::sync::OnceLock;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedTask {
pub id: String,
pub raw: String,
pub is_custom: bool,
pub source: TaskSource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TaskSource {
Explicit,
Env,
Branch(String),
}
const STRIPPED_PREFIXES: &[&str] = &[
"feature/",
"feat/",
"fix/",
"hotfix/",
"bugfix/",
"release/",
"chore/",
"docs/",
"refactor/",
"test/",
"ci/",
"perf/",
"build/",
"style/",
];
const EXCLUDED_CUSTOM_PREFIXES: &[&str] = &[
"FEATURE", "FEAT", "BUGFIX", "BUG", "FIX", "HOTFIX", "RELEASE", "CHORE", "DOCS", "DOC",
"REFACTOR", "TEST", "CI", "PERF", "BUILD", "STYLE", "WIP", "TMP",
];
fn cu_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"(?i)\bCU-([0-9a-z]+)").unwrap())
}
fn custom_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\b([A-Z][A-Z0-9]+-\d+)\b").unwrap())
}
pub fn current_branch() -> Option<String> {
let out = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let name = String::from_utf8(out.stdout).ok()?.trim().to_string();
if name.is_empty() || name == "HEAD" {
return None;
}
Some(name)
}
fn strip_prefix(branch: &str) -> &str {
let lower = branch.to_ascii_lowercase();
for p in STRIPPED_PREFIXES {
if lower.starts_with(p) {
return &branch[p.len()..];
}
}
branch
}
pub fn extract_task_id(branch: &str) -> Option<ResolvedTask> {
let stripped = strip_prefix(branch);
if let Some(m) = cu_regex().captures(stripped) {
let raw = m.get(0).unwrap().as_str().to_string();
let id = m.get(1).unwrap().as_str().to_string();
return Some(ResolvedTask {
id,
raw,
is_custom: false,
source: TaskSource::Branch(branch.to_string()),
});
}
for m in custom_regex().captures_iter(stripped) {
let matched = m.get(1).unwrap().as_str();
let prefix = matched.split('-').next().unwrap_or("");
if EXCLUDED_CUSTOM_PREFIXES.contains(&prefix) {
continue;
}
return Some(ResolvedTask {
id: matched.to_string(),
raw: matched.to_string(),
is_custom: true,
source: TaskSource::Branch(branch.to_string()),
});
}
None
}
pub fn parse_task_id(arg: &str) -> ResolvedTask {
let arg = arg.trim();
if let Some(m) = cu_regex().captures(arg) {
if m.get(0).unwrap().as_str().len() == arg.len() {
let id = m.get(1).unwrap().as_str().to_string();
return ResolvedTask {
id,
raw: arg.to_string(),
is_custom: false,
source: TaskSource::Explicit,
};
}
}
if let Some(m) = custom_regex().captures(arg) {
let matched = m.get(1).unwrap().as_str();
let prefix = matched.split('-').next().unwrap_or("");
if matched.len() == arg.len() && !EXCLUDED_CUSTOM_PREFIXES.contains(&prefix) {
return ResolvedTask {
id: arg.to_string(),
raw: arg.to_string(),
is_custom: true,
source: TaskSource::Explicit,
};
}
}
ResolvedTask {
id: arg.to_string(),
raw: arg.to_string(),
is_custom: false,
source: TaskSource::Explicit,
}
}
fn detect_enabled() -> bool {
if let Ok(v) = std::env::var("CLICKUP_GIT_DETECT") {
if v == "0" || v.eq_ignore_ascii_case("false") {
return false;
}
}
let cfg = Config::load().unwrap_or_default();
cfg.git.enabled.unwrap_or(true)
}
fn verbose_enabled() -> bool {
let cfg = Config::load().unwrap_or_default();
cfg.git.verbose.unwrap_or(true)
}
fn maybe_print_breadcrumb(cli: &Cli, task: &ResolvedTask) {
if cli.quiet || cli.output != "table" {
return;
}
if !verbose_enabled() {
return;
}
if let TaskSource::Branch(branch) = &task.source {
eprintln!("resolved task {} from branch {}", task.raw, branch);
}
}
pub fn resolve_task(
cli: &Cli,
explicit: Option<&str>,
allow_branch: bool,
) -> Result<Option<ResolvedTask>, CliError> {
if let Some(arg) = explicit {
let t = parse_task_id(arg);
return Ok(Some(t));
}
if let Ok(v) = std::env::var("CLICKUP_TASK_ID") {
if !v.is_empty() {
let mut t = parse_task_id(&v);
t.source = TaskSource::Env;
return Ok(Some(t));
}
}
if !allow_branch || !detect_enabled() {
return Ok(None);
}
let branch = match current_branch() {
Some(b) => b,
None => return Ok(None),
};
let resolved = extract_task_id(&branch);
if let Some(t) = &resolved {
maybe_print_breadcrumb(cli, t);
}
Ok(resolved)
}
pub fn require_task(
cli: &Cli,
explicit: Option<&str>,
allow_branch: bool,
) -> Result<ResolvedTask, CliError> {
match resolve_task(cli, explicit, allow_branch)? {
Some(t) => Ok(t),
None => Err(no_task_id_error(allow_branch)),
}
}
fn no_task_id_error(allow_branch: bool) -> CliError {
if !allow_branch {
return CliError::BranchDetect {
message: "No task ID provided. This command does not auto-detect from branch.".into(),
hint: "Pass the task ID explicitly.".into(),
};
}
match current_branch() {
Some(b) => CliError::BranchDetect {
message: format!(
"No task ID on the command line and none detected in branch \"{}\".",
b
),
hint: "Name your branch like feat/CU-abc123-... or PROJ-42-..., or pass the ID \
explicitly."
.into(),
},
None => CliError::BranchDetect {
message: "No task ID provided and not inside a git repository.".into(),
hint: "Pass the task ID explicitly, or run from a repo whose branch contains a \
task ID (e.g. feat/CU-abc123-...)."
.into(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
fn extract(b: &str) -> Option<(String, bool)> {
extract_task_id(b).map(|t| (t.id, t.is_custom))
}
#[test]
fn cu_plain_branch() {
assert_eq!(extract("CU-abc123-foo"), Some(("abc123".into(), false)));
}
#[test]
fn cu_with_feat_prefix() {
assert_eq!(
extract("feat/CU-abc123-foo"),
Some(("abc123".into(), false))
);
}
#[test]
fn cu_lowercase() {
assert_eq!(extract("cu-dead01-test"), Some(("dead01".into(), false)));
}
#[test]
fn cu_mixed_case_prefix() {
assert_eq!(extract("Feature/Cu-Abc123"), Some(("Abc123".into(), false)));
}
#[test]
fn cu_with_underscore_after_id() {
assert_eq!(
extract("CU-86d1u2bz4_React-Native-Pois-gone"),
Some(("86d1u2bz4".into(), false))
);
}
#[test]
fn cu_with_feature_prefix_and_underscore() {
assert_eq!(
extract("feature/CU-86d1u2bz4_something"),
Some(("86d1u2bz4".into(), false))
);
}
#[test]
fn custom_id_plain() {
assert_eq!(extract("PROJ-42-add-login"), Some(("PROJ-42".into(), true)));
}
#[test]
fn custom_id_with_fix_prefix() {
assert_eq!(
extract("fix/ENG-1234-auth"),
Some(("ENG-1234".into(), true))
);
}
#[test]
fn excluded_prefix_feature() {
assert_eq!(extract("FEATURE-123-something"), None);
}
#[test]
fn excluded_prefix_bugfix() {
assert_eq!(extract("BUGFIX-456-foo"), None);
}
#[test]
fn excluded_prefix_wip() {
assert_eq!(extract("WIP-1-in-progress"), None);
}
#[test]
fn no_match_main() {
assert_eq!(extract("main"), None);
}
#[test]
fn no_match_draft_work() {
assert_eq!(extract("draft-work"), None);
}
#[test]
fn no_match_head_literal() {
assert_eq!(extract("HEAD"), None);
}
#[test]
fn cu_first_match_wins() {
assert_eq!(extract("CU-aaa-refs-CU-bbb"), Some(("aaa".into(), false)));
}
#[test]
fn cu_wins_over_custom() {
assert_eq!(
extract("feat/CU-abc123-refs-PROJ-42-foo"),
Some(("abc123".into(), false))
);
}
#[test]
fn does_not_match_mid_word() {
assert_eq!(extract("xyzCU-abc"), None);
}
#[test]
fn empty_branch() {
assert_eq!(extract(""), None);
}
#[test]
fn parse_explicit_cu_stripped() {
let t = parse_task_id("CU-abc123");
assert_eq!(t.id, "abc123");
assert!(!t.is_custom);
assert_eq!(t.source, TaskSource::Explicit);
}
#[test]
fn parse_explicit_custom_flagged() {
let t = parse_task_id("PROJ-42");
assert_eq!(t.id, "PROJ-42");
assert!(t.is_custom);
}
#[test]
fn parse_explicit_plain() {
let t = parse_task_id("abc123");
assert_eq!(t.id, "abc123");
assert!(!t.is_custom);
}
#[test]
fn parse_explicit_excluded_prefix_not_custom() {
let t = parse_task_id("FEATURE-123");
assert_eq!(t.id, "FEATURE-123");
assert!(!t.is_custom);
}
#[test]
fn parse_explicit_trims_whitespace() {
let t = parse_task_id(" CU-abc123 ");
assert_eq!(t.id, "abc123");
}
}