use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::config::{Config, EpicId, InitiativeConfiguration, InitiativeId, ProjectId};
use crate::error::{Result, TixError};
use crate::model::Priority;
use crate::projects::Patch;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct InitiativeExtensions(toml::Table);
impl InitiativeExtensions {
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.0.len()
}
#[must_use]
pub fn get(&self, key: &str) -> Option<&toml::Value> {
self.0.get(key)
}
pub fn keys(&self) -> impl Iterator<Item = &str> {
self.0.keys().map(String::as_str)
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &toml::Value)> {
self.0.iter().map(|(k, v)| (k.as_str(), v))
}
#[must_use]
pub fn table(&self) -> &toml::Table {
&self.0
}
pub fn insert(&mut self, key: impl Into<String>, value: toml::Value) -> Option<toml::Value> {
self.0.insert(key.into(), value)
}
}
impl PartialEq for InitiativeExtensions {
fn eq(&self, other: &Self) -> bool {
toml::Value::Table(self.0.clone()).to_string()
== toml::Value::Table(other.0.clone()).to_string()
}
}
impl Eq for InitiativeExtensions {}
impl From<toml::Table> for InitiativeExtensions {
fn from(table: toml::Table) -> Self {
Self(table)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum AssociationKind {
Project,
Epic,
Milestone,
}
impl AssociationKind {
#[must_use]
pub fn field(self) -> &'static str {
match self {
AssociationKind::Project => "projects",
AssociationKind::Epic => "epics",
AssociationKind::Milestone => "milestones",
}
}
fn normalize(self, value: &str) -> Result<String> {
match self {
AssociationKind::Project => ProjectId::new(value)
.map(|id| id.to_string())
.map_err(|err| TixError::Config(format!("`projects`: {err}"))),
AssociationKind::Epic => EpicId::new(value)
.map(|id| id.to_string())
.map_err(|err| TixError::Config(format!("`epics`: {err}"))),
AssociationKind::Milestone => {
let trimmed = value.trim();
if trimmed.is_empty() {
Err(TixError::Config(
"`milestones` must not contain a blank value".into(),
))
} else {
Ok(trimmed.to_string())
}
}
}
}
}
impl std::fmt::Display for AssociationKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.field())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InitiativeAssociation {
pub initiative: InitiativeId,
pub kind: AssociationKind,
pub target: String,
}
impl InitiativeAssociation {
#[must_use]
pub fn new(initiative: InitiativeId, kind: AssociationKind, target: String) -> Self {
Self {
initiative,
kind,
target,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct NewInitiative {
pub name: String,
pub outcome: Option<String>,
pub owner: Option<String>,
pub status: Option<String>,
pub priority: Option<Priority>,
pub labels: Vec<String>,
pub target_date: Option<String>,
pub projects: Vec<String>,
pub epics: Vec<String>,
pub milestones: Vec<String>,
pub extensions: InitiativeExtensions,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct InitiativeEdit {
pub name: Patch<String>,
pub outcome: Patch<String>,
pub owner: Patch<String>,
pub status: Patch<String>,
pub priority: Patch<Priority>,
pub labels: Patch<Vec<String>>,
pub target_date: Patch<String>,
pub projects: Patch<Vec<String>>,
pub epics: Patch<Vec<String>>,
pub milestones: Patch<Vec<String>>,
}
fn normalize_optional(value: Option<String>) -> Option<String> {
value.filter(|v| !v.trim().is_empty())
}
fn normalize_references(kind: AssociationKind, values: &[String]) -> Result<Vec<String>> {
let mut out: Vec<String> = Vec::new();
for value in values {
let normalized = kind.normalize(value)?;
if !out.iter().any(|stored| stored == &normalized) {
out.push(normalized);
}
}
Ok(out)
}
fn normalize_labels(values: &[String]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for value in values {
if !out.iter().any(|stored| stored == value) {
out.push(value.clone());
}
}
out
}
fn record_from_new(new: &NewInitiative) -> Result<InitiativeConfiguration> {
Ok(InitiativeConfiguration {
name: new.name.clone(),
outcome: normalize_optional(new.outcome.clone()),
owner: normalize_optional(new.owner.clone()),
status: normalize_optional(new.status.clone()),
priority: new.priority.clone(),
labels: normalize_labels(&new.labels),
target_date: new.target_date.clone(),
projects: normalize_references(AssociationKind::Project, &new.projects)?,
epics: normalize_references(AssociationKind::Epic, &new.epics)?,
milestones: normalize_references(AssociationKind::Milestone, &new.milestones)?,
extensions: new.extensions.clone(),
})
}
pub fn resolve_initiative_id(config: &Config, reference: &str) -> Result<InitiativeId> {
if let Ok(id) = InitiativeId::new(reference) {
if config.initiatives.contains_key(id.as_str()) {
return Ok(id);
}
}
let normalized = reference.trim().to_lowercase();
if let Some((raw, _)) = config
.initiatives
.iter()
.find(|(_, record)| record.name.to_lowercase() == normalized)
{
return InitiativeId::new(raw)
.map_err(|_| TixError::InvalidInput(format!("unknown initiative `{reference}`")));
}
Err(TixError::InvalidInput(format!(
"unknown initiative `{reference}`"
)))
}
pub fn ids(config: &Config) -> impl Iterator<Item = &str> {
config.initiatives.keys().map(String::as_str)
}
#[must_use]
pub fn get<'a>(config: &'a Config, id: &InitiativeId) -> Option<&'a InitiativeConfiguration> {
config.initiatives.get(id.as_str())
}
#[must_use]
pub fn associations(config: &Config, id: &InitiativeId) -> Vec<InitiativeAssociation> {
let Some(record) = config.initiatives.get(id.as_str()) else {
return Vec::new();
};
let mut out = Vec::new();
for (kind, targets) in [
(AssociationKind::Project, &record.projects),
(AssociationKind::Epic, &record.epics),
(AssociationKind::Milestone, &record.milestones),
] {
out.extend(
targets
.iter()
.map(|target| InitiativeAssociation::new(id.clone(), kind, target.clone())),
);
}
out
}
#[must_use]
pub fn contains_association(
config: &Config,
id: &InitiativeId,
kind: AssociationKind,
target: &str,
) -> bool {
associations(config, id)
.iter()
.any(|stored| stored.kind == kind && stored.target == target)
}
fn transact<T>(
config: &mut Config,
path: &Path,
mutate: impl FnOnce(&mut Config) -> Result<T>,
) -> Result<T> {
let mut candidate = config.clone();
let outcome = mutate(&mut candidate)?;
candidate.validate()?;
candidate.save(path)?;
*config = candidate;
Ok(outcome)
}
pub fn create_initiative(
config: &mut Config,
path: &Path,
new: &NewInitiative,
) -> Result<InitiativeId> {
let record = record_from_new(new)?;
transact(config, path, |candidate| {
let id = candidate.allocate_initiative_id();
candidate.initiatives.insert(id.to_string(), record.clone());
Ok(id)
})
}
pub fn edit_initiative(
config: &mut Config,
path: &Path,
id: &InitiativeId,
edit: &InitiativeEdit,
) -> Result<InitiativeId> {
let Some(current) = config.initiatives.get(id.as_str()).cloned() else {
return Err(TixError::InvalidInput(format!("unknown initiative `{id}`")));
};
let edited = apply_edit(¤t, edit)?;
if edited == current {
return Ok(id.clone());
}
transact(config, path, |candidate| {
candidate.initiatives.insert(id.to_string(), edited.clone());
Ok(id.clone())
})
}
fn apply_edit(
record: &InitiativeConfiguration,
edit: &InitiativeEdit,
) -> Result<InitiativeConfiguration> {
let mut next = record.clone();
match &edit.name {
Patch::Unchanged => {}
Patch::Clear => next.name.clear(),
Patch::Set(value) => next.name = value.clone(),
}
apply_text_patch(&mut next.outcome, &edit.outcome);
apply_text_patch(&mut next.owner, &edit.owner);
apply_text_patch(&mut next.status, &edit.status);
apply_patch(&mut next.priority, &edit.priority);
if let Patch::Set(labels) = &edit.labels {
next.labels = normalize_labels(labels);
} else if matches!(edit.labels, Patch::Clear) {
next.labels.clear();
}
apply_text_patch(&mut next.target_date, &edit.target_date);
apply_reference_patch(&mut next.projects, AssociationKind::Project, &edit.projects)?;
apply_reference_patch(&mut next.epics, AssociationKind::Epic, &edit.epics)?;
apply_reference_patch(
&mut next.milestones,
AssociationKind::Milestone,
&edit.milestones,
)?;
Ok(next)
}
fn apply_patch<T: Clone>(field: &mut Option<T>, patch: &Patch<T>) {
match patch {
Patch::Unchanged => {}
Patch::Clear => *field = None,
Patch::Set(value) => *field = Some(value.clone()),
}
}
fn apply_text_patch(field: &mut Option<String>, patch: &Patch<String>) {
match patch {
Patch::Unchanged => {}
Patch::Clear => *field = None,
Patch::Set(value) => *field = normalize_optional(Some(value.clone())),
}
}
fn apply_reference_patch(
field: &mut Vec<String>,
kind: AssociationKind,
patch: &Patch<Vec<String>>,
) -> Result<()> {
match patch {
Patch::Unchanged => Ok(()),
Patch::Clear => {
field.clear();
Ok(())
}
Patch::Set(values) => {
*field = normalize_references(kind, values)?;
Ok(())
}
}
}
pub fn delete_initiative(config: &mut Config, path: &Path, id: &InitiativeId) -> Result<()> {
if !config.initiatives.contains_key(id.as_str()) {
return Err(TixError::InvalidInput(format!("unknown initiative `{id}`")));
}
transact(config, path, |candidate| {
candidate.initiatives.shift_remove(id.as_str());
Ok(())
})
}
pub fn associate(
config: &mut Config,
path: &Path,
reference: &str,
kind: AssociationKind,
target: &str,
) -> Result<bool> {
let target = kind.normalize(target)?;
let id = resolve_initiative_id(config, reference)?;
if contains_association(config, &id, kind, &target) {
return Ok(false);
}
transact(config, path, |candidate| {
let Some(record) = candidate.initiatives.get_mut(id.as_str()) else {
return Err(TixError::InvalidInput(format!("unknown initiative `{id}`")));
};
let field = match kind {
AssociationKind::Project => &mut record.projects,
AssociationKind::Epic => &mut record.epics,
AssociationKind::Milestone => &mut record.milestones,
};
if !field.iter().any(|stored| stored == &target) {
field.push(target.clone());
}
Ok(true)
})
}
pub fn dissociate(
config: &mut Config,
path: &Path,
reference: &str,
kind: AssociationKind,
target: &str,
) -> Result<bool> {
let target = kind.normalize(target)?;
let id = resolve_initiative_id(config, reference)?;
if !contains_association(config, &id, kind, &target) {
return Ok(false);
}
transact(config, path, |candidate| {
let Some(record) = candidate.initiatives.get_mut(id.as_str()) else {
return Err(TixError::InvalidInput(format!("unknown initiative `{id}`")));
};
let field = match kind {
AssociationKind::Project => &mut record.projects,
AssociationKind::Epic => &mut record.epics,
AssociationKind::Milestone => &mut record.milestones,
};
field.retain(|stored| stored != &target);
Ok(true)
})
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::config::InitiativeConfiguration;
#[test]
fn extension_equality_is_by_canonical_serialization() {
let mut left = InitiativeExtensions::default();
left.insert("a", toml::Value::String("1".into()));
let mut right = InitiativeExtensions::default();
right.insert("a", toml::Value::String("1".into()));
assert_eq!(left, right);
assert!(!left.is_empty());
assert_eq!(left.len(), 1);
assert_eq!(left.get("a").and_then(toml::Value::as_str), Some("1"));
right.insert("a", toml::Value::String("2".into()));
assert_ne!(left, right);
assert!(InitiativeExtensions::default().is_empty());
}
#[test]
fn references_collapse_and_are_never_resolved() {
let stored = normalize_references(
AssociationKind::Project,
&["p-b".into(), "p-a".into(), "p-b".into(), "p-missing".into()],
)
.unwrap();
assert_eq!(stored, ["p-b", "p-a", "p-missing"]);
let milestones = normalize_references(
AssociationKind::Milestone,
&[" 2026-Q4 ".into(), "2026-Q4".into()],
)
.unwrap();
assert_eq!(
milestones,
["2026-Q4"],
"a padded spelling is the same milestone once trimmed"
);
}
#[test]
fn a_malformed_reference_names_its_field() {
let err = normalize_references(AssociationKind::Project, &["p.1".into()])
.unwrap_err()
.to_string();
assert!(err.contains("projects"), "got {err}");
let err = normalize_references(AssociationKind::Epic, &["e 1".into()])
.unwrap_err()
.to_string();
assert!(err.contains("epics"), "got {err}");
let err = normalize_references(AssociationKind::Milestone, &[" ".into()])
.unwrap_err()
.to_string();
assert!(err.contains("milestones"), "got {err}");
}
#[test]
fn labels_collapse_keeping_the_first_spelling() {
assert_eq!(
normalize_labels(&["a".into(), "b".into(), "a".into()]),
["a", "b"]
);
assert_eq!(normalize_labels(&[]), [] as [std::string::String; 0]);
}
#[test]
fn associations_enumerate_in_kind_order() {
let mut config = Config::default();
let id = config.allocate_initiative_id();
config.initiatives.insert(
id.to_string(),
InitiativeConfiguration {
name: "One".into(),
projects: vec!["p-a".into()],
epics: vec!["e-a".into()],
milestones: vec!["2026-Q4".into()],
..InitiativeConfiguration::default()
},
);
let listed = associations(&config, &id);
assert_eq!(
listed
.iter()
.map(|a| (a.kind, a.target.as_str()))
.collect::<Vec<_>>(),
[
(AssociationKind::Project, "p-a"),
(AssociationKind::Epic, "e-a"),
(AssociationKind::Milestone, "2026-Q4"),
]
);
assert!(contains_association(
&config,
&id,
AssociationKind::Project,
"p-a"
));
assert!(!contains_association(
&config,
&id,
AssociationKind::Project,
"p-b"
));
assert_eq!(
associations(&config, &InitiativeId::new("i-none").unwrap()),
[] as [crate::roadmap::registry::InitiativeAssociation; 0]
);
}
}