use std::collections::{BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::config::Config;
use crate::error::{Result, TixError, render_diagnostics};
use crate::hierarchy::{self, HierarchyIndex};
use crate::model::{HistoryEventKind, Issue, Status, TransitionDecision};
use crate::relationships::{
CanonicalEdge, Diagnostic, IssueView, RelationKind, Relationship, RelationshipGraph,
};
use crate::slug::slugify;
mod epics;
mod lifecycle;
mod parent;
mod projects;
mod query;
mod relations;
mod status;
pub struct Store {
pub issues_dir: PathBuf,
pub config: Config,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RelationshipOutcome {
pub written: Option<Issue>,
pub relationship: Relationship,
pub changed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IssueSelector {
Number(u32),
Ref(crate::id::IssueRef),
Filename(String),
Slug(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AssetAdded {
pub link: String,
pub written_name: String,
}
thread_local! {
static WRITE_DISCLOSURES: std::cell::RefCell<Vec<String>> =
const { std::cell::RefCell::new(Vec::new()) };
}
pub(crate) fn push_disclosure(message: String) {
WRITE_DISCLOSURES.with(|queue| queue.borrow_mut().push(message));
}
pub(crate) fn mention_disclosure(message: String) {
push_disclosure(message);
}
pub(crate) fn retired_key_disclosure(filename: &str) {
push_disclosure(format!(
"remove `links:` by hand to silence \u{2014} {filename} (retired by \
C-001; never read as typed data, no migration)"
));
}
pub fn take_disclosures() -> Vec<String> {
WRITE_DISCLOSURES.with(|queue| std::mem::take(&mut *queue.borrow_mut()))
}
const ISSUE_WRITE_JOURNAL_LIMIT: usize = 4096;
#[derive(Debug, Default)]
struct IssueWriteJournal {
changed: BTreeSet<PathBuf>,
removed: BTreeSet<PathBuf>,
overflowed: bool,
}
#[derive(Debug)]
pub(crate) enum InternalIssueWrites {
None,
Delta(crate::watcher::FsDelta),
Overflow,
}
thread_local! {
static ISSUE_WRITE_JOURNAL: std::cell::RefCell<IssueWriteJournal> =
std::cell::RefCell::new(IssueWriteJournal::default());
}
pub(crate) fn journal_issue_change(path: &Path, present: bool) {
if !is_issue_rel_path(path) {
return;
}
ISSUE_WRITE_JOURNAL.with(|journal| {
let mut journal = journal.borrow_mut();
if journal.overflowed {
return;
}
if present {
journal.removed.remove(path);
journal.changed.insert(path.to_path_buf());
} else {
journal.changed.remove(path);
journal.removed.insert(path.to_path_buf());
}
if journal.changed.len() + journal.removed.len() > ISSUE_WRITE_JOURNAL_LIMIT {
journal.changed.clear();
journal.removed.clear();
journal.overflowed = true;
}
});
}
pub(crate) fn take_internal_issue_writes() -> InternalIssueWrites {
ISSUE_WRITE_JOURNAL.with(|journal| {
let mut journal = journal.borrow_mut();
if journal.overflowed {
*journal = IssueWriteJournal::default();
return InternalIssueWrites::Overflow;
}
if journal.changed.is_empty() && journal.removed.is_empty() {
return InternalIssueWrites::None;
}
let changed = std::mem::take(&mut journal.changed);
let removed = std::mem::take(&mut journal.removed);
InternalIssueWrites::Delta(crate::watcher::FsDelta {
changed: changed.into_iter().collect(),
removed: removed.into_iter().collect(),
})
})
}
thread_local! {
static WRITE_JOURNAL: std::cell::RefCell<Option<Vec<Undo>>> =
const { std::cell::RefCell::new(None) };
}
enum Undo {
Write {
path: PathBuf,
before: Option<Vec<u8>>,
},
Move { from: PathBuf, to: PathBuf },
}
pub(crate) fn journal_record_write(path: &Path) {
WRITE_JOURNAL.with(|journal| {
let mut borrow = journal.borrow_mut();
let Some(entries) = borrow.as_mut() else {
return;
};
if entries
.iter()
.any(|undo| matches!(undo, Undo::Write { path: seen, .. } if seen == path))
{
return;
}
entries.push(Undo::Write {
path: path.to_path_buf(),
before: std::fs::read(path).ok(),
});
});
}
fn journal_record_move(from: &Path, to: &Path) {
WRITE_JOURNAL.with(|journal| {
let mut borrow = journal.borrow_mut();
let Some(entries) = borrow.as_mut() else {
return;
};
entries.push(Undo::Move {
from: from.to_path_buf(),
to: to.to_path_buf(),
});
});
}
fn rollback(entries: Vec<Undo>) {
for entry in entries.into_iter().rev() {
match entry {
Undo::Write { path, before } => match before {
Some(bytes) => {
let _ = std::fs::write(&path, bytes);
}
None => {
let _ = std::fs::remove_file(&path);
}
},
Undo::Move { from, to } => {
let _ = std::fs::rename(&to, &from);
}
}
}
}
pub fn actor_identity() -> String {
crate::assignee::git_identity()
.map(|id| id.to_string())
.unwrap_or_else(|| "unknown".to_string())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatusChange<'a> {
pub column: &'a str,
pub commit: Option<&'a str>,
pub override_reason: Option<&'a str>,
}
impl<'a> StatusChange<'a> {
pub fn to(column: &'a str) -> Self {
Self {
column,
commit: None,
override_reason: None,
}
}
pub fn with_commit(mut self, commit: Option<&'a str>) -> Self {
self.commit = commit;
self
}
pub fn with_override(mut self, reason: Option<&'a str>) -> Self {
self.override_reason = reason;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatusOutcome {
pub issue: Issue,
pub decision: Option<TransitionDecision>,
pub written: bool,
}
struct TransitionAccepted {
changed: bool,
from: String,
to: String,
from_category: crate::workflow::WorkflowCategory,
to_category: crate::workflow::WorkflowCategory,
override_reason: Option<String>,
}
impl TransitionAccepted {
fn try_from(
outcome: crate::workflow::TransitionOutcome,
) -> std::result::Result<Self, crate::workflow::WorkflowDiagnostic> {
match outcome {
crate::workflow::TransitionOutcome::Accepted {
changed,
from,
to,
from_category,
to_category,
override_reason,
} => Ok(Self {
changed,
from,
to,
from_category,
to_category,
override_reason,
}),
crate::workflow::TransitionOutcome::Rejected(diagnostic) => Err(diagnostic),
}
}
}
fn canonical_placeholder(canonical: &CanonicalEdge) -> Relationship {
Relationship {
id: String::new(),
from: canonical.from,
to: canonical.to,
kind: canonical.kind,
display: String::new(),
}
}
fn relationship_error(errors: &[Diagnostic]) -> TixError {
if errors.is_empty() {
return TixError::InvalidInput("relationship rejected".into());
}
TixError::Refused(render_diagnostics(
"E-tix-relationship",
errors.iter().map(|d| (d.kind.as_str(), d.message.as_str())),
))
}
struct NumberClaim {
path: PathBuf,
armed: bool,
}
impl NumberClaim {
const DIR: &'static str = ".tix-numbers";
fn acquire(issues_dir: &Path, start: u32) -> Result<Self> {
const MAX_ATTEMPTS: u32 = 10_000;
let dir = claims_dir(issues_dir);
std::fs::create_dir_all(&dir)?;
let mut number = start;
for _ in 0..MAX_ATTEMPTS {
let path = dir.join(format!("{number}.claim"));
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(_) => {
return Ok(Self { path, armed: true });
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
number += 1;
}
Err(e) => return Err(TixError::Io(e)),
}
}
Err(TixError::Io(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!(
"no free issue number at or above {start} after {MAX_ATTEMPTS} attempts; \
{} is probably corrupt",
dir.display()
),
)))
}
fn number(&self) -> Option<u32> {
self.path.file_stem()?.to_str()?.parse::<u32>().ok()
}
fn release_number(issues_dir: &Path, number: u32) {
let _ = std::fs::remove_file(claims_dir(issues_dir).join(format!("{number}.claim")));
}
fn release(&mut self) {
if self.armed {
let _ = std::fs::remove_file(&self.path);
self.armed = false;
}
}
fn commit(&mut self) {
self.armed = false;
}
}
impl Drop for NumberClaim {
fn drop(&mut self) {
self.release();
}
}
impl std::fmt::Debug for NumberClaim {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NumberClaim")
.field("path", &self.path)
.field("armed", &self.armed)
.finish()
}
}
impl PartialEq for NumberClaim {
fn eq(&self, other: &Self) -> bool {
self.path == other.path
}
}
impl Eq for NumberClaim {}
#[derive(Debug, Clone)]
pub struct ParentOutcome {
pub written: Option<Issue>,
pub parent: Option<u32>,
pub event: Option<crate::hierarchy::ParentEvent>,
pub changed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectAssociationOutcome {
pub written: Option<Issue>,
pub project_ids: Vec<String>,
pub event: Option<crate::projects::ProjectEvent>,
pub changed: bool,
}
#[derive(Debug)]
pub struct BulkAssociationOutcome {
pub selector: String,
pub outcome: Result<ProjectAssociationOutcome>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpicReferenceOutcome {
pub written: Option<Issue>,
pub epic_ids: Vec<String>,
pub event: Option<crate::epics::EpicEvent>,
pub changed: bool,
}
#[derive(Debug)]
pub struct BulkReferenceOutcome {
pub selector: String,
pub outcome: Result<EpicReferenceOutcome>,
}
impl Store {
const PARALLEL_SCAN_THRESHOLD: usize = 256;
const PARALLEL_SCAN_THREADS: usize = 8;
pub fn mention_store(&self) -> Option<crate::mentions::store::MentionStore> {
if !self.config.mentions.enabled {
return None;
}
let dir = self.config.mentions.resolve_dir(&self.issues_dir);
Some(crate::mentions::store::MentionStore::new(dir))
}
fn reconcile_mentions(&self, issue: &Issue, file: &str) {
let Some(store) = self.mention_store() else {
return;
};
let id = issue.id.as_deref().unwrap_or(file);
store.reconcile(crate::mentions::IssueContent {
id,
number: issue.number,
file,
title: &issue.title,
body: &issue.body,
});
}
fn clear_mentions(&self, id: &str) {
let Some(store) = self.mention_store() else {
return;
};
if let Err(e) = store.clear_issue(id) {
crate::storage::mention_disclosure(format!("mentions: {e} (left untouched)"));
}
}
}
pub(crate) fn is_issue_rel_path(rel: &Path) -> bool {
let mut file_name: Option<&std::ffi::OsStr> = None;
for component in rel.components() {
match component {
std::path::Component::Normal(segment) => {
let Some(s) = segment.to_str() else {
return false;
};
if s.starts_with(".tix-tmp-") || s.ends_with("-assets") {
return false;
}
file_name = Some(segment);
}
std::path::Component::CurDir => {}
_ => return false,
}
}
file_name.is_some_and(|name| name.to_string_lossy().ends_with(".md"))
}
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('/').any(|component| component.is_empty()) {
return Err(TixError::InvalidInput(format!(
"filename path components must not be empty: {filename}"
)));
}
Ok(())
}
fn frontmatter_number(text: &str) -> Option<u32> {
let mut lines = text.lines();
if lines.next()?.trim() != "---" {
return None;
}
for line in lines {
let line = line.trim_end();
if line == "---" {
return None;
}
if let Some(rest) = line.strip_prefix("number:") {
return rest.trim().parse().ok();
}
}
None
}
fn claims_dir(issues_dir: &Path) -> PathBuf {
issues_dir
.parent()
.unwrap_or(issues_dir)
.join(NumberClaim::DIR)
}
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 flip_archived(filename: &str, archive: bool) -> Option<String> {
let (segment, basename) = split_relative(filename)?;
let flipped = if archive {
if basename.starts_with('.') {
return None;
}
format!(".{basename}")
} else {
basename.strip_prefix('.')?.to_string()
};
Some(if segment.is_empty() {
flipped
} else {
format!("{segment}/{flipped}")
})
}
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 stem = strip_leading_id(stem);
let stem = strip_date_prefix(stem);
strip_leading_id(stem).to_string()
}
fn strip_leading_id(stem: &str) -> &str {
match stem.split_once('-') {
Some((head, rest)) if crate::id::is_issue_id(head) => rest,
_ => stem,
}
}
fn strip_date_prefix(stem: &str) -> &str {
let bytes = stem.as_bytes();
if stem.len() > 10 && bytes[4] == b'-' && bytes[7] == b'-' && bytes[10] == b'-' {
&stem[11..]
} else {
stem
}
}
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"
)
}
pub(crate) fn atomic_write(dir: &Path, target: &Path, data: &[u8]) -> Result<()> {
write_file(dir, target, data, Link::Overwrite)
}
fn write_file_new(dir: &Path, target: &Path, data: &[u8]) -> Result<()> {
write_file(dir, target, data, Link::Exclusive)
}
#[derive(Clone, Copy)]
enum Link {
Overwrite,
Exclusive,
}
fn write_file(dir: &Path, target: &Path, data: &[u8], link: Link) -> Result<()> {
journal_record_write(target);
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));
}
let link_result = match link {
Link::Overwrite => std::fs::rename(&tmp, target),
Link::Exclusive => match std::fs::hard_link(&tmp, target) {
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e),
Err(_) => std::fs::rename(&tmp, target),
Ok(()) => {
let _ = std::fs::remove_file(&tmp);
Ok(())
}
},
};
if let Err(e) = link_result {
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;