#[allow(clippy::wildcard_imports)]
use super::*;
pub(super) fn validate_planning(planning: &PlanningConfig) -> Result<()> {
if planning.cycle_number == 0 {
return Err(TixError::Config(
"`planning.cycle_number` must be at least 1".into(),
));
}
Ok(())
}
pub(super) fn validate_repos(repos: &IndexMap<String, PathBuf>) -> Result<()> {
for (name, path) in repos {
if name.trim().is_empty() {
return Err(TixError::Config(
"`[repos]` entry names must not be blank".into(),
));
}
if path.as_os_str().is_empty() {
return Err(TixError::Config(format!(
"`repos.{name}` must name the repository path"
)));
}
}
Ok(())
}
pub(super) fn validate_team(label: &str, team: &Team) -> Result<()> {
if label.trim().is_empty() {
return Err(TixError::Config("`teams.<label>` must not be blank".into()));
}
if team.members.is_empty() {
return Err(TixError::Config(format!(
"`teams.{label}.members` must not be empty"
)));
}
let mut seen: HashSet<&str> = HashSet::new();
for member in &team.members {
if !seen.insert(member.as_str()) {
return Err(TixError::Config(format!(
"`teams.{label}.members` must be unique; found duplicate `{member}`"
)));
}
}
Ok(())
}
pub(crate) fn validate_registry_key<I: StableId>(section: &str, id: &str) -> Result<()> {
I::new(id).map_err(|err| TixError::Config(format!("`{section}`: {err}")))?;
Ok(())
}
pub(crate) fn validate_display_name(
section: &str,
id: &str,
name: &str,
noun: &str,
seen: &mut HashSet<String>,
) -> Result<()> {
if name.trim().is_empty() {
return Err(TixError::Config(format!(
"`{section}.{id}.name` must not be blank"
)));
}
if name.trim() != name {
return Err(TixError::Config(format!(
"`{section}.{id}.name` has leading or trailing whitespace"
)));
}
if !seen.insert(name.to_lowercase()) {
return Err(TixError::Config(format!(
"`{section}.{id}.name` (`{name}`) must be unique; another {noun} already uses it"
)));
}
Ok(())
}
pub(super) fn validate_optional_text(
section: &str,
id: &str,
fields: &[(&str, Option<&str>)],
) -> Result<()> {
for (field, value) in fields {
if value.is_some_and(|v| v.trim().is_empty()) {
return Err(TixError::Config(format!(
"`{section}.{id}.{field}` must not be blank when present"
)));
}
}
Ok(())
}
pub(super) fn validate_record_dates(
section: &str,
id: &str,
start_date: Option<&str>,
due_date: Option<&str>,
) -> Result<()> {
crate::model::validate_issue_dates(start_date, due_date)
.map_err(|err| TixError::Config(format!("`{section}.{id}`: {err}")))
}
pub(super) fn validate_projects(projects: &IndexMap<String, ProjectConfiguration>) -> Result<()> {
let mut seen_names: HashSet<String> = HashSet::new();
for (id, project) in projects {
validate_registry_key::<ProjectId>("projects", id)?;
validate_display_name("projects", id, &project.name, "project", &mut seen_names)?;
validate_optional_text(
"projects",
id,
&[
("description", project.description.as_deref()),
("status", project.status.as_deref()),
("lead", project.lead.as_deref()),
],
)?;
validate_record_dates(
"projects",
id,
project.start_date.as_deref(),
project.due_date.as_deref(),
)?;
}
Ok(())
}
pub(super) fn validate_epics(epics: &IndexMap<String, EpicConfiguration>) -> Result<()> {
let mut seen_names: HashSet<String> = HashSet::new();
for (id, epic) in epics {
validate_registry_key::<EpicId>("epics", id)?;
validate_display_name("epics", id, &epic.name, "epic", &mut seen_names)?;
validate_optional_text(
"epics",
id,
&[
("description", epic.description.as_deref()),
("status", epic.status.as_deref()),
("owner", epic.owner.as_deref()),
("milestone", epic.milestone.as_deref()),
("cycle", epic.cycle.as_deref()),
],
)?;
validate_record_dates(
"epics",
id,
epic.start_date.as_deref(),
epic.due_date.as_deref(),
)?;
if let Some(weight) = epic.weight
&& weight <= 0
{
return Err(TixError::Config(format!(
"`epics.{id}.weight` must be a positive integer; got {weight}"
)));
}
if let Some(parent) = epic.parent.as_deref() {
EpicId::new(parent)
.map_err(|err| TixError::Config(format!("`epics.{id}.parent`: {err}")))?;
if parent == id {
return Err(TixError::Config(format!(
"`epics.{id}.parent` must not name the epic itself"
)));
}
}
for reference in &epic.projects {
ProjectId::new(reference)
.map_err(|err| TixError::Config(format!("`epics.{id}.projects`: {err}")))?;
}
}
Ok(())
}
pub(crate) const INITIATIVE_KNOWN_FIELDS: &[&str] = &[
"name",
"outcome",
"owner",
"status",
"priority",
"labels",
"target_date",
"projects",
"epics",
"milestones",
];
pub(super) fn validate_initiatives(
initiatives: &IndexMap<String, InitiativeConfiguration>,
) -> Result<()> {
let mut seen_names: HashSet<String> = HashSet::new();
for (id, initiative) in initiatives {
validate_registry_key::<InitiativeId>("initiatives", id)?;
validate_display_name(
"initiatives",
id,
&initiative.name,
"initiative",
&mut seen_names,
)?;
validate_optional_text(
"initiatives",
id,
&[
("outcome", initiative.outcome.as_deref()),
("owner", initiative.owner.as_deref()),
("status", initiative.status.as_deref()),
],
)?;
for key in initiative.extensions.keys() {
if INITIATIVE_KNOWN_FIELDS.contains(&key) {
return Err(TixError::Config(format!(
"`initiatives.{id}.{key}` collides with a built-in field; rename the extension key"
)));
}
}
if let Some(date) = initiative.target_date.as_deref() {
if !crate::model::validate_date(date) {
return Err(TixError::Config(format!(
"`initiatives.{id}.target_date` (`{date}`) must be a `YYYY-MM-DD` calendar date"
)));
}
}
for label in &initiative.labels {
if label.trim().is_empty() {
return Err(TixError::Config(format!(
"`initiatives.{id}.labels` must not contain a blank value"
)));
}
}
for reference in &initiative.projects {
ProjectId::new(reference)
.map_err(|err| TixError::Config(format!("`initiatives.{id}.projects`: {err}")))?;
}
for reference in &initiative.epics {
EpicId::new(reference)
.map_err(|err| TixError::Config(format!("`initiatives.{id}.epics`: {err}")))?;
}
for reference in &initiative.milestones {
if reference.trim().is_empty() {
return Err(TixError::Config(format!(
"`initiatives.{id}.milestones` must not contain a blank value"
)));
}
}
}
Ok(())
}
pub(super) fn validate_cycle(name: &str, cycle: &PlanningCycle) -> Result<()> {
if name.trim().is_empty() {
return Err(TixError::Config("`cycles.<name>` must not be blank".into()));
}
if let Some(start) = &cycle.start_date {
if !crate::model::validate_date(start) {
return Err(TixError::Config(format!(
"`cycles.{name}.start_date` (`{start}`) must be a `YYYY-MM-DD` calendar date"
)));
}
}
if let Some(due) = &cycle.due_date {
if !crate::model::validate_date(due) {
return Err(TixError::Config(format!(
"`cycles.{name}.due_date` (`{due}`) must be a `YYYY-MM-DD` calendar date"
)));
}
if let Some(start) = &cycle.start_date
&& due.as_str() < start.as_str()
{
return Err(TixError::Config(format!(
"`cycles.{name}.due_date` (`{due}`) must be `>= start_date` (`{start}`)"
)));
}
}
Ok(())
}
pub(super) fn validate_template(name: &str, template: &IssueTemplate) -> Result<()> {
if name.trim().is_empty() {
return Err(TixError::Config(
"`templates` must not contain a blank name".into(),
));
}
for (field, values) in [
("labels", &template.labels),
("cycles", &template.cycles),
("milestones", &template.milestones),
] {
for value in values {
if value.trim().is_empty() {
return Err(TixError::Config(format!(
"`templates.{name}.{field}` must not contain a blank value"
)));
}
if value.contains(',') {
return Err(TixError::Config(format!(
"`templates.{name}.{field}` value `{value}` must not contain a comma \
โ the form joins these with `,`"
)));
}
}
}
if let Some(start) = &template.start_date
&& !crate::model::validate_date(start)
{
return Err(TixError::Config(format!(
"`templates.{name}.start_date` (`{start}`) must be a `YYYY-MM-DD` calendar date"
)));
}
if let Some(due) = &template.due_date {
if !crate::model::validate_date(due) {
return Err(TixError::Config(format!(
"`templates.{name}.due_date` (`{due}`) must be a `YYYY-MM-DD` calendar date"
)));
}
if let Some(start) = &template.start_date
&& due.as_str() < start.as_str()
{
return Err(TixError::Config(format!(
"`templates.{name}.due_date` (`{due}`) must be `>= start_date` (`{start}`)"
)));
}
}
Ok(())
}
pub(super) fn validate_categories(categories: &CategoryConfig) -> Result<()> {
if categories.order.is_empty() {
return Err(TixError::Config(
"`categories.order` must not be empty โ it is the global vocabulary".into(),
));
}
for key in &categories.order {
if !CATEGORY_KEYS.contains(&key.as_str()) {
return Err(TixError::Config(format!(
"unknown_category: `categories.order` names `{key}`, which is not in the \
global vocabulary ({}). The vocabulary is fixed and global (C-002); a \
board maps columns into it and does not define it.",
CATEGORY_KEYS.join(", ")
)));
}
}
if !categories.contains(&categories.default) {
return Err(TixError::Config(format!(
"unknown_category: `categories.default` is `{}`, which is not in \
`categories.order`",
categories.default
)));
}
if categories.default == COMPLETE_CATEGORY {
return Err(TixError::Config(format!(
"`categories.default` may not be `{COMPLETE_CATEGORY}`. Every column \
without an explicit mapping would resolve to complete, which changes \
completion meaning by accident. Map the completing column explicitly \
instead."
)));
}
Ok(())
}
pub(super) fn validate_category_map(
name: &str,
board: &BoardConfiguration,
categories: &CategoryConfig,
) -> Result<()> {
for (column, key) in &board.category_map {
if !board.columns.iter().any(|c| c == column) {
return Err(TixError::Config(format!(
"mapping_for_unknown_column: `kanban.{name}.category_map` maps \
`{column}`, which is not one of the board's columns ({})",
board.columns.join(", ")
)));
}
if !categories.contains(key) {
return Err(TixError::Config(format!(
"unknown_category: `kanban.{name}.category_map` maps `{column}` to \
`{key}`, which is not in the vocabulary ({})",
categories.order.join(", ")
)));
}
}
if board.category_map.is_empty() {
return Ok(());
}
let completers: Vec<&String> = board
.category_map
.iter()
.filter(|(_, key)| key.as_str() == COMPLETE_CATEGORY)
.map(|(column, _)| column)
.collect();
match completers.as_slice() {
[] => Err(TixError::Config(format!(
"missing_completion_category: `kanban.{name}.category_map` maps no column \
to `{COMPLETE_CATEGORY}` ({} mapped). Without one, no card on this board \
is ever complete โ map exactly one column to it.",
board.category_map.len()
))),
[_only] => Ok(()),
many => Err(TixError::Config(format!(
"`kanban.{name}.category_map` maps {} columns to `{COMPLETE_CATEGORY}` \
({}); exactly one column may complete work",
many.len(),
many.iter()
.map(|c| c.as_str())
.collect::<Vec<_>>()
.join(", ")
))),
}
}
pub(super) fn validate_board(name: &str, board: &BoardConfiguration) -> Result<()> {
if board.columns.is_empty() {
return Err(TixError::Config(format!(
"`kanban.{name}.columns` must not be empty"
)));
}
if board.columns.iter().any(|c| c.trim().is_empty()) {
return Err(TixError::Config(format!(
"`kanban.{name}.columns` must not contain blank names"
)));
}
let mut seen: HashSet<&str> = HashSet::new();
for column in &board.columns {
if !seen.insert(column.as_str()) {
return Err(TixError::Config(format!(
"`kanban.{name}.columns` must be unique; found duplicate `{column}`"
)));
}
}
if let Some(done) = &board.done_column
&& !board.columns.contains(done)
{
return Err(TixError::Config(format!(
"`kanban.{name}.done_column` (`{done}`) must be one of the configured columns"
)));
}
for hidden in &board.hidden_columns {
if !board.columns.contains(hidden) {
return Err(TixError::Config(format!(
"`kanban.{name}.hidden_columns` (`{hidden}`) must be a subset of `columns`"
)));
}
}
if board.title_lines == 0 {
return Err(TixError::Config(format!(
"`kanban.{name}.title_lines` must be at least 1"
)));
}
board.wip.validate(name, &board.columns)?;
Ok(())
}