tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Reference grammar and resolution for the `related_issues` frontmatter field.
//!
//! An entry in an issue's `related_issues:` list is a free-form reference to
//! another issue. It is stored verbatim and classified here at read time, so a
//! path round-trips byte-stable and a wrong classification never rewrites the
//! file. The six forms, in classification order, are:
//!
//! 1. a repository URL (`https://gitlab.com/foo/bar`, `gitlab.com/foo/bar`,
//!    `git@gitlab.com:foo/bar.git`);
//! 2. a URL with an issue reference (`gitlab.com/foo/bar#42`);
//! 3. an absolute path (`/home/…/2026-09-11-003-….md`);
//! 4. a relative path (`../../../other-repo/issues/…md`);
//! 5. a path/dir with a reference (`../other-repo/docs/issues#42`);
//! 6. a local selector (`42`, `#42`, a filename or title fragment).
//!
//! A bare `foo/bar` (no `./`/`../` prefix, first segment not a domain) is a
//! **local** fragment, not a path — so a legacy chalkboard id such as
//! `issue-2026-09-11-001` stays a local selector (and lint reports it as
//! unresolved in a store that has no such issue) rather than a path.

use std::path::{Path, PathBuf};

use crate::error::{Result, TixError};
use crate::model::Issue;
use crate::storage::Store;

/// A classified `related_issues` entry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RelatedRef {
    /// A local issue: number, filename fragment, or title fragment.
    Local(String),
    /// A filesystem path to another issue file (or a directory/repo root).
    Path {
        /// The path exactly as written.
        target: String,
        /// An optional issue reference after `#` when the target is a directory.
        reference: Option<String>,
        /// Whether `target` is absolute (`/…`) or relative (`./`, `../`).
        absolute: bool,
    },
    /// A repository URL, optionally with an issue reference after `#`.
    Url {
        /// The URL exactly as written (scheme-less `host/path` allowed).
        target: String,
        /// An optional issue reference after `#`.
        reference: Option<String>,
    },
}

impl RelatedRef {
    /// Whether this is a local-store selector (the only kind lint resolves).
    #[must_use]
    pub fn is_local(&self) -> bool {
        matches!(self, Self::Local(_))
    }

    /// A human display string, byte-identical to the stored entry.
    #[must_use]
    pub fn display(&self) -> String {
        match self {
            Self::Local(s) => s.clone(),
            Self::Path {
                target, reference, ..
            }
            | Self::Url { target, reference } => match reference {
                Some(r) => format!("{target}#{r}"),
                None => target.clone(),
            },
        }
    }

    /// The bare local selector (a leading `#` stripped), for a `Local` entry.
    #[must_use]
    pub fn local_selector(&self) -> Option<&str> {
        match self {
            Self::Local(s) => Some(s.strip_prefix('#').unwrap_or(s)),
            Self::Path { .. } | Self::Url { .. } => None,
        }
    }
}

/// Classify one `related_issues` entry. Never fails; an unclassifiable string
/// stays `Local` so it is preserved and can be diagnosed by lint.
#[must_use]
pub fn classify(raw: &str) -> RelatedRef {
    let s = raw.trim();
    if is_url(s) {
        let (target, reference) = split_fragment(s);
        return RelatedRef::Url { target, reference };
    }
    if s.starts_with('/') {
        let (target, reference) = split_fragment(s);
        return RelatedRef::Path {
            target,
            reference,
            absolute: true,
        };
    }
    if s.starts_with("./") || s.starts_with("../") {
        let (target, reference) = split_fragment(s);
        return RelatedRef::Path {
            target,
            reference,
            absolute: false,
        };
    }
    RelatedRef::Local(s.to_string())
}

/// Classify every entry of a `related_issues` list, preserving order.
#[must_use]
pub fn classify_all(raws: &[String]) -> Vec<RelatedRef> {
    raws.iter().map(|r| classify(r)).collect()
}

/// Whether a trimmed entry is a repository URL.
fn is_url(s: &str) -> bool {
    if s.contains("://") {
        return true;
    }
    if let Some(rest) = s.strip_prefix("git@") {
        // scp-like `git@host:path`
        return rest.contains(':');
    }
    // Scheme-less host: `host.tld/path`. A dot in a *single* segment
    // (`notes.md`) is a filename, not a host, so a path segment is required.
    match s.split_once('/') {
        Some((host, _)) => {
            !s.starts_with('.') && !host.is_empty() && host.contains('.') && !host.starts_with('.')
        }
        None => false,
    }
}

/// Split a trailing `#reference` off a URL or path. A leading `#` (a local
/// number such as `#42`) never reaches here — it is classified `Local`.
fn split_fragment(s: &str) -> (String, Option<String>) {
    match s.split_once('#') {
        Some((target, reference)) if !reference.is_empty() => {
            (target.to_string(), Some(reference.to_string()))
        }
        _ => (s.to_string(), None),
    }
}

/// Read an issue file at an arbitrary path, including one **outside** the
/// store. Read-only: nothing routes a write through the returned issue.
pub fn read_issue_file(path: &Path) -> Result<Issue> {
    let contents = std::fs::read_to_string(path)?;
    let mut issue = Issue::parse(&contents).map_err(|e| match e {
        TixError::Parse(msg) => TixError::Parse(format!("{}: {msg}", path.display())),
        other => other,
    })?;
    issue.filename = path.to_string_lossy().to_string();
    Ok(issue)
}

/// Resolve a local entry through the store's selector grammar
/// (number → filename fragment → title fragment).
pub fn resolve_local(store: &Store, raw: &str) -> Result<Issue> {
    let raw = raw.strip_prefix('#').unwrap_or(raw);
    let selector = crate::commands::parse_selector(store, raw)?;
    store.get(selector)
}

