Skip to main content

tix/
layout.rs

1//! Configurable on-disk issue layout (`[layout]` in `tix.toml`).
2//!
3//! A directory template and a filename template, with `{token}` placeholders,
4//! decide where a new issue is written and (when `move_on_change`) where an
5//! existing one belongs after a templated value changes. The reader is
6//! layout-agnostic ([`crate::storage::Store::scan`] walks any depth), so a store
7//! can hold several layouts at once and migrate gradually.
8
9use crate::assignee::derive_segment;
10use crate::config::LayoutConfig;
11use crate::error::{Result, TixError};
12use crate::model::Issue;
13use crate::slug::slugify;
14
15/// Directory-template tokens and their source on [`Issue`].
16const DIRECTORY_TOKENS: &[&str] = &[
17    "assignee",
18    "type",
19    "status",
20    "priority",
21    "cycle",
22    "milestone",
23];
24
25/// Filename-template tokens and their source on [`Issue`].
26const FILENAME_TOKENS: &[&str] = &["id", "date", "slug", "number", "title"];
27
28/// Validate both templates: known tokens only, a safe relative path, and (for
29/// the filename) a `.md` suffix. Called at config load so a bad template is
30/// refused before any file is touched.
31pub 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
88/// The `{token}` names in a template, in order of appearance.
89fn 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/// The store-relative directory an issue belongs in, from the directory template.
102#[must_use]
103pub fn render_directory(layout: &LayoutConfig, issue: &Issue) -> String {
104    substitute(&layout.directory, |t| directory_value(t, issue))
105}
106
107/// The store-relative path an issue belongs at, from the configured templates.
108#[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
115/// Replace every `{token}` in `template` with `value(token)`.
116fn 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
137/// A single safe path component, or `fallback` when the value slugifies empty.
138fn 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        // No id yet: the id component is empty, which is why the create path
247        // always assigns one before rendering.
248        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}