#![doc = include_str!("../README.md")]
use std::fmt::{Display, Formatter};
use std::os::unix::fs::MetadataExt;
use std::str::FromStr;
#[derive(Debug)]
pub enum Error {
IO(std::io::Error),
Utf8Error(std::str::Utf8Error),
MissingBody,
UnknownHeader(String),
DuplicateHeader(String),
InvalidTitle(String),
InvalidOwner(String),
InvalidAccountable(String),
InvalidStatus(String),
InvalidSize(String),
InvalidPriority(String),
InvalidExtension(String),
MissingParent(String),
}
impl From<std::io::Error> for Error {
fn from(e: std::io::Error) -> Self {
Error::IO(e)
}
}
impl From<std::str::Utf8Error> for Error {
fn from(e: std::str::Utf8Error) -> Self {
Error::Utf8Error(e)
}
}
#[derive(Debug, Eq, PartialEq)]
pub enum Status {
Proposed,
Planned,
InProgress,
InReview,
Completed,
}
impl FromStr for Status {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"proposed" => Ok(Status::Proposed),
"planned" => Ok(Status::Planned),
"in-progress" => Ok(Status::InProgress),
"in-review" => Ok(Status::InReview),
"completed" => Ok(Status::Completed),
_ => Err(Error::InvalidStatus(s.to_string())),
}
}
}
impl Display for Status {
fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
match self {
Status::Proposed => write!(f, "proposed"),
Status::Planned => write!(f, "planned"),
Status::InProgress => write!(f, "in-progress"),
Status::InReview => write!(f, "in-review"),
Status::Completed => write!(f, "completed"),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TShirtSize {
XXXL,
XXL,
XL,
L,
M,
S,
XS,
}
impl TShirtSize {
pub fn cost_in_days(&self) -> usize {
match self {
TShirtSize::XXXL => usize::MAX,
TShirtSize::XXL => 6 * 28,
TShirtSize::XL => 3 * 28,
TShirtSize::L => 3 * 14,
TShirtSize::M => 7,
TShirtSize::S => 1,
TShirtSize::XS => 0,
}
}
pub fn cannot_contain(&self, sub_tasks: &[TShirtSize]) -> bool {
self.cost_in_days() < sub_tasks.iter().map(|t| t.cost_in_days()).sum()
}
}
impl FromStr for TShirtSize {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"XXXL" => Ok(TShirtSize::XXXL),
"XXL" => Ok(TShirtSize::XXL),
"XL" => Ok(TShirtSize::XL),
"L" => Ok(TShirtSize::L),
"M" => Ok(TShirtSize::M),
"S" => Ok(TShirtSize::S),
"XS" => Ok(TShirtSize::XS),
_ => Err(Error::InvalidSize(s.to_string())),
}
}
}
impl Display for TShirtSize {
fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
match self {
TShirtSize::XXXL => write!(f, "XXXL"),
TShirtSize::XXL => write!(f, "XXL"),
TShirtSize::XL => write!(f, "XL"),
TShirtSize::L => write!(f, "L"),
TShirtSize::M => write!(f, "M"),
TShirtSize::S => write!(f, "S"),
TShirtSize::XS => write!(f, "XS"),
}
}
}
#[derive(Debug, Eq, PartialEq)]
pub enum Header {
Title(String),
Owner(String),
Accountable(String),
Status(Status),
Size(TShirtSize),
Priority(u64),
Parent(utf8path::Path<'static>),
Custom(String, String),
}
impl Header {
fn key(&self) -> &str {
match self {
Header::Title(_) => "title",
Header::Owner(_) => "owner",
Header::Accountable(_) => "accountable",
Header::Status(_) => "status",
Header::Size(_) => "size",
Header::Priority(_) => "priority",
Header::Parent(_) => "parent",
Header::Custom(key, _) => key,
}
}
}
impl FromStr for Header {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (header, value) = s.split_once(": ").unwrap();
let value = value.trim();
match header {
"title" => {
if value.is_empty() {
Err(Error::InvalidTitle(value.to_string()))
} else {
Ok(Header::Title(value.to_string()))
}
}
"owner" => {
if value.is_empty() || !is_github_username(value) {
Err(Error::InvalidOwner(value.to_string()))
} else {
Ok(Header::Owner(value.to_string()))
}
}
"accountable" => {
if !is_github_username(value) {
Err(Error::InvalidAccountable(value.to_string()))
} else {
Ok(Header::Accountable(value.to_string()))
}
}
"status" => {
let status = value.parse::<Status>()?;
Ok(Header::Status(status))
}
"size" => {
let size = value.parse::<TShirtSize>()?;
Ok(Header::Size(size))
}
"priority" => {
let priority = value
.parse::<u64>()
.map_err(|_| Error::InvalidPriority(value.to_string()))?;
Ok(Header::Priority(priority))
}
"parent" => Ok(Header::Parent(utf8path::Path::from(value).into_owned())),
_ => {
if header.starts_with("x-gh-")
&& is_github_username(header.chars().skip(5).collect::<String>())
{
Ok(Header::Custom(header.to_string(), value.to_string()))
} else {
Err(Error::InvalidExtension(header.to_string()))
}
}
}
}
}
impl Display for Header {
fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
match self {
Header::Title(title) => write!(f, "title: {}", title.trim()),
Header::Owner(owner) => write!(f, "owner: {}", owner.trim()),
Header::Accountable(accountable) => write!(f, "accountable: {}", accountable.trim()),
Header::Status(status) => write!(f, "status: {}", status),
Header::Size(size) => write!(f, "size: {}", size),
Header::Priority(priority) => write!(f, "priority: {}", priority),
Header::Parent(parent) => write!(f, "parent: {}", parent),
Header::Custom(header, value) => write!(f, "{}: {}", header.trim(), value.trim()),
}
}
}
#[derive(Debug, Eq, PartialEq)]
pub struct Objective {
pub headers: Vec<Header>,
pub body: String,
}
impl Objective {
pub fn parent(&self) -> Option<utf8path::Path<'_>> {
self.headers.iter().find_map(|header| match header {
Header::Parent(parent) => Some(parent.clone()),
_ => None,
})
}
pub fn owner(&self) -> Option<String> {
self.headers.iter().find_map(|header| match header {
Header::Owner(owner) => Some(owner.clone()),
_ => None,
})
}
pub fn accountable(&self) -> Option<String> {
self.headers.iter().find_map(|header| match header {
Header::Accountable(accountable) => Some(accountable.clone()),
_ => None,
})
}
pub fn size(&self) -> TShirtSize {
self.headers
.iter()
.find_map(|header| match header {
Header::Size(size) => Some(*size),
_ => None,
})
.unwrap_or(TShirtSize::XXXL)
}
pub fn priority(&self) -> u64 {
self.headers
.iter()
.find_map(|header| match header {
Header::Priority(priority) => Some(*priority),
_ => None,
})
.unwrap_or(0)
}
}
impl FromStr for Objective {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Some((headers, body)) = s.split_once("\n\n") {
let headers = headers
.split('\n')
.map(|s| s.parse::<Header>())
.collect::<Result<Vec<_>, Error>>()?;
for (idx1, h1) in headers.iter().enumerate() {
for (idx2, h2) in headers.iter().enumerate() {
if idx1 != idx2 && h1.key() == h2.key() {
return Err(Error::DuplicateHeader(h1.key().to_string()));
}
}
}
Ok(Objective {
headers,
body: body.to_string(),
})
} else {
Err(Error::MissingBody)
}
}
}
impl Display for Objective {
fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
for header in self.headers.iter() {
writeln!(f, "{}", header)?;
}
write!(f, "\n{}\n", self.body.trim())
}
}
#[derive(Debug, Default, Eq, PartialEq)]
pub struct ObjectiveGraph {
objectives: Vec<(utf8path::Path<'static>, Objective)>,
parent_to_child: Vec<(usize, usize)>,
child_to_parent: Vec<(usize, usize)>,
}
impl ObjectiveGraph {
pub fn load<'a>(root: impl Into<utf8path::Path<'a>>) -> Result<Self, Error> {
let root = root.into().into_owned();
let objectives = Self::load_recursive(root)?;
let mut parent_to_child = vec![];
let mut child_to_parent = vec![];
for (child_idx, child) in objectives.iter().enumerate() {
for (parent_idx, parent) in objectives.iter().enumerate() {
let Some(child_parent) = child.1.parent() else {
continue;
};
fn is_same_file(
lhs: utf8path::Path<'_>,
rhs: utf8path::Path<'_>,
) -> Result<bool, Error> {
let lhs = lhs.into_std().metadata()?;
let rhs = rhs.into_std().metadata()?;
Ok(lhs.dev() == rhs.dev() && lhs.ino() == rhs.ino())
}
if is_same_file(child_parent, parent.0.clone())? {
parent_to_child.push((parent_idx, child_idx));
child_to_parent.push((child_idx, parent_idx));
}
}
}
Ok(Self {
objectives,
parent_to_child,
child_to_parent,
})
}
pub fn merge(&mut self, other: Self) {
let offset = self.objectives.len();
self.objectives.extend(other.objectives);
self.parent_to_child.extend(
other
.parent_to_child
.into_iter()
.map(|(a, b)| (a + offset, b + offset)),
);
self.child_to_parent.extend(
other
.child_to_parent
.into_iter()
.map(|(a, b)| (a + offset, b + offset)),
);
}
pub fn report(&mut self) -> Result<Vec<(Lint, &'static str)>, Error> {
let mut lint = vec![];
for (child_idx, _) in self.parent_to_child.iter() {
let mut visited = vec![false; self.objectives.len()];
let mut stack = vec![child_idx];
while let Some(idx) = stack.pop().copied() {
if visited[idx] {
lint.push((
Lint::GraphNotADag(self.objectives[idx].0.clone()),
"cycle detected",
));
return Ok(lint);
}
visited[idx] = true;
for (_, child) in self
.parent_to_child
.iter()
.filter(|(parent, _)| *parent == idx)
{
stack.push(child);
}
}
}
for (path, objective) in self.objectives.iter() {
if objective.owner().is_none() && objective.accountable().is_none() {
lint.push((
Lint::InvalidOwnership(path.clone()),
"neither owner nor accountable were set",
));
continue;
}
if objective.owner().is_some() && objective.accountable().is_some() {
lint.push((
Lint::InvalidOwnership(path.clone()),
"both owner and accountable were set",
));
continue;
}
if objective.accountable().is_some() && objective.parent().is_none() {
lint.push((
Lint::InvalidOwnership(path.clone()),
"no parent was set for accountable",
));
continue;
}
if objective.owner().is_some() && objective.parent().is_some() {
lint.push((Lint::InvalidOwnership(path.clone()), "owner with parent"));
continue;
}
}
for (parent_idx, (path, parent)) in self.objectives.iter().enumerate() {
let children = self
.parent_to_child
.iter()
.filter(|(parent, _)| *parent == parent_idx)
.map(|(_, child_idx)| &self.objectives[*child_idx].1)
.collect::<Vec<_>>();
let mut grouped_by_owner = children
.iter()
.map(|child| (child.owner().or(child.accountable()).unwrap(), child))
.collect::<Vec<_>>();
grouped_by_owner.sort_by_key(|(owner, _)| owner.clone());
let uniq_owners = grouped_by_owner
.iter()
.map(|(owner, _)| owner)
.collect::<std::collections::HashSet<_>>();
let mut uniq_owners = uniq_owners.iter().collect::<Vec<_>>();
uniq_owners.sort();
for owner in uniq_owners {
let shirts = grouped_by_owner
.iter()
.filter(|(o, _)| o == *owner)
.map(|(_, child)| child)
.map(|child| child.size())
.collect::<Vec<_>>();
if parent.size().cannot_contain(&shirts) {
lint.push((
Lint::TooMuchWork(path.clone(), owner.to_string()),
"too much work",
));
}
}
}
for (parent_idx, (parent_path, parent)) in self.objectives.iter().enumerate() {
let children = self
.parent_to_child
.iter()
.filter(|(parent, _)| *parent == parent_idx)
.map(|(_, child_idx)| &self.objectives[*child_idx])
.collect::<Vec<_>>();
for (child_path, child) in children {
if parent.priority() < child.priority() {
lint.push((
Lint::HigherPriority(parent_path.clone(), child_path.clone()),
"child has higher-number/lower-priority than its parent",
));
}
}
}
Ok(lint)
}
fn load_recursive(root: utf8path::Path) -> Result<Vec<(utf8path::Path, Objective)>, Error> {
let mut objectives = vec![];
for entry in std::fs::read_dir(root.clone())? {
let entry = entry.unwrap();
let path = utf8path::Path::try_from(entry.path())?;
if path.clone().into_std().is_file() {
let contents = std::fs::read_to_string(&path)?;
let mut objective = contents.parse::<Objective>()?;
for header in objective.headers.iter_mut() {
if let Header::Parent(parent) = header {
*parent = path.dirname().join(parent.clone()).into_owned();
if !path.clone().into_std().is_file() {
return Err(Error::MissingParent(parent.to_string()));
}
}
}
objectives.push((path, objective));
} else if path.clone().into_std().is_dir() {
objectives.extend(Self::load_recursive(path)?);
}
}
Ok(objectives)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Lint {
GraphNotADag(utf8path::Path<'static>),
InvalidOwnership(utf8path::Path<'static>),
TooMuchWork(utf8path::Path<'static>, String),
HigherPriority(utf8path::Path<'static>, utf8path::Path<'static>),
}
pub fn is_github_username(s: impl AsRef<str>) -> bool {
let s = s.as_ref();
fn consecutive_underscore(s: &str) -> bool {
s.chars().any(|c| c == '_')
&& s.chars()
.zip(s.chars().skip(1))
.any(|(a, b)| a == '_' && b == '_')
}
s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
&& !s.starts_with('-')
&& !s.ends_with('-')
&& !consecutive_underscore(s)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status() {
assert_eq!(Status::Proposed, "proposed".parse().unwrap());
assert_eq!(Status::Planned, "planned".parse().unwrap());
assert_eq!(Status::InProgress, "in-progress".parse().unwrap());
assert_eq!(Status::InReview, "in-review".parse().unwrap());
assert_eq!(Status::Completed, "completed".parse().unwrap());
}
#[test]
fn tshirt_size() {
assert_eq!(TShirtSize::XXXL, "XXXL".parse().unwrap());
assert_eq!(TShirtSize::XXL, "XXL".parse().unwrap());
assert_eq!(TShirtSize::XL, "XL".parse().unwrap());
assert_eq!(TShirtSize::L, "L".parse().unwrap());
assert_eq!(TShirtSize::M, "M".parse().unwrap());
assert_eq!(TShirtSize::S, "S".parse().unwrap());
assert_eq!(TShirtSize::XS, "XS".parse().unwrap());
}
#[test]
fn header() {
assert_eq!(
Header::Title("foo".to_string()),
"title: foo".parse().unwrap()
);
assert_eq!(
Header::Owner("foo".to_string()),
"owner: foo".parse().unwrap()
);
assert_eq!(
Header::Accountable("foo".to_string()),
"accountable: foo".parse().unwrap()
);
assert_eq!(
Header::Status(Status::Proposed),
"status: proposed".parse().unwrap()
);
assert_eq!(
Header::Size(TShirtSize::XXXL),
"size: XXXL".parse().unwrap()
);
assert_eq!(Header::Priority(42), "priority: 42".parse().unwrap());
assert_eq!(
Header::Custom("x-gh-foo".to_string(), "bar".to_string()),
"x-gh-foo: bar".parse().unwrap()
);
}
#[test]
fn objective() {
assert_eq!(
Objective {
headers: vec![
Header::Title("foo".to_string()),
Header::Owner("bar".to_string()),
Header::Accountable("baz".to_string()),
Header::Status(Status::Proposed),
Header::Size(TShirtSize::XXXL),
Header::Priority(42),
Header::Parent(utf8path::Path::from("../foo.md")),
Header::Custom("x-gh-foo".to_string(), "bar".to_string())
],
body: "quux\n".to_string()
},
r#"title: foo
owner: bar
accountable: baz
status: proposed
size: XXXL
priority: 42
parent: ../foo.md
x-gh-foo: bar
quux
"#
.parse()
.unwrap()
);
assert_eq!(
r#"title: foo
owner: bar
accountable: baz
status: proposed
size: XXXL
priority: 42
parent: ../foo.md
x-gh-foo: bar
quux
"#,
Objective {
headers: vec![
Header::Title("foo".to_string()),
Header::Owner("bar".to_string()),
Header::Accountable("baz".to_string()),
Header::Status(Status::Proposed),
Header::Size(TShirtSize::XXXL),
Header::Priority(42),
Header::Parent(utf8path::Path::from("../foo.md")),
Header::Custom("x-gh-foo".to_string(), "bar".to_string())
],
body: "quux\n".to_string()
}
.to_string(),
);
}
#[test]
fn objective_graph_not_dag() {
let mut objective_graph = ObjectiveGraph::default();
objective_graph.objectives.push((
utf8path::Path::from("foo.md"),
Objective {
headers: vec![
Header::Title("foo".to_string()),
Header::Owner("bar".to_string()),
Header::Accountable("baz".to_string()),
Header::Status(Status::Proposed),
Header::Size(TShirtSize::XXXL),
Header::Priority(42),
Header::Parent(utf8path::Path::from("bar.md")),
Header::Custom("x-gh-foo".to_string(), "bar".to_string()),
],
body: "".to_string(),
},
));
objective_graph.objectives.push((
utf8path::Path::from("foo.md"),
Objective {
headers: vec![
Header::Title("foo".to_string()),
Header::Owner("bar".to_string()),
Header::Accountable("baz".to_string()),
Header::Status(Status::Proposed),
Header::Size(TShirtSize::XXXL),
Header::Priority(42),
Header::Parent(utf8path::Path::from("foo.md")),
Header::Custom("x-gh-foo".to_string(), "bar".to_string()),
],
body: "".to_string(),
},
));
objective_graph.parent_to_child.push((0, 1));
objective_graph.parent_to_child.push((1, 0));
objective_graph.child_to_parent.push((0, 1));
objective_graph.child_to_parent.push((1, 0));
let lint = vec![(
Lint::GraphNotADag(utf8path::Path::from("foo.md").into_owned()),
"cycle detected",
)];
assert_eq!(lint, objective_graph.report().unwrap());
}
}