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)
}
}
fn atomic_write(path: &Path, content: &str) -> std::io::Result<()> {
let tmp_path = path.with_extension(format!("tmp.{}", std::process::id()));
std::fs::write(&tmp_path, content)?;
std::fs::rename(&tmp_path, path)?;
Ok(())
}
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 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();
let rc = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
if rc != 0 {
let err = io::Error::last_os_error();
return Err(err);
}
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();
let rc = unsafe { libc::flock(fd, libc::LOCK_SH | libc::LOCK_NB) };
if rc == 0 {
unsafe {
libc::flock(fd, libc::LOCK_UN);
}
false
} else {
true
}
}
#[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");
}
}
}
#[derive(Debug, Default, Clone)]
struct Cache {
open_count: usize,
latest_open_title: Option<String>,
dir_mtime: Option<std::time::SystemTime>,
}
struct Inner {
issues_dir: PathBuf,
cache: Cache,
}
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> {
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 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 latest_open_title: Option<String> = None;
let mut latest_open_updated: Option<chrono::DateTime<chrono::Utc>> = 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.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);
}
}
}
}
let mut g = self.inner.write();
g.cache = Cache {
open_count,
latest_open_title,
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 issue = parse_issue(&raw, Some(path.clone())).map_err(IssueError::Other)?;
let mut new = mutator(issue)?;
new.meta.updated_at = Utc::now();
let content = serialize_issue(&new).map_err(IssueError::Other)?;
atomic_write(&path, &content)?;
store.invalidate();
Ok(new.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 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
}
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());
}
#[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 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("한글"));
}
}