use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::assignee::derive_segment;
use crate::config::Config;
use crate::error::{Result, TixError};
use crate::model::{Issue, Status};
use crate::slug::slugify;
pub struct Store {
pub issues_dir: PathBuf,
pub config: Config,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IssueSelector {
Number(u32),
Filename(String),
Slug(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AssetAdded {
pub link: String,
pub written_name: String,
}
impl Store {
pub fn open(config: &Config) -> Result<Store> {
let issues_dir = &config.issues_dir;
if !issues_dir.exists() {
std::fs::create_dir_all(issues_dir)?;
}
Ok(Store {
issues_dir: issues_dir.clone(),
config: config.clone(),
})
}
pub fn list(&self) -> Result<Vec<Issue>> {
let mut issues: Vec<Issue> = Vec::new();
if !self.issues_dir.exists() {
return Ok(issues);
}
for entry in std::fs::read_dir(&self.issues_dir)? {
let entry = entry?;
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if path.is_dir() {
if name.starts_with(".tix-tmp-") || name.ends_with("-assets") {
continue;
}
for child in std::fs::read_dir(&path)? {
let child = child?;
let child_path = child.path();
if !child_path.is_file() {
continue;
}
let child_name = child.file_name().to_string_lossy().to_string();
if !child_name.ends_with(".md") {
continue;
}
let rel = format!("{name}/{child_name}");
let contents = std::fs::read_to_string(&child_path)?;
let mut issue = Issue::parse(&contents).map_err(|e| match e {
TixError::Parse(msg) => TixError::Parse(format!("{rel}: {msg}")),
other => other,
})?;
issue.filename = rel;
issues.push(issue);
}
} else if path.is_file() && name.ends_with(".md") {
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!("{name}: {msg}")),
other => other,
})?;
issue.filename = name;
issues.push(issue);
}
}
issues.sort_by(|a, b| a.filename.cmp(&b.filename));
Ok(issues)
}
pub fn get(&self, selector: IssueSelector) -> Result<Issue> {
match selector {
IssueSelector::Number(num) => {
let issues = self.list()?;
issues
.into_iter()
.find(|i| i.number == num)
.ok_or_else(|| TixError::NotFound(format!("issue #{num}")))
}
IssueSelector::Filename(name) => {
validate_filename(&name)?;
let path = self.issues_dir.join(&name);
if !path.is_file() {
return Err(TixError::NotFound(format!("file {name}")));
}
let contents = std::fs::read_to_string(&path)?;
let mut issue = Issue::parse(&contents)?;
issue.filename = name;
Ok(issue)
}
IssueSelector::Slug(slug) => {
let issues = self.list()?;
issues
.into_iter()
.find(|i| slugify(&i.title) == slug)
.ok_or_else(|| TixError::NotFound(format!("slug {slug}")))
}
}
}
pub fn create(
&self,
issue: &mut Issue,
body: &str,
filename_stem: Option<&str>,
) -> Result<PathBuf> {
issue.body = body.to_string();
issue.bump_updated();
std::fs::create_dir_all(&self.issues_dir)?;
issue.number = self.resolve_next_number()?;
let segment = derive_segment(issue.assignee.as_deref());
std::fs::create_dir_all(self.issues_dir.join(&segment))?;
let stem = match filename_stem {
Some(s) => s.to_string(),
None => issue.filename_stem(),
};
let filename = format!("{segment}/{stem}.md");
issue.filename = filename.clone();
let path = self.issues_dir.join(&filename);
let contents = issue.to_file_contents();
atomic_write(&self.issues_dir, &path, contents.as_bytes())?;
Ok(path)
}
fn resolve_next_number(&self) -> Result<u32> {
let issues = self.list()?;
match issues.iter().map(|i| i.number).max() {
None => Ok(0),
Some(max) => max
.checked_add(1)
.ok_or_else(|| TixError::InvalidInput("issue-number overflow".to_string())),
}
}
pub fn config_path(&self) -> Result<std::path::PathBuf> {
if let Some(root) = Config::project_root(&self.issues_dir) {
Ok(root.join("tix.toml"))
} else {
Err(TixError::Config(
"tix.toml not found in any parent directory of issues_dir".into(),
))
}
}
pub fn update(&self, filename: &str, issue: &Issue) -> Result<()> {
validate_filename(filename)?;
let path = self.issues_dir.join(filename);
if !path.is_file() {
return Err(TixError::NotFound(format!("file {filename}")));
}
let contents = issue.to_file_contents();
atomic_write(&self.issues_dir, &path, contents.as_bytes())
}
pub fn add_asset(&self, filename: &str, source_path: &Path) -> Result<AssetAdded> {
validate_filename(filename)?;
let mut issue = self.get(IssueSelector::Filename(filename.to_string()))?;
let (segment, basename) = split_relative(filename).ok_or_else(|| {
TixError::InvalidInput(format!("invalid relative issue filename: {filename}"))
})?;
let stem = basename
.strip_suffix(".md")
.ok_or_else(|| {
TixError::InvalidInput(format!(
"asset target must be a `.md` issue file: {filename}"
))
})?
.to_string();
let slug = slug_from_stem(&stem);
let dir_name = format!("{slug}-assets");
let asset_dir = self.issues_dir.join(&segment).join(&dir_name);
std::fs::create_dir_all(&asset_dir)?;
let source_name = source_path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| {
TixError::InvalidInput("asset source must have a file name".to_string())
})?
.to_string();
let written_name = resolve_asset_name(&asset_dir, &source_name);
atomic_copy(&asset_dir, &asset_dir.join(&written_name), source_path)?;
let label = written_name
.rsplit_once('.')
.map(|(stem, _)| stem)
.unwrap_or(&written_name);
let link = if is_image_name(&written_name) {
format!("")
} else {
format!("[{label}]({dir_name}/{written_name})")
};
if !issue.body.is_empty() {
if !issue.body.ends_with('\n') {
issue.body.push('\n');
}
if !issue.body.ends_with("\n\n") {
issue.body.push('\n');
}
}
issue.body.push_str(&link);
issue.body.push('\n');
issue.bump_updated();
self.update(filename, &issue)?;
Ok(AssetAdded { link, written_name })
}
pub fn delete(&self, filename: &str) -> Result<()> {
validate_filename(filename)?;
let path = self.issues_dir.join(filename);
if !path.is_file() {
return Err(TixError::NotFound(format!("file {filename}")));
}
std::fs::remove_file(&path)?;
if let Some((segment, basename)) = split_relative(filename) {
let stem = basename.strip_suffix(".md").unwrap_or(&basename);
let slug = slug_from_stem(stem);
let asset_dir = self
.issues_dir
.join(&segment)
.join(format!("{slug}-assets"));
if asset_dir.exists()
&& let Err(e) = std::fs::remove_dir_all(&asset_dir)
{
eprintln!(
"warning: failed to remove asset dir {}: {e}",
asset_dir.display()
);
}
}
Ok(())
}
pub fn set_status(&self, filename: &str, new_status: &str) -> Result<Issue> {
let mut issue = self.get(IssueSelector::Filename(filename.to_string()))?;
issue.status = Status::new(new_status);
issue.bump_updated();
let path = self.issues_dir.join(filename);
let contents = issue.to_file_contents();
atomic_write(&self.issues_dir, &path, contents.as_bytes())?;
Ok(issue)
}
pub fn reassign(&self, filename: &str, new_assignee: Option<&str>) -> Result<PathBuf> {
self.get(IssueSelector::Filename(filename.to_string()))?;
let (old_segment, file_name) = split_relative(filename).ok_or_else(|| {
TixError::InvalidInput(format!("invalid relative issue filename: {filename}"))
})?;
let new_segment = derive_segment(new_assignee);
if old_segment == new_segment {
return Ok(PathBuf::from(filename));
}
let stem = file_name
.strip_suffix(".md")
.unwrap_or(&file_name)
.to_string();
let target_dir = self.issues_dir.join(&new_segment);
std::fs::create_dir_all(&target_dir)?;
let new_name = resolve_target_name(&target_dir, &stem);
std::fs::rename(self.issues_dir.join(filename), target_dir.join(&new_name))?;
let slug = slug_from_stem(&stem);
let asset_name = format!("{slug}-assets");
let old_asset_dir = self.issues_dir.join(&old_segment).join(&asset_name);
if old_asset_dir.exists() {
let moved = resolve_asset_dir_name(&target_dir, &asset_name);
std::fs::rename(&old_asset_dir, target_dir.join(&moved))?;
}
Ok(PathBuf::from(format!("{new_segment}/{new_name}")))
}
}
fn validate_filename(filename: &str) -> Result<()> {
if filename.is_empty() {
return Err(TixError::InvalidInput(format!(
"filename must not be empty: {filename:?}"
)));
}
if filename.starts_with('/') {
return Err(TixError::InvalidInput(format!(
"filename must be relative (no leading `/`): {filename}"
)));
}
if filename.contains('\\') {
return Err(TixError::InvalidInput(format!(
"filename must not contain backslashes: {filename}"
)));
}
if filename.contains("..") {
return Err(TixError::InvalidInput(format!(
"filename must not contain `..`: {filename}"
)));
}
if filename.contains('\0') {
return Err(TixError::InvalidInput(format!(
"filename must not contain NUL: {filename}"
)));
}
if filename.split('/').count() > 2 {
return Err(TixError::InvalidInput(format!(
"filename may have at most one segment prefix: {filename}"
)));
}
if filename.split('/').any(|component| component.is_empty()) {
return Err(TixError::InvalidInput(format!(
"filename path components must not be empty: {filename}"
)));
}
Ok(())
}
fn split_relative(filename: &str) -> Option<(String, String)> {
let mut parts = filename.rsplitn(2, '/');
let basename = parts.next()?;
let segment = parts.next().unwrap_or("");
Some((segment.to_string(), basename.to_string()))
}
fn resolve_target_name(dir: &Path, stem: &str) -> String {
let mut candidate = format!("{stem}.md");
let mut i = 1u32;
while dir.join(&candidate).exists() {
candidate = format!("{stem}-{i}.md");
i += 1;
}
candidate
}
fn slug_from_stem(stem: &str) -> String {
let bytes = stem.as_bytes();
if stem.len() > 10 && bytes[4] == b'-' && bytes[7] == b'-' && bytes[10] == b'-' {
stem[11..].to_string()
} else {
stem.to_string()
}
}
fn resolve_asset_name(dir: &Path, name: &str) -> String {
if !dir.join(name).exists() {
return name.to_string();
}
let (stem, ext) = match name.rsplit_once('.') {
Some((s, e)) => (s, format!(".{e}")),
None => (name, String::new()),
};
let mut i = 1u32;
loop {
let candidate = format!("{stem}-{i}{ext}");
if !dir.join(&candidate).exists() {
return candidate;
}
i += 1;
}
}
fn resolve_asset_dir_name(dir: &Path, name: &str) -> String {
let mut candidate = name.to_string();
let mut i = 1u32;
while dir.join(&candidate).exists() {
candidate = format!("{name}-{i}");
i += 1;
}
candidate
}
fn atomic_copy(dir: &Path, target: &Path, source: &Path) -> Result<()> {
let pid = std::process::id();
let nonce = nanos();
let tmp = dir.join(format!(".tix-tmp-{pid}-{nonce}"));
let copy_result = (|| -> std::io::Result<()> {
std::fs::copy(source, &tmp)?;
let f = std::fs::File::open(&tmp)?;
f.sync_all()?;
Ok(())
})();
if let Err(e) = copy_result {
let _ = std::fs::remove_file(&tmp);
return Err(TixError::Io(e));
}
if let Err(e) = std::fs::rename(&tmp, target) {
let _ = std::fs::remove_file(&tmp);
return Err(TixError::Io(e));
}
Ok(())
}
fn is_image_name(name: &str) -> bool {
matches!(
name.rsplit('.')
.next()
.unwrap_or("")
.to_ascii_lowercase()
.as_str(),
"png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp"
)
}
fn atomic_write(dir: &Path, target: &Path, data: &[u8]) -> Result<()> {
let pid = std::process::id();
let nonce = nanos();
let tmp = dir.join(format!(".tix-tmp-{pid}-{nonce}"));
let write_result = (|| -> std::io::Result<()> {
let mut f = std::fs::File::create(&tmp)?;
use std::io::Write;
f.write_all(data)?;
f.sync_all()?;
Ok(())
})();
if let Err(e) = write_result {
let _ = std::fs::remove_file(&tmp);
return Err(TixError::Io(e));
}
if let Err(e) = std::fs::rename(&tmp, target) {
let _ = std::fs::remove_file(&tmp);
return Err(TixError::Io(e));
}
let _ = std::fs::File::open(dir).and_then(|f| f.sync_all());
Ok(())
}
fn nanos() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Priority, today};
use std::fs;
fn make_store() -> (tempfile::TempDir, Store) {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let config = Config {
issues_dir: dir.clone(),
..Config::default()
};
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let store = Store {
issues_dir: dir,
config,
};
(tmp, store)
}
fn write_issue(store: &Store, name: &str, contents: &str) {
fs::write(store.issues_dir.join(name), contents).unwrap();
}
fn sample_frontmatter(number: u32, title: &str, status: &str) -> String {
format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
)
}
#[test]
fn list_returns_sorted_by_filename() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-03-01-c.md",
&sample_frontmatter(3, "C", "todo"),
);
write_issue(
&store,
"2026-01-01-a.md",
&sample_frontmatter(1, "A", "todo"),
);
write_issue(
&store,
"2026-02-01-b.md",
&sample_frontmatter(2, "B", "todo"),
);
let issues = store.list().unwrap();
assert_eq!(issues.len(), 3);
assert_eq!(issues[0].number, 1);
assert_eq!(issues[1].number, 2);
assert_eq!(issues[2].number, 3);
assert_eq!(issues[0].title, "A");
}
#[test]
fn list_ignores_non_md_files() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-01-01-a.md",
&sample_frontmatter(1, "A", "todo"),
);
write_issue(&store, "readme.txt", "not an issue");
write_issue(&store, "2026-01-01-sub", "no extension");
let issues = store.list().unwrap();
assert_eq!(issues.len(), 1);
}
#[test]
fn list_errors_on_malformed_frontmatter() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(&store, "2026-01-01-bad.md", "no frontmatter here\n");
let err = store.list().unwrap_err();
assert!(matches!(err, TixError::Parse(_)), "got {err:?}");
}
#[test]
fn get_by_number() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-01-01-x.md",
&sample_frontmatter(5, "X", "todo"),
);
let issue = store.get(IssueSelector::Number(5)).unwrap();
assert_eq!(issue.title, "X");
}
#[test]
fn get_by_filename() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-01-01-y.md",
&sample_frontmatter(2, "Y", "done"),
);
let issue = store
.get(IssueSelector::Filename("2026-01-01-y.md".into()))
.unwrap();
assert_eq!(issue.status.display(), "done");
}
#[test]
fn get_by_slug() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-01-01-z.md",
&sample_frontmatter(3, "Z", "todo"),
);
let issue = store.get(IssueSelector::Slug("z".into())).unwrap();
assert_eq!(issue.number, 3);
}
#[test]
fn get_not_found() {
let (tmp, store) = make_store();
let _ = tmp;
assert!(store.get(IssueSelector::Number(99)).is_err());
}
#[test]
fn create_assigns_number_from_live_issues_and_leaves_config_untouched() {
let (tmp, store) = make_store();
let _ = tmp;
assert_eq!(store.config.state.next_number, 0);
let mut issue = Issue {
number: 0,
title: "First".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
created: "2026-01-01".into(),
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
};
let path = store.create(&mut issue, "Hello world", None).unwrap();
assert!(path.exists());
assert_eq!(issue.number, 0, "empty store starts at 0");
let config_path = tmp.path().join("tix.toml");
let on_disk = std::fs::read_to_string(&config_path).unwrap();
assert!(
!on_disk.contains("next_number"),
"create must not persist an issue-number counter:\n{on_disk}"
);
let reloaded = Config::load_with_warning(&config_path, |_| {}).unwrap();
assert_eq!(reloaded.state.next_number, 0, "state is never written");
}
#[test]
fn create_resolves_max_live_number_plus_one() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-01-01-a.md",
&sample_frontmatter(0, "A", "todo"),
);
write_issue(
&store,
"2026-01-01-b.md",
&sample_frontmatter(1, "B", "todo"),
);
write_issue(
&store,
"2026-01-01-c.md",
&sample_frontmatter(2, "C", "todo"),
);
let mut issue = Issue {
number: 0,
title: "D".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
created: "2026-01-01".into(),
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
};
store.create(&mut issue, "", None).unwrap();
assert_eq!(issue.number, 3);
}
#[test]
fn create_reuses_number_of_deleted_highest_issue() {
let (_tmp, store) = make_store();
let mut issue_a = Issue {
number: 0,
title: "A".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
created: "2026-01-01".into(),
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
};
store.create(&mut issue_a, "", None).unwrap();
let filename = issue_a.filename.clone();
assert_eq!(issue_a.number, 0);
store.delete(&filename).unwrap();
let mut issue_b = Issue {
number: 0,
title: "B".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
created: "2026-01-02".into(),
updated: "2026-01-02".into(),
filename: String::new(),
body: String::new(),
};
store.create(&mut issue_b, "", None).unwrap();
assert_eq!(issue_b.number, 0, "empty store resumes at floor 0");
}
#[test]
fn create_with_custom_filename_stem() {
let (tmp, store) = make_store();
let _ = tmp;
let mut issue = Issue {
number: 0,
title: "T".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
created: "2026-01-01".into(),
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
};
let path = store
.create(&mut issue, "body", Some("custom-stem"))
.unwrap();
assert!(path.to_string_lossy().contains("custom-stem.md"));
}
#[test]
fn create_ensures_issues_dir_exists() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("nested").join("issues");
let config_path = tmp.path().join("tix.toml");
let config = Config {
issues_dir: dir.clone(),
..Config::default()
};
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let store = Store {
issues_dir: dir,
config,
};
let mut issue = Issue {
number: 0,
title: "T".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
created: "2026-01-01".into(),
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
};
store.create(&mut issue, "", None).unwrap();
assert_eq!(issue.number, 0);
}
#[test]
fn update_replaces_content_atomically() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-01-01-u.md",
&sample_frontmatter(1, "U", "todo"),
);
let mut issue = store
.get(IssueSelector::Filename("2026-01-01-u.md".into()))
.unwrap();
issue.title = "Updated".into();
issue.bump_updated();
store.update("2026-01-01-u.md", &issue).unwrap();
let reloaded = store
.get(IssueSelector::Filename("2026-01-01-u.md".into()))
.unwrap();
assert_eq!(reloaded.title, "Updated");
}
#[test]
fn update_not_found() {
let (tmp, store) = make_store();
let _ = tmp;
let issue = Issue {
number: 1,
title: "T".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
created: "2026-01-01".into(),
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
};
assert!(store.update("nope.md", &issue).is_err());
}
#[test]
fn delete_removes_file() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-01-01-d.md",
&sample_frontmatter(1, "D", "todo"),
);
assert!(store.issues_dir.join("2026-01-01-d.md").exists());
store.delete("2026-01-01-d.md").unwrap();
assert!(!store.issues_dir.join("2026-01-01-d.md").exists());
}
#[test]
fn delete_not_found() {
let (tmp, store) = make_store();
let _ = tmp;
assert!(store.delete("nope.md").is_err());
}
#[test]
fn stray_tmp_file_not_treated_as_issue() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-01-01-a.md",
&sample_frontmatter(1, "A", "todo"),
);
fs::write(store.issues_dir.join(".tix-tmp-9999-0"), "orphaned data").unwrap();
let issues = store.list().unwrap();
assert_eq!(issues.len(), 1);
}
#[test]
fn set_status_changes_status_and_bumps_updated() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-01-01-s.md",
&sample_frontmatter(1, "S", "todo"),
);
let issue = store.set_status("2026-01-01-s.md", "in progress").unwrap();
assert_eq!(issue.status.display(), "in progress");
assert_eq!(issue.updated, today());
assert_eq!(issue.title, "S");
assert_eq!(issue.body, "Body text.");
}
#[test]
fn set_status_preserves_file_path() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-01-01-f.md",
&sample_frontmatter(1, "F", "todo"),
);
let _ = store.set_status("2026-01-01-f.md", "done").unwrap();
assert!(store.issues_dir.join("2026-01-01-f.md").exists());
}
#[test]
fn path_traversal_with_dotdot_refused() {
let err = validate_filename("../etc/passwd").unwrap_err();
assert!(matches!(err, TixError::InvalidInput(_)));
}
#[test]
fn path_traversal_with_slash_refused() {
let err = validate_filename("sub/../../x.md").unwrap_err();
assert!(matches!(err, TixError::InvalidInput(_)));
}
#[test]
fn path_traversal_with_nul_refused() {
let err = validate_filename("file\0.md").unwrap_err();
assert!(matches!(err, TixError::InvalidInput(_)));
}
#[test]
fn delete_refuses_traversal_before_touching_filesystem() {
let (tmp, store) = make_store();
let outside = tmp.path().join("escape.md");
fs::write(&outside, "secret").unwrap();
let err = store.delete("../escape.md").unwrap_err();
assert!(matches!(err, TixError::InvalidInput(_)));
assert!(
outside.exists(),
"traversal must not delete outside issues_dir"
);
assert_eq!(fs::read_to_string(&outside).unwrap(), "secret");
}
#[test]
fn numbers_stable_across_list_and_set_status() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-01-01-x.md",
&sample_frontmatter(42, "X", "todo"),
);
let before = store.list().unwrap();
assert_eq!(before[0].number, 42);
let after = store.set_status("2026-01-01-x.md", "done").unwrap();
assert_eq!(after.number, 42);
let rescan = store.list().unwrap();
assert_eq!(rescan[0].number, 42);
}
#[test]
fn atomic_write_no_temp_left_on_success() {
let (tmp, store) = make_store();
let _ = tmp;
atomic_write(
&store.issues_dir,
&store.issues_dir.join("test.md"),
b"hello",
)
.unwrap();
let entries: Vec<_> = fs::read_dir(&store.issues_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
assert_eq!(entries, vec!["test.md"]);
}
#[test]
fn list_mixes_grouped_and_flat_issues_sorted_by_relative_filename() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
write_issue(
&store,
"alice/2026-01-01-a.md",
&sample_frontmatter(1, "A", "todo"),
);
write_issue(
&store,
"bob/2026-05-10-b.md",
&sample_frontmatter(2, "B", "todo"),
);
write_issue(
&store,
"2026-02-01-c.md",
&sample_frontmatter(3, "C", "todo"),
);
let issues = store.list().unwrap();
let filenames: Vec<&str> = issues.iter().map(|i| i.filename.as_str()).collect();
assert_eq!(issues.len(), 3);
assert_eq!(
filenames,
vec![
"2026-02-01-c.md",
"alice/2026-01-01-a.md",
"bob/2026-05-10-b.md"
],
"deterministic sort by relative filename"
);
}
#[test]
fn list_sorts_chronologically_within_a_group() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue(
&store,
"alice/2026-03-01-c.md",
&sample_frontmatter(3, "C", "todo"),
);
write_issue(
&store,
"alice/2026-01-01-a.md",
&sample_frontmatter(1, "A", "todo"),
);
write_issue(
&store,
"alice/2026-02-01-b.md",
&sample_frontmatter(2, "B", "todo"),
);
let issues = store.list().unwrap();
let numbers: Vec<u32> = issues.iter().map(|i| i.number).collect();
assert_eq!(numbers, vec![1, 2, 3]);
}
#[test]
fn list_ignores_non_md_files_in_segments() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue(
&store,
"alice/2026-01-01-a.md",
&sample_frontmatter(1, "A", "todo"),
);
fs::write(store.issues_dir.join("alice/notes.txt"), "not an issue").unwrap();
fs::write(store.issues_dir.join("alice/.tix-tmp-123-456"), "orphan").unwrap();
let issues = store.list().unwrap();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].filename, "alice/2026-01-01-a.md");
}
#[test]
fn list_skips_temp_marker_directories() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join(".tix-tmp-999-0")).unwrap();
write_issue(
&store,
".tix-tmp-999-0/2026-01-01-junk.md",
&sample_frontmatter(9, "J", "todo"),
);
write_issue(
&store,
"2026-01-01-a.md",
&sample_frontmatter(1, "A", "todo"),
);
let issues = store.list().unwrap();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].filename, "2026-01-01-a.md");
}
#[test]
fn list_parse_error_carries_grouped_relative_path() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
fs::write(
store.issues_dir.join("alice/2026-01-01-bad.md"),
"no frontmatter here\n",
)
.unwrap();
let err = store.list().unwrap_err();
assert!(
matches!(&err, TixError::Parse(m) if m.starts_with("alice/2026-01-01-bad.md:")),
"parse error must carry the relative path, got {err:?}"
);
}
#[test]
fn create_writes_into_assignee_segment_and_records_relative_filename() {
let (tmp, store) = make_store();
let _ = tmp;
let mut issue = Issue {
number: 0,
title: "T".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
created: "2026-01-01".into(),
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
};
let path = store
.create(&mut issue, "body", Some("custom-stem"))
.unwrap();
let rel = "herbert-maschke/custom-stem.md";
assert_eq!(issue.filename, rel);
assert_eq!(path, store.issues_dir.join(rel));
assert!(store.issues_dir.join("herbert-maschke").is_dir());
assert!(store.issues_dir.join(rel).is_file());
}
#[test]
fn create_without_assignee_writes_unassigned_segment() {
let (tmp, store) = make_store();
let _ = tmp;
let mut issue = Issue {
number: 0,
title: "T".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
created: "2026-01-01".into(),
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
};
let path = store.create(&mut issue, "", Some("stem")).unwrap();
assert_eq!(issue.filename, "unassigned/stem.md");
assert!(store.issues_dir.join("unassigned").is_dir());
assert!(store.issues_dir.join("unassigned/stem.md").is_file());
assert_eq!(path, store.issues_dir.join("unassigned/stem.md"));
}
#[test]
fn create_with_at_handle_derives_bare_segment() {
let (tmp, store) = make_store();
let _ = tmp;
let mut issue = Issue {
number: 0,
title: "T".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: Some("@alice".into()),
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
created: "2026-01-01".into(),
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
};
store.create(&mut issue, "", Some("stem")).unwrap();
assert_eq!(issue.filename, "alice/stem.md");
assert!(store.issues_dir.join("alice/stem.md").is_file());
}
#[test]
fn reassign_moves_file_between_segments() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue(
&store,
"alice/2026-01-01-x.md",
&sample_frontmatter(1, "X", "todo"),
);
let new_path = store
.reassign("alice/2026-01-01-x.md", Some("@bob"))
.unwrap();
assert_eq!(new_path, PathBuf::from("bob/2026-01-01-x.md"));
assert!(store.issues_dir.join("bob/2026-01-01-x.md").is_file());
assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
}
#[test]
fn reassign_into_occupied_segment_picks_deterministic_suffix() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
write_issue(
&store,
"alice/2026-01-01-x.md",
&sample_frontmatter(1, "X", "todo"),
);
write_issue(
&store,
"bob/2026-01-01-x.md",
&sample_frontmatter(2, "X", "todo"),
);
let new_path = store
.reassign("alice/2026-01-01-x.md", Some("@bob"))
.unwrap();
assert_eq!(new_path, PathBuf::from("bob/2026-01-01-x-1.md"));
assert!(store.issues_dir.join("bob/2026-01-01-x.md").is_file());
assert!(store.issues_dir.join("bob/2026-01-01-x-1.md").is_file());
assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
}
#[test]
fn reassign_legacy_flat_file_into_segment() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-01-01-y.md",
&sample_frontmatter(1, "Y", "todo"),
);
let new_path = store.reassign("2026-01-01-y.md", Some("alice")).unwrap();
assert_eq!(new_path, PathBuf::from("alice/2026-01-01-y.md"));
assert!(store.issues_dir.join("alice/2026-01-01-y.md").is_file());
assert!(!store.issues_dir.join("2026-01-01-y.md").exists());
}
#[test]
fn reassign_to_none_uses_unassigned_fallback() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue(
&store,
"alice/2026-01-01-z.md",
&sample_frontmatter(1, "Z", "todo"),
);
let new_path = store.reassign("alice/2026-01-01-z.md", None).unwrap();
assert_eq!(new_path, PathBuf::from("unassigned/2026-01-01-z.md"));
assert!(
store
.issues_dir
.join("unassigned/2026-01-01-z.md")
.is_file()
);
assert!(!store.issues_dir.join("alice/2026-01-01-z.md").exists());
}
#[test]
fn reassign_keeps_number_and_created_stable() {
let (tmp, store) = make_store();
let _ = tmp;
let mut issue = Issue {
number: 0,
title: "Stable".into(),
status: Status::default(),
labels: vec![],
priority: Priority::default(),
assignee: Some("alice".into()),
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
created: "2026-01-01".into(),
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
};
store.create(&mut issue, "body", None).unwrap();
let number = issue.number;
let created = issue.created.clone();
let before = issue.filename.clone();
let new_path = store.reassign(&before, Some("@bob")).unwrap();
let rescan = store.list().unwrap();
let moved = rescan
.iter()
.find(|i| i.filename == new_path)
.expect("moved file must be listed at its new relative path");
assert_eq!(moved.number, number);
assert_eq!(moved.created, created);
assert_eq!(moved.title, "Stable");
}
#[test]
fn reassign_equal_segment_is_noop() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue(
&store,
"alice/2026-01-01-x.md",
&sample_frontmatter(1, "X", "todo"),
);
let new_path = store
.reassign("alice/2026-01-01-x.md", Some("alice"))
.unwrap();
assert_eq!(new_path, PathBuf::from("alice/2026-01-01-x.md"));
assert!(store.issues_dir.join("alice/2026-01-01-x.md").is_file());
}
#[test]
fn reassign_missing_file_is_not_found() {
let (tmp, store) = make_store();
let _ = tmp;
let err = store.reassign("nope.md", Some("alice")).unwrap_err();
assert!(matches!(err, TixError::NotFound(_)));
}
#[test]
fn validate_filename_allows_flat_and_single_segment_prefix() {
validate_filename("2026-01-01-x.md").unwrap();
validate_filename("alice/2026-01-01-x.md").unwrap();
}
#[test]
fn validate_filename_refuses_deeper_segments() {
let err = validate_filename("alice/bob/2026-01-01-x.md").unwrap_err();
assert!(matches!(err, TixError::InvalidInput(_)));
}
#[test]
fn validate_filename_refuses_leading_and_trailing_slash_and_absolute() {
let err = validate_filename("/alice/2026-01-01-x.md").unwrap_err();
assert!(matches!(err, TixError::InvalidInput(_)));
let err = validate_filename("/etc/passwd").unwrap_err();
assert!(matches!(err, TixError::InvalidInput(_)));
let err = validate_filename("alice/").unwrap_err();
assert!(matches!(err, TixError::InvalidInput(_)));
}
#[test]
fn path_traversal_with_segment_dotdot_refused() {
let err = validate_filename("alice/../../x.md").unwrap_err();
assert!(matches!(err, TixError::InvalidInput(_)));
let err = validate_filename("alice/../x.md").unwrap_err();
assert!(matches!(err, TixError::InvalidInput(_)));
}
#[test]
fn get_update_delete_set_status_work_on_grouped_relative_paths() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue(
&store,
"alice/2026-01-01-g.md",
&sample_frontmatter(7, "G", "todo"),
);
let got = store
.get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
.unwrap();
assert_eq!(got.number, 7);
let done = store.set_status("alice/2026-01-01-g.md", "done").unwrap();
assert_eq!(done.status.display(), "done");
assert!(store.issues_dir.join("alice/2026-01-01-g.md").is_file());
let mut edited = store
.get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
.unwrap();
edited.title = "Updated".into();
store.update("alice/2026-01-01-g.md", &edited).unwrap();
let reloaded = store
.get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
.unwrap();
assert_eq!(reloaded.title, "Updated");
store.delete("alice/2026-01-01-g.md").unwrap();
assert!(!store.issues_dir.join("alice/2026-01-01-g.md").exists());
}
#[test]
fn grouped_round_trip_create_list_get_update_delete() {
let (tmp, store) = make_store();
let _ = tmp;
let mut issue = Issue {
number: 0,
title: "Round trip".into(),
status: Status::default(),
labels: vec!["bug".into()],
priority: Priority::default(),
assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
created: "2026-01-01".into(),
updated: "2026-01-01".into(),
filename: String::new(),
body: String::new(),
};
store.create(&mut issue, "initial body", None).unwrap();
let rel = issue.filename.clone();
assert_eq!(rel, "herbert-maschke/2026-01-01-round-trip.md");
let listed = store.list().unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].filename, rel);
assert_eq!(listed[0].number, issue.number);
let got = store.get(IssueSelector::Filename(rel.clone())).unwrap();
let mut edited = got.clone();
edited.title = "Renamed".into();
store.update(&rel, &edited).unwrap();
let reloaded = store.get(IssueSelector::Filename(rel.clone())).unwrap();
assert_eq!(reloaded.title, "Renamed");
store.delete(&rel).unwrap();
assert!(!store.issues_dir.join(&rel).exists());
assert!(store.list().unwrap().is_empty());
}
#[test]
fn legacy_flat_file_edit_and_status_still_work_after_layout_flip() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(
&store,
"2026-01-01-legacy.md",
&sample_frontmatter(5, "Legacy", "todo"),
);
let listed = store.list().unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].filename, "2026-01-01-legacy.md");
let mutated = store
.set_status("2026-01-01-legacy.md", "in progress")
.unwrap();
assert_eq!(mutated.status.display(), "in progress");
assert!(store.issues_dir.join("2026-01-01-legacy.md").is_file());
}
fn write_asset_source(tmp: &tempfile::TempDir, name: &str, contents: &[u8]) -> PathBuf {
let path = tmp.path().join(name);
fs::write(&path, contents).unwrap();
path
}
fn write_issue_with_basic_body(store: &Store, name: &str, number: u32) {
write_issue(
store,
name,
&sample_frontmatter(number, "Asset host", "todo"),
);
}
#[test]
fn add_asset_image_inserts_image_link_and_writes_file() {
let (tmp, store) = make_store();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
let added = store
.add_asset("alice/2026-01-01-fix-login-bug.md", &src)
.unwrap();
assert_eq!(added.written_name, "diagram.png");
assert_eq!(added.link, "");
assert!(
store
.issues_dir
.join("alice/fix-login-bug-assets/diagram.png")
.is_file()
);
}
#[test]
fn add_asset_non_image_inserts_generic_markdown_link() {
let (tmp, store) = make_store();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
let src = write_asset_source(&tmp, "report.pdf", b"%PDF");
let added = store
.add_asset("alice/2026-01-01-fix-login-bug.md", &src)
.unwrap();
assert_eq!(added.link, "[report](fix-login-bug-assets/report.pdf)");
assert!(
store
.issues_dir
.join("alice/fix-login-bug-assets/report.pdf")
.is_file()
);
}
#[test]
fn add_asset_collision_resolves_with_numeric_suffix_and_link_points_at_it() {
let (tmp, store) = make_store();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
let src = write_asset_source(&tmp, "x.png", b"first");
let first = store
.add_asset("alice/2026-01-01-fix-login-bug.md", &src)
.unwrap();
assert_eq!(first.written_name, "x.png");
let again = store
.add_asset("alice/2026-01-01-fix-login-bug.md", &src)
.unwrap();
assert_eq!(again.written_name, "x-1.png");
assert_eq!(again.link, "");
assert!(
store
.issues_dir
.join("alice/fix-login-bug-assets/x.png")
.is_file()
);
assert!(
store
.issues_dir
.join("alice/fix-login-bug-assets/x-1.png")
.is_file()
);
}
#[test]
fn add_asset_appends_single_link_and_preserves_description() {
let (tmp, store) = make_store();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue(
&store,
"alice/2026-01-01-fix-login-bug.md",
"---\nnumber: 1\ntitle: \"H\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nTitle line.\n\n## Comments\n\n### alice (2026-01-01)\n\nExisting comment.\n",
);
let src = write_asset_source(&tmp, "diagram.svg", b"<svg/>");
store
.add_asset("alice/2026-01-01-fix-login-bug.md", &src)
.unwrap();
let reloaded = store
.get(crate::storage::IssueSelector::Filename(
"alice/2026-01-01-fix-login-bug.md".into(),
))
.unwrap();
assert_eq!(reloaded.description(), "Title line.");
let link_count = reloaded.body.matches("![diagram]").count();
assert_eq!(link_count, 1);
assert!(
reloaded
.body
.ends_with("fix-login-bug-assets/diagram.svg)\n")
);
}
#[test]
fn add_asset_missing_issue_is_not_found() {
let (tmp, store) = make_store();
let _ = tmp;
let src = write_asset_source(&tmp, "x.png", b"x");
let err = store.add_asset("missing.md", &src).unwrap_err();
assert!(matches!(err, TixError::NotFound(_)));
}
#[test]
fn add_asset_legacy_flat_file_uses_stem_assets_under_issues_dir() {
let (tmp, store) = make_store();
write_issue_with_basic_body(&store, "2026-01-01-legacy.md", 1);
let src = write_asset_source(&tmp, "x.png", b"x");
let added = store.add_asset("2026-01-01-legacy.md", &src).unwrap();
assert_eq!(added.link, "");
assert!(store.issues_dir.join("legacy-assets/x.png").is_file());
}
#[test]
fn reassign_moves_asset_dir_with_the_file() {
let (tmp, store) = make_store();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
store
.add_asset("alice/2026-01-01-fix-login-bug.md", &src)
.unwrap();
let new_path = store
.reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
.unwrap();
assert_eq!(new_path, PathBuf::from("bob/2026-01-01-fix-login-bug.md"));
assert!(
store
.issues_dir
.join("bob/fix-login-bug-assets/diagram.png")
.is_file()
);
assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
}
#[test]
fn reassign_without_asset_dir_behaves_as_before() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue_with_basic_body(&store, "alice/2026-01-01-plain.md", 1);
let new_path = store
.reassign("alice/2026-01-01-plain.md", Some("@bob"))
.unwrap();
assert_eq!(new_path, PathBuf::from("bob/2026-01-01-plain.md"));
assert!(store.issues_dir.join("bob/2026-01-01-plain.md").is_file());
assert!(!store.issues_dir.join("bob/plain-assets").exists());
}
#[test]
fn reassign_into_segment_with_same_named_asset_dir_uses_suffix_no_loss() {
let (tmp, store) = make_store();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
let existing = store.issues_dir.join("bob/fix-login-bug-assets");
fs::create_dir_all(&existing).unwrap();
fs::write(existing.join("keep.txt"), "precious").unwrap();
write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
store
.add_asset("alice/2026-01-01-fix-login-bug.md", &src)
.unwrap();
let new_path = store
.reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
.unwrap();
assert_eq!(new_path, PathBuf::from("bob/2026-01-01-fix-login-bug.md"));
assert!(existing.join("keep.txt").is_file());
assert!(
store
.issues_dir
.join("bob/fix-login-bug-assets-1/diagram.png")
.is_file()
);
assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
}
#[test]
fn reassign_preserves_markdown_link_resolution_in_new_segment() {
let (tmp, store) = make_store();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
store
.add_asset("alice/2026-01-01-fix-login-bug.md", &src)
.unwrap();
store
.reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
.unwrap();
assert!(
store
.issues_dir
.join("bob/fix-login-bug-assets/diagram.png")
.is_file()
);
}
#[test]
fn delete_removes_asset_dir_no_orphans() {
let (tmp, store) = make_store();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
let src = write_asset_source(&tmp, "x.png", b"x");
store
.add_asset("alice/2026-01-01-fix-login-bug.md", &src)
.unwrap();
store.delete("alice/2026-01-01-fix-login-bug.md").unwrap();
assert!(
!store
.issues_dir
.join("alice/2026-01-01-fix-login-bug.md")
.exists()
);
assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
}
#[test]
fn delete_without_asset_dir_still_deletes_file() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue_with_basic_body(&store, "alice/2026-01-01-plain.md", 1);
store.delete("alice/2026-01-01-plain.md").unwrap();
assert!(!store.issues_dir.join("alice/2026-01-01-plain.md").exists());
}
#[test]
fn list_ignores_asset_directories() {
let (tmp, store) = make_store();
write_issue_with_basic_body(&store, "2026-01-01-legacy.md", 1);
let src = write_asset_source(&tmp, "x.png", b"x");
store.add_asset("2026-01-01-legacy.md", &src).unwrap();
let issues = store.list().unwrap();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].filename, "2026-01-01-legacy.md");
}
#[test]
fn list_ignores_nested_asset_directories_under_segment() {
let (_tmp, store) = make_store();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
let src = write_asset_source(&_tmp, "x.png", b"x");
store
.add_asset("alice/2026-01-01-fix-login-bug.md", &src)
.unwrap();
let issues = store.list().unwrap();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].filename, "alice/2026-01-01-fix-login-bug.md");
}
#[test]
fn asset_dir_with_formless_md_child_is_not_scanned_as_segment() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
let assets_dir = store.issues_dir.join("alice/fix-login-bug-assets");
fs::create_dir_all(&assets_dir).unwrap();
fs::write(assets_dir.join("notes.md"), "no frontmatter").unwrap();
let issues = store.list().unwrap();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].filename, "alice/2026-01-01-fix-login-bug.md");
}
}