use std::collections::HashSet;
use std::path::{Path, PathBuf};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use crate::board_store::BoardStore;
use crate::cycle_store::{CycleStore, is_implicit_default};
use crate::error::{Result, TixError};
use crate::keybindings::{EffectiveBindings, KeyBindings};
use crate::records::RecordStore;
#[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>,
}
#[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>,
}
#[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()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct BoardConfiguration {
pub columns: Vec<String>,
pub done_column: Option<String>,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub category_map: IndexMap<String, String>,
pub hidden_columns: Vec<String>,
pub show_mismatch: bool,
pub wrap_titles: bool,
pub title_lines: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub card_order: Vec<String>,
pub order_by: OrderBy,
}
impl Default for BoardConfiguration {
fn default() -> Self {
Self {
columns: ["todo", "in progress", "in review", "done"]
.into_iter()
.map(String::from)
.collect(),
done_column: Some("done".to_string()),
category_map: IndexMap::new(),
hidden_columns: Vec::new(),
show_mismatch: false,
wrap_titles: true,
title_lines: 3,
card_order: Vec::new(),
order_by: OrderBy::default(),
}
}
}
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> {
if let Some(done) = self.done_column.as_deref() {
return Some(done);
}
if self.columns.iter().any(|c| c == "done") {
Some("done")
} 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, Eq, 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 = "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,
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, skip_serializing_if = "LayoutConfig::is_default")]
pub layout: LayoutConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom_types: Option<Vec<String>>,
}
#[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()
}
}
fn default_true() -> bool {
true
}
#[allow(clippy::trivially_copy_pass_by_ref)] fn is_true(value: &bool) -> bool {
*value
}
pub const DEFAULT_TYPES: &[&str] = &[
"default",
"bug",
"feature",
"task",
"improvement",
"documentation",
"question",
"duplicate",
"chore",
"open",
];
#[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}-{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(),
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,
layout: LayoutConfig::default(),
custom_types: None,
}
}
}
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.custom_types = Some(DEFAULT_TYPES.iter().map(|s| (*s).to_string()).collect());
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.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)?;
}
crate::triage::config::validate(self)?;
Ok(())
}
pub fn effective_keybindings(&self) -> Result<EffectiveBindings> {
KeyBindings::resolve(&self.keybindings)
}
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");
}
pub fn custom_attributes(&self) -> Vec<String> {
self.custom_attributes
.keys
.iter()
.map(|k| k.trim().to_ascii_lowercase())
.collect()
}
#[must_use]
pub fn custom_types(&self) -> Vec<String> {
self.custom_types
.clone()
.unwrap_or_else(|| DEFAULT_TYPES.iter().map(|s| (*s).to_string()).collect())
}
#[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 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() {
config.kanban = KanbanSection {
configurations: boards.into_iter().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| match e {
TixError::Config(msg) => TixError::Config(format!("{}: {msg}", path.display())),
other => other,
})?;
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 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_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(())
}
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"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use tempfile::TempDir;
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 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!(board.card_order.is_empty());
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 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 = 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(), 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 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 = 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(), 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);
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)"
);
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}");
}
}