use crate::config::{RepoMode, ResolvedConfig};
use crate::error::{McError, McResult};
use crate::frontmatter;
use regex::Regex;
use std::fmt;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntityKind {
Customer,
Project,
Meeting,
Research,
Task,
Sprint,
Proposal,
Contact,
}
impl EntityKind {
pub fn label(&self) -> &'static str {
match self {
EntityKind::Customer => "customer",
EntityKind::Project => "project",
EntityKind::Meeting => "meeting",
EntityKind::Research => "research",
EntityKind::Task => "task",
EntityKind::Sprint => "sprint",
EntityKind::Proposal => "proposal",
EntityKind::Contact => "contact",
}
}
pub fn label_plural(&self) -> &'static str {
match self {
EntityKind::Customer => "customers",
EntityKind::Project => "projects",
EntityKind::Meeting => "meetings",
EntityKind::Research => "research",
EntityKind::Task => "tasks",
EntityKind::Sprint => "sprints",
EntityKind::Proposal => "proposals",
EntityKind::Contact => "contacts",
}
}
pub fn prefix<'a>(&self, cfg: &'a ResolvedConfig) -> &'a str {
match self {
EntityKind::Customer => &cfg.id_prefixes.customer,
EntityKind::Project => &cfg.id_prefixes.project,
EntityKind::Meeting => &cfg.id_prefixes.meeting,
EntityKind::Research => &cfg.id_prefixes.research,
EntityKind::Task => &cfg.id_prefixes.task,
EntityKind::Sprint => &cfg.id_prefixes.sprint,
EntityKind::Proposal => &cfg.id_prefixes.proposal,
EntityKind::Contact => &cfg.id_prefixes.contact,
}
}
pub fn base_dir<'a>(&self, cfg: &'a ResolvedConfig) -> &'a Path {
match self {
EntityKind::Customer => &cfg.customers_dir,
EntityKind::Project => &cfg.projects_dir,
EntityKind::Meeting => &cfg.meetings_dir,
EntityKind::Research => &cfg.research_dir,
EntityKind::Task => &cfg.tasks_dir,
EntityKind::Sprint => &cfg.sprints_dir,
EntityKind::Proposal => &cfg.proposals_dir,
EntityKind::Contact => &cfg.customers_dir, }
}
pub fn statuses<'a>(&self, cfg: &'a ResolvedConfig) -> &'a [String] {
match self {
EntityKind::Customer => &cfg.statuses.customer,
EntityKind::Project => &cfg.statuses.project,
EntityKind::Meeting => &cfg.statuses.meeting,
EntityKind::Research => &cfg.statuses.research,
EntityKind::Task => &cfg.statuses.task,
EntityKind::Sprint => &cfg.statuses.sprint,
EntityKind::Proposal => &cfg.statuses.proposal,
EntityKind::Contact => &cfg.statuses.contact,
}
}
#[allow(dead_code)]
pub fn available_in_mode(&self, mode: RepoMode) -> bool {
match mode {
RepoMode::Standalone => true,
RepoMode::Embedded => matches!(
self,
EntityKind::Task
| EntityKind::Meeting
| EntityKind::Research
| EntityKind::Sprint
| EntityKind::Proposal
),
}
}
pub fn from_str_loose(s: &str) -> McResult<Self> {
match s.to_lowercase().as_str() {
"customer" | "customers" => Ok(EntityKind::Customer),
"project" | "projects" => Ok(EntityKind::Project),
"meeting" | "meetings" => Ok(EntityKind::Meeting),
"research" => Ok(EntityKind::Research),
"task" | "tasks" => Ok(EntityKind::Task),
"sprint" | "sprints" => Ok(EntityKind::Sprint),
"proposal" | "proposals" | "prop" => Ok(EntityKind::Proposal),
"contact" | "contacts" => Ok(EntityKind::Contact),
_ => Err(McError::Other(format!("Unknown entity kind: {s}"))),
}
}
pub fn from_id(id: &str, cfg: &ResolvedConfig) -> McResult<Self> {
if id.starts_with(&format!("{}-", cfg.id_prefixes.customer)) {
Ok(EntityKind::Customer)
} else if id.starts_with(&format!("{}-", cfg.id_prefixes.project)) {
Ok(EntityKind::Project)
} else if id.starts_with(&format!("{}-", cfg.id_prefixes.meeting)) {
Ok(EntityKind::Meeting)
} else if id.starts_with(&format!("{}-", cfg.id_prefixes.research)) {
Ok(EntityKind::Research)
} else if id.starts_with(&format!("{}-", cfg.id_prefixes.task)) {
Ok(EntityKind::Task)
} else if id.starts_with(&format!("{}-", cfg.id_prefixes.sprint)) {
Ok(EntityKind::Sprint)
} else if id.starts_with(&format!("{}-", cfg.id_prefixes.proposal)) {
Ok(EntityKind::Proposal)
} else if id.starts_with(&format!("{}-", cfg.id_prefixes.contact)) {
Ok(EntityKind::Contact)
} else {
Err(McError::InvalidId(format!(
"{} (expected format like {}-001, {}-002, {}-003, {}-001, {}-001, {}-001, {}-001, or {}-001)",
id,
cfg.id_prefixes.customer,
cfg.id_prefixes.project,
cfg.id_prefixes.meeting,
cfg.id_prefixes.research,
cfg.id_prefixes.task,
cfg.id_prefixes.sprint,
cfg.id_prefixes.proposal,
cfg.id_prefixes.contact,
)))
}
}
}
impl fmt::Display for EntityKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.label())
}
}
#[derive(Debug, Clone)]
pub struct EntityId {
pub prefix: String,
pub number: u32,
}
impl EntityId {
pub fn new(prefix: &str, number: u32) -> Self {
Self {
prefix: prefix.to_string(),
number,
}
}
pub fn to_string_padded(&self) -> String {
format!("{}-{:03}", self.prefix, self.number)
}
}
impl fmt::Display for EntityId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_string_padded())
}
}
pub struct TaskLocation {
pub tasks_dir: PathBuf,
}
pub fn collect_all_task_dirs(cfg: &ResolvedConfig) -> Vec<TaskLocation> {
let mut locations = Vec::new();
locations.push(TaskLocation {
tasks_dir: cfg.tasks_dir.clone(),
});
if cfg.projects_dir.is_dir() {
if let Ok(entries) = std::fs::read_dir(&cfg.projects_dir) {
for entry in entries.filter_map(|e| e.ok()) {
if entry.file_type().is_ok_and(|ft| ft.is_dir()) {
let tasks_subdir = entry.path().join("tasks");
locations.push(TaskLocation {
tasks_dir: tasks_subdir,
});
}
}
}
}
if cfg.customers_dir.is_dir() {
if let Ok(entries) = std::fs::read_dir(&cfg.customers_dir) {
for entry in entries.filter_map(|e| e.ok()) {
if entry.file_type().is_ok_and(|ft| ft.is_dir()) {
let tasks_subdir = entry.path().join("tasks");
locations.push(TaskLocation {
tasks_dir: tasks_subdir,
});
}
}
}
}
locations
}
pub struct ContactLocation {
pub contacts_dir: PathBuf,
}
pub fn collect_all_contact_dirs(cfg: &ResolvedConfig) -> Vec<ContactLocation> {
let mut locations = Vec::new();
if cfg.customers_dir.is_dir() {
if let Ok(entries) = std::fs::read_dir(&cfg.customers_dir) {
for entry in entries.filter_map(|e| e.ok()) {
if entry.file_type().is_ok_and(|ft| ft.is_dir()) {
let contacts_subdir = entry.path().join("contacts");
locations.push(ContactLocation {
contacts_dir: contacts_subdir,
});
}
}
}
}
locations
}
pub fn next_id(kind: EntityKind, cfg: &ResolvedConfig) -> McResult<EntityId> {
let prefix = kind.prefix(cfg);
let mut max_num: u32 = 0;
let id_re = Regex::new(&format!(r"^{}-(\d+)", regex::escape(prefix)))
.expect("regex with escaped prefix is always valid");
match kind {
EntityKind::Customer | EntityKind::Project | EntityKind::Research | EntityKind::Sprint => {
let base = kind.base_dir(cfg);
if base.is_dir() {
for entry in std::fs::read_dir(base)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
let name = entry.file_name();
let name = name.to_string_lossy();
if let Some(caps) = id_re.captures(&name) {
if let Ok(n) = caps[1].parse::<u32>() {
max_num = max_num.max(n);
}
}
}
}
}
}
EntityKind::Meeting | EntityKind::Proposal => {
let base = kind.base_dir(cfg);
if base.is_dir() {
for entry in WalkDir::new(base).into_iter().filter_map(|e| e.ok()) {
let path = entry.path();
if path.extension().is_some_and(|e| e == "md") {
if let Ok(content) = std::fs::read_to_string(path) {
if let Some((fm_str, _)) = frontmatter::split_frontmatter(&content) {
if let Ok(val) = frontmatter::parse_raw(&fm_str, path) {
if let Some(id_val) = frontmatter::get_str(&val, "id") {
if let Some(caps) = id_re.captures(id_val) {
if let Ok(n) = caps[1].parse::<u32>() {
max_num = max_num.max(n);
}
}
}
}
}
}
}
}
}
}
EntityKind::Task => {
let locations = collect_all_task_dirs(cfg);
for loc in &locations {
for subfolder in &["todo", "done"] {
let dir = loc.tasks_dir.join(subfolder);
if dir.is_dir() {
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.filter_map(|e| e.ok()) {
let name = entry.file_name();
let name = name.to_string_lossy();
if let Some(caps) = id_re.captures(&name) {
if let Ok(n) = caps[1].parse::<u32>() {
max_num = max_num.max(n);
}
}
}
}
}
}
}
}
EntityKind::Contact => {
let locations = collect_all_contact_dirs(cfg);
for loc in &locations {
if loc.contacts_dir.is_dir() {
if let Ok(entries) = std::fs::read_dir(&loc.contacts_dir) {
for entry in entries.filter_map(|e| e.ok()) {
let name = entry.file_name();
let name = name.to_string_lossy();
if let Some(caps) = id_re.captures(&name) {
if let Ok(n) = caps[1].parse::<u32>() {
max_num = max_num.max(n);
}
}
}
}
}
}
}
}
Ok(EntityId::new(prefix, max_num + 1))
}