use crate::assignee::derive_segment;
use crate::config::LayoutConfig;
use crate::error::{Result, TixError};
use crate::model::Issue;
use crate::slug::slugify;
const DIRECTORY_TOKENS: &[&str] = &[
"assignee",
"type",
"status",
"priority",
"cycle",
"milestone",
];
const FILENAME_TOKENS: &[&str] = &["id", "date", "slug", "number", "title"];
pub fn validate(layout: &LayoutConfig) -> Result<()> {
validate_template("directory", &layout.directory, DIRECTORY_TOKENS, false)?;
validate_template("filename", &layout.filename, FILENAME_TOKENS, true)?;
Ok(())
}
fn validate_template(
field: &str,
template: &str,
allowed: &[&str],
require_md: bool,
) -> Result<()> {
if template.trim().is_empty() {
return Err(TixError::Config(format!(
"`layout.{field}` must not be blank"
)));
}
if template.starts_with('/') {
return Err(TixError::Config(format!(
"`layout.{field}` must be relative (no leading `/`)"
)));
}
if template.contains("..") {
return Err(TixError::Config(format!(
"`layout.{field}` must not contain `..`"
)));
}
if template.contains('\\') || template.contains('\0') {
return Err(TixError::Config(format!(
"`layout.{field}` must not contain `\\` or NUL"
)));
}
if template.split('/').any(str::is_empty) {
return Err(TixError::Config(format!(
"`layout.{field}` must not contain empty path components"
)));
}
for token in tokens(template) {
if !allowed.contains(&token.as_str()) {
return Err(TixError::Config(format!(
"`layout.{field}` has unknown token `{{{token}}}`; allowed: {}",
allowed
.iter()
.map(|t| format!("{{{t}}}"))
.collect::<Vec<_>>()
.join(", ")
)));
}
}
if require_md && !template.ends_with(".md") {
return Err(TixError::Config(format!(
"`layout.{field}` must end in `.md`"
)));
}
Ok(())
}
fn tokens(template: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = template;
while let Some(open) = rest.find('{') {
let after = &rest[open + 1..];
let Some(close) = after.find('}') else { break };
out.push(after[..close].to_string());
rest = &after[close + 1..];
}
out
}
#[must_use]
pub fn render_directory(layout: &LayoutConfig, issue: &Issue) -> String {
substitute(&layout.directory, |t| directory_value(t, issue))
}
#[must_use]
pub fn render(layout: &LayoutConfig, issue: &Issue) -> String {
let dir = render_directory(layout, issue);
let file = substitute(&layout.filename, |t| filename_value(t, issue));
format!("{dir}/{file}")
}
fn substitute(template: &str, value: impl Fn(&str) -> String) -> String {
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some(open) = rest.find('{') {
out.push_str(&rest[..open]);
let after = &rest[open + 1..];
match after.find('}') {
Some(close) => {
out.push_str(&value(&after[..close]));
rest = &after[close + 1..];
}
None => {
out.push('{');
rest = after;
}
}
}
out.push_str(rest);
out
}
fn component(raw: Option<&str>, fallback: &str) -> String {
raw.map(slugify)
.filter(|s| !s.is_empty() && s != "untitled")
.unwrap_or_else(|| fallback.to_string())
}
fn directory_value(token: &str, issue: &Issue) -> String {
match token {
"assignee" => derive_segment(issue.assignee.as_deref()),
"type" => component(issue.type_.as_deref(), "default"),
"status" => component(Some(issue.status.display()), "unknown"),
"priority" => component(Some(&issue.priority.to_string()), "medium"),
"cycle" => component(
issue
.cycles
.as_ref()
.and_then(|c| c.first())
.map(String::as_str),
"_",
),
"milestone" => component(
issue
.milestones
.as_ref()
.and_then(|m| m.first())
.map(String::as_str),
"_",
),
_ => "_".to_string(),
}
}
fn filename_value(token: &str, issue: &Issue) -> String {
match token {
"id" => issue.id.clone().unwrap_or_default(),
"date" => issue.created.clone(),
"slug" | "title" => slugify(&issue.title),
"number" => issue.number.to_string(),
_ => "untitled".to_string(),
}
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::model::{Issue, Priority, Status};
fn issue() -> Issue {
let mut i = Issue {
projects: None,
id: Some("A1B2C3D4".into()),
number: 7,
title: "Fix: Login Crash!".into(),
status: Status::new("in progress"),
labels: vec![],
priority: Priority::Medium,
assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
parent: None,
series: None,
health: None,
files: vec![],
cycles: Some(vec!["October 2026".into()]),
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
entered_at: None,
source: None,
created: "2026-10-05".into(),
type_: None,
updated: "2026-10-05".into(),
filename: String::new(),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
};
i.extra_frontmatter.clear();
i.type_ = Some("bug".into());
i
}
#[test]
fn default_layout_renders_date_id_assignee_type_status() {
let layout = LayoutConfig::default();
assert_eq!(
render(&layout, &issue()),
"herbert-maschke/bug/in-progress/2026-10-05-A1B2C3D4-fix-login-crash.md"
);
}
#[test]
fn id_token_renders_empty_for_a_legacy_issue() {
let layout = LayoutConfig::default();
let mut i = issue();
i.id = None;
assert_eq!(
render(&layout, &i),
"herbert-maschke/bug/in-progress/2026-10-05--fix-login-crash.md"
);
}
#[test]
fn missing_type_and_cycle_fall_back() {
let layout = LayoutConfig {
directory: "{type}/{cycle}".into(),
filename: "{number}-{slug}.md".into(),
move_on_change: true,
};
let mut i = issue();
i.type_ = None;
i.cycles = None;
assert_eq!(render(&layout, &i), "default/_/7-fix-login-crash.md");
}
#[test]
fn validate_rejects_unknown_tokens_and_unsafe_paths() {
let bad = LayoutConfig {
directory: "{nope}".into(),
filename: "{date}-{slug}.md".into(),
move_on_change: true,
};
assert!(validate(&bad).is_err());
let abs = LayoutConfig {
directory: "/etc".into(),
filename: "{date}-{slug}.md".into(),
move_on_change: true,
};
assert!(validate(&abs).is_err());
let no_md = LayoutConfig {
directory: "{assignee}".into(),
filename: "{date}-{slug}".into(),
move_on_change: true,
};
assert!(validate(&no_md).is_err());
assert!(validate(&LayoutConfig::default()).is_ok());
}
}