use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GoalRef {
Charter(String),
Task(String),
Setpoint(String),
}
impl GoalRef {
pub fn kind(&self) -> &'static str {
match self {
GoalRef::Charter(_) => "charter",
GoalRef::Task(_) => "task",
GoalRef::Setpoint(_) => "setpoint",
}
}
pub fn id(&self) -> &str {
match self {
GoalRef::Charter(id) | GoalRef::Task(id) | GoalRef::Setpoint(id) => id,
}
}
pub fn parse_lenient(s: &str) -> Option<GoalRef> {
s.parse().ok()
}
}
impl serde::Serialize for GoalRef {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.collect_str(self)
}
}
pub fn de_lenient<'de, D>(d: D) -> Result<Option<GoalRef>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize;
Ok(Option::<String>::deserialize(d)?
.as_deref()
.and_then(GoalRef::parse_lenient))
}
pub fn de_lenient_vec<'de, D>(d: D) -> Result<Vec<GoalRef>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize;
Ok(Vec::<String>::deserialize(d)?
.iter()
.filter_map(|s| GoalRef::parse_lenient(s))
.collect())
}
impl fmt::Display for GoalRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}", self.kind(), self.id())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseGoalRefError(String);
impl fmt::Display for ParseGoalRefError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for ParseGoalRefError {}
impl FromStr for GoalRef {
type Err = ParseGoalRefError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let s = s.trim();
let Some((kind, id)) = s.split_once(':') else {
return Err(ParseGoalRefError(format!(
"`{s}` is not a goal reference; expected `task:<id>`"
)));
};
let id = id.trim();
if id.is_empty() {
return Err(ParseGoalRefError(format!(
"`{s}` names a kind with no identifier"
)));
}
match kind.trim() {
"charter" => Ok(GoalRef::Charter(id.to_string())),
"task" => Ok(GoalRef::Task(id.to_string())),
"setpoint" => Ok(GoalRef::Setpoint(id.to_string())),
other => Err(ParseGoalRefError(format!(
"`{other}` is not a kind of goal; expected charter, task or setpoint"
))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_reference_round_trips_through_its_wire_form() {
for original in [
GoalRef::Task("01J8ZK".into()),
GoalRef::Charter("do-no-harm".into()),
GoalRef::Setpoint("attention-debt".into()),
] {
let rendered = original.to_string();
assert_eq!(rendered.parse::<GoalRef>().unwrap(), original);
}
}
#[test]
fn an_id_may_contain_a_colon_because_only_the_first_one_separates() {
let r: GoalRef = "task:urn:uid:7".parse().unwrap();
assert_eq!(r, GoalRef::Task("urn:uid:7".into()));
assert_eq!(r.to_string(), "task:urn:uid:7");
}
#[test]
fn a_malformed_reference_says_what_was_wrong() {
let no_colon = "notes.md".parse::<GoalRef>().unwrap_err().to_string();
assert!(no_colon.contains("not a goal reference"), "{no_colon}");
let bad_kind = "banana:7".parse::<GoalRef>().unwrap_err().to_string();
assert!(bad_kind.contains("not a kind of goal"), "{bad_kind}");
let no_id = "task:".parse::<GoalRef>().unwrap_err().to_string();
assert!(no_id.contains("no identifier"), "{no_id}");
}
#[test]
fn a_record_with_an_unknown_kind_degrades_to_no_reference() {
assert_eq!(GoalRef::parse_lenient("epic:7"), None);
assert_eq!(GoalRef::parse_lenient("notes.md"), None);
assert_eq!(GoalRef::parse_lenient("task:"), None);
assert_eq!(
GoalRef::parse_lenient("task:7"),
Some(GoalRef::Task("7".into()))
);
}
#[test]
fn surrounding_whitespace_is_not_part_of_the_identifier() {
assert_eq!(
" task: 01J8ZK ".parse::<GoalRef>().unwrap(),
GoalRef::Task("01J8ZK".into())
);
}
}