/// The directory of the referencing issue file — the anchor for relative paths.
///
/// Anchored to the file, never to the process cwd, so the CLI and the TUI
/// resolve the same entry to the same target regardless of where `tix` runs.
#[must_use]
pub fn base_dir(issues_dir: &Path, issue_filename: &str) -> PathBuf {
    let file = issues_dir.join(issue_filename);
    file.parent()
        .map_or_else(|| issues_dir.to_path_buf(), Path::to_path_buf)
}

/// The filesystem path a `Path` entry points at, or `None` for other kinds.
#[must_use]
pub fn resolve_path(base_dir: &Path, r: &RelatedRef) -> Option<PathBuf> {
    match r {
        RelatedRef::Path {
            target, absolute, ..
        } => {
            if *absolute {
                Some(PathBuf::from(target))
            } else {
                Some(base_dir.join(target))
            }
        }
        RelatedRef::Local(_) | RelatedRef::Url { .. } => None,
    }
}

#[cfg(test)]
mod tests {
    #[allow(clippy::wildcard_imports)]
    use super::*;

    #[test]
    fn classifies_the_six_forms_deterministically() {
        assert_eq!(classify("42"), RelatedRef::Local("42".into()));
        assert_eq!(classify("  #42 "), RelatedRef::Local("#42".into()));
        assert_eq!(
            classify("issue-2026-09-11-001"),
            RelatedRef::Local("issue-2026-09-11-001".into())
        );
        // A bare `foo/bar` is a local fragment, not a path.
        assert_eq!(classify("foo/bar"), RelatedRef::Local("foo/bar".into()));
        // A dotted filename with no path segment is a local fragment, not a URL.
        assert_eq!(classify("notes.md"), RelatedRef::Local("notes.md".into()));

        assert_eq!(
            classify("./x.md"),
            RelatedRef::Path {
                target: "./x.md".into(),
                reference: None,
                absolute: false
            }
        );
        assert_eq!(
            classify("../repo/issues/2026-01-01-x.md"),
            RelatedRef::Path {
                target: "../repo/issues/2026-01-01-x.md".into(),
                reference: None,
                absolute: false
            }
        );
        assert_eq!(
            classify("/abs/x.md"),
            RelatedRef::Path {
                target: "/abs/x.md".into(),
                reference: None,
                absolute: true
            }
        );
        assert_eq!(
            classify("../repo/docs/issues#7"),
            RelatedRef::Path {
                target: "../repo/docs/issues".into(),
                reference: Some("7".into()),
                absolute: false
            }
        );

        assert_eq!(
            classify("https://gitlab.com/foo/bar"),
            RelatedRef::Url {
                target: "https://gitlab.com/foo/bar".into(),
                reference: None
            }
        );
        assert_eq!(
            classify("gitlab.com/foo/bar"),
            RelatedRef::Url {
                target: "gitlab.com/foo/bar".into(),
                reference: None
            }
        );
        assert_eq!(
            classify("gitlab.com/foo/bar#42"),
            RelatedRef::Url {
                target: "gitlab.com/foo/bar".into(),
                reference: Some("42".into())
            }
        );
        assert_eq!(
            classify("git@gitlab.com:foo/bar.git"),
            RelatedRef::Url {
                target: "git@gitlab.com:foo/bar.git".into(),
                reference: None
            }
        );
        assert_eq!(
            classify("https://gitlab.com/foo/bar/-/issues/42"),
            RelatedRef::Url {
                target: "https://gitlab.com/foo/bar/-/issues/42".into(),
                reference: None
            }
        );
    }

    #[test]
    fn display_is_byte_identical_to_the_stored_entry() {
        for raw in [
            "42",
            "#42",
            "./x.md",
            "../a/b#7",
            "/abs/x.md",
            "gitlab.com/foo/bar#42",
            "git@gitlab.com:foo/bar.git",
        ] {
            assert_eq!(classify(raw).display(), raw, "round trip for {raw}");
        }
    }

    #[test]
    fn is_local_and_local_selector() {
        assert!(classify("7").is_local());
        assert_eq!(classify("#7").local_selector(), Some("7"));
        assert_eq!(classify("7").local_selector(), Some("7"));
        assert!(!classify("./x").is_local());
        assert_eq!(classify("./x").local_selector(), None);
    }

    #[test]
    fn resolve_path_anchors_relative_paths_to_the_base_dir() {
        let base = Path::new("/repo/docs/issues/alice");
        assert_eq!(
            resolve_path(base, &classify("../other/x.md")),
            Some(PathBuf::from("/repo/docs/issues/alice/../other/x.md"))
        );
        assert_eq!(
            resolve_path(base, &classify("/abs/x.md")),
            Some(PathBuf::from("/abs/x.md"))
        );
        assert_eq!(resolve_path(base, &classify("42")), None);
    }

    #[test]
    fn read_issue_file_parses_a_foreign_file() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("foreign.md");
        std::fs::write(
            &path,
            "---\nnumber: 3\ntitle: Foreign\nstatus: todo\nlabels: []\n\
             priority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nbody\n",
        )
        .unwrap();
        let issue = read_issue_file(&path).unwrap();
        assert_eq!(issue.number, 3);
        assert_eq!(issue.title, "Foreign");
        assert_eq!(issue.filename, path.to_string_lossy());
    }

    #[test]
    fn base_dir_is_the_issue_files_parent() {
        assert_eq!(
            base_dir(Path::new("/repo/docs/issues"), "alice/2026-01-01-x.md"),
            PathBuf::from("/repo/docs/issues/alice")
        );
    }
}