use std::fs::{self, OpenOptions};
use std::hash::{Hash, Hasher};
use std::io;
use std::os::unix::io::AsRawFd;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
#[derive(Debug, thiserror::Error)]
pub enum IssueError {
#[error("issue #{id} was modified since last read; re-read and retry")]
Conflict { id: u32 },
#[error("issue #{id} is currently being worked on by session {owner}")]
Assigned {
id: u32,
owner: String,
acquired_at: DateTime<Utc>,
},
#[error("issue #{id} is not assigned to session {caller}; run `start` first")]
NotAssigned { id: u32, caller: String },
#[error("issue #{id} not found")]
NotFound { id: u32 },
#[error(transparent)]
Io(#[from] io::Error),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
#[default]
Open,
Closed,
}
impl std::fmt::Display for Status {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Open => write!(f, "open"),
Self::Closed => write!(f, "closed"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Priority {
Low,
#[default]
Medium,
High,
Critical,
}
impl std::fmt::Display for Priority {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Low => write!(f, "low"),
Self::Medium => write!(f, "medium"),
Self::High => write!(f, "high"),
Self::Critical => write!(f, "critical"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Assignment {
pub session: String,
pub acquired_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GithubRef {
pub repo: String,
pub number: u64,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IssueMeta {
pub id: u32,
pub title: String,
#[serde(default)]
pub status: Status,
#[serde(default)]
pub priority: Priority,
#[serde(default)]
pub labels: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assignee: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub closed_at: Option<DateTime<Utc>>,
#[serde(default)]
pub sessions: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assigned_to: Option<Assignment>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github: Option<GithubRef>,
}
#[derive(Debug, Clone)]
pub struct Issue {
pub meta: IssueMeta,
pub body: String,
pub path: Option<PathBuf>,
}
impl Issue {
pub fn list_badge(&self) -> String {
let lock = if self.meta.assigned_to.is_some() {
"🔒 "
} else {
""
};
format!("{}{}", lock, self.meta.status)
}
}
#[derive(Debug, Clone, Default)]
pub struct IssuePatch {
pub title: Option<String>,
pub body: Option<String>,
pub status: Option<Status>,
pub priority: Option<Priority>,
pub labels: Option<Vec<String>>,
}
use super::fs_util::atomic_write;
const FRONTMATTER_DELIM: &str = "---";
pub fn parse_issue(raw: &str, path: Option<PathBuf>) -> Result<Issue> {
let raw = raw.strip_prefix('\u{feff}').unwrap_or(raw);
let after_open = match raw.strip_prefix(FRONTMATTER_DELIM) {
Some(rest) => rest,
None => {
return Ok(Issue {
meta: empty_meta(),
body: raw.to_string(),
path,
});
}
};
let mut yaml = String::new();
let mut body = String::new();
let mut closed = false;
for line in after_open.split_inclusive('\n') {
if !closed && line.trim_end() == FRONTMATTER_DELIM {
closed = true;
continue;
}
if !closed {
yaml.push_str(line);
} else {
body.push_str(line);
}
}
let meta: IssueMeta =
serde_yaml::from_str(&yaml).context("failed to parse issue frontmatter")?;
Ok(Issue { meta, body, path })
}
pub fn serialize_issue(issue: &Issue) -> Result<String> {
let yaml = serde_yaml::to_string(&issue.meta).context("failed to serialize frontmatter")?;
let body = if issue.body.is_empty() {
String::new()
} else if issue.body.ends_with('\n') {
issue.body.clone()
} else {
format!("{}\n", issue.body)
};
Ok(format!(
"{open}\n{yaml}{close}\n{body}",
open = FRONTMATTER_DELIM,
close = FRONTMATTER_DELIM
))
}
pub fn content_hash(raw: &str) -> String {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
raw.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
pub fn issues_dir(start: &Path) -> PathBuf {
let mut dir = start.to_path_buf();
loop {
if dir.join(".oxi").is_dir() {
return dir.join(".oxi").join("issues");
}
if !dir.pop() {
break;
}
}
start.join(".oxi").join("issues")
}
pub fn issue_filename(id: u32, title: &str) -> String {
let slug = slugify(title);
if slug.is_empty() {
format!("{:04}.md", id)
} else {
format!("{:04}-{}.md", id, slug)
}
}
fn empty_meta() -> IssueMeta {
let now = Utc::now();
IssueMeta {
id: 0,
title: String::new(),
status: Status::default(),
priority: Priority::default(),
labels: vec![],
assignee: None,
created_at: now,
updated_at: now,
closed_at: None,
sessions: vec![],
assigned_to: None,
github: None,
}
}
fn slugify(s: &str) -> String {
let mut out = String::new();
let mut prev_dash = false;
for c in s.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
prev_dash = false;
} else if !prev_dash {
out.push('-');
prev_dash = true;
}
}
out.trim_matches('-').to_string()
}
pub mod liveness {
use super::*;
pub const TUI_OWNERSHIP_ID: &str = "tui";
pub fn alive_path(issues_dir: &Path, session_id: &str) -> PathBuf {
issues_dir.join(".alive").join(session_id)
}
pub fn acquire(issues_dir: &Path, session_id: &str) -> io::Result<AliveGuard> {
let dir = issues_dir.join(".alive");
fs::create_dir_all(&dir)?;
let path = dir.join(session_id);
let file = OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(&path)?;
let fd = file.as_raw_fd();
try_flock_exclusive(fd)?;
Ok(AliveGuard { _file: file, path })
}
pub fn is_session_alive(issues_dir: &Path, session_id: &str) -> bool {
let path = alive_path(issues_dir, session_id);
if !path.exists() {
return false;
}
let Ok(file) = OpenOptions::new().read(true).write(true).open(&path) else {
return false;
};
let fd = file.as_raw_fd();
probe_flock_shared(fd).is_err()
}
fn try_flock_exclusive(fd: i32) -> io::Result<()> {
let rc = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
if rc == 0 {
Ok(())
} else {
Err(io::Error::last_os_error())
}
}
fn probe_flock_shared(fd: i32) -> io::Result<()> {
let rc = unsafe { libc::flock(fd, libc::LOCK_SH | libc::LOCK_NB) };
if rc == 0 {
unsafe { libc::flock(fd, libc::LOCK_UN) };
Ok(())
} else {
Err(io::Error::last_os_error())
}
}
pub const ORPHAN_AGE_SECS: u64 = 3600;
pub fn reap_orphans(issues_dir: &Path) -> io::Result<usize> {
let dir = issues_dir.join(".alive");
let rd = match fs::read_dir(&dir) {
Ok(rd) => rd,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
Err(e) => return Err(e),
};
let now = std::time::SystemTime::now();
let mut removed = 0;
for entry in rd.flatten() {
let sid = entry.file_name();
let sid = sid.to_string_lossy();
if is_session_alive(issues_dir, &sid) {
continue; }
let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
let age = now.duration_since(mtime).map(|d| d.as_secs()).unwrap_or(0);
if age < ORPHAN_AGE_SECS {
continue; }
if fs::remove_file(entry.path()).is_ok() {
removed += 1;
}
}
Ok(removed)
}
#[derive(Debug)]
pub struct AliveGuard {
_file: fs::File,
path: PathBuf,
}
impl AliveGuard {
pub fn path(&self) -> &Path {
&self.path
}
}
impl Drop for AliveGuard {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn acquire_then_alive() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().to_path_buf();
let sid = "s1";
let _g = acquire(&dir, sid).unwrap();
assert!(is_session_alive(&dir, sid));
drop(_g);
assert!(!is_session_alive(&dir, sid));
}
#[test]
fn second_acquire_fails_while_held() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().to_path_buf();
let sid = "s2";
let g = acquire(&dir, sid).unwrap();
let second = acquire(&dir, sid);
assert!(second.is_err(), "second acquire should fail while held");
assert!(is_session_alive(&dir, sid));
drop(g);
assert!(acquire(&dir, sid).is_ok(), "after drop, acquire succeeds");
}
fn backdate(path: &std::path::Path, secs: u64) {
use std::fs::FileTimes;
let then = std::time::SystemTime::now() - std::time::Duration::from_secs(secs);
let f = std::fs::File::open(path)
.or_else(|_| {
std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false) .open(path)
})
.unwrap();
f.set_times(FileTimes::new().set_modified(then)).unwrap();
}
#[test]
fn reap_idempotent() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().to_path_buf();
assert_eq!(reap_orphans(&dir).unwrap(), 0);
fs::create_dir_all(dir.join(".alive")).unwrap();
assert_eq!(reap_orphans(&dir).unwrap(), 0);
assert_eq!(reap_orphans(&dir).unwrap(), 0);
}
#[test]
fn reap_skips_recent_dead_files() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().to_path_buf();
fs::create_dir_all(dir.join(".alive")).unwrap();
let recent = dir.join(".alive").join("dead-recent");
fs::write(&recent, b"").unwrap();
assert_eq!(reap_orphans(&dir).unwrap(), 0);
assert!(
recent.exists(),
"recent dead orphan must be preserved by the age gate"
);
}
#[test]
fn reap_removes_old_dead_and_keeps_alive() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().to_path_buf();
let _g_live = acquire(&dir, "alive-session").unwrap();
fs::create_dir_all(dir.join(".alive")).unwrap();
let old = dir.join(".alive").join("dead-old");
fs::write(&old, b"").unwrap();
backdate(&old, ORPHAN_AGE_SECS + 60);
let removed = reap_orphans(&dir).unwrap();
assert_eq!(removed, 1, "only the old dead orphan should be reaped");
assert!(!old.exists(), "old dead orphan must be removed");
assert!(
is_session_alive(&dir, "alive-session"),
"live lock must survive reap"
);
}
}
}
#[derive(Debug, Default, Clone)]
struct Cache {
open_count: usize,
latest_open_title: Option<String>,
locked_open_count: usize,
top_priority: Option<Priority>,
top_free_priority: Option<Priority>,
dir_mtime: Option<std::time::SystemTime>,
}
#[derive(Debug, Clone)]
pub struct IssueSummary {
pub open_count: usize,
pub locked_open_count: usize,
pub top_priority: Option<Priority>,
pub top_free_priority: Option<Priority>,
pub latest_open_title: Option<String>,
}
impl IssueSummary {
pub fn is_empty(&self) -> bool {
self.open_count == 0
}
}
struct Inner {
issues_dir: PathBuf,
cache: Cache,
}
impl Cache {
fn empty() -> Self {
Self {
open_count: 0,
latest_open_title: None,
locked_open_count: 0,
top_priority: None,
top_free_priority: None,
dir_mtime: None,
}
}
}
impl std::fmt::Debug for Inner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Inner")
.field("issues_dir", &self.issues_dir)
.finish()
}
}
#[derive(Clone, Debug)]
pub struct FileIssueStore {
inner: Arc<RwLock<Inner>>,
}
impl FileIssueStore {
pub fn open(issues_dir: PathBuf) -> Result<Self> {
if let Err(e) = liveness::reap_orphans(&issues_dir) {
tracing::warn!(error = %e, "issue liveness reap failed (non-fatal)");
}
Ok(Self {
inner: Arc::new(RwLock::new(Inner {
issues_dir,
cache: Cache::default(),
})),
})
}
pub fn open_from_cwd(start: &Path) -> Result<Self> {
Self::open(issues_dir(start))
}
pub fn issues_dir(&self) -> PathBuf {
self.inner.read().issues_dir.clone()
}
pub fn open_count(&self) -> usize {
self.refresh_if_stale();
self.inner.read().cache.open_count
}
pub fn latest_open_title(&self) -> Option<String> {
self.refresh_if_stale();
self.inner.read().cache.latest_open_title.clone()
}
pub fn summary(&self) -> IssueSummary {
self.refresh_if_stale();
let g = self.inner.read();
IssueSummary {
open_count: g.cache.open_count,
locked_open_count: g.cache.locked_open_count,
top_priority: g.cache.top_priority,
top_free_priority: g.cache.top_free_priority,
latest_open_title: g.cache.latest_open_title.clone(),
}
}
pub fn top_free_priority(&self) -> Option<Priority> {
self.refresh_if_stale();
self.inner.read().cache.top_free_priority
}
pub fn has_any(&self) -> bool {
self.refresh_if_stale();
let dir = self.inner.read().issues_dir.clone();
fs::read_dir(&dir)
.map(|rd| {
rd.filter_map(|e| e.ok())
.any(|e| e.path().extension().and_then(|x| x.to_str()) == Some("md"))
})
.unwrap_or(false)
}
fn refresh_if_stale(&self) {
let dir = self.inner.read().issues_dir.clone();
let cur_dir_mtime = fs::metadata(&dir).and_then(|m| m.modified()).ok();
let needs = {
let g = self.inner.read();
match (g.cache.dir_mtime, cur_dir_mtime) {
(None, _) => true, (Some(_), None) => false, (Some(cached), Some(cur)) => cached != cur,
}
};
if !needs {
return;
}
let mut open_count = 0;
let mut locked_open_count = 0;
let mut top_priority: Option<Priority> = None;
let mut latest_open_title: Option<String> = None;
let mut latest_open_updated: Option<chrono::DateTime<chrono::Utc>> = None;
let mut top_free_priority: Option<Priority> = None;
if let Ok(rd) = fs::read_dir(&dir) {
for entry in rd.flatten() {
let p = entry.path();
if p.extension().and_then(|x| x.to_str()) != Some("md") {
continue;
}
if let Ok(raw) = fs::read_to_string(&p)
&& let Ok(issue) = parse_issue(&raw, None)
&& issue.meta.status == Status::Open
{
open_count += 1;
if issue.meta.assigned_to.is_some() {
locked_open_count += 1;
}
top_priority = Some(match top_priority {
Some(existing) => existing.max(issue.meta.priority),
None => issue.meta.priority,
});
if issue.meta.updated_at
> latest_open_updated.unwrap_or(chrono::DateTime::<chrono::Utc>::MIN_UTC)
{
latest_open_updated = Some(issue.meta.updated_at);
latest_open_title = Some(issue.meta.title);
}
if issue.meta.assigned_to.is_none() {
top_free_priority = Some(match top_free_priority {
Some(cur) if cur >= issue.meta.priority => cur,
_ => issue.meta.priority,
});
}
}
}
}
let mut g = self.inner.write();
g.cache = Cache {
open_count,
latest_open_title,
locked_open_count,
top_priority,
top_free_priority,
dir_mtime: cur_dir_mtime,
};
}
pub fn invalidate(&self) {
self.inner.write().cache = Cache::default();
}
pub fn list(&self, filter: &IssueFilter) -> Result<Vec<Issue>> {
self.refresh_if_stale();
let dir = self.inner.read().issues_dir.clone();
let mut out = Vec::new();
if let Ok(rd) = fs::read_dir(&dir) {
for entry in rd.flatten() {
let p = entry.path();
if p.extension().and_then(|x| x.to_str()) != Some("md") {
continue;
}
let raw = fs::read_to_string(&p)?;
let issue = parse_issue(&raw, Some(p.clone()))?;
if filter.matches(&issue) {
out.push(issue);
}
}
}
out.sort_by_key(|i| std::cmp::Reverse(i.meta.updated_at));
Ok(out)
}
pub fn read(&self, id: u32) -> Result<(Issue, String)> {
let path = self.path_for_id(id)?;
let raw = fs::read_to_string(&path)
.with_context(|| format!("issue #{} not found at {}", id, path.display()))?;
let issue = parse_issue(&raw, Some(path))?;
Ok((issue, content_hash(&raw)))
}
pub fn next_id(&self) -> Result<u32> {
let dir = self.inner.read().issues_dir.clone();
fs::create_dir_all(&dir)?;
let mut max = 0u32;
if let Ok(rd) = fs::read_dir(&dir) {
for entry in rd.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
let num_str = name.split('-').next().unwrap_or(&name);
if let Ok(n) = num_str.trim_end_matches(".md").parse::<u32>() {
max = max.max(n);
}
}
}
Ok(max + 1)
}
pub fn create(
&self,
title: String,
body: String,
priority: Priority,
labels: Vec<String>,
caller_session: Option<&str>,
) -> Result<Issue> {
let id = self.next_id()?;
let now = Utc::now();
let sessions = caller_session
.map(|s| vec![s.to_string()])
.unwrap_or_default();
let issue = Issue {
meta: IssueMeta {
id,
title,
status: Status::Open,
priority,
labels,
assignee: None,
created_at: now,
updated_at: now,
closed_at: None,
sessions,
assigned_to: None,
github: None,
},
body,
path: None,
};
for _ in 0..4 {
let path = self
.issues_dir()
.join(issue_filename(id, &issue.meta.title));
if path.exists() {
continue;
}
let content = serialize_issue(&issue)?;
atomic_write(&path, &content)?;
self.invalidate();
let mut saved = issue.clone();
saved.path = Some(path);
return Ok(saved);
}
anyhow::bail!("could not allocate a free issue id after retries");
}
pub async fn update<F>(
&self,
id: u32,
expected_hash: Option<String>,
mutator: F,
) -> std::result::Result<Issue, IssueError>
where
F: FnOnce(Issue) -> std::result::Result<Issue, IssueError> + Send + 'static,
{
let path = self.path_for_id(id).map_err(IssueError::Other)?;
let path_for_closure = path.clone();
let store = self.clone();
oxi_agent::tools::file_mutation_queue::global_mutation_queue()
.with_queue(&path, move || async move {
let path = path_for_closure;
let raw = fs::read_to_string(&path)?;
if let Some(expected) = expected_hash.as_deref()
&& content_hash(&raw) != expected
{
return Err(IssueError::Conflict { id });
}
let before = parse_issue(&raw, Some(path.clone())).map_err(IssueError::Other)?;
let before_updated_at = before.meta.updated_at;
let before_bytes = serialize_issue(&before).map_err(IssueError::Other)?;
let after = mutator(before)?;
let mut probe = after.clone();
probe.meta.updated_at = before_updated_at;
let probe_bytes = serialize_issue(&probe).map_err(IssueError::Other)?;
if probe_bytes == before_bytes {
return Ok(after.with_path(path));
}
let mut final_issue = after;
final_issue.meta.updated_at = Utc::now();
let content = serialize_issue(&final_issue).map_err(IssueError::Other)?;
atomic_write(&path, &content)?;
store.invalidate();
Ok(final_issue.with_path(path))
})
.await
}
pub async fn close(
&self,
id: u32,
caller: &str,
expected_hash: Option<String>,
) -> std::result::Result<Issue, IssueError> {
let now = Utc::now();
let caller = caller.to_string();
self.update(id, expected_hash, move |mut issue| {
require_owner(&issue, id, &caller)?;
issue.meta.status = Status::Closed;
issue.meta.closed_at = Some(now);
issue.meta.assigned_to = None; Ok(issue)
})
.await
}
pub async fn reopen(
&self,
id: u32,
expected_hash: Option<String>,
) -> std::result::Result<Issue, IssueError> {
self.update(id, expected_hash, move |mut issue| {
if issue.meta.status == Status::Open {
return Ok(issue);
}
issue.meta.status = Status::Open;
issue.meta.closed_at = None;
issue.meta.assigned_to = None;
Ok(issue)
})
.await
}
pub async fn start(
&self,
id: u32,
caller: &str,
expected_hash: Option<String>,
) -> std::result::Result<Issue, IssueError> {
let issues_dir = self.issues_dir();
let caller_owned = caller.to_string();
self.update(id, expected_hash, move |mut issue| {
if let Some(ref a) = issue.meta.assigned_to {
if a.session == caller_owned {
return Ok(issue);
}
if liveness::is_session_alive(&issues_dir, &a.session) {
return Err(IssueError::Assigned {
id,
owner: a.session.clone(),
acquired_at: a.acquired_at,
});
}
}
issue.meta.assigned_to = Some(Assignment {
session: caller_owned.clone(),
acquired_at: Utc::now(),
});
if !issue.meta.sessions.contains(&caller_owned) {
issue.meta.sessions.push(caller_owned.clone());
}
Ok(issue)
})
.await
}
pub async fn release(
&self,
id: u32,
caller: &str,
expected_hash: Option<String>,
) -> std::result::Result<Issue, IssueError> {
let caller = caller.to_string();
self.update(id, expected_hash, move |mut issue| {
require_owner(&issue, id, &caller)?;
issue.meta.assigned_to = None;
Ok(issue)
})
.await
}
pub async fn link_session(
&self,
id: u32,
session: &str,
expected_hash: Option<String>,
) -> std::result::Result<Issue, IssueError> {
let session = session.to_string();
self.update(id, expected_hash, move |mut issue| {
if !issue.meta.sessions.contains(&session) {
issue.meta.sessions.push(session);
}
Ok(issue)
})
.await
}
pub async fn apply_patch(
&self,
id: u32,
patch: IssuePatch,
caller: Option<String>,
expected_hash: Option<String>,
) -> std::result::Result<Issue, IssueError> {
self.update(id, expected_hash, move |mut issue| {
if let Some(caller) = caller.as_deref()
&& let Some(ref a) = issue.meta.assigned_to
&& !a.session.is_empty()
&& a.session != caller
{
return Err(IssueError::NotAssigned {
id,
caller: caller.to_string(),
});
}
if let Some(t) = patch.title {
issue.meta.title = t;
}
if let Some(b) = patch.body {
issue.body = b;
}
if let Some(s) = patch.status {
issue.meta.status = s;
issue.meta.closed_at = match s {
Status::Closed => Some(Utc::now()),
Status::Open => None, };
}
if let Some(p) = patch.priority {
issue.meta.priority = p;
}
if let Some(l) = patch.labels {
issue.meta.labels = l;
}
Ok(issue)
})
.await
}
fn path_for_id(&self, id: u32) -> Result<PathBuf> {
let dir = self.inner.read().issues_dir.clone();
if let Ok(rd) = fs::read_dir(&dir) {
for entry in rd.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
let num_str = name.split('-').next().unwrap_or(&name);
if num_str.trim_end_matches(".md").parse::<u32>().ok() == Some(id) {
return Ok(entry.path());
}
}
}
Err(anyhow::anyhow!(IssueError::NotFound { id }))
}
}
trait WithPath {
fn with_path(self, path: PathBuf) -> Self;
}
impl WithPath for Issue {
fn with_path(mut self, path: PathBuf) -> Self {
self.path = Some(path);
self
}
}
fn require_owner(issue: &Issue, id: u32, caller: &str) -> std::result::Result<(), IssueError> {
match &issue.meta.assigned_to {
Some(a) if a.session == caller => Ok(()),
_ => Err(IssueError::NotAssigned {
id,
caller: caller.to_string(),
}),
}
}
#[derive(Debug, Clone, Default)]
pub struct IssueFilter {
pub status: Option<Status>,
pub priority: Option<Priority>,
pub label: Option<String>,
pub assigned_to_session: Option<String>,
pub text: Option<String>,
}
impl IssueFilter {
fn matches(&self, issue: &Issue) -> bool {
if let Some(s) = self.status
&& issue.meta.status != s
{
return false;
}
if let Some(p) = self.priority
&& issue.meta.priority != p
{
return false;
}
if let Some(ref label) = self.label
&& !issue.meta.labels.iter().any(|l| l == label)
{
return false;
}
if let Some(ref session) = self.assigned_to_session {
let mine = issue
.meta
.assigned_to
.as_ref()
.map(|a| &a.session == session)
.unwrap_or(false);
if !mine {
return false;
}
}
if let Some(ref text) = self.text
&& !issue
.meta
.title
.to_lowercase()
.contains(&text.to_lowercase())
{
return false;
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_meta(id: u32, title: &str, priority: Priority) -> IssueMeta {
let now = Utc::now();
IssueMeta {
id,
title: title.into(),
status: Status::Open,
priority,
labels: vec![],
assignee: None,
created_at: now,
updated_at: now,
closed_at: None,
sessions: vec![],
assigned_to: None,
github: None,
}
}
fn tmp_store() -> (tempfile::TempDir, FileIssueStore) {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join(".oxi").join("issues");
fs::create_dir_all(&dir).unwrap();
let store = FileIssueStore::open(dir).unwrap();
(tmp, store)
}
#[test]
fn roundtrip_serialization() {
let issue = Issue {
meta: sample_meta(1, "Test", Priority::High),
body: "## Body\n\nHello.".into(),
path: None,
};
let s = serialize_issue(&issue).unwrap();
assert!(s.starts_with("---\n"));
let parsed = parse_issue(&s, None).unwrap();
assert_eq!(parsed.meta.id, 1);
assert_eq!(parsed.meta.title, "Test");
assert_eq!(parsed.meta.priority, Priority::High);
assert!(parsed.body.contains("Hello."));
}
#[tokio::test]
async fn create_read_list() {
let (_tmp, store) = tmp_store();
let created = store
.create(
"Fix bug".into(),
"body".into(),
Priority::High,
vec![],
None,
)
.unwrap();
assert_eq!(created.meta.id, 1);
let (read, hash) = store.read(1).unwrap();
assert_eq!(read.meta.title, "Fix bug");
assert!(!hash.is_empty());
let list = store.list(&IssueFilter::default()).unwrap();
assert_eq!(list.len(), 1);
}
#[tokio::test]
async fn content_hash_detects_conflict() {
let (_tmp, store) = tmp_store();
store
.create("Orig".into(), "b".into(), Priority::Low, vec![], None)
.unwrap();
let (_, hash) = store.read(1).unwrap();
let wrong = Some("deadbeefdeadbeef".to_string());
let err = store
.update(1, wrong, |_| {
Ok(Issue {
meta: sample_meta(1, "x", Priority::Low),
body: "x".into(),
path: None,
})
})
.await
.unwrap_err();
assert!(matches!(err, IssueError::Conflict { id: 1 }));
let _ok = store
.update(1, Some(hash), |mut i| {
i.meta.title = "Updated".into();
Ok(i)
})
.await
.unwrap();
let (read, _) = store.read(1).unwrap();
assert_eq!(read.meta.title, "Updated");
}
#[tokio::test]
async fn start_rejects_live_owner() {
let (_tmp, store) = tmp_store();
store
.create("T".into(), "b".into(), Priority::Low, vec![], None)
.unwrap();
let issues_dir = store.issues_dir();
let _guard_a = liveness::acquire(&issues_dir, "sessionA").unwrap();
let (_, hash) = store.read(1).unwrap();
store.start(1, "sessionA", Some(hash)).await.unwrap();
let (_, hash2) = store.read(1).unwrap();
let err = store.start(1, "sessionB", Some(hash2)).await.unwrap_err();
assert!(matches!(err, IssueError::Assigned { owner, .. } if owner == "sessionA"));
}
#[tokio::test]
async fn start_reclaims_dead_owner() {
let (_tmp, store) = tmp_store();
store
.create("T".into(), "b".into(), Priority::Low, vec![], None)
.unwrap();
let issues_dir = store.issues_dir();
{
let _g = liveness::acquire(&issues_dir, "sessionA").unwrap();
let (_, h) = store.read(1).unwrap();
store.start(1, "sessionA", Some(h)).await.unwrap();
}
let (_, hash) = store.read(1).unwrap();
let reclaimed = store.start(1, "sessionB", Some(hash)).await.unwrap();
assert_eq!(
reclaimed.meta.assigned_to.as_ref().unwrap().session,
"sessionB"
);
}
#[tokio::test]
async fn close_requires_owner() {
let (_tmp, store) = tmp_store();
store
.create("T".into(), "b".into(), Priority::Low, vec![], None)
.unwrap();
let (_, hash) = store.read(1).unwrap();
store.start(1, "sessionA", Some(hash)).await.unwrap();
let (_, hash2) = store.read(1).unwrap();
let err = store.close(1, "sessionB", Some(hash2)).await.unwrap_err();
assert!(matches!(err, IssueError::NotAssigned { .. }));
let (_, hash3) = store.read(1).unwrap();
let closed = store.close(1, "sessionA", Some(hash3)).await.unwrap();
assert_eq!(closed.meta.status, Status::Closed);
assert!(closed.meta.assigned_to.is_none());
}
#[tokio::test]
async fn reopen_flips_closed_to_open() {
let (_tmp, store) = tmp_store();
let issues_dir = store.issues_dir();
let _guard = crate::store::issues::liveness::acquire(&issues_dir, "tui").unwrap();
store
.create("T".into(), "b".into(), Priority::Low, vec![], None)
.unwrap();
let (_, h) = store.read(1).unwrap();
store.start(1, "tui", Some(h)).await.unwrap();
let (_, h) = store.read(1).unwrap();
store.close(1, "tui", Some(h)).await.unwrap();
let (_, h) = store.read(1).unwrap();
let reopened = store.reopen(1, Some(h)).await.unwrap();
assert_eq!(reopened.meta.status, Status::Open);
assert!(reopened.meta.closed_at.is_none());
assert!(reopened.meta.assigned_to.is_none());
}
#[tokio::test]
async fn reopen_is_idempotent_on_already_open() {
let (_tmp, store) = tmp_store();
store
.create("T".into(), "b".into(), Priority::Low, vec![], None)
.unwrap();
let (_, h) = store.read(1).unwrap();
let reopened = store.reopen(1, Some(h)).await.unwrap();
assert_eq!(reopened.meta.status, Status::Open);
assert!(reopened.meta.closed_at.is_none());
}
#[test]
fn slugify_basic() {
assert_eq!(slugify("Fix Login Bug!"), "fix-login-bug");
assert_eq!(slugify(" spaces "), "spaces");
assert_eq!(slugify("a__b"), "a-b");
assert_eq!(slugify(""), "");
}
#[test]
fn issue_filename_format() {
assert_eq!(issue_filename(12, "Fix Login"), "0012-fix-login.md");
assert_eq!(issue_filename(1, ""), "0001.md");
}
#[tokio::test]
async fn open_count_caches() {
let (_tmp, store) = tmp_store();
assert_eq!(store.open_count(), 0);
store
.create("A".into(), "b".into(), Priority::Low, vec![], None)
.unwrap();
store
.create("B".into(), "b".into(), Priority::Low, vec![], None)
.unwrap();
assert_eq!(store.open_count(), 2);
let issues_dir = store.issues_dir();
let _guard = liveness::acquire(&issues_dir, "sessionA").unwrap();
let (_, h) = store.read(1).unwrap();
store.start(1, "sessionA", Some(h)).await.unwrap();
let (_, h) = store.read(1).unwrap();
store.close(1, "sessionA", Some(h)).await.unwrap();
store.invalidate();
assert_eq!(store.open_count(), 1);
}
#[tokio::test]
async fn summary_reflects_lock_and_priority() {
let (_tmp, store) = tmp_store();
let issues_dir = store.issues_dir();
let _guard = liveness::acquire(&issues_dir, "sessionA").unwrap();
store
.create("Lowly".into(), "".into(), Priority::Low, vec![], None)
.unwrap();
store
.create("Crit".into(), "".into(), Priority::Critical, vec![], None)
.unwrap();
store
.create("Closed".into(), "".into(), Priority::Medium, vec![], None)
.unwrap();
let (_, h) = store.read(3).unwrap();
store.start(3, "sessionA", Some(h)).await.unwrap();
let (_, h) = store.read(3).unwrap();
store.close(3, "sessionA", Some(h)).await.unwrap();
let (_, h) = store.read(1).unwrap();
store.start(1, "sessionA", Some(h)).await.unwrap();
store.invalidate();
let s = store.summary();
assert_eq!(s.open_count, 2);
assert_eq!(s.locked_open_count, 1);
assert_eq!(s.top_priority, Some(Priority::Critical));
assert!(s.latest_open_title.is_some());
assert!(!s.is_empty());
}
#[tokio::test]
async fn summary_empty_when_no_issues() {
let (_tmp, store) = tmp_store();
let s = store.summary();
assert_eq!(s.open_count, 0);
assert_eq!(s.locked_open_count, 0);
assert!(s.top_priority.is_none());
assert!(s.latest_open_title.is_none());
assert!(s.is_empty());
}
#[tokio::test]
async fn latest_open_title_caches_and_handles_cjk() {
let (_tmp, store) = tmp_store();
assert!(store.latest_open_title().is_none());
let cjk_title =
"버그 수정: 한글 제목도 정상이어야 합니다 — 멀티바이트 인코딩 안전성".to_string();
let cjk_body =
"요약\n\n이 이슈는 한글 본문을 포함합니다. 본문에는 영문과 한글이 섞여 있습니다. "
.repeat(4);
let created = store
.create(cjk_title.clone(), cjk_body, Priority::High, vec![], None)
.unwrap();
assert_eq!(created.meta.title, cjk_title);
let title = store.latest_open_title();
assert_eq!(title.as_deref(), Some(cjk_title.as_str()));
let (read_back, _hash) = store.read(created.meta.id).unwrap();
assert!(read_back.body.contains("한글"));
}
#[tokio::test]
async fn start_with_distinct_live_owners_collides() {
let (_tmp, store) = tmp_store();
let issues_dir = store.issues_dir();
store
.create("T".into(), "b".into(), Priority::Low, vec![], None)
.unwrap();
let _guard_a = liveness::acquire(&issues_dir, "proc-A").unwrap();
let (_, h) = store.read(1).unwrap();
store.start(1, "proc-A", Some(h)).await.unwrap();
let _guard_b = liveness::acquire(&issues_dir, "proc-B").unwrap();
let (_, h2) = store.read(1).unwrap();
let err = store.start(1, "proc-B", Some(h2)).await.unwrap_err();
assert!(
matches!(err, IssueError::Assigned { ref owner, .. } if owner == "proc-A"),
"a second distinct live owner must be rejected, got: {err:?}"
);
}
#[tokio::test]
async fn empty_session_assignment_is_immediately_reclaimable_documentation() {
let (_tmp, store) = tmp_store();
store
.create("T".into(), "b".into(), Priority::Low, vec![], None)
.unwrap();
let issues_dir = store.issues_dir();
let (_, h) = store.read(1).unwrap();
store.start(1, "", Some(h)).await.unwrap();
assert!(
!liveness::is_session_alive(&issues_dir, ""),
"no flock can be held under the empty string"
);
let _guard_c = liveness::acquire(&issues_dir, "proc-C").unwrap();
let (_, h2) = store.read(1).unwrap();
let reclaimed = store.start(1, "proc-C", Some(h2)).await.unwrap();
assert_eq!(
reclaimed.meta.assigned_to.as_ref().unwrap().session,
"proc-C",
"empty-string assignment is reclaimable — this is the #13 bug shape"
);
}
#[tokio::test]
async fn reopen_clears_closed_at() {
let (_tmp, store) = tmp_store();
store
.create("T".into(), "b".into(), Priority::Low, vec![], None)
.unwrap();
let (_, h) = store.read(1).unwrap();
store.start(1, "proc-X", Some(h)).await.unwrap();
let (_, h) = store.read(1).unwrap();
store.close(1, "proc-X", Some(h)).await.unwrap();
let (closed, _) = store.read(1).unwrap();
assert_eq!(closed.meta.status, Status::Closed);
assert!(closed.meta.closed_at.is_some());
let (_, h) = store.read(1).unwrap();
store.reopen(1, Some(h)).await.unwrap();
let (reopened, _) = store.read(1).unwrap();
assert_eq!(reopened.meta.status, Status::Open);
assert!(
reopened.meta.closed_at.is_none(),
"reopen must clear closed_at (#4)"
);
}
#[tokio::test]
async fn apply_patch_status_open_clears_closed_at() {
let (_tmp, store) = tmp_store();
store
.create("T".into(), "b".into(), Priority::Low, vec![], None)
.unwrap();
let (_, h) = store.read(1).unwrap();
store.start(1, "proc-X", Some(h)).await.unwrap();
let (_, h) = store.read(1).unwrap();
store.close(1, "proc-X", Some(h)).await.unwrap();
let (_, h) = store.read(1).unwrap();
store
.apply_patch(
1,
IssuePatch {
status: Some(Status::Open),
..Default::default()
},
None,
Some(h),
)
.await
.unwrap();
let (after, _) = store.read(1).unwrap();
assert_eq!(after.meta.status, Status::Open);
assert!(
after.meta.closed_at.is_none(),
"apply_patch status=Open must clear closed_at (#4)"
);
}
#[tokio::test]
async fn noop_update_does_not_bump_timestamp() {
let (_tmp, store) = tmp_store();
store
.create("T".into(), "b".into(), Priority::Low, vec![], None)
.unwrap();
let (before, _) = store.read(1).unwrap();
let ts_before = before.meta.updated_at;
let (_, h) = store.read(1).unwrap();
store
.apply_patch(1, IssuePatch::default(), None, Some(h))
.await
.unwrap();
let (after, _) = store.read(1).unwrap();
assert_eq!(
after.meta.updated_at, ts_before,
"no-op update must not bump updated_at (#12)"
);
std::thread::sleep(std::time::Duration::from_millis(5));
let (_, h2) = store.read(1).unwrap();
store
.apply_patch(
1,
IssuePatch {
title: Some("New".into()),
..Default::default()
},
None,
Some(h2),
)
.await
.unwrap();
let (after2, _) = store.read(1).unwrap();
assert_ne!(
after2.meta.updated_at, ts_before,
"real update must bump updated_at"
);
assert_eq!(after2.meta.title, "New");
}
#[tokio::test]
async fn apply_patch_labels_clear_vs_keep() {
let (_tmp, store) = tmp_store();
store
.create(
"T".into(),
"b".into(),
Priority::Low,
vec!["a".into(), "b".into()],
None,
)
.unwrap();
let (_, h) = store.read(1).unwrap();
store
.apply_patch(
1,
IssuePatch {
priority: Some(Priority::High),
..Default::default()
},
None,
Some(h),
)
.await
.unwrap();
let (kept, _) = store.read(1).unwrap();
assert_eq!(kept.meta.labels, vec!["a".to_string(), "b".to_string()]);
assert_eq!(kept.meta.priority, Priority::High);
let (_, h) = store.read(1).unwrap();
store
.apply_patch(
1,
IssuePatch {
labels: Some(vec![]),
..Default::default()
},
None,
Some(h),
)
.await
.unwrap();
let (cleared, _) = store.read(1).unwrap();
assert!(cleared.meta.labels.is_empty(), "Some([]) must clear labels");
let (_, h) = store.read(1).unwrap();
store
.apply_patch(
1,
IssuePatch {
labels: Some(vec!["z".into()]),
..Default::default()
},
None,
Some(h),
)
.await
.unwrap();
let (replaced, _) = store.read(1).unwrap();
assert_eq!(replaced.meta.labels, vec!["z".to_string()]);
}
#[tokio::test]
async fn apply_patch_enforces_ownership() {
let (_tmp, store) = tmp_store();
store
.create("T".into(), "b".into(), Priority::Low, vec![], None)
.unwrap();
let (_, h) = store.read(1).unwrap();
store.start(1, "proc-A", Some(h)).await.unwrap();
let (_, h) = store.read(1).unwrap();
let err = store
.apply_patch(
1,
IssuePatch {
title: Some("X".into()),
..Default::default()
},
Some("proc-B".into()),
Some(h),
)
.await
.unwrap_err();
assert!(
matches!(err, IssueError::NotAssigned { ref caller, .. } if caller == "proc-B"),
"non-owner must be rejected, got: {err:?}"
);
let (_, h) = store.read(1).unwrap();
store
.apply_patch(
1,
IssuePatch {
title: Some("X".into()),
..Default::default()
},
Some("proc-A".into()),
Some(h),
)
.await
.unwrap();
let (patched, _) = store.read(1).unwrap();
assert_eq!(patched.meta.title, "X");
}
#[tokio::test]
async fn top_free_priority_ignores_assigned_and_closed() {
let (_tmp, store) = tmp_store();
store
.create("low".into(), "".into(), Priority::Low, vec![], None)
.unwrap();
store
.create("high".into(), "".into(), Priority::High, vec![], None)
.unwrap();
store
.create(
"critical-assigned".into(),
"".into(),
Priority::Critical,
vec![],
None,
)
.unwrap();
store
.create(
"critical-closed".into(),
"".into(),
Priority::Critical,
vec![],
None,
)
.unwrap();
let (_, h) = store.read(3).unwrap();
store.start(3, "proc", Some(h)).await.unwrap();
let (_, h) = store.read(4).unwrap();
store.start(4, "proc", Some(h)).await.unwrap();
let (_, h) = store.read(4).unwrap();
store.close(4, "proc", Some(h)).await.unwrap();
assert_eq!(store.top_free_priority(), Some(Priority::High));
let (_, h) = store.read(1).unwrap();
store.start(1, "proc", Some(h)).await.unwrap();
let (_, h) = store.read(2).unwrap();
store.start(2, "proc", Some(h)).await.unwrap();
assert_eq!(
store.top_free_priority(),
None,
"no open unassigned issue → None"
);
}
}