use std::path::{Path, PathBuf};
use crate::error::{Result, TixError};
use crate::model::Issue;
use crate::storage::Store;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RelatedRef {
Local(String),
Path {
target: String,
reference: Option<String>,
absolute: bool,
},
Url {
target: String,
reference: Option<String>,
},
}
impl RelatedRef {
#[must_use]
pub fn is_local(&self) -> bool {
matches!(self, Self::Local(_))
}
#[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(),
},
}
}
#[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,
}
}
}
#[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())
}
#[must_use]
pub fn classify_all(raws: &[String]) -> Vec<RelatedRef> {
raws.iter().map(|r| classify(r)).collect()
}
fn is_url(s: &str) -> bool {
if s.contains("://") {
return true;
}
if let Some(rest) = s.strip_prefix("git@") {
return rest.contains(':');
}
match s.split_once('/') {
Some((host, _)) => {
!s.starts_with('.') && !host.is_empty() && host.contains('.') && !host.starts_with('.')
}
None => false,
}
}
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),
}
}
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)
}
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)
}
#[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)
}
#[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 {
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())
);
assert_eq!(classify("foo/bar"), RelatedRef::Local("foo/bar".into()));
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")
);
}
}