use std::fmt;
use snafu::{ResultExt, Snafu, ensure};
use crate::name::{NameError, ProjectName, TagName, TaskName};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum NameComponent {
Project,
Tag,
Task,
}
impl fmt::Display for NameComponent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
NameComponent::Project => "project",
NameComponent::Tag => "tag",
NameComponent::Task => "task",
};
f.write_str(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
pub enum TaskRefError {
#[snafu(display("task reference is empty"))]
EmptyInput,
#[snafu(display("task reference has no `:` separator"))]
MissingColon,
#[snafu(display("tag reference malformed: missing `]` or `:` after `]`"))]
MalformedTagBrackets,
#[snafu(display("invalid {component} name: {source}"))]
InvalidName {
component: NameComponent,
source: NameError,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum TaskRef {
SameProject(TaskName),
Project(ProjectName, TaskName),
FanOut(TaskName),
SiblingFanOut(TaskName),
Tag(TagName, TaskName),
SiblingTag(TagName, TaskName),
}
impl TaskRef {
pub fn parse(s: &str) -> Result<Self, TaskRefError> {
ensure!(!s.is_empty(), EmptyInputSnafu);
if let Some(rest) = s.strip_prefix("~:") {
let task = TaskName::try_new(rest).context(InvalidNameSnafu {
component: NameComponent::Task,
})?;
return Ok(TaskRef::SameProject(task));
}
if let Some(rest) = s.strip_prefix("^~:") {
let task = TaskName::try_new(rest).context(InvalidNameSnafu {
component: NameComponent::Task,
})?;
return Ok(TaskRef::SiblingFanOut(task));
}
if let Some(rest) = s.strip_prefix("^:") {
let task = TaskName::try_new(rest).context(InvalidNameSnafu {
component: NameComponent::Task,
})?;
return Ok(TaskRef::FanOut(task));
}
if let Some(rest) = s.strip_prefix('[') {
let close_idx = rest.find(']').ok_or(TaskRefError::MalformedTagBrackets)?;
let (tag_str, after_close) = rest.split_at(close_idx);
let after_bracket = &after_close[']'.len_utf8()..];
let (task_str, is_sibling) = if let Some(rest) = after_bracket.strip_prefix("~:") {
(rest, true)
} else if let Some(rest) = after_bracket.strip_prefix(':') {
(rest, false)
} else {
return Err(TaskRefError::MalformedTagBrackets);
};
let tag = TagName::try_new(tag_str).context(InvalidNameSnafu {
component: NameComponent::Tag,
})?;
let task = TaskName::try_new(task_str).context(InvalidNameSnafu {
component: NameComponent::Task,
})?;
return Ok(if is_sibling {
TaskRef::SiblingTag(tag, task)
} else {
TaskRef::Tag(tag, task)
});
}
let colon_idx = s.find(':').ok_or(TaskRefError::MissingColon)?;
let (proj_str, after_colon) = s.split_at(colon_idx);
let task_str = &after_colon[':'.len_utf8()..];
let project = ProjectName::try_new(proj_str).context(InvalidNameSnafu {
component: NameComponent::Project,
})?;
let task = TaskName::try_new(task_str).context(InvalidNameSnafu {
component: NameComponent::Task,
})?;
Ok(TaskRef::Project(project, task))
}
#[must_use]
pub fn is_same_project(&self) -> bool {
matches!(self, TaskRef::SameProject(_))
}
#[must_use]
pub fn is_project(&self) -> bool {
matches!(self, TaskRef::Project(_, _))
}
#[must_use]
pub fn is_fan_out(&self) -> bool {
matches!(self, TaskRef::FanOut(_))
}
#[must_use]
pub fn is_sibling_fan_out(&self) -> bool {
matches!(self, TaskRef::SiblingFanOut(_))
}
#[must_use]
pub fn is_tag(&self) -> bool {
matches!(self, TaskRef::Tag(_, _))
}
#[must_use]
pub fn is_sibling_tag(&self) -> bool {
matches!(self, TaskRef::SiblingTag(_, _))
}
}
impl fmt::Display for TaskRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TaskRef::SameProject(task) => write!(f, "~:{task}"),
TaskRef::Project(project, task) => write!(f, "{project}:{task}"),
TaskRef::FanOut(task) => write!(f, "^:{task}"),
TaskRef::SiblingFanOut(task) => write!(f, "^~:{task}"),
TaskRef::Tag(tag, task) => write!(f, "[{tag}]:{task}"),
TaskRef::SiblingTag(tag, task) => write!(f, "[{tag}]~:{task}"),
}
}
}
#[cfg(test)]
mod tests {
use crate::name::NameError;
use crate::task_ref::{NameComponent, TaskRef, TaskRefError};
#[test]
fn ref_001_rejects_invalid_project_name() {
assert!(matches!(
TaskRef::parse("lib core:compile"),
Err(TaskRefError::InvalidName {
component: NameComponent::Project,
source: NameError::InvalidChar { c: ' ' },
})
));
}
#[test]
fn ref_001_rejects_invalid_tag_name() {
assert!(matches!(
TaskRef::parse("[a b]:compile"),
Err(TaskRefError::InvalidName {
component: NameComponent::Tag,
source: NameError::InvalidChar { c: ' ' },
})
));
}
#[test]
fn ref_001_rejects_invalid_task_name() {
assert!(matches!(
TaskRef::parse("~:foo bar"),
Err(TaskRefError::InvalidName {
component: NameComponent::Task,
source: NameError::InvalidChar { c: ' ' },
})
));
}
#[test]
fn ref_001_rejects_empty_project_name() {
assert!(matches!(
TaskRef::parse(":compile"),
Err(TaskRefError::InvalidName {
component: NameComponent::Project,
source: NameError::Empty,
})
));
}
#[test]
fn ref_001_rejects_empty_tag_name() {
assert!(matches!(
TaskRef::parse("[]:compile"),
Err(TaskRefError::InvalidName {
component: NameComponent::Tag,
source: NameError::Empty,
})
));
}
#[test]
fn ref_001_rejects_empty_task_in_same_project() {
assert!(matches!(
TaskRef::parse("~:"),
Err(TaskRefError::InvalidName {
component: NameComponent::Task,
source: NameError::Empty,
})
));
}
#[test]
fn ref_001_rejects_empty_task_in_fan_out() {
assert!(matches!(
TaskRef::parse("^:"),
Err(TaskRefError::InvalidName {
component: NameComponent::Task,
source: NameError::Empty,
})
));
}
#[test]
fn ref_001_rejects_empty_task_in_project() {
assert!(matches!(
TaskRef::parse("lib:"),
Err(TaskRefError::InvalidName {
component: NameComponent::Task,
source: NameError::Empty,
})
));
}
#[test]
fn ref_001_rejects_empty_task_in_tag() {
assert!(matches!(
TaskRef::parse("[typescript]:"),
Err(TaskRefError::InvalidName {
component: NameComponent::Task,
source: NameError::Empty,
})
));
}
#[test]
fn ref_002_parses_same_project() {
let r = TaskRef::parse("~:compile").unwrap();
assert!(r.is_same_project());
assert_eq!(r.to_string(), "~:compile");
}
#[test]
fn ref_002_parses_same_project_with_underscore_and_dash() {
let r = TaskRef::parse("~:test_unit-1").unwrap();
assert!(matches!(r, TaskRef::SameProject(_)));
}
#[test]
fn ref_003_parses_project() {
let r = TaskRef::parse("lib_core:compile").unwrap();
assert!(r.is_project());
assert_eq!(r.to_string(), "lib_core:compile");
}
#[test]
fn ref_003_parses_project_with_dash() {
let r = TaskRef::parse("web-frontend:bundle").unwrap();
assert!(matches!(r, TaskRef::Project(_, _)));
}
#[test]
fn ref_004_parses_fan_out() {
let r = TaskRef::parse("^:compile").unwrap();
assert!(r.is_fan_out());
assert_eq!(r.to_string(), "^:compile");
}
#[test]
fn ref_005_parses_tag() {
let r = TaskRef::parse("[typescript]:compile").unwrap();
assert!(r.is_tag());
assert_eq!(r.to_string(), "[typescript]:compile");
}
#[test]
fn ref_005_parses_tag_with_dash() {
let r = TaskRef::parse("[release]:publish").unwrap();
assert!(matches!(r, TaskRef::Tag(_, _)));
}
#[test]
fn ref_008_parses_sibling_fan_out() {
let r = TaskRef::parse("^~:compile").unwrap();
assert!(r.is_sibling_fan_out());
assert_eq!(r.to_string(), "^~:compile");
}
#[test]
fn ref_008_parses_sibling_fan_out_with_underscore_and_dash() {
let r = TaskRef::parse("^~:test_unit-1").unwrap();
assert!(matches!(r, TaskRef::SiblingFanOut(_)));
}
#[test]
fn ref_008_parses_sibling_tag() {
let r = TaskRef::parse("[typescript]~:compile").unwrap();
assert!(r.is_sibling_tag());
assert_eq!(r.to_string(), "[typescript]~:compile");
}
#[test]
fn ref_008_parses_sibling_tag_with_dash() {
let r = TaskRef::parse("[release]~:publish").unwrap();
assert!(matches!(r, TaskRef::SiblingTag(_, _)));
}
#[test]
fn ref_008_rejects_empty_task_in_sibling_fan_out() {
assert!(matches!(
TaskRef::parse("^~:"),
Err(TaskRefError::InvalidName {
component: NameComponent::Task,
source: NameError::Empty,
})
));
}
#[test]
fn ref_008_rejects_empty_task_in_sibling_tag() {
assert!(matches!(
TaskRef::parse("[typescript]~:"),
Err(TaskRefError::InvalidName {
component: NameComponent::Task,
source: NameError::Empty,
})
));
}
#[test]
fn ref_008_rejects_invalid_task_in_sibling_fan_out() {
assert!(matches!(
TaskRef::parse("^~:foo bar"),
Err(TaskRefError::InvalidName {
component: NameComponent::Task,
source: NameError::InvalidChar { c: ' ' },
})
));
}
#[test]
fn ref_008_rejects_invalid_tag_in_sibling_tag() {
assert!(matches!(
TaskRef::parse("[a b]~:compile"),
Err(TaskRefError::InvalidName {
component: NameComponent::Tag,
source: NameError::InvalidChar { c: ' ' },
})
));
}
#[test]
fn ref_008_rejects_empty_tag_in_sibling_tag() {
assert!(matches!(
TaskRef::parse("[]~:compile"),
Err(TaskRefError::InvalidName {
component: NameComponent::Tag,
source: NameError::Empty,
})
));
}
#[test]
fn ref_006_dispatch_same_project() {
let r = TaskRef::parse("~:compile").unwrap();
assert!(r.is_same_project());
}
#[test]
fn ref_006_dispatch_fan_out() {
let r = TaskRef::parse("^:compile").unwrap();
assert!(r.is_fan_out());
}
#[test]
fn ref_006_dispatch_sibling_fan_out() {
let r = TaskRef::parse("^~:compile").unwrap();
assert!(r.is_sibling_fan_out());
}
#[test]
fn ref_006_dispatch_tag() {
let r = TaskRef::parse("[t]:compile").unwrap();
assert!(r.is_tag());
}
#[test]
fn ref_006_dispatch_sibling_tag() {
let r = TaskRef::parse("[t]~:compile").unwrap();
assert!(r.is_sibling_tag());
}
#[test]
fn ref_006_dispatch_project_default() {
let r = TaskRef::parse("p:t").unwrap();
assert!(r.is_project());
}
#[test]
fn ref_006_rejects_tilde_without_colon() {
assert!(matches!(
TaskRef::parse("~compile"),
Err(TaskRefError::MissingColon)
));
}
#[test]
fn ref_006_rejects_caret_without_colon() {
assert!(matches!(
TaskRef::parse("^compile"),
Err(TaskRefError::MissingColon)
));
}
#[test]
fn ref_006_rejects_tag_missing_close_bracket() {
assert!(matches!(
TaskRef::parse("[tag"),
Err(TaskRefError::MalformedTagBrackets)
));
}
#[test]
fn ref_006_rejects_tag_missing_colon_after_close() {
assert!(matches!(
TaskRef::parse("[tag]compile"),
Err(TaskRefError::MalformedTagBrackets)
));
}
#[test]
fn ref_006_rejects_sibling_fan_out_without_colon() {
assert!(matches!(
TaskRef::parse("^~compile"),
Err(TaskRefError::MissingColon)
));
}
#[test]
fn ref_006_rejects_sibling_tag_without_colon() {
assert!(matches!(
TaskRef::parse("[tag]~compile"),
Err(TaskRefError::MalformedTagBrackets)
));
}
#[test]
fn ref_006_rejects_sibling_tag_bare_tilde_after_close() {
assert!(matches!(
TaskRef::parse("[tag]~"),
Err(TaskRefError::MalformedTagBrackets)
));
}
#[test]
fn ref_006_rejects_tilde_suffix_on_project_name() {
assert!(matches!(
TaskRef::parse("lib~:compile"),
Err(TaskRefError::InvalidName {
component: NameComponent::Project,
source: NameError::InvalidChar { c: '~' },
})
));
}
#[test]
fn ref_006_rejects_no_colon_at_all() {
assert!(matches!(
TaskRef::parse("compile"),
Err(TaskRefError::MissingColon)
));
}
#[test]
fn ref_006_rejects_empty_input() {
assert!(matches!(TaskRef::parse(""), Err(TaskRefError::EmptyInput)));
}
#[test]
fn ref_007_rejects_space_after_same_project_sigil() {
assert!(matches!(
TaskRef::parse("~: foo"),
Err(TaskRefError::InvalidName {
component: NameComponent::Task,
source: NameError::InvalidLeadingChar { c: ' ' },
})
));
}
#[test]
fn ref_007_rejects_non_ascii_in_task() {
assert!(matches!(
TaskRef::parse("~:caf\u{00E9}"),
Err(TaskRefError::InvalidName {
component: NameComponent::Task,
source: NameError::InvalidChar { c: '\u{00E9}' },
})
));
}
#[test]
fn ref_007_rejects_tab_in_project() {
assert!(matches!(
TaskRef::parse("lib\tcore:compile"),
Err(TaskRefError::InvalidName {
component: NameComponent::Project,
source: NameError::InvalidChar { c: '\t' },
})
));
}
#[test]
fn round_trip_same_project() {
let original = "~:compile";
let r = TaskRef::parse(original).unwrap();
assert_eq!(r.to_string(), original);
assert_eq!(TaskRef::parse(&r.to_string()).unwrap(), r);
}
#[test]
fn round_trip_project() {
let original = "lib_core:compile";
let r = TaskRef::parse(original).unwrap();
assert_eq!(r.to_string(), original);
assert_eq!(TaskRef::parse(&r.to_string()).unwrap(), r);
}
#[test]
fn round_trip_fan_out() {
let original = "^:test";
let r = TaskRef::parse(original).unwrap();
assert_eq!(r.to_string(), original);
assert_eq!(TaskRef::parse(&r.to_string()).unwrap(), r);
}
#[test]
fn round_trip_tag() {
let original = "[typescript]:compile";
let r = TaskRef::parse(original).unwrap();
assert_eq!(r.to_string(), original);
assert_eq!(TaskRef::parse(&r.to_string()).unwrap(), r);
}
#[test]
fn round_trip_sibling_fan_out() {
let original = "^~:test";
let r = TaskRef::parse(original).unwrap();
assert_eq!(r.to_string(), original);
assert_eq!(TaskRef::parse(&r.to_string()).unwrap(), r);
}
#[test]
fn round_trip_sibling_tag() {
let original = "[typescript]~:compile";
let r = TaskRef::parse(original).unwrap();
assert_eq!(r.to_string(), original);
assert_eq!(TaskRef::parse(&r.to_string()).unwrap(), r);
}
use proptest::prelude::*;
fn name_strategy() -> impl Strategy<Value = String> {
"[A-Za-z0-9][A-Za-z0-9_-]{0,15}"
}
proptest! {
#[test]
fn prop_same_project_round_trips(task in name_strategy()) {
let s = format!("~:{task}");
let r = TaskRef::parse(&s).unwrap();
prop_assert!(r.is_same_project());
prop_assert_eq!(r.to_string(), s);
}
#[test]
fn prop_project_round_trips(
project in name_strategy(),
task in name_strategy(),
) {
let s = format!("{project}:{task}");
let r = TaskRef::parse(&s).unwrap();
prop_assert!(r.is_project());
prop_assert_eq!(r.to_string(), s);
}
#[test]
fn prop_fan_out_round_trips(task in name_strategy()) {
let s = format!("^:{task}");
let r = TaskRef::parse(&s).unwrap();
prop_assert!(r.is_fan_out());
prop_assert_eq!(r.to_string(), s);
}
#[test]
fn prop_sibling_fan_out_round_trips(task in name_strategy()) {
let s = format!("^~:{task}");
let r = TaskRef::parse(&s).unwrap();
prop_assert!(r.is_sibling_fan_out());
prop_assert_eq!(r.to_string(), s);
}
#[test]
fn prop_tag_round_trips(
tag in name_strategy(),
task in name_strategy(),
) {
let s = format!("[{tag}]:{task}");
let r = TaskRef::parse(&s).unwrap();
prop_assert!(r.is_tag());
prop_assert_eq!(r.to_string(), s);
}
#[test]
fn prop_sibling_tag_round_trips(
tag in name_strategy(),
task in name_strategy(),
) {
let s = format!("[{tag}]~:{task}");
let r = TaskRef::parse(&s).unwrap();
prop_assert!(r.is_sibling_tag());
prop_assert_eq!(r.to_string(), s);
}
}
}