1use crate::assignee::derive_segment;
10use crate::config::LayoutConfig;
11use crate::error::{Result, TixError};
12use crate::model::Issue;
13use crate::slug::slugify;
14
15const DIRECTORY_TOKENS: &[&str] = &[
17 "assignee",
18 "type",
19 "status",
20 "priority",
21 "cycle",
22 "milestone",
23];
24
25const FILENAME_TOKENS: &[&str] = &["id", "date", "slug", "number", "title"];
27
28pub fn validate(layout: &LayoutConfig) -> Result<()> {
32 validate_template("directory", &layout.directory, DIRECTORY_TOKENS, false)?;
33 validate_template("filename", &layout.filename, FILENAME_TOKENS, true)?;
34 Ok(())
35}
36
37fn validate_template(
38 field: &str,
39 template: &str,
40 allowed: &[&str],
41 require_md: bool,
42) -> Result<()> {
43 if template.trim().is_empty() {
44 return Err(TixError::Config(format!(
45 "`layout.{field}` must not be blank"
46 )));
47 }
48 if template.starts_with('/') {
49 return Err(TixError::Config(format!(
50 "`layout.{field}` must be relative (no leading `/`)"
51 )));
52 }
53 if template.contains("..") {
54 return Err(TixError::Config(format!(
55 "`layout.{field}` must not contain `..`"
56 )));
57 }
58 if template.contains('\\') || template.contains('\0') {
59 return Err(TixError::Config(format!(
60 "`layout.{field}` must not contain `\\` or NUL"
61 )));
62 }
63 if template.split('/').any(str::is_empty) {
64 return Err(TixError::Config(format!(
65 "`layout.{field}` must not contain empty path components"
66 )));
67 }
68 for token in tokens(template) {
69 if !allowed.contains(&token.as_str()) {
70 return Err(TixError::Config(format!(
71 "`layout.{field}` has unknown token `{{{token}}}`; allowed: {}",
72 allowed
73 .iter()
74 .map(|t| format!("{{{t}}}"))
75 .collect::<Vec<_>>()
76 .join(", ")
77 )));
78 }
79 }
80 if require_md && !template.ends_with(".md") {
81 return Err(TixError::Config(format!(
82 "`layout.{field}` must end in `.md`"
83 )));
84 }
85 Ok(())
86}
87
88fn tokens(template: &str) -> Vec<String> {
90 let mut out = Vec::new();
91 let mut rest = template;
92 while let Some(open) = rest.find('{') {
93 let after = &rest[open + 1..];
94 let Some(close) = after.find('}') else { break };
95 out.push(after[..close].to_string());
96 rest = &after[close + 1..];
97 }
98 out
99}
100
101#[must_use]
103pub fn render_directory(layout: &LayoutConfig, issue: &Issue) -> String {
104 substitute(&layout.directory, |t| directory_value(t, issue))
105}
106
107#[must_use]
109pub fn render(layout: &LayoutConfig, issue: &Issue) -> String {
110 let dir = render_directory(layout, issue);
111 let file = substitute(&layout.filename, |t| filename_value(t, issue));
112 format!("{dir}/{file}")
113}
114
115fn substitute(template: &str, value: impl Fn(&str) -> String) -> String {
117 let mut out = String::with_capacity(template.len());
118 let mut rest = template;
119 while let Some(open) = rest.find('{') {
120 out.push_str(&rest[..open]);
121 let after = &rest[open + 1..];
122 match after.find('}') {
123 Some(close) => {
124 out.push_str(&value(&after[..close]));
125 rest = &after[close + 1..];
126 }
127 None => {
128 out.push('{');
129 rest = after;
130 }
131 }
132 }
133 out.push_str(rest);
134 out
135}
136
137fn component(raw: Option<&str>, fallback: &str) -> String {
139 raw.map(slugify)
140 .filter(|s| !s.is_empty() && s != "untitled")
141 .unwrap_or_else(|| fallback.to_string())
142}
143
144fn directory_value(token: &str, issue: &Issue) -> String {
145 match token {
146 "assignee" => derive_segment(issue.assignee.as_deref()),
147 "type" => component(issue.type_.as_deref(), "default"),
148 "status" => component(Some(issue.status.display()), "unknown"),
149 "priority" => component(Some(&issue.priority.to_string()), "medium"),
150 "cycle" => component(
151 issue
152 .cycles
153 .as_ref()
154 .and_then(|c| c.first())
155 .map(String::as_str),
156 "_",
157 ),
158 "milestone" => component(
159 issue
160 .milestones
161 .as_ref()
162 .and_then(|m| m.first())
163 .map(String::as_str),
164 "_",
165 ),
166 _ => "_".to_string(),
167 }
168}
169
170fn filename_value(token: &str, issue: &Issue) -> String {
171 match token {
172 "id" => issue.id.clone().unwrap_or_default(),
173 "date" => issue.created.clone(),
174 "slug" | "title" => slugify(&issue.title),
175 "number" => issue.number.to_string(),
176 _ => "untitled".to_string(),
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 #[allow(clippy::wildcard_imports)]
183 use super::*;
184 use crate::model::{Issue, Priority, Status};
185
186 fn issue() -> Issue {
187 let mut i = Issue {
188 projects: None,
189 id: Some("A1B2C3D4".into()),
190 number: 7,
191 title: "Fix: Login Crash!".into(),
192 status: Status::new("in progress"),
193 labels: vec![],
194 priority: Priority::Medium,
195 assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
196 estimate: None,
197 commit: None,
198 gitlab_id: None,
199 linear_id: None,
200 mission: None,
201 task: None,
202 relationships: vec![],
203 related_issues: vec![],
204 parent: None,
205 series: None,
206 health: None,
207 files: vec![],
208 cycles: Some(vec!["October 2026".into()]),
209 milestones: None,
210 reviewer: None,
211 start_date: None,
212 due_date: None,
213 entered_at: None,
214 source: None,
215 created: "2026-10-05".into(),
216 type_: None,
217 updated: "2026-10-05".into(),
218 filename: String::new(),
219 body: String::new(),
220 history: crate::model::History::new(),
221 has_retired_links_key: false,
222 time_entries: vec![],
223 epics: None,
224 extra_frontmatter: std::collections::BTreeMap::new(),
225 archived: false,
226 };
227 i.extra_frontmatter.clear();
228 i.type_ = Some("bug".into());
229 i
230 }
231
232 #[test]
233 fn default_layout_renders_date_id_assignee_type_status() {
234 let layout = LayoutConfig::default();
235 assert_eq!(
236 render(&layout, &issue()),
237 "herbert-maschke/bug/in-progress/2026-10-05-A1B2C3D4-fix-login-crash.md"
238 );
239 }
240
241 #[test]
242 fn id_token_renders_empty_for_a_legacy_issue() {
243 let layout = LayoutConfig::default();
244 let mut i = issue();
245 i.id = None;
246 assert_eq!(
249 render(&layout, &i),
250 "herbert-maschke/bug/in-progress/2026-10-05--fix-login-crash.md"
251 );
252 }
253
254 #[test]
255 fn missing_type_and_cycle_fall_back() {
256 let layout = LayoutConfig {
257 directory: "{type}/{cycle}".into(),
258 filename: "{number}-{slug}.md".into(),
259 move_on_change: true,
260 };
261 let mut i = issue();
262 i.type_ = None;
263 i.cycles = None;
264 assert_eq!(render(&layout, &i), "default/_/7-fix-login-crash.md");
265 }
266
267 #[test]
268 fn validate_rejects_unknown_tokens_and_unsafe_paths() {
269 let bad = LayoutConfig {
270 directory: "{nope}".into(),
271 filename: "{date}-{slug}.md".into(),
272 move_on_change: true,
273 };
274 assert!(validate(&bad).is_err());
275 let abs = LayoutConfig {
276 directory: "/etc".into(),
277 filename: "{date}-{slug}.md".into(),
278 move_on_change: true,
279 };
280 assert!(validate(&abs).is_err());
281 let no_md = LayoutConfig {
282 directory: "{assignee}".into(),
283 filename: "{date}-{slug}".into(),
284 move_on_change: true,
285 };
286 assert!(validate(&no_md).is_err());
287 assert!(validate(&LayoutConfig::default()).is_ok());
288 }
289}