use std::collections::HashSet;
use std::hash::{BuildHasher, Hash, Hasher};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use indexmap::IndexMap;
use serde::ser::SerializeStruct;
use serde::{Deserialize, Serialize, Serializer};
use crate::board_store::BoardStore;
use crate::capacity::config::CapacityConfig;
use crate::cycle_store::{CycleStore, is_implicit_default};
use crate::error::{Result, TixError};
use crate::keybindings::{EffectiveBindings, KeyBindings};
use crate::records::RecordStore;
use crate::templates::registry::TemplateRegistry;
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RemoteConfig {
pub token: Option<String>,
pub base_url: Option<String>,
pub project: Option<String>,
pub team: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ExportConfig {
pub gitlab: Option<RemoteConfig>,
pub linear: Option<RemoteConfig>,
}
impl ExportConfig {
pub fn is_empty(&self) -> bool {
self.gitlab.is_none() && self.linear.is_none()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Team {
pub members: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct PlanningCycle {
pub description: Option<String>,
pub start_date: Option<String>,
pub due_date: Option<String>,
}
pub trait StableId: Sized {
const PREFIX: &'static str;
const LABEL: &'static str;
const ARTICLE: &'static str;
const MAX_LEN: usize = 128;
fn from_validated(raw: String) -> Self;
fn as_str(&self) -> &str;
fn new(raw: &str) -> Result<Self> {
if raw.trim().is_empty() {
return Err(TixError::Config(format!(
"{} {} must not be blank",
Self::ARTICLE,
Self::LABEL
)));
}
if raw.trim() != raw {
return Err(TixError::Config(format!(
"{} `{raw}` has leading or trailing whitespace",
Self::LABEL
)));
}
if raw.chars().count() > Self::MAX_LEN {
return Err(TixError::Config(format!(
"{} is {} characters; the limit is {}",
Self::LABEL,
raw.chars().count(),
Self::MAX_LEN
)));
}
if let Some(bad) = raw.chars().find(|c| is_structural_key_char(*c)) {
return Err(TixError::Config(format!(
"{} `{raw}` contains `{bad}`; use letters, digits, `-`, `_`, or `:`",
Self::LABEL
)));
}
Ok(Self::from_validated(raw.to_string()))
}
#[must_use]
fn generate() -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
std::process::id().hash(&mut hasher);
COUNTER.fetch_add(1, Ordering::Relaxed).hash(&mut hasher);
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |since| since.as_nanos())
.hash(&mut hasher);
let bits = hasher.finish();
Self::from_validated(format!("{}{bits:016x}", Self::PREFIX))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct ProjectId(String);
impl StableId for ProjectId {
const PREFIX: &'static str = "p-";
const LABEL: &'static str = "project id";
const ARTICLE: &'static str = "a";
fn from_validated(raw: String) -> Self {
Self(raw)
}
fn as_str(&self) -> &str {
&self.0
}
}
impl ProjectId {
pub const MAX_LEN: usize = <Self as StableId>::MAX_LEN;
pub fn new(raw: &str) -> Result<Self> {
<Self as StableId>::new(raw)
}
#[must_use]
pub fn generate() -> Self {
<Self as StableId>::generate()
}
#[must_use]
pub fn as_str(&self) -> &str {
<Self as StableId>::as_str(self)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct EpicId(String);
impl StableId for EpicId {
const PREFIX: &'static str = "e-";
const LABEL: &'static str = "epic id";
const ARTICLE: &'static str = "an";
fn from_validated(raw: String) -> Self {
Self(raw)
}
fn as_str(&self) -> &str {
&self.0
}
}
impl EpicId {
pub const MAX_LEN: usize = <Self as StableId>::MAX_LEN;
pub fn new(raw: &str) -> Result<Self> {
<Self as StableId>::new(raw)
}
#[must_use]
pub fn generate() -> Self {
<Self as StableId>::generate()
}
#[must_use]
pub fn as_str(&self) -> &str {
<Self as StableId>::as_str(self)
}
}
impl std::fmt::Display for ProjectId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl TryFrom<String> for ProjectId {
type Error = TixError;
fn try_from(raw: String) -> Result<Self> {
Self::new(&raw)
}
}
impl From<ProjectId> for String {
fn from(id: ProjectId) -> Self {
id.0
}
}
impl std::fmt::Display for EpicId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl TryFrom<String> for EpicId {
type Error = TixError;
fn try_from(raw: String) -> Result<Self> {
Self::new(&raw)
}
}
impl From<EpicId> for String {
fn from(id: EpicId) -> Self {
id.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct InitiativeId(String);
impl StableId for InitiativeId {
const PREFIX: &'static str = "i-";
const LABEL: &'static str = "initiative id";
const ARTICLE: &'static str = "an";
fn from_validated(raw: String) -> Self {
Self(raw)
}
fn as_str(&self) -> &str {
&self.0
}
}
impl InitiativeId {
pub const MAX_LEN: usize = <Self as StableId>::MAX_LEN;
pub fn new(raw: &str) -> Result<Self> {
<Self as StableId>::new(raw)
}
#[must_use]
pub fn generate() -> Self {
<Self as StableId>::generate()
}
#[must_use]
pub fn as_str(&self) -> &str {
<Self as StableId>::as_str(self)
}
}
impl std::fmt::Display for InitiativeId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl TryFrom<String> for InitiativeId {
type Error = TixError;
fn try_from(raw: String) -> Result<Self> {
Self::new(&raw)
}
}
impl From<InitiativeId> for String {
fn from(id: InitiativeId) -> Self {
id.0
}
}
fn is_structural_key_char(c: char) -> bool {
c.is_whitespace()
|| matches!(c, '.' | '"' | '\'' | '[' | ']' | '#' | '=' | '\\')
|| c.is_control()
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ProjectConfiguration {
pub name: String,
pub description: Option<String>,
pub status: Option<String>,
pub lead: Option<String>,
pub start_date: Option<String>,
pub due_date: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct EpicConfiguration {
pub name: String,
pub description: Option<String>,
pub status: Option<String>,
pub owner: Option<String>,
pub start_date: Option<String>,
pub due_date: Option<String>,
pub weight: Option<i64>,
pub parent: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub projects: Vec<String>,
pub milestone: Option<String>,
pub cycle: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct InitiativeConfiguration {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub outcome: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<crate::model::Priority>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_date: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub projects: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub epics: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub milestones: Vec<String>,
#[serde(flatten)]
pub extensions: crate::roadmap::registry::InitiativeExtensions,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct PlanningConfig {
pub cycle_number: u32,
pub default_cycle: Option<String>,
pub default_milestone: Option<String>,
pub default_team: Option<String>,
}
impl PlanningConfig {
pub fn is_default(&self) -> bool {
self == &Self::default()
}
}
impl Default for PlanningConfig {
fn default() -> Self {
Self {
cycle_number: 1,
default_cycle: None,
default_milestone: None,
default_team: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct IssueTemplate {
pub title: Option<String>,
pub body: Option<String>,
pub labels: Vec<String>,
pub status: Option<String>,
pub priority: Option<String>,
pub assignee: Option<String>,
pub estimate: Option<String>,
pub reviewer: Option<String>,
pub cycles: Vec<String>,
pub milestones: Vec<String>,
pub start_date: Option<String>,
pub due_date: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct StateConfig {
#[serde(skip_serializing)]
pub next_number: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum OrderBy {
#[default]
#[serde(rename = "created_at_desc")]
CreatedAtDesc,
#[serde(rename = "created_at_asc")]
CreatedAtAsc,
#[serde(rename = "none")]
None,
}
impl OrderBy {
pub fn as_str(self) -> &'static str {
match self {
Self::CreatedAtDesc => "created_at_desc",
Self::CreatedAtAsc => "created_at_asc",
Self::None => "none",
}
}
pub fn next(self) -> Self {
match self {
Self::CreatedAtDesc => Self::CreatedAtAsc,
Self::CreatedAtAsc => Self::None,
Self::None => Self::CreatedAtDesc,
}
}
}
pub const CATEGORY_KEYS: &[&str] = &[
"backlog",
"unstarted",
"active",
"blocked",
"completed",
"canceled",
];
pub const TERMINAL_CATEGORIES: &[&str] = &["completed", "canceled"];
pub const COMPLETE_CATEGORY: &str = "completed";
pub const SAFE_DEFAULT_CATEGORY: &str = "unstarted";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CategoryConfig {
pub order: Vec<String>,
pub default: String,
}
impl Default for CategoryConfig {
fn default() -> Self {
Self {
order: CATEGORY_KEYS.iter().map(|k| (*k).to_string()).collect(),
default: SAFE_DEFAULT_CATEGORY.to_string(),
}
}
}
impl CategoryConfig {
pub fn is_default(&self) -> bool {
self == &Self::default()
}
pub fn contains(&self, key: &str) -> bool {
self.order.iter().any(|k| k == key)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct TransitionConfig {
pub terminal_locked: bool,
}
impl Default for TransitionConfig {
fn default() -> Self {
Self {
terminal_locked: true,
}
}
}
impl TransitionConfig {
pub fn is_default(&self) -> bool {
self == &Self::default()
}
}
const DEFAULT_TITLE_LINES: usize = 3;
const DEFAULT_DONE_COLUMN: &str = "done";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct HealthConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy: Option<String>,
}
impl HealthConfig {
pub fn is_empty(&self) -> bool {
self.policy.is_none()
}
pub fn policy(&self) -> std::result::Result<crate::health::HealthPolicy, TixError> {
match self.policy.as_deref() {
Some(version) => crate::health::HealthPolicy::for_version(version)
.map_err(|error| TixError::Config(error.render())),
None => Ok(crate::health::HealthPolicy::v1()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct BoardConfiguration {
pub columns: Vec<String>,
pub done_column: Option<String>,
pub category_map: IndexMap<String, String>,
pub hidden_columns: Vec<String>,
pub show_mismatch: bool,
pub wrap_titles: bool,
pub title_lines: usize,
pub card_order: Vec<String>,
pub order_by: OrderBy,
pub wip: crate::wip::WipConfig,
}
impl Default for BoardConfiguration {
fn default() -> Self {
Self {
columns: ["todo", "in progress", "in review", "done"]
.into_iter()
.map(String::from)
.collect(),
done_column: Some(DEFAULT_DONE_COLUMN.to_string()),
category_map: IndexMap::new(),
hidden_columns: Vec::new(),
show_mismatch: false,
wrap_titles: true,
title_lines: DEFAULT_TITLE_LINES,
card_order: Vec::new(),
order_by: OrderBy::default(),
wip: crate::wip::WipConfig::default(),
}
}
}
impl Serialize for BoardConfiguration {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
let done = self
.done_column
.as_deref()
.filter(|done| *done != DEFAULT_DONE_COLUMN);
let order_by = (self.order_by != OrderBy::default()).then_some(self.order_by);
let mut len = 1; len += usize::from(done.is_some());
len += usize::from(!self.category_map.is_empty());
len += usize::from(!self.hidden_columns.is_empty());
len += usize::from(self.show_mismatch);
len += usize::from(!self.wrap_titles);
len += usize::from(self.title_lines != DEFAULT_TITLE_LINES);
len += usize::from(!self.card_order.is_empty());
len += usize::from(order_by.is_some());
len += usize::from(!self.wip.is_empty());
let mut board = serializer.serialize_struct("BoardConfiguration", len)?;
board.serialize_field("columns", &self.columns)?;
if let Some(done) = done {
board.serialize_field("done_column", done)?;
}
if !self.category_map.is_empty() {
board.serialize_field("category_map", &self.category_map)?;
}
if !self.hidden_columns.is_empty() {
board.serialize_field("hidden_columns", &self.hidden_columns)?;
}
if self.show_mismatch {
board.serialize_field("show_mismatch", &self.show_mismatch)?;
}
if !self.wrap_titles {
board.serialize_field("wrap_titles", &self.wrap_titles)?;
}
if self.title_lines != DEFAULT_TITLE_LINES {
board.serialize_field("title_lines", &self.title_lines)?;
}
if !self.card_order.is_empty() {
board.serialize_field("card_order", &self.card_order)?;
}
if let Some(order_by) = order_by {
board.serialize_field("order_by", &order_by)?;
}
if !self.wip.is_empty() {
board.serialize_field("wip", &self.wip)?;
}
board.end()
}
}
pub fn starter_board() -> BoardConfiguration {
BoardConfiguration {
columns: [
"triage",
"todo",
"in progress",
"in review",
"done",
"wontfix",
]
.into_iter()
.map(String::from)
.collect(),
category_map: IndexMap::from([
("done".to_string(), "completed".to_string()),
("wontfix".to_string(), "canceled".to_string()),
]),
..BoardConfiguration::default()
}
}
impl BoardConfiguration {
pub fn category_for<'c>(&'c self, column: &str, categories: &'c CategoryConfig) -> &'c str {
if let Some(mapped) = self.category_map.get(column) {
return mapped;
}
if self.category_map.is_empty() && self.effective_done_column() == Some(column) {
return COMPLETE_CATEGORY;
}
&categories.default
}
pub fn effective_done_column(&self) -> Option<&str> {
self.done_column
.as_deref()
.or_else(|| self.inferred_done_column())
}
fn inferred_done_column(&self) -> Option<&str> {
if self.columns.iter().any(|c| c == DEFAULT_DONE_COLUMN) {
Some(DEFAULT_DONE_COLUMN)
} else {
self.columns.last().map(String::as_str)
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, transparent)]
pub struct KanbanSection {
pub configurations: IndexMap<String, BoardConfiguration>,
}
impl KanbanSection {
pub fn is_empty(&self) -> bool {
self.configurations.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
#[serde(default, skip_serializing_if = "KanbanSection::is_empty")]
pub kanban: KanbanSection,
pub issues_dir: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub milestones_dir: Option<PathBuf>,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub teams: IndexMap<String, Team>,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub cycles: IndexMap<String, PlanningCycle>,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub templates: IndexMap<String, IssueTemplate>,
#[serde(
default,
skip_serializing_if = "crate::views::registry::ViewRegistry::is_empty"
)]
pub views: crate::views::registry::ViewRegistry,
#[serde(default, skip_serializing_if = "HealthConfig::is_empty")]
pub health: HealthConfig,
#[serde(default, skip_serializing_if = "TemplateRegistry::is_empty")]
pub issue_templates: TemplateRegistry,
#[serde(default, skip_serializing_if = "PlanningConfig::is_default")]
pub planning: PlanningConfig,
#[serde(default, skip_serializing_if = "CategoryConfig::is_default")]
pub categories: CategoryConfig,
#[serde(default, skip_serializing_if = "TransitionConfig::is_default")]
pub transitions: TransitionConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub triage: Option<crate::triage::TriageConfig>,
pub editor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub viewer: Option<String>,
pub agent: Option<String>,
#[serde(skip_serializing)]
pub state: StateConfig,
#[serde(default, skip_serializing_if = "ExportConfig::is_empty")]
pub export: ExportConfig,
#[serde(default, skip_serializing_if = "KeyBindings::is_empty")]
pub keybindings: KeyBindings,
#[serde(default, skip_serializing_if = "CustomAttributes::is_empty")]
pub custom_attributes: CustomAttributes,
#[serde(default = "default_true", skip_serializing_if = "is_true")]
pub lint_foreign_repositories: bool,
#[serde(default = "default_true", skip_serializing_if = "is_true")]
pub tips_on_startup: bool,
#[serde(default, skip_serializing_if = "MentionsConfig::is_default")]
pub mentions: MentionsConfig,
#[serde(default, skip_serializing_if = "LayoutConfig::is_default")]
pub layout: LayoutConfig,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "custom_types"
)]
pub types: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub type_colors: IndexMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tags_dir: Option<PathBuf>,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub repos: IndexMap<String, PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_repo: Option<PathBuf>,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub projects: IndexMap<String, ProjectConfiguration>,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub epics: IndexMap<String, EpicConfiguration>,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub initiatives: IndexMap<String, InitiativeConfiguration>,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub cycle_schedules:
IndexMap<String, crate::cycle_schedule::config::CycleScheduleConfiguration>,
#[serde(default, skip_serializing_if = "CapacityConfig::is_empty")]
pub capacity: CapacityConfig,
#[serde(default, skip_serializing_if = "BeadsConfig::is_default")]
pub beads: BeadsConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct BeadsConfig {
pub mode: BeadsMode,
pub binary: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace: Option<PathBuf>,
pub id_prefix: String,
}
impl Default for BeadsConfig {
fn default() -> Self {
BeadsConfig {
mode: BeadsMode::default(),
binary: crate::sync::beads::DEFAULT_BINARY.to_string(),
workspace: None,
id_prefix: crate::sync::beads::DEFAULT_ID_PREFIX.to_string(),
}
}
}
impl BeadsConfig {
#[must_use]
pub fn is_default(&self) -> bool {
*self == BeadsConfig::default()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BeadsMode {
#[default]
Off,
Mirror,
Sync,
}
impl BeadsMode {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
BeadsMode::Off => "off",
BeadsMode::Mirror => "mirror",
BeadsMode::Sync => "sync",
}
}
#[must_use]
pub fn from_key(key: &str) -> Option<Self> {
match key {
"off" => Some(BeadsMode::Off),
"mirror" => Some(BeadsMode::Mirror),
"sync" => Some(BeadsMode::Sync),
_ => None,
}
}
pub const ALL: [BeadsMode; 3] = [BeadsMode::Off, BeadsMode::Mirror, BeadsMode::Sync];
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CustomAttributes {
pub keys: Vec<String>,
}
impl CustomAttributes {
pub fn is_empty(&self) -> bool {
self.keys.is_empty()
}
}
pub(crate) fn default_true() -> bool {
true
}
#[allow(clippy::trivially_copy_pass_by_ref)] pub(crate) fn is_true(value: &bool) -> bool {
*value
}
pub const DEFAULT_TYPES: &[&str] = &[
"default",
"bug",
"feature",
"task",
"improvement",
"documentation",
"question",
"duplicate",
"chore",
"open",
];
pub const BUILTIN_LABELS: &[&str] = &["decision-pending"];
pub const DEFAULT_TYPE_COLORS: &[(&str, &str)] = &[
("default", "gray"),
("bug", "red"),
("feature", "green"),
("task", "blue"),
("improvement", "cyan"),
("documentation", "yellow"),
("question", "magenta"),
("duplicate", "dark_gray"),
("chore", "dark_gray"),
("open", "white"),
];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct MentionsConfig {
pub enabled: bool,
pub dir: PathBuf,
}
pub const DEFAULT_MENTIONS_DIR: &str = "docs/mentions";
impl Default for MentionsConfig {
fn default() -> Self {
MentionsConfig {
enabled: true,
dir: PathBuf::from(DEFAULT_MENTIONS_DIR),
}
}
}
impl MentionsConfig {
#[must_use]
pub fn is_default(&self) -> bool {
*self == Self::default()
}
#[must_use]
pub fn resolve_dir(&self, issues_dir: &Path) -> PathBuf {
if self.dir.is_absolute() {
return self.dir.clone();
}
match Config::project_root(issues_dir) {
Some(root) => root.join(&self.dir),
None => self.dir.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct LayoutConfig {
pub directory: String,
pub filename: String,
pub move_on_change: bool,
}
impl Default for LayoutConfig {
fn default() -> Self {
LayoutConfig {
directory: "{assignee}/{type}/{status}".to_string(),
filename: "{date}-{id}-{slug}.md".to_string(),
move_on_change: true,
}
}
}
impl LayoutConfig {
#[must_use]
pub fn is_default(&self) -> bool {
*self == Self::default()
}
}
impl Default for Config {
fn default() -> Self {
Config {
kanban: KanbanSection {
configurations: IndexMap::from([(
"default".to_string(),
BoardConfiguration::default(),
)]),
},
issues_dir: PathBuf::from("docs/issues"),
milestones_dir: None,
teams: IndexMap::new(),
cycles: IndexMap::new(),
templates: IndexMap::new(),
views: crate::views::registry::ViewRegistry::new(),
health: HealthConfig::default(),
issue_templates: TemplateRegistry::default(),
planning: PlanningConfig::default(),
categories: CategoryConfig::default(),
transitions: TransitionConfig::default(),
triage: None,
editor: None,
viewer: None,
state: StateConfig::default(),
export: ExportConfig::default(),
agent: None,
keybindings: KeyBindings::default(),
custom_attributes: CustomAttributes::default(),
lint_foreign_repositories: true,
tips_on_startup: true,
mentions: MentionsConfig::default(),
layout: LayoutConfig::default(),
types: None,
labels: None,
type_colors: IndexMap::new(),
tags_dir: None,
repos: IndexMap::new(),
parent_repo: None,
projects: IndexMap::new(),
epics: IndexMap::new(),
initiatives: IndexMap::new(),
cycle_schedules: IndexMap::new(),
capacity: CapacityConfig::new(),
beads: BeadsConfig::default(),
}
}
}
impl Config {
#[must_use]
pub fn starter() -> Config {
let mut config = Config::default();
config
.kanban
.configurations
.insert("default".to_string(), starter_board());
config.triage = Some(crate::triage::TriageConfig {
entry_status: Some("triage".to_string()),
accepted_status: Some("todo".to_string()),
declined_status: Some("wontfix".to_string()),
deferred_status: None,
});
config.types = Some(DEFAULT_TYPES.iter().map(|s| (*s).to_string()).collect());
config.tags_dir = Some(PathBuf::from("docs/tags"));
config
}
pub(crate) fn validate(&self) -> Result<()> {
if self.issues_dir.as_os_str().is_empty() {
return Err(TixError::Config("`issues_dir` must not be blank".into()));
}
if self.milestones_dir().as_os_str().is_empty() {
return Err(TixError::Config(
"`milestones_dir` must not be blank".into(),
));
}
if self.tags_dir().as_os_str().is_empty() {
return Err(TixError::Config("`tags_dir` must not be blank".into()));
}
validate_repos(&self.repos)?;
self.health.policy()?;
if self
.parent_repo
.as_deref()
.is_some_and(|p| p.as_os_str().is_empty())
{
return Err(TixError::Config("`parent_repo` must not be blank".into()));
}
if self.viewer.as_deref().is_some_and(|v| v.trim().is_empty()) {
return Err(TixError::Config(
"`viewer` must not be blank; use `auto` for the default chain".into(),
));
}
validate_planning(&self.planning)?;
crate::layout::validate(&self.layout)?;
validate_categories(&self.categories)?;
self.effective_keybindings()?;
if self.kanban.configurations.is_empty() {
return Err(TixError::Config(
"`[kanban]` must define at least one configuration".into(),
));
}
for (name, board) in &self.kanban.configurations {
validate_board(name, board)?;
validate_category_map(name, board, &self.categories)?;
}
for (label, team) in &self.teams {
validate_team(label, team)?;
}
for (name, cycle) in &self.cycles {
validate_cycle(name, cycle)?;
}
for (name, template) in &self.templates {
validate_template(name, template)?;
}
validate_projects(&self.projects)?;
validate_epics(&self.epics)?;
validate_initiatives(&self.initiatives)?;
crate::cycle_schedule::config::validate_schedules(&self.cycle_schedules)?;
crate::capacity::config::validate(&self.capacity, self)?;
self.views
.check_structure()
.map_err(|refusal| TixError::Config(refusal.render()))?;
self.issue_templates.validate(self)?;
crate::triage::config::validate(self)?;
Ok(())
}
pub fn effective_keybindings(&self) -> Result<EffectiveBindings> {
KeyBindings::resolve(&self.keybindings)
}
#[must_use]
pub fn allocate_project_id(&self) -> ProjectId {
self.allocate_project_id_with(ProjectId::generate)
}
fn allocate_project_id_with(&self, draw: impl FnMut() -> ProjectId) -> ProjectId {
allocate_stable_id(&self.projects, draw)
}
#[must_use]
pub fn allocate_epic_id(&self) -> EpicId {
self.allocate_epic_id_with(EpicId::generate)
}
fn allocate_epic_id_with(&self, draw: impl FnMut() -> EpicId) -> EpicId {
allocate_stable_id(&self.epics, draw)
}
#[must_use]
pub fn allocate_initiative_id(&self) -> InitiativeId {
self.allocate_initiative_id_with(InitiativeId::generate)
}
fn allocate_initiative_id_with(&self, draw: impl FnMut() -> InitiativeId) -> InitiativeId {
allocate_stable_id(&self.initiatives, draw)
}
#[must_use]
pub fn allocate_cycle_schedule_id(&self) -> crate::cycle_schedule::config::CycleScheduleId {
self.allocate_cycle_schedule_id_with(
crate::cycle_schedule::config::CycleScheduleId::generate,
)
}
fn allocate_cycle_schedule_id_with(
&self,
draw: impl FnMut() -> crate::cycle_schedule::config::CycleScheduleId,
) -> crate::cycle_schedule::config::CycleScheduleId {
allocate_stable_id(&self.cycle_schedules, draw)
}
fn restore_declaration_order(&mut self, contents: &str) {
crate::config::restore_section_order(&mut self.kanban.configurations, contents, "kanban");
crate::config::restore_section_order(&mut self.cycles, contents, "cycles");
crate::config::restore_section_order(&mut self.teams, contents, "teams");
crate::config::restore_section_order(&mut self.templates, contents, "templates");
self.views.restore_declaration_order(contents);
crate::config::restore_section_order(
&mut self.issue_templates.templates,
contents,
"issue_templates",
);
crate::config::restore_section_order(&mut self.projects, contents, "projects");
crate::config::restore_section_order(&mut self.epics, contents, "epics");
crate::config::restore_section_order(&mut self.initiatives, contents, "initiatives");
crate::config::restore_section_order(
&mut self.cycle_schedules,
contents,
"cycle_schedules",
);
self.capacity.restore_declaration_order(contents);
}
pub fn custom_attributes(&self) -> Vec<String> {
self.custom_attributes
.keys
.iter()
.map(|k| k.trim().to_ascii_lowercase())
.collect()
}
#[must_use]
pub fn types(&self) -> Vec<String> {
self.types
.clone()
.unwrap_or_else(|| DEFAULT_TYPES.iter().map(|s| (*s).to_string()).collect())
}
#[must_use]
pub fn custom_types(&self) -> Vec<String> {
self.types()
}
#[must_use]
pub fn labels(&self) -> Vec<String> {
let mut out: Vec<String> = BUILTIN_LABELS.iter().map(|s| (*s).to_string()).collect();
if let Some(extra) = &self.labels {
for label in extra {
if !out.contains(label) {
out.push(label.clone());
}
}
}
out
}
#[must_use]
pub fn type_color(&self, type_name: &str) -> Option<String> {
let needle = type_name.trim().to_ascii_lowercase();
self.type_colors
.iter()
.find(|(name, _)| name.to_ascii_lowercase() == needle)
.map(|(_, color)| color.clone())
.or_else(|| {
DEFAULT_TYPE_COLORS
.iter()
.find(|(name, _)| *name == needle)
.map(|(_, color)| (*color).to_string())
})
}
#[must_use]
pub fn attributes(&self) -> Vec<String> {
if self.custom_attributes.keys.is_empty() {
crate::model::KNOWN_FRONTMATTER_KEYS
.iter()
.filter(|key| **key != "links")
.map(|key| (*key).to_string())
.collect()
} else {
self.custom_attributes.keys.clone()
}
}
pub fn milestones_dir(&self) -> &Path {
self.milestones_dir
.as_deref()
.unwrap_or(Path::new("docs/milestones"))
}
pub fn tags_dir(&self) -> &Path {
self.tags_dir.as_deref().unwrap_or(Path::new("docs/tags"))
}
pub fn repos(&self) -> &IndexMap<String, PathBuf> {
&self.repos
}
pub fn parent_repo(&self) -> Option<&Path> {
self.parent_repo.as_deref()
}
pub fn resolve_repo_paths(&mut self, base: &Path) {
if let Some(path) = self.tags_dir.as_ref() {
if path.is_relative() {
self.tags_dir = Some(base.join(path));
}
}
for path in self.repos.values_mut() {
if path.is_relative() {
*path = base.join(&*path);
}
}
if let Some(path) = self.parent_repo.clone() {
if path.is_relative() {
self.parent_repo = Some(base.join(path));
}
}
}
pub fn default_config(&self) -> &BoardConfiguration {
self.kanban
.configurations
.get("default")
.or_else(|| self.kanban.configurations.first().map(|(_, board)| board))
.expect("validate() guarantees at least one configuration")
}
pub fn configs(&self) -> impl Iterator<Item = (&str, &BoardConfiguration)> {
self.kanban
.configurations
.iter()
.map(|(name, board)| (name.as_str(), board))
}
pub fn load_with_warning(path: &Path, mut sink: impl FnMut(&str)) -> Result<Config> {
let contents = std::fs::read_to_string(path)
.map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
let mut config: Config = toml::from_str(&contents)
.map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
config.restore_declaration_order(&contents);
let root = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let board_store = BoardStore::new(root);
if board_store.exists() {
let boards = board_store
.list()
.map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
if !boards.is_empty() {
let declared = std::mem::take(&mut config.kanban.configurations);
config.kanban = KanbanSection {
configurations: boards
.into_iter()
.map(|(name, mut board)| {
if let Some(from_toml) = declared.get(&name) {
board.wip = from_toml.wip.clone();
}
(name, board)
})
.collect(),
};
}
}
let cycle_store = CycleStore::new(root);
if cycle_store.exists() {
let cycles = cycle_store
.list()
.map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
config.cycles = cycles
.into_iter()
.map(|record| (record.name.clone(), record.into()))
.collect();
}
if config.kanban.configurations.is_empty() {
config.kanban = KanbanSection {
configurations: IndexMap::from([(
"default".to_string(),
BoardConfiguration::default(),
)]),
};
}
config.validate().map_err(|e| at_file(path, e))?;
crate::capacity::config::validate_cycle_references(&config.capacity, &config, root)
.map_err(|e| at_file(path, e))?;
for remote in [config.export.gitlab.as_ref(), config.export.linear.as_ref()]
.into_iter()
.flatten()
{
if remote.token.is_some() {
sink(
"Warning: token found in tix.toml — prefer GITLAB_TOKEN / LINEAR_TOKEN env vars",
);
}
}
Ok(config)
}
pub fn load(project_root: &Path) -> Result<Config> {
let path = project_root.join("tix.toml");
if !path.is_file() {
return Ok(Config::default());
}
Self::load_with_warning(&path, |w| eprintln!("{w}"))
}
pub fn project_root(cwd: &Path) -> Option<PathBuf> {
let mut dir = Some(cwd.to_path_buf());
while let Some(current) = dir {
if current.join("tix.toml").is_file() {
return Some(current);
}
dir = current.parent().map(Path::to_path_buf);
}
None
}
fn sanitized_for_disk(&self) -> Config {
let mut copy = self.clone();
if let Some(gitlab) = copy.export.gitlab.as_mut() {
gitlab.token = None;
}
if let Some(linear) = copy.export.linear.as_mut() {
linear.token = None;
}
copy
}
pub fn save(&self, path: &Path) -> Result<()> {
let dir = match path.parent() {
Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
_ => PathBuf::from("."),
};
std::fs::create_dir_all(&dir)?;
let board_store = BoardStore::new(&dir);
let board_store_active = board_store.exists();
if board_store_active {
for (name, board) in &self.kanban.configurations {
let filename = format!("{}.md", crate::slug::slugify(name));
let body = RecordStore::new(board_store.dir()).body_of(&filename);
board_store.save(name, board, body.as_deref())?;
}
}
let cycle_store = CycleStore::new(&dir);
let cycle_store_active = cycle_store.exists();
if cycle_store_active {
for (name, cycle) in &self.cycles {
if is_implicit_default(name, cycle) {
continue;
}
let filename = format!("{}.md", crate::slug::slugify(name));
let body = RecordStore::new(cycle_store.dir()).body_of(&filename);
cycle_store.save(name, cycle, body.as_deref())?;
}
}
let mut disk = self.sanitized_for_disk();
if board_store_active {
disk.kanban = KanbanSection::default();
}
if cycle_store_active {
disk.cycles.clear();
}
let contents = toml::to_string_pretty(&disk)
.map_err(|e| TixError::Config(format!("cannot serialize tix.toml: {e}")))?;
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("tix.toml");
let tmp = dir.join(format!("{file_name}.tmp-{}", std::process::id()));
let write_result = (|| -> std::io::Result<()> {
let mut f = std::fs::File::create(&tmp)?;
use std::io::Write;
f.write_all(contents.as_bytes())?;
f.sync_all()?;
Ok(())
})();
if let Err(e) = write_result {
let _ = std::fs::remove_file(&tmp);
return Err(TixError::Io(e));
}
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
return Err(TixError::Io(e));
}
Ok(())
}
}
fn at_file(path: &Path, error: TixError) -> TixError {
match error {
TixError::Config(msg) => TixError::Config(format!("{}: {msg}", path.display())),
other => other,
}
}
pub(crate) fn allocate_stable_id<I: StableId, V>(
registry: &IndexMap<String, V>,
mut draw: impl FnMut() -> I,
) -> I {
loop {
let candidate = draw();
if !registry.contains_key(candidate.as_str()) {
return candidate;
}
}
}
pub(crate) fn restore_section_order<V: Clone>(
map: &mut IndexMap<String, V>,
contents: &str,
section: &str,
) {
let mut order: Vec<String> = Vec::new();
for line in contents.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']'))
&& let Some((head, key)) = rest.split_once('.')
&& head.trim() == section
&& !key.trim().contains('.')
&& let Some(name) = unquote_key(key.trim())
&& !order.iter().any(|n| n == name)
{
order.push(name.to_string());
}
}
if order.is_empty() {
return;
}
let mut ordered = IndexMap::new();
for name in &order {
if let Some(value) = map.shift_remove(name) {
ordered.insert(name.clone(), value);
}
}
ordered.extend(map.iter().map(|(n, v)| (n.clone(), (*v).clone())));
*map = ordered;
}
fn unquote_key(key: &str) -> Option<&str> {
if let Some(inner) = key.strip_prefix('"').and_then(|k| k.strip_suffix('"')) {
Some(inner)
} else if let Some(inner) = key.strip_prefix('\'').and_then(|k| k.strip_suffix('\'')) {
Some(inner)
} else {
Some(key)
}
}
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(())
}
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(())
}
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(())
}
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(())
}
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}")))
}
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(())
}
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(())
}
const INITIATIVE_KNOWN_FIELDS: &[&str] = &[
"name",
"outcome",
"owner",
"status",
"priority",
"labels",
"target_date",
"projects",
"epics",
"milestones",
];
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(())
}
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(())
}
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(())
}
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(())
}
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(", ")
))),
}
}
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(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use tempfile::TempDir;
use crate::templates::{
PromptKind, ReferenceKind, Template, TemplateDefaults, TemplatePrompt, TemplateReference,
TemplateScope,
};
const LEGACY_PRETTY_BOARD: &str = r#"issues_dir = "docs/issues"
[kanban.default]
columns = [
"todo",
"in progress",
"in review",
"done",
]
"#;
fn write_toml(dir: &Path, contents: &str) -> PathBuf {
let path = dir.join("tix.toml");
std::fs::write(&path, contents).unwrap();
path
}
#[test]
fn starter_config_carries_a_working_inbox() {
let config = Config::starter();
config
.validate()
.expect("the config `tix init` writes must load");
let board = config.default_config();
for column in [
"triage",
"todo",
"in progress",
"in review",
"done",
"wontfix",
] {
assert!(
board.columns.iter().any(|c| c == column),
"the starter board is missing `{column}`: {:?}",
board.columns
);
}
let triage = config
.triage
.as_ref()
.expect("the starter config has an inbox");
assert_eq!(triage.entry_status.as_deref(), Some("triage"));
assert_eq!(triage.accepted_status.as_deref(), Some("todo"));
assert_eq!(triage.declined_status.as_deref(), Some("wontfix"));
assert_eq!(triage.effective_deferred_status(), Some("triage"));
assert_eq!(
board.category_map.get("wontfix").map(String::as_str),
Some("canceled")
);
assert_eq!(
board.category_map.get("done").map(String::as_str),
Some("completed")
);
for column in ["triage", "todo", "in progress", "in review"] {
assert!(
!board.category_map.contains_key(column),
"`{column}` must stay unmapped, or adjacency will refuse work moving back"
);
}
}
#[test]
fn repos_and_parent_repo_load_round_trip_and_resolve() {
let tmp = TempDir::new().unwrap();
let raw = "issues_dir = \"docs/issues\"\n\
parent_repo = \"../parent\"\n\n\
[repos]\n\
tix = \"../tix\"\n\
website = \"child/website\"\n";
let path = write_toml(tmp.path(), raw);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(cfg.repos.len(), 2);
assert_eq!(cfg.repos["tix"], PathBuf::from("../tix"));
assert_eq!(cfg.repos["website"], PathBuf::from("child/website"));
assert_eq!(cfg.parent_repo(), Some(Path::new("../parent")));
cfg.save(&path).unwrap();
let reread = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(reread.repos, cfg.repos, "repos round-trip");
assert_eq!(
reread.parent_repo(),
cfg.parent_repo(),
"parent round-trips"
);
let mut resolved = cfg.clone();
resolved.resolve_repo_paths(Path::new("/root"));
assert_eq!(resolved.repos["tix"], PathBuf::from("/root/../tix"));
assert_eq!(
resolved.repos["website"],
PathBuf::from("/root/child/website")
);
assert_eq!(resolved.parent_repo(), Some(Path::new("/root/../parent")));
let mut absolute = Config::default();
absolute.repos.insert("x".into(), PathBuf::from("/abs/x"));
absolute.parent_repo = Some(PathBuf::from("/abs/parent"));
absolute.resolve_repo_paths(Path::new("/root"));
assert_eq!(absolute.repos["x"], PathBuf::from("/abs/x"));
assert_eq!(absolute.parent_repo(), Some(Path::new("/abs/parent")));
}
#[test]
fn types_rename_and_type_colors() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\ncustom_types = [\"bug\", \"epic\"]\n",
);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(cfg.types(), vec!["bug", "epic"]);
cfg.save(&path).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
assert!(after.contains("types = ["), "{after}");
assert!(!after.contains("custom_types"), "{after}");
let defaults = Config::default();
assert_eq!(defaults.type_color("bug").as_deref(), Some("red"));
assert_eq!(defaults.type_color("BUG").as_deref(), Some("red"));
assert_eq!(defaults.type_color("nonsense"), None);
let mut overridden = Config::default();
overridden.type_colors.insert("bug".into(), "cyan".into());
assert_eq!(overridden.type_color("Bug").as_deref(), Some("cyan"));
}
#[test]
fn a_blank_repo_path_is_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[repos]\ntix = \"\"\n",
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("repos.tix"), "got {err}");
}
#[test]
fn an_empty_repos_section_is_not_written() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
cfg.save(&path).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
assert!(
!after.contains("[repos]") && !after.contains("parent_repo"),
"an untouched config gains no repos keys on save: {after}"
);
}
#[test]
fn defaults_match_contract() {
let c = Config::default();
let board = c.default_config();
assert_eq!(board.columns, ["todo", "in progress", "in review", "done"]);
assert_eq!(board.done_column.as_deref(), Some("done"));
assert_eq!(board.hidden_columns, Vec::<String>::new());
assert!(!board.show_mismatch);
assert!(board.wrap_titles);
assert_eq!(board.title_lines, 3);
assert_eq!(board.card_order, [] as [std::string::String; 0]);
assert_eq!(board.order_by, OrderBy::CreatedAtDesc);
assert_eq!(c.issues_dir, PathBuf::from("docs/issues"));
assert_eq!(c.editor, None);
assert_eq!(c.state.next_number, 0);
assert_eq!(c.export.gitlab, None);
assert_eq!(c.export.linear, None);
}
#[test]
fn decision_pending_is_a_builtin_label_that_needs_no_key() {
let bare = Config::default();
assert_eq!(bare.labels, None);
assert_eq!(bare.labels(), vec!["decision-pending".to_string()]);
let starter = Config::starter();
assert_eq!(starter.labels, None);
assert_eq!(starter.labels(), vec!["decision-pending".to_string()]);
for written in [
toml::to_string_pretty(&bare).unwrap(),
toml::to_string_pretty(&starter).unwrap(),
] {
assert!(
!written.contains("labels ="),
"no labels key is written:\n{written}"
);
}
let configured = Config {
labels: Some(vec!["decision-pending".into(), "spec-kitty".into()]),
..Config::default()
};
assert_eq!(
configured.labels(),
vec!["decision-pending".to_string(), "spec-kitty".to_string()]
);
}
#[test]
fn load_returns_defaults_when_file_absent() {
let tmp = TempDir::new().unwrap();
let cfg = Config::load(tmp.path()).unwrap();
assert_eq!(cfg.state.next_number, 0);
assert_eq!(cfg.default_config().columns.len(), 4);
}
#[test]
fn load_synthesizes_default_config_when_kanban_section_missing() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(cfg.kanban.configurations.len(), 1);
assert!(cfg.kanban.configurations.contains_key("default"));
assert_eq!(cfg.default_config().columns.len(), 4);
}
#[test]
fn load_parses_state_and_keeps_editor_and_remote_targets() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
r#"issues_dir = "tickets"
editor = "nvim"
[kanban.default]
columns = ["todo", "done"]
done_column = "done"
[state]
next_number = 17
[export.gitlab]
project = "acme/tix"
"#,
);
let cfg = Config::load_with_warning(&path, |_| panic!("no token, no warning")).unwrap();
assert_eq!(
cfg.kanban.configurations.get("default").unwrap().columns,
["todo", "done"]
);
assert_eq!(cfg.issues_dir, PathBuf::from("tickets"));
assert_eq!(cfg.editor.as_deref(), Some("nvim"));
assert_eq!(cfg.state.next_number, 17);
assert_eq!(
cfg.export.gitlab.unwrap().project.as_deref(),
Some("acme/tix")
);
assert_eq!(cfg.export.linear, None);
}
#[test]
fn viewer_is_parsed_and_blank_is_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), "issues_dir = \"tickets\"\nviewer = \"less\"\n");
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(cfg.viewer.as_deref(), Some("less"));
let blank = write_toml(tmp.path(), "issues_dir = \"tickets\"\nviewer = \" \"\n");
let err = Config::load_with_warning(&blank, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(err.to_string().contains("viewer"), "{err}");
}
#[test]
fn viewer_is_omitted_from_the_file_when_unset() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("tix.toml");
let mut config = Config::default();
config.save(&path).unwrap();
assert!(
!std::fs::read_to_string(&path).unwrap().contains("viewer"),
"an unset viewer writes no key"
);
config.viewer = Some("less".to_string());
config.save(&path).unwrap();
assert!(
std::fs::read_to_string(&path)
.unwrap()
.contains(r#"viewer = "less""#),
"a set viewer round-trips"
);
}
#[test]
fn empty_columns_rejected_as_config_error() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), "[kanban.scratch]\ncolumns = []\n");
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(err.to_string().contains("kanban.scratch.columns"));
}
#[test]
fn duplicate_columns_rejected_as_config_error() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"[kanban.scratch]\ncolumns = [\"todo\", \"todo\"]\n",
);
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(err.to_string().contains("duplicate"));
}
#[test]
fn blank_column_name_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"[kanban.scratch]\ncolumns = [\"todo\", \" \"]\n",
);
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
}
#[test]
fn done_column_must_be_in_columns() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"[kanban.scratch]\ncolumns = [\"todo\", \"in progress\"]\ndone_column = \"in review\"\n",
);
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(err.to_string().contains("kanban.scratch.done_column"));
}
#[test]
fn hidden_columns_must_be_subset_of_columns() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\nhidden_columns = [\"nope\"]\n",
);
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(err.to_string().contains("kanban.scratch.hidden_columns"));
}
#[test]
fn hidden_done_column_loads_fine() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\nhidden_columns = [\"done\"]\n",
);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
let board = cfg.kanban.configurations.get("scratch").unwrap();
assert_eq!(board.done_column.as_deref(), Some("done"));
assert_eq!(board.hidden_columns, ["done"]);
}
#[test]
fn legacy_top_level_keys_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), "columns = [\"todo\", \"done\"]\n");
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(err.to_string().contains("tix.toml"));
assert!(err.to_string().contains("columns"));
}
#[test]
fn token_warning_fires_once_per_remote_with_token() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
r#"[export.gitlab]
project = "a/b"
token = "gl-secret"
[export.linear]
team = "Eng"
token = "lin-secret"
"#,
);
let mut warnings: Vec<String> = Vec::new();
let cfg = Config::load_with_warning(&path, |w| warnings.push(w.to_string())).unwrap();
assert_eq!(warnings.len(), 2);
assert!(warnings[0].contains("GITLAB_TOKEN"));
assert!(warnings[1].contains("LINEAR_TOKEN"));
for w in &warnings {
assert!(!w.contains("secret"));
}
assert_eq!(
cfg.export.gitlab.unwrap().token.as_deref(),
Some("gl-secret")
);
}
#[test]
fn no_token_means_no_warning() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
let mut fired = false;
Config::load_with_warning(&path, |_| fired = true).unwrap();
assert!(!fired);
}
#[test]
fn save_never_writes_the_state_section() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
let mut cfg = Config::default();
cfg.state.next_number = 42;
cfg.save(&path).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(reloaded.state.next_number, 0, "state is never persisted");
assert_eq!(
reloaded.default_config().columns,
cfg.default_config().columns
);
let on_disk = std::fs::read_to_string(&path).unwrap();
assert!(
!on_disk.contains("next_number"),
"next_number leaked into tix.toml:\n{on_disk}"
);
assert!(
!on_disk.contains("[state]"),
"state section written:\n{on_disk}"
);
}
#[test]
fn multi_config_roundtrip_preserves_declaration_order() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
r#"issues_dir = "docs/issues"
[kanban.release]
columns = ["planned", "in progress", "done"]
done_column = "done"
show_mismatch = true
[kanban.personal]
columns = ["todo", "done"]
done_column = "done"
hidden_columns = ["done"]
"#,
);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
let names: Vec<&str> = cfg.configs().map(|(n, _)| n).collect();
assert_eq!(names, ["release", "personal"]);
assert_eq!(
cfg.default_config().columns,
["planned", "in progress", "done"]
);
cfg.save(&path).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
cfg, reloaded,
"order and values survive save/load round trip"
);
let names2: Vec<&str> = reloaded.configs().map(|(n, _)| n).collect();
assert_eq!(names2, ["release", "personal"]);
}
#[test]
fn save_never_serializes_tokens_back_out() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
let mut cfg = Config::default();
cfg.export.gitlab = Some(RemoteConfig {
token: Some("secret".into()),
project: Some("a/b".into()),
..RemoteConfig::default()
});
cfg.save(&path).unwrap();
let on_disk = std::fs::read_to_string(&path).unwrap();
assert!(
!on_disk.contains("secret"),
"token leaked to disk:\n{on_disk}"
);
assert!(
on_disk.contains("a/b"),
"non-secret fields survive:\n{on_disk}"
);
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(reloaded.export.gitlab.unwrap().token, None);
}
#[test]
fn save_is_atomic_and_leaves_no_temp_file() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
Config::default().save(&path).unwrap();
let entries: Vec<_> = std::fs::read_dir(tmp.path())
.unwrap()
.filter_map(|e| e.ok())
.collect();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].file_name().to_string_lossy(), "tix.toml");
}
#[test]
fn default_loadwrites_are_idempotent() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
let cfg = Config::default();
cfg.save(&path).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(cfg, reloaded);
}
#[test]
fn effective_done_column_falls_back_to_last() {
let board = BoardConfiguration {
columns: vec!["todo".into(), "later".into()],
done_column: None,
..Default::default()
};
assert_eq!(board.effective_done_column(), Some("later"));
let with_done = BoardConfiguration {
columns: vec!["todo".into(), "done".into(), "later".into()],
done_column: None,
..Default::default()
};
assert_eq!(with_done.effective_done_column(), Some("done"));
}
#[test]
fn project_root_walks_up_the_tree() {
let tmp = TempDir::new().unwrap();
let nested = tmp.path().join("a").join("b");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(tmp.path().join("tix.toml"), "issues_dir = \"tix\"\n").unwrap();
assert_eq!(
Config::project_root(&nested),
Some(tmp.path().to_path_buf())
);
assert_eq!(
Config::project_root(tmp.path()),
Some(tmp.path().to_path_buf())
);
}
#[test]
fn project_root_is_none_when_no_toml_exists() {
let tmp = TempDir::new().unwrap();
assert_eq!(Config::project_root(tmp.path()), None);
}
#[test]
fn order_by_parses_all_names_and_defaults_to_desc() {
let tmp = TempDir::new().unwrap();
for name in ["created_at_desc", "created_at_asc", "none"] {
let path = write_toml(
tmp.path(),
&format!(
"[kanban.x]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\norder_by = \"{name}\"\n"
),
);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
let board = cfg.kanban.configurations.get("x").unwrap();
assert_eq!(board.order_by.as_str(), name);
}
assert_eq!(OrderBy::default(), OrderBy::CreatedAtDesc);
assert_eq!(OrderBy::CreatedAtDesc.next(), OrderBy::CreatedAtAsc);
assert_eq!(OrderBy::CreatedAtAsc.next(), OrderBy::None);
assert_eq!(OrderBy::None.next(), OrderBy::CreatedAtDesc);
}
#[test]
fn zero_title_lines_rejected_as_config_error() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\ntitle_lines = 0\n",
);
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(
err.to_string().contains("kanban.scratch.title_lines"),
"error names the offending field: {err}"
);
}
#[test]
fn unknown_order_by_rejected_as_config_error() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\norder_by = \"bogus\"\n",
);
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
}
#[test]
fn board_without_display_keys_loads_default_display_settings() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"[kanban.default]\ncolumns = [\"todo\", \"done\"]\n",
);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
let board = cfg.default_config();
assert!(board.wrap_titles);
assert_eq!(board.title_lines, 3);
assert_eq!(board.order_by, OrderBy::CreatedAtDesc);
}
#[test]
fn display_settings_roundtrip_through_save() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
let mut cfg = Config::default();
{
let board = cfg.kanban.configurations.get_mut("default").unwrap();
board.wrap_titles = false;
board.title_lines = 5;
board.order_by = OrderBy::None;
}
cfg.save(&path).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
reloaded, cfg,
"display settings survive save/load round trip"
);
let on_disk = std::fs::read_to_string(&path).unwrap();
for key in ["wrap_titles", "title_lines", "order_by"] {
assert!(on_disk.contains(key), "missing `{key}` in:\n{on_disk}");
}
}
#[test]
fn manual_card_order_roundtrips_through_save() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
let mut cfg = Config::default();
{
let board = cfg.kanban.configurations.get_mut("default").unwrap();
board.card_order = vec!["a.md".into(), "b.md".into()];
}
cfg.save(&path).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
reloaded
.kanban
.configurations
.get("default")
.unwrap()
.card_order,
vec!["a.md".to_string(), "b.md".to_string()]
);
let on_disk = std::fs::read_to_string(&path).unwrap();
assert!(
on_disk.contains("card_order"),
"missing card_order in:\n{on_disk}"
);
}
#[test]
fn planning_defaults_match_contract() {
let c = Config::default();
assert_eq!(c.planning.cycle_number, 1);
assert_eq!(c.planning.default_cycle, None);
assert_eq!(c.planning.default_milestone, None);
assert_eq!(c.planning.default_team, None);
assert!(c.teams.is_empty());
assert!(c.cycles.is_empty());
assert_eq!(c.milestones_dir, None);
assert_eq!(c.milestones_dir(), Path::new("docs/milestones"));
}
#[test]
fn planning_sections_roundtrip_and_preserve_declaration_order() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
r#"issues_dir = "docs/issues"
milestones_dir = "docs/milestones"
[planning]
cycle_number = 1
default_cycle = "Cycle 1"
default_milestone = "Backlog"
default_team = "default"
[cycles."Cycle 1"]
[cycles."Cycle 2"]
start_date = "2026-09-21"
due_date = "2026-09-30"
[teams.default]
members = ["Herb Mason <herb@example.com>", "@alice"]
[teams.platform]
members = ["bob"]
"#,
);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(cfg.planning.cycle_number, 1);
assert_eq!(cfg.planning.default_cycle.as_deref(), Some("Cycle 1"));
assert_eq!(cfg.planning.default_milestone.as_deref(), Some("Backlog"));
assert_eq!(cfg.planning.default_team.as_deref(), Some("default"));
assert_eq!(cfg.milestones_dir(), Path::new("docs/milestones"));
let cycles: Vec<&str> = cfg.cycles.keys().map(String::as_str).collect();
assert_eq!(cycles, ["Cycle 1", "Cycle 2"]);
let cycle2 = cfg.cycles.get("Cycle 2").unwrap();
assert_eq!(cycle2.start_date.as_deref(), Some("2026-09-21"));
assert_eq!(cycle2.due_date.as_deref(), Some("2026-09-30"));
assert_eq!(cycle2.description, None);
let teams: Vec<&str> = cfg.teams.keys().map(String::as_str).collect();
assert_eq!(teams, ["default", "platform"]);
assert_eq!(
cfg.teams.get("default").unwrap().members,
["Herb Mason <herb@example.com>", "@alice"]
);
cfg.save(&path).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(cfg, reloaded);
assert_eq!(
reloaded
.cycles
.keys()
.map(String::as_str)
.collect::<Vec<_>>(),
["Cycle 1", "Cycle 2"]
);
assert_eq!(
reloaded
.teams
.keys()
.map(String::as_str)
.collect::<Vec<_>>(),
["default", "platform"]
);
}
const FULL_CATEGORIES: &str = r#"issues_dir = "docs/issues"
[categories]
order = ["backlog", "unstarted", "active", "blocked", "completed", "canceled"]
default = "unstarted"
[transitions]
terminal_locked = true
[kanban.default]
columns = ["triage", "wip", "review", "shipped"]
done_column = "shipped"
category_map = { triage = "backlog", wip = "active", review = "active", shipped = "completed" }
[export]
"#;
#[test]
fn category_sections_parse_and_default_sensibly() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), FULL_CATEGORIES);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(cfg.categories.order, CATEGORY_KEYS);
assert_eq!(cfg.categories.default, "unstarted");
assert!(cfg.transitions.terminal_locked);
let board = cfg.default_config();
assert_eq!(
board.category_map.get("shipped").map(String::as_str),
Some("completed")
);
assert_eq!(
board.category_map.get("triage").map(String::as_str),
Some("backlog")
);
}
#[test]
fn category_sections_default_when_absent() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(cfg.categories, CategoryConfig::default());
assert_eq!(cfg.transitions, TransitionConfig::default());
assert!(cfg.categories.is_default(), "and is not written back");
assert!(cfg.transitions.is_default());
}
#[test]
fn deny_unknown_fields_rejects_an_unknown_key_in_each_new_section() {
let tmp = TempDir::new().unwrap();
let bad_categories = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[categories]\nnope = 1\n",
);
let err = Config::load_with_warning(&bad_categories, |_| {})
.expect_err("an unknown key in [categories] must be refused");
assert!(err.to_string().contains("unknown field"), "got: {err}");
let tmp2 = TempDir::new().unwrap();
let bad_transitions = write_toml(
tmp2.path(),
"issues_dir = \"docs/issues\"\n\n[transitions]\nnope = 1\n",
);
let err = Config::load_with_warning(&bad_transitions, |_| {})
.expect_err("an unknown key in [transitions] must be refused");
assert!(err.to_string().contains("unknown field"), "got: {err}");
}
#[test]
fn a_configured_vocabulary_naming_an_unknown_key_is_refused() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[categories]\norder = [\"backlog\", \"wontfix\"]\n",
);
let err = Config::load_with_warning(&path, |_| {})
.expect_err("the vocabulary is global and fixed; a new key is not a default");
assert!(err.to_string().contains("unknown_category"), "got: {err}");
}
#[test]
fn a_default_of_completed_is_refused() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[categories]\ndefault = \"completed\"\n",
);
let err = Config::load_with_warning(&path, |_| {})
.expect_err("a completion-changing default must be an escalation");
let msg = err.to_string();
assert!(msg.contains("may not be `completed`"), "got: {msg}");
}
#[test]
fn a_default_outside_the_vocabulary_is_refused() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[categories]\ndefault = \"wontfix\"\n",
);
let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
assert!(err.to_string().contains("unknown_category"), "got: {err}");
}
#[test]
fn a_mapped_board_with_no_completion_column_is_refused() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
r#"issues_dir = "docs/issues"
[kanban.default]
columns = ["triage", "wip", "shipped"]
done_column = "shipped"
category_map = { triage = "backlog", wip = "active" }
"#,
);
let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
assert!(
err.to_string().contains("missing_completion_category"),
"got: {err}"
);
}
#[test]
fn a_board_with_two_completion_columns_is_refused() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
r#"issues_dir = "docs/issues"
[kanban.default]
columns = ["shipped", "cancelled"]
done_column = "shipped"
category_map = { shipped = "completed", cancelled = "completed" }
"#,
);
let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
let msg = err.to_string();
assert!(
msg.contains("exactly one column may complete"),
"got: {msg}"
);
}
#[test]
fn a_mapping_for_a_column_the_board_does_not_configure_is_refused() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
r#"issues_dir = "docs/issues"
[kanban.default]
columns = ["triage", "shipped"]
done_column = "shipped"
category_map = { triage = "backlog", shipped = "completed", nope = "active" }
"#,
);
let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
assert!(
err.to_string().contains("mapping_for_unknown_column"),
"got: {err}"
);
}
#[test]
fn a_mapping_to_a_category_outside_the_vocabulary_is_refused() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
r#"issues_dir = "docs/issues"
[kanban.default]
columns = ["triage", "shipped"]
done_column = "shipped"
category_map = { triage = "wontfix", shipped = "completed" }
"#,
);
let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
assert!(err.to_string().contains("unknown_category"), "got: {err}");
}
#[test]
fn an_unmapped_column_is_accepted_and_resolves_by_the_safe_default() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
r#"issues_dir = "docs/issues"
[kanban.default]
columns = ["triage", "wip", "shipped"]
done_column = "shipped"
category_map = { shipped = "completed" }
"#,
);
let cfg =
Config::load_with_warning(&path, |_| {}).expect("an unmapped column is not an error");
let board = cfg.default_config();
assert_eq!(board.category_for("triage", &cfg.categories), "unstarted");
assert_eq!(board.category_for("wip", &cfg.categories), "unstarted");
assert_eq!(board.category_for("shipped", &cfg.categories), "completed");
}
#[test]
fn a_legacy_board_with_no_map_takes_done_column_as_completed() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
r#"issues_dir = "docs/issues"
[kanban.default]
columns = ["todo", "in progress", "in review", "done"]
done_column = "done"
"#,
);
let cfg = Config::load_with_warning(&path, |_| {})
.expect("a legacy board must load, and it does");
let board = cfg.default_config();
assert!(board.category_map.is_empty());
assert_eq!(board.category_for("done", &cfg.categories), "completed");
assert_eq!(board.category_for("todo", &cfg.categories), "unstarted");
assert_eq!(
board.category_for("in review", &cfg.categories),
"unstarted",
"and nothing else is silently complete"
);
}
#[test]
fn a_defaulted_config_gains_no_new_keys_on_save() {
let tmp = TempDir::new().unwrap();
let legacy = LEGACY_PRETTY_BOARD;
let path = write_toml(tmp.path(), legacy);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
cfg.save(&path).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
assert_eq!(
after, legacy,
"no `[categories]`, no `[transitions]`, no `category_map` may appear"
);
}
#[test]
fn explicit_default_board_keys_are_normalized_away_by_save() {
let tmp = TempDir::new().unwrap();
let explicit = r#"issues_dir = "docs/issues"
[kanban.default]
columns = [
"todo",
"in progress",
"in review",
"done",
]
done_column = "done"
hidden_columns = []
show_mismatch = false
wrap_titles = true
title_lines = 3
order_by = "created_at_desc"
[export]
"#;
let path = write_toml(tmp.path(), explicit);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
cfg.save(&path).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
assert_eq!(
after, LEGACY_PRETTY_BOARD,
"a redundant explicit default collapses to the canonical form"
);
for key in [
"done_column",
"hidden_columns",
"show_mismatch",
"wrap_titles",
"title_lines",
"order_by",
"[export]",
] {
assert!(!after.contains(key), "`{key}` must be omitted:\n{after}");
}
assert_eq!(
cfg,
Config::load_with_warning(&path, |_| {}).unwrap(),
"and the collapsed form reloads to the same config"
);
}
#[test]
fn a_board_mapping_without_a_categories_section_uses_the_built_in_vocabulary() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
r#"issues_dir = "docs/issues"
[kanban.default]
columns = ["triage", "shipped"]
done_column = "shipped"
category_map = { triage = "backlog", shipped = "completed" }
[export]
"#,
);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(cfg.categories, CategoryConfig::default());
cfg.save(&path).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
assert!(
!after.contains("[categories]"),
"the section must stay unmaterialized: {after}"
);
assert!(
after.contains("category_map"),
"while the board's own mapping survives: {after}"
);
}
#[test]
fn a_full_category_config_round_trips_with_its_values_and_order() {
let tmp = TempDir::new().unwrap();
let authored = r#"issues_dir = "docs/issues"
[categories]
order = ["backlog", "active", "completed", "canceled"]
default = "backlog"
[transitions]
terminal_locked = false
[kanban.default]
columns = ["triage", "wip", "shipped"]
done_column = "shipped"
category_map = { triage = "backlog", wip = "active", shipped = "completed" }
[export]
"#;
let path = write_toml(tmp.path(), authored);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
cfg.save(&path).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
reloaded.categories, cfg.categories,
"the vocabulary and its default survive"
);
assert_eq!(
reloaded.categories.order,
vec!["backlog", "active", "completed", "canceled"],
"and `order` is a Vec, so the declared order is preserved exactly"
);
assert_eq!(
reloaded.default_config().category_map,
cfg.default_config().category_map,
"the mapping's contents survive, in whatever order they are written"
);
assert!(after.contains("[categories]"), "after: {after}");
assert!(after.contains("[transitions]"), "after: {after}");
assert!(
!reloaded.transitions.terminal_locked,
"and the value survives"
);
let reloaded_again = Config::load_with_warning(&path, |_| {}).unwrap();
reloaded_again.save(&path).unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
after,
"a second save must be a no-op"
);
}
#[test]
fn two_boards_with_different_columns_map_to_the_same_categories() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
r#"issues_dir = "docs/issues"
[kanban.alpha]
columns = ["triage", "wip", "review", "shipped"]
done_column = "shipped"
category_map = { triage = "backlog", wip = "active", review = "active", shipped = "completed" }
[kanban.beta]
columns = ["new", "doing", "merged"]
done_column = "merged"
[export]
"#,
);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
let alpha = &cfg.kanban.configurations["alpha"];
let beta = &cfg.kanban.configurations["beta"];
assert_eq!(alpha.category_for("shipped", &cfg.categories), "completed");
assert_eq!(beta.category_for("merged", &cfg.categories), "completed");
assert_eq!(alpha.category_for("wip", &cfg.categories), "active");
assert_eq!(beta.category_for("doing", &cfg.categories), "unstarted");
assert!(beta.category_map.is_empty());
}
#[test]
fn canceled_is_terminal_but_not_complete() {
assert!(TERMINAL_CATEGORIES.contains(&"canceled"));
assert!(TERMINAL_CATEGORIES.contains(&"completed"));
assert_eq!(
COMPLETE_CATEGORY, "completed",
"completion is defined by this category alone"
);
assert_ne!(COMPLETE_CATEGORY, "canceled");
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
r#"issues_dir = "docs/issues"
[kanban.default]
columns = ["triage", "dropped", "shipped"]
done_column = "shipped"
category_map = { triage = "backlog", dropped = "canceled", shipped = "completed" }
"#,
);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
let board = cfg.default_config();
assert_eq!(board.category_for("dropped", &cfg.categories), "canceled");
assert_ne!(
board.category_for("dropped", &cfg.categories),
COMPLETE_CATEGORY
);
}
#[test]
fn blocked_as_a_category_is_mappable_and_is_its_own_key() {
assert!(CATEGORY_KEYS.contains(&"blocked"));
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
r#"issues_dir = "docs/issues"
[kanban.default]
columns = ["wip", "on hold", "shipped"]
done_column = "shipped"
category_map = { wip = "active", "on hold" = "blocked", shipped = "completed" }
"#,
);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
let board = cfg.default_config();
assert_eq!(board.category_for("on hold", &cfg.categories), "blocked");
assert!(
!cfg.default_config()
.category_map
.values()
.any(|v| v.contains("state")),
"a category is a position, never a derived state"
);
}
#[test]
fn legacy_file_roundtrips_byte_identical() {
let tmp = TempDir::new().unwrap();
let legacy = LEGACY_PRETTY_BOARD;
let path = write_toml(tmp.path(), legacy);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(cfg.planning, PlanningConfig::default());
assert!(cfg.teams.is_empty());
assert!(cfg.cycles.is_empty());
assert_eq!(cfg.milestones_dir, None);
assert_eq!(
cfg.default_config().columns,
["todo", "in progress", "in review", "done"]
);
assert_eq!(cfg.default_config().effective_done_column(), Some("done"));
assert_eq!(cfg.default_config().order_by, OrderBy::CreatedAtDesc);
assert_eq!(
cfg.default_config().hidden_columns,
[] as [std::string::String; 0]
);
assert!(!cfg.default_config().show_mismatch);
assert!(cfg.default_config().wrap_titles);
assert_eq!(cfg.default_config().title_lines, DEFAULT_TITLE_LINES);
assert!(!cfg.default_config().category_map.contains_key("done"));
cfg.save(&path).unwrap();
let on_disk = std::fs::read_to_string(&path).unwrap();
assert_eq!(
on_disk, legacy,
"legacy tix.toml must save byte-identical (NFR-003)"
);
assert!(
!on_disk.contains("[export]"),
"an empty `[export]` table must not be materialized:\n{on_disk}"
);
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(cfg, reloaded);
}
#[test]
fn legacy_file_in_any_layout_never_gains_planning_keys() {
let tmp = TempDir::new().unwrap();
let legacy = r#"issues_dir = "docs/issues"
[kanban.default]
columns = ["todo", "in progress", "in review", "done"]
done_column = "done"
"#;
let path = write_toml(tmp.path(), legacy);
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
cfg.save(&path).unwrap();
let on_disk = std::fs::read_to_string(&path).unwrap();
assert!(!on_disk.contains("milestones_dir"));
assert!(!on_disk.contains("[planning]"));
assert!(!on_disk.contains("[teams]"));
assert!(!on_disk.contains("[cycles"));
}
#[test]
fn planning_default_section_with_partial_keys_loads() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), "[planning]\ndefault_cycle = \"Cycle 1\"\n");
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
cfg.planning.cycle_number, 1,
"counter defaults when omitted"
);
assert_eq!(cfg.planning.default_cycle.as_deref(), Some("Cycle 1"));
assert_eq!(cfg.planning.default_milestone, None);
assert_eq!(cfg.planning.default_team, None);
}
#[test]
fn planning_rejects_unknown_keys() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), "[planning]\ncounter = 5\n");
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(err.to_string().contains("counter"));
}
#[test]
fn planning_rejects_zero_cycle_number() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), "[planning]\ncycle_number = 0\n");
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(err.to_string().contains("cycle_number"));
}
#[test]
fn cycle_dates_are_validated_and_compared() {
let tmp = TempDir::new().unwrap();
let bad = write_toml(tmp.path(), "[cycles.x]\nstart_date = \"2026-13-01\"\n");
let err = Config::load_with_warning(&bad, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(err.to_string().contains("start_date"));
let ordered = write_toml(
tmp.path(),
"[cycles.x]\nstart_date = \"2026-09-30\"\ndue_date = \"2026-09-01\"\n",
);
let err = Config::load_with_warning(&ordered, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(err.to_string().contains("due_date"));
assert!(err.to_string().contains("start_date"));
}
#[test]
fn cycles_reject_unknown_keys() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), "[cycles.X]\nbogus = 1\n");
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(err.to_string().contains("bogus"));
}
#[test]
fn cycle_without_dates_loads_fine() {
let tmp = TempDir::new().unwrap();
let ok = write_toml(tmp.path(), "[cycles.Alpha]\ndescription = \"a\"\n");
let cfg = Config::load_with_warning(&ok, |_| {}).unwrap();
assert_eq!(
cfg.cycles.get("Alpha").unwrap().description.as_deref(),
Some("a")
);
}
#[test]
fn teams_validate_members_nonempty_and_unique() {
let tmp = TempDir::new().unwrap();
let empty = write_toml(tmp.path(), "[teams.dev]\nmembers = []\n");
let err = Config::load_with_warning(&empty, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(err.to_string().contains("members"));
let dup = write_toml(
tmp.path(),
"[teams.dev]\nmembers = [\"alice\", \"alice\"]\n",
);
let err = Config::load_with_warning(&dup, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(err.to_string().contains("unique"));
}
#[test]
fn empty_teams_table_is_disabled_not_error() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), "[teams]\n");
let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
assert!(cfg.teams.is_empty());
cfg.save(&path).unwrap();
let on_disk = std::fs::read_to_string(&path).unwrap();
assert!(!on_disk.contains("[teams]"));
}
#[test]
fn blank_milestones_dir_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), "milestones_dir = \"\"\n");
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(err.to_string().contains("milestones_dir"));
}
#[test]
fn sparse_keybindings_roundtrip_without_defaults() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"[keybindings.board]\nnew_issue = [\"ctrl+n\", \"f4\"]\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
config.save(&path).unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.contains("[keybindings.board]"));
assert!(!contents.contains("move_left"));
assert_eq!(Config::load_with_warning(&path, |_| {}).unwrap(), config);
}
#[test]
fn omitted_keybindings_retain_effective_defaults() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
let config = Config::load_with_warning(&path, |_| {}).unwrap();
let bindings = config.effective_keybindings().unwrap();
assert_eq!(
bindings.label(
crate::keybindings::InputContext::Board,
crate::keybindings::TuiAction::NewIssue
),
"n"
);
}
#[test]
fn edit_conflict_keybindings_override_the_prompt() {
use crate::keybindings::{InputContext, TuiAction};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"[keybindings.edit_conflict]\nconfirm = [\"ctrl+k\"]\nignore = [\"escape\"]\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
let bindings = config.effective_keybindings().unwrap();
assert_eq!(
bindings.label(InputContext::EditConflict, TuiAction::Confirm),
"Ctrl+k"
);
assert_eq!(
bindings.action(
InputContext::EditConflict,
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)
),
Some(TuiAction::Confirm)
);
assert_eq!(
bindings.action(
InputContext::EditConflict,
KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)
),
None,
"an override replaces the action's whole alias list"
);
assert_eq!(
bindings.action(
InputContext::EditConflict,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)
),
Some(TuiAction::Reedit)
);
assert_eq!(
bindings.action(
InputContext::EditConflict,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)
),
Some(TuiAction::Ignore)
);
}
#[test]
fn keybindings_reject_unknown_fields_and_empty_actions() {
let tmp = TempDir::new().unwrap();
let unknown = write_toml(tmp.path(), "[keybindings.board]\nbogus = [\"x\"]\n");
assert!(
Config::load_with_warning(&unknown, |_| {})
.unwrap_err()
.to_string()
.contains("bogus")
);
let empty = write_toml(tmp.path(), "[keybindings.board]\nnew_issue = []\n");
assert!(
Config::load_with_warning(&empty, |_| {})
.unwrap_err()
.to_string()
.contains("new_issue")
);
}
#[test]
fn shell_mode_switches_are_configurable() {
use crate::keybindings::{InputContext, TuiAction};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"[keybindings.shell]\nswitch_to_milestones = [\"g\"]\nswitch_to_board = [\"f3\", \"b\"]\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
let bindings = config.effective_keybindings().unwrap();
assert_eq!(
bindings.label(InputContext::Shell, TuiAction::SwitchToMilestones),
"g"
);
assert_eq!(
bindings.label(InputContext::Shell, TuiAction::SwitchToBoard),
"F3 / b"
);
assert_eq!(
bindings.action(
InputContext::Shell,
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)
),
Some(TuiAction::SwitchToMilestones)
);
assert_eq!(
bindings.action(
InputContext::Shell,
KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE)
),
None,
"an override replaces the action's whole alias list"
);
assert_eq!(
bindings.action(
InputContext::Shell,
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)
),
Some(TuiAction::SwitchToBoard),
"a second alias keeps working"
);
let unknown = write_toml(
tmp.path(),
"[keybindings.shell]\nswitch_to_nowhere = [\"g\"]\n",
);
let error = Config::load_with_warning(&unknown, |_| {})
.unwrap_err()
.to_string();
assert!(
error.contains("switch_to_nowhere"),
"the error names the offending key: {error:?}"
);
let bad_chord = write_toml(
tmp.path(),
"[keybindings.shell]\nswitch_to_milestones = [\"ctrl+nosuchkey\"]\n",
);
let error = Config::load_with_warning(&bad_chord, |_| {})
.unwrap_err()
.to_string();
assert!(
error.contains("switch_to_milestones") && error.contains("nosuchkey"),
"the error names the key and the chord: {error:?}"
);
}
#[test]
fn templates_round_trip_through_save_and_load() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
let mut config = Config::starter();
config.templates.insert(
"bug".into(),
IssueTemplate {
title: Some("Bug: ".into()),
body: Some("## Steps\n".into()),
labels: vec!["bug".into(), "urgent".into()],
status: Some("todo".into()),
cycles: vec!["Default".into()],
start_date: Some("2026-01-01".into()),
due_date: Some("2026-01-02".into()),
..IssueTemplate::default()
},
);
config.validate().expect("the fixture is a valid config");
config.save(&path).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.contains("[templates.bug]"), "{raw}");
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(reloaded.templates, config.templates);
let bug = &reloaded.templates["bug"];
assert_eq!(bug.labels, ["bug".to_string(), "urgent".to_string()]);
assert_eq!(bug.body.as_deref(), Some("## Steps\n"));
assert_eq!(bug.due_date.as_deref(), Some("2026-01-02"));
}
#[test]
fn a_config_without_templates_writes_no_section() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
Config::default().save(&path).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
assert!(
!raw.contains("templates"),
"an absent section must not appear on disk:\n{raw}"
);
assert!(
Config::load_with_warning(&path, |_| {})
.unwrap()
.templates
.is_empty()
);
}
#[test]
fn a_template_value_with_a_comma_is_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[templates.bug]\nlabels = [\"bug,urgent\"]\n",
);
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(
err.to_string().contains("must not contain a comma"),
"got {err}"
);
}
#[test]
fn a_template_with_a_backwards_date_range_is_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[templates.bug]\n\
start_date = \"2026-02-01\"\ndue_date = \"2026-01-01\"\n",
);
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(err.to_string().contains(">= start_date"), "got {err}");
}
#[test]
fn an_unknown_template_key_is_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[templates.bug]\ntitel = \"typo\"\n",
);
let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
assert!(err.to_string().contains("unknown field"), "got {err}");
}
#[test]
fn issue_templates_round_trip_through_save_and_load() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
let mut config = Config::starter();
config
.issue_templates
.insert(
"bug",
Template {
name: Some("Bug report".to_string()),
defaults: TemplateDefaults {
labels: vec!["bug".to_string()],
priority: Some("high".to_string()),
..TemplateDefaults::default()
},
prompts: vec![TemplatePrompt {
key: "priority".to_string(),
label: Some("Priority".to_string()),
kind: PromptKind::Select,
required: true,
default: None,
options_ref: Some("priorities".to_string()),
}],
references: vec![TemplateReference {
kind: ReferenceKind::Label,
value: "bug".to_string(),
required: true,
}],
..Template::default()
},
)
.unwrap();
config.validate().expect("the fixture is a valid config");
config.save(&path).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.contains("[issue_templates.bug]"), "{raw}");
assert!(raw.contains("[[issue_templates.bug.prompts]]"), "{raw}");
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(reloaded.issue_templates, config.issue_templates);
let bug = reloaded.issue_templates.get("bug").unwrap();
assert_eq!(bug.name.as_deref(), Some("Bug report"));
assert_eq!(bug.defaults.labels, ["bug".to_string()]);
assert_eq!(bug.prompts[0].kind, PromptKind::Select);
assert!(bug.prompts[0].required);
assert_eq!(bug.references[0].kind, ReferenceKind::Label);
}
#[test]
fn a_config_without_issue_templates_writes_no_section() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
Config::default().save(&path).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
assert!(
!raw.contains("issue_templates"),
"an absent section must not appear on disk:\n{raw}"
);
assert!(
Config::load_with_warning(&path, |_| {})
.unwrap()
.issue_templates
.is_empty()
);
}
#[test]
fn a_legacy_config_gains_no_issue_templates_section() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), LEGACY_PRETTY_BOARD);
let before = std::fs::read_to_string(&path).unwrap();
let config = Config::load_with_warning(&path, |_| {}).unwrap();
assert!(
config.issue_templates.is_empty(),
"a legacy config has no stable-ID registry"
);
config.save(&path).unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
before,
"a legacy config must round-trip byte-identically"
);
}
#[test]
fn issue_template_declaration_order_is_restored() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[issue_templates.zeta]\nname = \"Zeta\"\n\n\
[issue_templates.alpha]\nname = \"Alpha\"\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
config.issue_templates.ids().collect::<Vec<_>>(),
["zeta", "alpha"],
"declaration order is the display order"
);
config.save(&path).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
reloaded.issue_templates.ids().collect::<Vec<_>>(),
["zeta", "alpha"]
);
}
#[test]
fn a_rejected_template_write_leaves_tix_toml_unchanged() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[issue_templates.bug]\nname = \"Bug report\"\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
let before = std::fs::read(&path).unwrap();
let mut rejected = config.clone();
rejected.issue_templates.get_mut("bug").unwrap().scope =
TemplateScope::board("no-such-board");
let err = rejected.validate().unwrap_err().to_string();
assert!(
err.contains("issue_templates.bug.scope") && err.contains("no-such-board"),
"{err}"
);
assert_eq!(
std::fs::read(&path).unwrap(),
before,
"a rejected write must not touch tix.toml"
);
let mut duplicate = config.clone();
let err = duplicate
.issue_templates
.insert("bug", Template::default())
.unwrap_err();
assert!(err.to_string().contains("already exists"), "{err}");
assert_eq!(duplicate.issue_templates, config.issue_templates);
assert_eq!(std::fs::read(&path).unwrap(), before);
}
#[test]
fn unknown_issue_template_metadata_round_trips_byte_stably() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[issue_templates.bug]\nname = \"Bug report\"\n\
reporter_hint = \"include the build id\"\n\n\
[issue_templates.bug.future]\nlevel = 3\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
let bug = config.issue_templates.get("bug").unwrap();
assert_eq!(
bug.extensions
.get("reporter_hint")
.and_then(toml::Value::as_str),
Some("include the build id")
);
assert!(bug.extensions.get("future").is_some());
config.save(&path).unwrap();
let first = std::fs::read(&path).unwrap();
let text = String::from_utf8(first.clone()).unwrap();
assert!(
text.contains("reporter_hint") && text.contains("future"),
"unknown metadata must survive a save:\n{text}"
);
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
reloaded.save(&path).unwrap();
assert_eq!(
std::fs::read(&path).unwrap(),
first,
"a second round trip must be byte-identical"
);
}
#[test]
fn wip_config_round_trips_byte_stably_and_resolves() {
let tmp = TempDir::new().unwrap();
let configured = "issues_dir = \"docs/issues\"\n\n\
[kanban.default]\ncolumns = [\n \"todo\",\n \"in progress\",\n \"done\",\n]\n\n\
[kanban.default.wip.units]\nd = 1\nw = 5\n\n\
[kanban.default.wip.limits.\"in progress\"]\nmax_items = 3\n\
max_estimate = 10\nunit = \"d\"\npolicy = \"block\"\n";
let path = write_toml(tmp.path(), configured);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
let board = config.default_config();
let limit = board
.wip
.effective_limit("in progress")
.expect("the configured limit is in force");
assert_eq!(limit.max_items, Some(3));
assert_eq!(limit.max_estimate, Some(10));
assert_eq!(limit.unit.as_deref(), Some("d"));
assert_eq!(limit.policy, Some(crate::wip::LimitPolicy::Block));
assert_eq!(board.wip.factor("w"), Some(5));
assert!(
board.wip.effective_limit("done").is_none(),
"a column without a limit has none"
);
config.save(&path).unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
configured,
"a configured board must round trip byte-for-byte"
);
}
#[test]
fn wip_config_limit_on_an_unknown_column_is_refused() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
[kanban.default.wip.limits.\"in progress\"]\nmax_items = 3\n\
policy = \"warn\"\n",
);
let error = Config::load_with_warning(&path, |_| {})
.expect_err("an unknown column is refused")
.to_string();
assert!(
error.contains("kanban.default.wip.limits.`in progress`"),
"{error}"
);
}
#[test]
fn wip_config_absent_limits_add_no_key() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), LEGACY_PRETTY_BOARD);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
assert!(config.default_config().wip.is_empty());
config.save(&path).unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
LEGACY_PRETTY_BOARD,
"a board without limits must not gain a `wip` key"
);
}
const TWO_PROJECTS: &str = "issues_dir = \"docs/issues\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
[projects.\"p-3f9a2c\"]\n\
name = \"Platform hardening\"\n\
description = \"Reduce operational risk across the sync path.\"\n\
status = \"active\"\n\
lead = \"Alice <alice@example.com>\"\n\
start_date = \"2026-09-01\"\n\
due_date = \"2026-12-31\"\n\n\
[projects.\"p-7b1e04\"]\n\
name = \"Docs refresh\"\n";
#[test]
fn project_registry_parses_every_contract_field() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), TWO_PROJECTS);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
let platform = &config.projects["p-3f9a2c"];
assert_eq!(platform.name, "Platform hardening");
assert_eq!(
platform.description.as_deref(),
Some("Reduce operational risk across the sync path.")
);
assert_eq!(platform.status.as_deref(), Some("active"));
assert_eq!(platform.lead.as_deref(), Some("Alice <alice@example.com>"));
assert_eq!(platform.start_date.as_deref(), Some("2026-09-01"));
assert_eq!(platform.due_date.as_deref(), Some("2026-12-31"));
let docs = &config.projects["p-7b1e04"];
assert_eq!(docs.name, "Docs refresh");
assert!(docs.description.is_none() && docs.status.is_none() && docs.lead.is_none());
assert!(docs.start_date.is_none() && docs.due_date.is_none());
}
#[test]
fn project_registry_rejects_an_unknown_field() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[projects.\"p-1\"]\nname = \"One\"\nownr = \"typo\"\n",
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("ownr"), "got {err}");
}
#[test]
fn project_declaration_order_is_restored() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
[projects.\"p-zeta\"]\nname = \"Zeta\"\n\n\
[projects.\"p-alpha\"]\nname = \"Alpha\"\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
config.projects.keys().collect::<Vec<_>>(),
["p-zeta", "p-alpha"],
"declaration order is the display order"
);
config.save(&path).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
reloaded.projects.keys().collect::<Vec<_>>(),
["p-zeta", "p-alpha"],
"declaration order survives a save/reload through the ordered map"
);
}
#[test]
fn a_legacy_tix_toml_gains_no_projects_section() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), LEGACY_PRETTY_BOARD);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
assert!(config.projects.is_empty());
config.save(&path).unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
LEGACY_PRETTY_BOARD,
"a repository with no projects must round-trip byte-identically"
);
}
#[test]
fn an_empty_project_record_writes_no_optional_keys() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
[projects.\"p-1\"]\nname = \"One\"\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
config.save(&path).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
for key in ["description", "status", "lead", "start_date", "due_date"] {
assert!(
!after.contains(key),
"an absent optional field must not be written: {key} in {after}"
);
}
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(reloaded, config);
}
#[test]
fn a_blank_project_name_is_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[projects.\"p-1\"]\nname = \" \"\n",
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("projects.p-1.name"), "got {err}");
}
#[test]
fn duplicate_project_names_are_rejected() {
for second in ["Same", "same"] {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
&format!(
"issues_dir = \"docs/issues\"\n\n\
[projects.\"p-1\"]\nname = \"Same\"\n\n\
[projects.\"p-2\"]\nname = \"{second}\"\n"
),
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("must be unique"), "got {err}");
}
}
#[test]
fn a_padded_project_name_is_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[projects.\"p-1\"]\nname = \" One \"\n",
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("leading or trailing whitespace"), "got {err}");
}
#[test]
fn a_blank_project_id_is_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[projects.\"\"]\nname = \"No id\"\n",
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("must not be blank"), "got {err}");
}
#[test]
fn a_project_id_with_a_structural_character_is_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[projects.\"p.1\"]\nname = \"Dotted\"\n",
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("contains"), "got {err}");
}
#[test]
fn blank_optional_project_text_is_rejected() {
for key in ["description", "status", "lead"] {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
&format!(
"issues_dir = \"docs/issues\"\n\n\
[projects.\"p-1\"]\nname = \"One\"\n{key} = \"\"\n"
),
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains(&format!("projects.p-1.{key}")), "got {err}");
}
}
#[test]
fn a_bad_or_inverted_project_date_is_rejected() {
let tmp = TempDir::new().unwrap();
let bad = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[projects.\"p-1\"]\nname = \"One\"\nstart_date = \"2026-13-01\"\n",
);
let err = Config::load_with_warning(&bad, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("projects.p-1"), "got {err}");
let inverted = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[projects.\"p-1\"]\nname = \"One\"\n\
start_date = \"2026-12-01\"\ndue_date = \"2026-01-01\"\n",
);
let err = Config::load_with_warning(&inverted, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("projects.p-1"), "got {err}");
}
#[test]
fn a_valid_project_date_pair_passes() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), TWO_PROJECTS);
Config::load_with_warning(&path, |_| {}).expect("a valid registry must load");
}
#[test]
fn allocated_project_ids_have_the_generated_shape_and_successive_draws_differ() {
let mut config = Config::default();
let first = config.allocate_project_id();
config.projects.insert(
first.to_string(),
ProjectConfiguration {
name: "One".into(),
..ProjectConfiguration::default()
},
);
let second = config.allocate_project_id();
assert_ne!(first, second, "two allocations must be distinct");
assert_eq!(first.as_str().len(), 18, "`p-` plus 16 hex digits");
assert!(first.as_str().starts_with("p-"));
config.projects.shift_remove(first.as_str());
let third = config.allocate_project_id();
assert_ne!(
third, first,
"a deleted project's id must not be handed out"
);
assert_ne!(third, second);
}
#[test]
fn allocation_redraws_past_a_stored_id() {
let mut config = Config::default();
config.projects.insert(
"p-aaaa".into(),
ProjectConfiguration {
name: "Stored".into(),
..ProjectConfiguration::default()
},
);
let scripted = ["p-aaaa", "p-bbbb"];
let mut draws = 0;
let allocated = config.allocate_project_id_with(|| {
let drawn = scripted[draws];
draws += 1;
ProjectId::new(drawn).unwrap()
});
assert_eq!(allocated.as_str(), "p-bbbb");
assert_eq!(draws, 2, "the stored id must have been drawn and redrawn");
}
#[test]
fn schedule_allocation_redraws_past_a_stored_id() {
let mut config = Config::default();
config.cycle_schedules.insert(
"cs-aaaa".into(),
crate::cycle_schedule::config::CycleScheduleConfiguration {
name: "Stored".into(),
..crate::cycle_schedule::config::CycleScheduleConfiguration::default()
},
);
let scripted = ["cs-aaaa", "cs-bbbb"];
let mut draws = 0;
let allocated = config.allocate_cycle_schedule_id_with(|| {
let drawn = scripted[draws];
draws += 1;
crate::cycle_schedule::config::CycleScheduleId::new(drawn).unwrap()
});
assert_eq!(allocated.as_str(), "cs-bbbb");
assert_eq!(draws, 2, "the stored id must have been drawn and redrawn");
}
#[test]
fn a_hand_written_project_id_loads_and_is_never_allocated_again() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[projects.\"p-ffffffffffffffff\"]\nname = \"Hand written\"\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
assert!(config.projects.contains_key("p-ffffffffffffffff"));
let mut draws = 0;
let allocated = config.allocate_project_id_with(|| {
draws += 1;
if draws < 3 {
ProjectId::new("p-ffffffffffffffff").unwrap()
} else {
ProjectId::new("p-0000000000000000").unwrap()
}
});
assert_eq!(allocated.as_str(), "p-0000000000000000");
assert_eq!(draws, 3);
}
#[test]
fn a_rename_changes_the_label_and_never_the_key() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), TWO_PROJECTS);
let mut config = Config::load_with_warning(&path, |_| {}).unwrap();
config.projects["p-3f9a2c"].name = "Platform hardening (renamed)".into();
config.save(&path).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert!(
reloaded.projects.contains_key("p-3f9a2c"),
"the id is the key and must survive a rename"
);
assert_eq!(
reloaded.projects["p-3f9a2c"].name,
"Platform hardening (renamed)"
);
}
#[test]
fn project_id_validation_accepts_handles_and_rejects_junk() {
assert!(ProjectId::new("p-platform").is_ok());
assert!(ProjectId::new("p-3f9a2c").is_ok());
assert!(ProjectId::new("").is_err());
assert!(ProjectId::new(" p-1").is_err(), "surrounding whitespace");
assert!(ProjectId::new("p 1").is_err(), "inner whitespace");
assert!(ProjectId::new("p.1").is_err(), "structural `.`");
assert!(ProjectId::new(&"x".repeat(ProjectId::MAX_LEN + 1)).is_err());
}
const TWO_EPICS: &str = "issues_dir = \"docs/issues\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
[epics.\"e-7c4a91\"]\n\
name = \"Harden the sync path\"\n\
description = \"Reduce operational risk across every tracker adapter.\"\n\
status = \"active\"\n\
owner = \"Alice <alice@example.com>\"\n\
start_date = \"2026-09-01\"\n\
due_date = \"2026-12-31\"\n\
weight = 5\n\
parent = \"e-2b8f30\"\n\
projects = [\"p-3f9a2c\"]\n\
milestone = \"2026-Q4\"\n\
cycle = \"October 2026\"\n\n\
[epics.\"e-2b8f30\"]\n\
name = \"Q4 reliability\"\n";
#[test]
fn epic_registry_parses_every_contract_field() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), TWO_EPICS);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
let sync = &config.epics["e-7c4a91"];
assert_eq!(sync.name, "Harden the sync path");
assert_eq!(
sync.description.as_deref(),
Some("Reduce operational risk across every tracker adapter.")
);
assert_eq!(sync.status.as_deref(), Some("active"));
assert_eq!(sync.owner.as_deref(), Some("Alice <alice@example.com>"));
assert_eq!(sync.start_date.as_deref(), Some("2026-09-01"));
assert_eq!(sync.due_date.as_deref(), Some("2026-12-31"));
assert_eq!(sync.weight, Some(5));
assert_eq!(sync.parent.as_deref(), Some("e-2b8f30"));
assert_eq!(sync.projects, ["p-3f9a2c"]);
assert_eq!(sync.milestone.as_deref(), Some("2026-Q4"));
assert_eq!(sync.cycle.as_deref(), Some("October 2026"));
let q4 = &config.epics["e-2b8f30"];
assert_eq!(q4.name, "Q4 reliability");
assert!(q4.description.is_none() && q4.status.is_none() && q4.owner.is_none());
assert!(q4.start_date.is_none() && q4.due_date.is_none());
assert!(q4.weight.is_none() && q4.parent.is_none() && q4.milestone.is_none());
assert!(q4.cycle.is_none());
assert_eq!(q4.projects, [] as [std::string::String; 0]);
}
#[test]
fn epic_registry_rejects_an_unknown_field() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[epics.\"e-1\"]\nname = \"One\"\nownr = \"typo\"\n",
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("ownr"), "got {err}");
}
#[test]
fn an_epic_record_takes_weight_but_never_an_estimate_field() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[epics.\"e-1\"]\nname = \"One\"\nestimate = \"2\"\n",
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("estimate"), "got {err}");
}
#[test]
fn epic_declaration_order_is_restored() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
[epics.\"e-zeta\"]\nname = \"Zeta\"\n\n\
[epics.\"e-alpha\"]\nname = \"Alpha\"\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
config.epics.keys().collect::<Vec<_>>(),
["e-zeta", "e-alpha"],
"declaration order is the display order"
);
config.save(&path).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
reloaded.epics.keys().collect::<Vec<_>>(),
["e-zeta", "e-alpha"],
"declaration order survives a save/reload through the ordered map"
);
}
#[test]
fn a_legacy_tix_toml_gains_no_epics_section() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), LEGACY_PRETTY_BOARD);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
assert!(config.epics.is_empty());
config.save(&path).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
assert_eq!(
after, LEGACY_PRETTY_BOARD,
"a repository with no epics must round-trip byte-identically"
);
assert!(
!after.contains("[epics"),
"an empty registry writes no section: {after}"
);
}
#[test]
fn epics_and_projects_registries_coexist() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), TWO_PROJECTS);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
assert!(config.epics.is_empty(), "no `[epics]` means no epics");
config.save(&path).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
assert!(!after.contains("[epics"), "got {after}");
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(reloaded.projects, config.projects);
assert!(reloaded.epics.is_empty());
let both_dir = TempDir::new().unwrap();
let both = write_toml(
both_dir.path(),
"issues_dir = \"docs/issues\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
[projects.\"p-3f9a2c\"]\nname = \"Platform\"\n\n\
[epics.\"e-1\"]\nname = \"Harden\"\nprojects = [\"p-3f9a2c\"]\n",
);
let config = Config::load_with_warning(&both, |_| {}).unwrap();
assert_eq!(config.projects.keys().collect::<Vec<_>>(), ["p-3f9a2c"]);
assert_eq!(config.epics.keys().collect::<Vec<_>>(), ["e-1"]);
config.save(&both).unwrap();
let reloaded = Config::load_with_warning(&both, |_| {}).unwrap();
assert_eq!(reloaded.projects, config.projects);
assert_eq!(reloaded.epics, config.epics);
}
#[test]
fn an_empty_epic_record_writes_no_optional_keys() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
[epics.\"e-1\"]\nname = \"One\"\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
config.save(&path).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
for key in [
"description",
"status",
"owner",
"start_date",
"due_date",
"weight",
"parent",
"projects",
"milestone",
"cycle",
] {
assert!(
!after.contains(key),
"an absent optional field must not be written: {key} in {after}"
);
}
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(reloaded, config);
}
#[test]
fn a_blank_epic_name_is_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \" \"\n",
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("epics.e-1.name"), "got {err}");
}
#[test]
fn duplicate_epic_names_are_rejected() {
for second in ["Same", "same"] {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
&format!(
"issues_dir = \"docs/issues\"\n\n\
[epics.\"e-1\"]\nname = \"Same\"\n\n\
[epics.\"e-2\"]\nname = \"{second}\"\n"
),
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("must be unique"), "got {err}");
}
}
#[test]
fn a_padded_epic_name_is_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \" One \"\n",
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("leading or trailing whitespace"), "got {err}");
}
#[test]
fn a_blank_epic_id_is_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[epics.\"\"]\nname = \"No id\"\n",
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("must not be blank"), "got {err}");
}
#[test]
fn an_epic_id_with_a_structural_character_is_rejected() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[epics.\"e.1\"]\nname = \"Dotted\"\n",
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("contains"), "got {err}");
}
#[test]
fn blank_optional_epic_text_is_rejected() {
for key in ["description", "status", "owner", "milestone", "cycle"] {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
&format!(
"issues_dir = \"docs/issues\"\n\n\
[epics.\"e-1\"]\nname = \"One\"\n{key} = \"\"\n"
),
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains(&format!("epics.e-1.{key}")), "got {err}");
}
}
#[test]
fn a_bad_or_inverted_epic_date_is_rejected() {
let tmp = TempDir::new().unwrap();
let bad = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[epics.\"e-1\"]\nname = \"One\"\nstart_date = \"2026-13-01\"\n",
);
let err = Config::load_with_warning(&bad, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("epics.e-1"), "got {err}");
let inverted = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[epics.\"e-1\"]\nname = \"One\"\n\
start_date = \"2026-12-01\"\ndue_date = \"2026-01-01\"\n",
);
let err = Config::load_with_warning(&inverted, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("epics.e-1"), "got {err}");
}
#[test]
fn a_non_positive_or_unparseable_weight_is_rejected() {
for body in ["weight = 0", "weight = -3"] {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
&format!(
"issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \"One\"\n{body}\n"
),
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("positive integer"), "{body}: got {err}");
assert!(err.contains("epics.e-1.weight"), "{body}: got {err}");
}
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \"One\"\nweight = \"big\"\n",
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("weight"), "got {err}");
}
#[test]
fn epic_references_are_shape_checked_but_never_resolved() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
[epics.\"e-1\"]\nname = \"Child\"\n\
parent = \"e-missing\"\nprojects = [\"p-missing\"]\n\
milestone = \"2026-Q4\"\ncycle = \"No such cycle\"\n",
);
let config = Config::load_with_warning(&path, |_| {})
.expect("a dangling reference must stay loadable");
let epic = &config.epics["e-1"];
assert_eq!(epic.parent.as_deref(), Some("e-missing"));
assert_eq!(epic.projects, ["p-missing"]);
assert_eq!(epic.milestone.as_deref(), Some("2026-Q4"));
assert_eq!(epic.cycle.as_deref(), Some("No such cycle"));
for (body, needle) in [
("parent = \"e.1\"", "epics.e-1.parent"),
("parent = \"\"", "epics.e-1.parent"),
("parent = \"e-1\"", "must not name the epic itself"),
("projects = [\"p.1\"]", "epics.e-1.projects"),
] {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
&format!(
"issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \"One\"\n{body}\n"
),
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains(needle), "{body}: got {err}");
}
}
#[test]
fn allocated_epic_ids_have_the_generated_shape_and_successive_draws_differ() {
let mut config = Config::default();
let first = config.allocate_epic_id();
config.epics.insert(
first.to_string(),
EpicConfiguration {
name: "One".into(),
..EpicConfiguration::default()
},
);
let second = config.allocate_epic_id();
assert_ne!(first, second, "two allocations must be distinct");
assert_eq!(first.as_str().len(), 18, "`e-` plus 16 hex digits");
assert!(first.as_str().starts_with("e-"));
config.epics.shift_remove(first.as_str());
let third = config.allocate_epic_id();
assert_ne!(third, first, "a deleted epic's id must not be handed out");
assert_ne!(third, second);
}
#[test]
fn epic_allocation_redraws_past_a_stored_id() {
let mut config = Config::default();
config.epics.insert(
"e-aaaa".into(),
EpicConfiguration {
name: "Stored".into(),
..EpicConfiguration::default()
},
);
let scripted = ["e-aaaa", "e-bbbb"];
let mut draws = 0;
let allocated = config.allocate_epic_id_with(|| {
let drawn = scripted[draws];
draws += 1;
EpicId::new(drawn).unwrap()
});
assert_eq!(allocated.as_str(), "e-bbbb");
assert_eq!(draws, 2, "the stored id must have been drawn and redrawn");
}
#[test]
fn a_hand_written_epic_id_loads_and_is_never_allocated_again() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[epics.\"e-ffffffffffffffff\"]\nname = \"Hand written\"\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
assert!(config.epics.contains_key("e-ffffffffffffffff"));
let mut draws = 0;
let allocated = config.allocate_epic_id_with(|| {
draws += 1;
if draws < 3 {
EpicId::new("e-ffffffffffffffff").unwrap()
} else {
EpicId::new("e-0000000000000000").unwrap()
}
});
assert_eq!(allocated.as_str(), "e-0000000000000000");
assert_eq!(draws, 3);
}
#[test]
fn an_epic_rename_changes_the_label_and_never_the_key() {
let tmp = TempDir::new().unwrap();
let path = write_toml(tmp.path(), TWO_EPICS);
let mut config = Config::load_with_warning(&path, |_| {}).unwrap();
config.epics["e-7c4a91"].name = "Harden the sync path (renamed)".into();
config.save(&path).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert!(
reloaded.epics.contains_key("e-7c4a91"),
"the id is the key and must survive a rename"
);
assert_eq!(
reloaded.epics["e-7c4a91"].name,
"Harden the sync path (renamed)"
);
}
#[test]
fn epic_id_validation_accepts_handles_and_rejects_junk() {
assert!(EpicId::new("e-platform").is_ok());
assert!(EpicId::new("e-7c4a91").is_ok());
assert!(EpicId::new("").is_err());
assert!(EpicId::new(" e-1").is_err(), "surrounding whitespace");
assert!(EpicId::new("e 1").is_err(), "inner whitespace");
assert!(EpicId::new("e.1").is_err(), "structural `.`");
assert!(EpicId::new(&"x".repeat(EpicId::MAX_LEN + 1)).is_err());
}
#[test]
fn the_two_registries_share_the_id_rules() {
assert_eq!(
ProjectId::new("").unwrap_err().to_string(),
"config error: a project id must not be blank"
);
assert_eq!(
EpicId::new("").unwrap_err().to_string(),
"config error: an epic id must not be blank"
);
assert_eq!(
ProjectId::new(" p-1").unwrap_err().to_string(),
"config error: project id ` p-1` has leading or trailing whitespace"
);
assert_eq!(
EpicId::new("e-1 ").unwrap_err().to_string(),
"config error: epic id `e-1 ` has leading or trailing whitespace"
);
assert_eq!(ProjectId::MAX_LEN, EpicId::MAX_LEN);
for handle in ["x-y", "a_b", "a:b", "a-1"] {
assert!(ProjectId::new(handle).is_ok(), "{handle}");
assert!(EpicId::new(handle).is_ok(), "{handle}");
}
for junk in ["", " a", "a b", "a.b", "a[b", "a\"b"] {
assert!(ProjectId::new(junk).is_err(), "{junk}");
assert!(EpicId::new(junk).is_err(), "{junk}");
}
}
const ONE_INITIATIVE: &str = "issues_dir = \"docs/issues\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
[initiatives.\"i-1\"]\nname = \"Grow the platform\"\n";
#[test]
fn initiative_id_validation_accepts_handles_and_rejects_junk() {
assert!(InitiativeId::new("i-outcome").is_ok());
assert!(InitiativeId::new("i-7c4a91").is_ok());
assert!(InitiativeId::new("").is_err());
assert!(InitiativeId::new(" i-1").is_err(), "surrounding whitespace");
assert!(InitiativeId::new("i 1").is_err(), "inner whitespace");
assert!(InitiativeId::new("i.1").is_err(), "structural `.`");
assert!(InitiativeId::new(&"x".repeat(InitiativeId::MAX_LEN + 1)).is_err());
}
#[test]
fn the_three_registries_share_the_id_rules() {
assert_eq!(
InitiativeId::new("").unwrap_err().to_string(),
"config error: an initiative id must not be blank"
);
assert_eq!(
InitiativeId::new(" i-1").unwrap_err().to_string(),
"config error: initiative id ` i-1` has leading or trailing whitespace"
);
assert_eq!(InitiativeId::MAX_LEN, ProjectId::MAX_LEN);
assert_eq!(InitiativeId::MAX_LEN, EpicId::MAX_LEN);
}
#[test]
fn allocated_initiative_ids_have_the_generated_shape_and_successive_draws_differ() {
let mut config = Config::default();
let first = config.allocate_initiative_id();
config.initiatives.insert(
first.to_string(),
InitiativeConfiguration {
name: "One".into(),
..InitiativeConfiguration::default()
},
);
let second = config.allocate_initiative_id();
assert_ne!(first, second, "two allocations must be distinct");
assert_eq!(first.as_str().len(), 18, "`i-` plus 16 hex digits");
assert!(first.as_str().starts_with("i-"));
config.initiatives.shift_remove(first.as_str());
let third = config.allocate_initiative_id();
assert_ne!(
third, first,
"a deleted initiative's id must not be handed out"
);
assert_ne!(third, second);
}
#[test]
fn initiative_allocation_redraws_past_a_stored_id() {
let mut config = Config::default();
config.initiatives.insert(
"i-aaaa".into(),
InitiativeConfiguration {
name: "Stored".into(),
..InitiativeConfiguration::default()
},
);
let scripted = ["i-aaaa", "i-bbbb"];
let mut draws = 0;
let allocated = config.allocate_initiative_id_with(|| {
let drawn = scripted[draws];
draws += 1;
InitiativeId::new(drawn).unwrap()
});
assert_eq!(allocated.as_str(), "i-bbbb");
assert_eq!(draws, 2, "the stored id must have been drawn and redrawn");
}
#[test]
fn an_absent_initiatives_section_and_an_empty_record_write_nothing_extra() {
let tmp = TempDir::new().unwrap();
let legacy = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n[kanban.default]\ncolumns = [\"todo\", \"done\"]\n",
);
let config = Config::load_with_warning(&legacy, |_| {}).unwrap();
assert!(config.initiatives.is_empty());
config.save(&legacy).unwrap();
assert!(
!std::fs::read_to_string(&legacy)
.unwrap()
.contains("[initiatives"),
"a legacy file gains no section"
);
let path = write_toml(tmp.path(), ONE_INITIATIVE);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
config.save(&path).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
for key in [
"outcome",
"owner",
"status",
"priority",
"labels",
"target_date",
"projects",
"epics",
"milestones",
] {
assert!(
!after.contains(key),
"an absent optional field must not be written: {key} in {after}"
);
}
assert_eq!(Config::load_with_warning(&path, |_| {}).unwrap(), config);
}
#[test]
fn an_initiative_preserves_unknown_metadata_through_a_round_trip() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
[initiatives.\"i-1\"]\nname = \"One\"\ncustom_field = \"keep me\"\n\
nested = { answer = 42 }\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
let extensions = &config.initiatives["i-1"].extensions;
assert_eq!(extensions.len(), 2);
assert_eq!(
extensions.get("custom_field").and_then(toml::Value::as_str),
Some("keep me")
);
config.save(&path).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(reloaded.initiatives["i-1"].extensions, *extensions);
}
#[test]
fn initiative_validation_refuses_blank_names_bad_dates_and_bad_references() {
for (body, needle) in [
("name = \" \"", "initiatives.i-1.name"),
("name = \" One \"", "leading or trailing whitespace"),
(
"name = \"One\"\ntarget_date = \"2026-13-01\"",
"initiatives.i-1.target_date",
),
(
"name = \"One\"\ntarget_date = \"not a date\"",
"initiatives.i-1.target_date",
),
(
"name = \"One\"\nprojects = [\"p.1\"]",
"initiatives.i-1.projects",
),
("name = \"One\"\nepics = [\"e.1\"]", "initiatives.i-1.epics"),
(
"name = \"One\"\nmilestones = [\"\"]",
"initiatives.i-1.milestones",
),
(
"name = \"One\"\nlabels = [\" \"]",
"initiatives.i-1.labels",
),
("name = \"One\"\nowner = \"\"", "initiatives.i-1.owner"),
] {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
&format!(
"issues_dir = \"docs/issues\"\n\n[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
[initiatives.\"i-1\"]\n{body}\n"
),
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains(needle), "{body}: got {err}");
}
}
#[test]
fn duplicate_initiative_names_are_rejected_case_folded() {
for second in ["Same", "same"] {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
&format!(
"issues_dir = \"docs/issues\"\n\n\
[initiatives.\"i-1\"]\nname = \"Same\"\n\n\
[initiatives.\"i-2\"]\nname = \"{second}\"\n"
),
);
let err = Config::load_with_warning(&path, |_| {})
.unwrap_err()
.to_string();
assert!(err.contains("must be unique"), "got {err}");
}
}
#[test]
fn initiative_references_are_shape_checked_but_never_resolved() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
[initiatives.\"i-1\"]\nname = \"One\"\n\
projects = [\"p-missing\"]\nepics = [\"e-missing\"]\nmilestones = [\"2026-Q4\"]\n",
);
let config = Config::load_with_warning(&path, |_| {})
.expect("a dangling reference must stay loadable");
let initiative = &config.initiatives["i-1"];
assert_eq!(initiative.projects, ["p-missing"]);
assert_eq!(initiative.epics, ["e-missing"]);
assert_eq!(initiative.milestones, ["2026-Q4"]);
}
#[test]
fn initiative_declaration_order_survives_a_save_and_reload() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
[initiatives.\"i-zulu\"]\nname = \"Zulu\"\n\n\
[initiatives.\"i-alpha\"]\nname = \"Alpha\"\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
config.initiatives.keys().collect::<Vec<_>>(),
["i-zulu", "i-alpha"]
);
config.save(&path).unwrap();
let written = std::fs::read_to_string(&path).unwrap();
assert!(
written.find("[initiatives.i-zulu]").unwrap()
< written.find("[initiatives.i-alpha]").unwrap(),
"declaration order is preserved on write:\n{written}"
);
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
reloaded.initiatives.keys().collect::<Vec<_>>(),
["i-zulu", "i-alpha"]
);
}
#[test]
fn an_initiative_priority_uses_the_shared_shape() {
let tmp = TempDir::new().unwrap();
let path = write_toml(
tmp.path(),
"issues_dir = \"docs/issues\"\n\n\
[initiatives.\"i-1\"]\nname = \"Known\"\npriority = \"HIGH\"\n\n\
[initiatives.\"i-2\"]\nname = \"Unknown\"\npriority = \"urgent-ish\"\n",
);
let config = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
config.initiatives["i-1"].priority,
Some(crate::model::Priority::High)
);
assert_eq!(
config.initiatives["i-2"].priority,
Some(crate::model::Priority::Other("urgent-ish".into()))
);
config.save(&path).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
let written = std::fs::read_to_string(&path).unwrap();
assert!(written.contains("priority = \"high\""), "{written}");
assert!(written.contains("priority = \"urgent-ish\""), "{written}");
assert_eq!(reloaded.initiatives, config.initiatives);
}
#[test]
fn a_shadowing_extension_key_is_refused_by_validation() {
for key in INITIATIVE_KNOWN_FIELDS {
let mut extensions = crate::roadmap::registry::InitiativeExtensions::default();
extensions.insert(*key, toml::Value::String("evil".into()));
let mut config = Config::default();
config.initiatives.insert(
"i-1".into(),
InitiativeConfiguration {
name: "One".into(),
extensions,
..InitiativeConfiguration::default()
},
);
let err = config.validate().unwrap_err().to_string();
assert!(
err.contains("collides with a built-in field"),
"{key}: got {err}"
);
}
let mut config = Config::default();
config.initiatives.insert(
"i-1".into(),
InitiativeConfiguration {
name: "One".into(),
..InitiativeConfiguration::default()
},
);
config.validate().unwrap();
}
}