use std::collections::HashSet;
use std::path::{Path, PathBuf};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use crate::error::{Result, TixError};
#[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 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,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct BoardConfiguration {
pub columns: Vec<String>,
pub done_column: Option<String>,
pub hidden_columns: Vec<String>,
pub show_mismatch: bool,
pub wrap_titles: bool,
pub title_lines: usize,
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()),
hidden_columns: Vec::new(),
show_mismatch: false,
wrap_titles: true,
title_lines: 3,
order_by: OrderBy::default(),
}
}
}
impl BoardConfiguration {
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>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub kanban: KanbanSection,
pub issues_dir: PathBuf,
pub editor: Option<String>,
pub agent: Option<String>,
#[serde(skip_serializing)]
pub state: StateConfig,
pub export: ExportConfig,
}
impl Default for Config {
fn default() -> Self {
Config {
kanban: KanbanSection {
configurations: IndexMap::from([(
"default".to_string(),
BoardConfiguration::default(),
)]),
},
issues_dir: PathBuf::from("docs/issues"),
editor: None,
state: StateConfig::default(),
export: ExportConfig::default(),
agent: None,
}
}
}
impl Config {
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.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)?;
}
Ok(())
}
fn restore_declaration_order(&mut self, contents: &str) {
crate::config::restore_declaration_order(&mut self.kanban, contents);
}
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);
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 disk = self.sanitized_for_disk();
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_declaration_order(section: &mut KanbanSection, contents: &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 mut parts = rest.split('.').map(str::trim);
if parts.next() == Some("kanban")
&& let Some(name) = parts.next()
&& parts.next().is_none()
&& !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(board) = section.configurations.shift_remove(name) {
ordered.insert(name.clone(), board);
}
}
ordered.extend(
section
.configurations
.iter()
.map(|(n, b)| (n.clone(), b.clone())),
);
section.configurations = ordered;
}
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 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.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 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}");
}
}
}