use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::error::{Result, TixError};
use crate::workflow::{self, WorkflowCategory};
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct TriageConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entry_status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub accepted_status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub declined_status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deferred_status: Option<String>,
}
impl TriageConfig {
#[must_use]
pub fn effective_deferred_status(&self) -> Option<&str> {
self.deferred_status
.as_deref()
.or(self.entry_status.as_deref())
}
}
pub fn validate(config: &Config) -> Result<()> {
let Some(triage) = config.triage.as_ref() else {
return Ok(());
};
let entry = required(triage.entry_status.as_deref(), "entry_status")?;
let declined = required(triage.declined_status.as_deref(), "declined_status")?;
let mut named = vec![("entry_status", entry), ("declined_status", declined)];
if let Some(accepted) = triage.accepted_status.as_deref() {
named.push(("accepted_status", accepted));
}
if let Some(deferred) = triage.deferred_status.as_deref() {
named.push(("deferred_status", deferred));
}
let (board, resolution) = workflow::active_resolution(config);
for (field, value) in named {
if resolution.resolve(value).is_none() {
return Err(TixError::Config(format!(
"unknown_column: `[triage].{field}` names `{value}`, which is not a \
column on board `{board}` ({})",
resolution.column_list()
)));
}
}
let category = resolution
.resolve(declined)
.expect("`declined_status` was checked against this resolution above");
if category != WorkflowCategory::Canceled {
return Err(TixError::Config(format!(
"`[triage].declined_status` must map to the `{}` category, but \
`{declined}` maps to `{}`",
WorkflowCategory::Canceled.key(),
category.key()
)));
}
Ok(())
}
fn required<'a>(value: Option<&'a str>, field: &str) -> Result<&'a str> {
match value.map(str::trim) {
None => Err(TixError::Config(format!(
"`[triage].{field}` is required when the `[triage]` section is present"
))),
Some("") => Err(TixError::Config(format!(
"`[triage].{field}` must not be blank"
))),
Some(value) => Ok(value),
}
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use std::path::Path;
use tempfile::TempDir;
const BOARD: &str = r#"issues_dir = "docs/issues"
[kanban.default]
columns = ["triage", "todo", "in progress", "in review", "done", "wontfix"]
done_column = "done"
category_map = { triage = "backlog", todo = "unstarted", "in progress" = "active", "in review" = "active", done = "completed", wontfix = "canceled" }
"#;
fn config_with(section: &str) -> Config {
let text = format!("{BOARD}{section}");
toml::from_str(&text).expect("fixture is a well-formed tix.toml")
}
const LEGACY: &str = "issues_dir = \"docs/issues\"\n\n\
[kanban.default]\n\
columns = [\n \"todo\",\n \"in progress\",\n \"in review\",\n \"done\",\n]\n";
fn valid_section() -> &'static str {
"\n[triage]\nentry_status = \"triage\"\ndeclined_status = \"wontfix\"\n"
}
fn err(config: &Config) -> String {
config
.validate()
.expect_err("this fixture must be rejected")
.to_string()
}
#[test]
fn triage_config_valid_section_loads_and_validates() {
let config = config_with(valid_section());
let triage = config.triage.as_ref().expect("the section was written");
assert_eq!(triage.entry_status.as_deref(), Some("triage"));
assert_eq!(triage.declined_status.as_deref(), Some("wontfix"));
assert_eq!(triage.accepted_status, None, "accepted stays optional");
config.validate().expect("every named column resolves");
}
#[test]
fn triage_config_absent_section_never_materializes() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
std::fs::write(&path, BOARD).unwrap();
let config = Config::load_with_warning(&path, |_| {}).unwrap();
assert!(
config.triage.is_none(),
"a file with no `[triage]` loads as no inbox"
);
config
.validate()
.expect("no section means nothing to check");
config.save(&path).unwrap();
let first = std::fs::read_to_string(&path).unwrap();
assert!(
!first.contains("[triage]") && !first.contains("entry_status"),
"saving must not add a `[triage]` section:\n{first}"
);
let again = Config::load_with_warning(&path, |_| {}).unwrap();
again.save(&path).unwrap();
let second = std::fs::read_to_string(&path).unwrap();
assert_eq!(
second, first,
"a config with no inbox must save byte-stable"
);
}
#[test]
fn triage_config_legacy_bytes_are_unchanged_by_a_load_save_cycle() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
std::fs::write(&path, LEGACY).unwrap();
let config = Config::load_with_warning(&path, |_| {}).unwrap();
config.save(&path).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
assert_eq!(
after, LEGACY,
"the legacy fixture must save byte-identical; no `[triage]` key"
);
}
#[test]
fn triage_config_rejects_an_unrecognized_key() {
let result = toml::from_str::<TriageConfig>("entry_staust = \"triage\"");
assert!(
result.is_err(),
"`[triage]` is `deny_unknown_fields`, so `entry_staust` is refused"
);
}
#[test]
fn triage_config_requires_entry_and_declined_when_section_present() {
let config = config_with("\n[triage]\n");
let message = err(&config);
assert!(
message.contains("`[triage].entry_status` is required"),
"first missing key, got: {message}"
);
let config = config_with("\n[triage]\nentry_status = \"triage\"\n");
let message = err(&config);
assert!(
message.contains("`[triage].declined_status` is required"),
"entry alone is not enough, got: {message}"
);
}
#[test]
fn triage_config_rejects_a_blank_named_status() {
let config =
config_with("\n[triage]\nentry_status = \" \"\ndeclined_status = \"wontfix\"\n");
let message = err(&config);
assert!(
message.contains("`[triage].entry_status` must not be blank"),
"whitespace is not a column, got: {message}"
);
}
#[test]
fn triage_config_rejects_an_unknown_column_and_lists_the_real_ones() {
let config = config_with(
"\n[triage]\nentry_status = \"no-such-column\"\ndeclined_status = \"wontfix\"\n",
);
let message = err(&config);
assert!(
message.contains("unknown_column: "),
"reuse #183's diagnostic name rather than inventing one, got: {message}"
);
assert!(
message.contains("`[triage].entry_status` names `no-such-column`"),
"name the field and the value, got: {message}"
);
assert!(
message.contains("triage, todo, in progress, in review, done, wontfix"),
"a refusal has to say what to type instead, got: {message}"
);
}
#[test]
fn triage_config_deferred_defaults_to_entry_and_writes_no_key() {
let triage: TriageConfig =
toml::from_str("entry_status = \"triage\"\ndeclined_status = \"wontfix\"")
.expect("parses");
assert_eq!(
triage.effective_deferred_status(),
Some("triage"),
"an unset deferred status is the entry status (data-model §1)"
);
let serialized = toml::to_string_pretty(&triage).expect("serializes");
assert!(
!serialized.contains("deferred_status"),
"the default must not materialize a key (T005):\n{serialized}"
);
let triage: TriageConfig = toml::from_str(
"entry_status = \"triage\"\ndeclined_status = \"wontfix\"\ndeferred_status = \"todo\"",
)
.expect("parses");
assert_eq!(
triage.effective_deferred_status(),
Some("todo"),
"an explicit deferred status wins"
);
}
#[test]
fn triage_config_declined_must_map_to_canceled_not_completion() {
let config =
config_with("\n[triage]\nentry_status = \"triage\"\ndeclined_status = \"done\"\n");
let message = err(&config);
assert!(
message.contains("`[triage].declined_status` must map to the `canceled` category"),
"a completed decline is refused, got: {message}"
);
assert!(
message.contains("`completed`"),
"say what it maps to instead, got: {message}"
);
}
#[test]
fn triage_config_canceled_is_terminal_but_is_never_completion() {
let canceled = WorkflowCategory::Canceled;
assert!(canceled.is_terminal(), "canceled ends the workflow");
assert!(
!canceled.is_complete(),
"canceled is not completion — the only definition of `complete`"
);
assert_ne!(
canceled,
WorkflowCategory::Completed,
"decline and completion are different categories"
);
}
#[test]
fn triage_config_accepted_is_optional_but_checked_when_named() {
let config = config_with(valid_section());
config
.validate()
.expect("an unconfigured accept target is fine");
let text = format!(
"{BOARD}\n[triage]\nentry_status = \"triage\"\n\
accepted_status = \"ghost\"\ndeclined_status = \"wontfix\"\n"
);
let config: Config = toml::from_str(&text).expect("fixture parses");
let message = err(&config);
assert!(
message.contains("`[triage].accepted_status` names `ghost`"),
"a named column is checked like any other, got: {message}"
);
}
#[test]
fn triage_config_section_survives_a_save_and_reload() {
let config = config_with(valid_section());
config.validate().expect("validates");
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
config.save(&path).unwrap();
let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
reloaded.triage, config.triage,
"the section round-trips through disk"
);
reloaded
.validate()
.expect("and still validates after the trip");
}
#[test]
fn triage_config_partial_section_writes_only_what_was_set() {
let config = config_with(valid_section());
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
config.save(&path).unwrap();
let on_disk = std::fs::read_to_string(&path).unwrap();
let section = on_disk
.split("[triage]")
.nth(1)
.expect("the configured section is written");
assert!(
section.contains("entry_status") && section.contains("declined_status"),
"the required keys are present:\n{on_disk}"
);
assert!(
!section.contains("accepted_status") && !section.contains("deferred_status"),
"unset optional keys must stay unwritten:\n{on_disk}"
);
}
#[test]
fn triage_config_save_is_atomic_and_leaves_no_temp_file_behind() {
let config = config_with(valid_section());
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
config.save(&path).unwrap();
config.save(&path).unwrap();
let leftovers: Vec<String> = std::fs::read_dir(tmp.path())
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|name| name.contains(".tmp-"))
.collect();
assert!(
leftovers.is_empty(),
"a temp file survived the rename: {leftovers:?}"
);
assert!(Path::new(&path).exists(), "and the target is intact");
}
}