tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Configurable on-disk issue layout (`[layout]` in `tix.toml`).
//!
//! A directory template and a filename template, with `{token}` placeholders,
//! decide where a new issue is written and (when `move_on_change`) where an
//! existing one belongs after a templated value changes. The reader is
//! layout-agnostic ([`crate::storage::Store::scan`] walks any depth), so a store
//! can hold several layouts at once and migrate gradually.

use crate::assignee::derive_segment;
use crate::config::LayoutConfig;
use crate::error::{Result, TixError};
use crate::model::Issue;
use crate::slug::slugify;

/// Directory-template tokens and their source on [`Issue`].
const DIRECTORY_TOKENS: &[&str] = &[
    "assignee",
    "type",
    "status",
    "priority",
    "cycle",
    "milestone",
];

/// Filename-template tokens and their source on [`Issue`].
const FILENAME_TOKENS: &[&str] = &["id", "date", "slug", "number", "title"];

/// Validate both templates: known tokens only, a safe relative path, and (for
/// the filename) a `.md` suffix. Called at config load so a bad template is
/// refused before any file is touched.
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(())
}

/// The `{token}` names in a template, in order of appearance.
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
}

/// The store-relative directory an issue belongs in, from the directory template.
#[must_use]
pub fn render_directory(layout: &LayoutConfig, issue: &Issue) -> String {
    substitute(&layout.directory, |t| directory_value(t, issue))
}

/// The store-relative path an issue belongs at, from the configured templates.
#[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}")
}

/// Replace every `{token}` in `template` with `value(token)`.
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
}

/// A single safe path component, or `fallback` when the value slugifies empty.
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;
        // No id yet: the id component is empty, which is why the create path
        // always assigns one before rendering.
        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());
    }
}