1use std::fs::{self, OpenOptions};
53use std::hash::{Hash, Hasher};
54use std::io;
55use std::os::unix::io::AsRawFd;
56use std::path::{Path, PathBuf};
57use std::sync::Arc;
58
59use anyhow::{Context, Result};
60use chrono::{DateTime, Utc};
61use parking_lot::RwLock;
62use serde::{Deserialize, Serialize};
63
64#[derive(Debug, thiserror::Error)]
74pub enum IssueError {
75 #[error("issue #{id} was modified since last read; re-read and retry")]
78 Conflict { id: u32 },
79
80 #[error("issue #{id} is currently being worked on by session {owner}")]
82 Assigned {
83 id: u32,
84 owner: String,
85 acquired_at: DateTime<Utc>,
86 },
87
88 #[error("issue #{id} is not assigned to session {caller}; run `start` first")]
90 NotAssigned { id: u32, caller: String },
91
92 #[error("issue #{id} not found")]
94 NotFound { id: u32 },
95
96 #[error(transparent)]
97 Io(#[from] io::Error),
98
99 #[error(transparent)]
100 Other(#[from] anyhow::Error),
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
109#[serde(rename_all = "lowercase")]
110pub enum Status {
111 #[default]
112 Open,
113 Closed,
114}
115
116impl std::fmt::Display for Status {
117 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118 match self {
119 Self::Open => write!(f, "open"),
120 Self::Closed => write!(f, "closed"),
121 }
122 }
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
127#[serde(rename_all = "lowercase")]
128pub enum Priority {
129 Low,
130 #[default]
131 Medium,
132 High,
133 Critical,
134}
135
136impl std::fmt::Display for Priority {
137 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138 match self {
139 Self::Low => write!(f, "low"),
140 Self::Medium => write!(f, "medium"),
141 Self::High => write!(f, "high"),
142 Self::Critical => write!(f, "critical"),
143 }
144 }
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153pub struct Assignment {
154 pub session: String,
156 pub acquired_at: DateTime<Utc>,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub struct GithubRef {
164 pub repo: String,
165 pub number: u64,
166 pub url: String,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct IssueMeta {
172 pub id: u32,
173 pub title: String,
174 #[serde(default)]
175 pub status: Status,
176 #[serde(default)]
177 pub priority: Priority,
178 #[serde(default)]
179 pub labels: Vec<String>,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub assignee: Option<String>,
182 pub created_at: DateTime<Utc>,
183 pub updated_at: DateTime<Utc>,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub closed_at: Option<DateTime<Utc>>,
186 #[serde(default)]
188 pub sessions: Vec<String>,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub assigned_to: Option<Assignment>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub github: Option<GithubRef>,
195}
196
197#[derive(Debug, Clone)]
199pub struct Issue {
200 pub meta: IssueMeta,
201 pub body: String,
203 pub path: Option<PathBuf>,
205}
206
207impl Issue {
208 pub fn list_badge(&self) -> String {
210 let lock = if self.meta.assigned_to.is_some() {
211 "🔒 "
212 } else {
213 ""
214 };
215 format!("{}{}", lock, self.meta.status)
216 }
217}
218
219fn atomic_write(path: &Path, content: &str) -> std::io::Result<()> {
226 let tmp_path = path.with_extension(format!("tmp.{}", std::process::id()));
227 std::fs::write(&tmp_path, content)?;
228 std::fs::rename(&tmp_path, path)?;
229 Ok(())
230}
231
232const FRONTMATTER_DELIM: &str = "---";
233
234pub fn parse_issue(raw: &str, path: Option<PathBuf>) -> Result<Issue> {
248 let raw = raw.strip_prefix('\u{feff}').unwrap_or(raw);
249
250 let after_open = match raw.strip_prefix(FRONTMATTER_DELIM) {
254 Some(rest) => rest,
255 None => {
256 return Ok(Issue {
257 meta: empty_meta(),
258 body: raw.to_string(),
259 path,
260 });
261 }
262 };
263
264 let mut yaml = String::new();
267 let mut body = String::new();
268 let mut closed = false;
269 for line in after_open.split_inclusive('\n') {
270 if !closed && line.trim_end() == FRONTMATTER_DELIM {
271 closed = true;
272 continue;
273 }
274 if !closed {
275 yaml.push_str(line);
276 } else {
277 body.push_str(line);
278 }
279 }
280
281 let meta: IssueMeta =
282 serde_yaml::from_str(&yaml).context("failed to parse issue frontmatter")?;
283 Ok(Issue { meta, body, path })
284}
285
286pub fn serialize_issue(issue: &Issue) -> Result<String> {
288 let yaml = serde_yaml::to_string(&issue.meta).context("failed to serialize frontmatter")?;
289 let body = if issue.body.is_empty() {
292 String::new()
293 } else if issue.body.ends_with('\n') {
294 issue.body.clone()
295 } else {
296 format!("{}\n", issue.body)
297 };
298 Ok(format!(
299 "{open}\n{yaml}{close}\n{body}",
300 open = FRONTMATTER_DELIM,
301 close = FRONTMATTER_DELIM
302 ))
303}
304
305pub fn content_hash(raw: &str) -> String {
308 let mut hasher = std::collections::hash_map::DefaultHasher::new();
309 raw.hash(&mut hasher);
310 format!("{:016x}", hasher.finish())
311}
312
313pub fn issues_dir(start: &Path) -> PathBuf {
323 let mut dir = start.to_path_buf();
324 loop {
325 if dir.join(".oxi").is_dir() {
326 return dir.join(".oxi").join("issues");
327 }
328 if !dir.pop() {
329 break;
330 }
331 }
332 start.join(".oxi").join("issues")
333}
334
335pub fn issue_filename(id: u32, title: &str) -> String {
337 let slug = slugify(title);
338 if slug.is_empty() {
339 format!("{:04}.md", id)
340 } else {
341 format!("{:04}-{}.md", id, slug)
342 }
343}
344
345fn empty_meta() -> IssueMeta {
347 let now = Utc::now();
348 IssueMeta {
349 id: 0,
350 title: String::new(),
351 status: Status::default(),
352 priority: Priority::default(),
353 labels: vec![],
354 assignee: None,
355 created_at: now,
356 updated_at: now,
357 closed_at: None,
358 sessions: vec![],
359 assigned_to: None,
360 github: None,
361 }
362}
363
364fn slugify(s: &str) -> String {
366 let mut out = String::new();
367 let mut prev_dash = false;
368 for c in s.chars() {
369 if c.is_ascii_alphanumeric() {
370 out.push(c.to_ascii_lowercase());
371 prev_dash = false;
372 } else if !prev_dash {
373 out.push('-');
374 prev_dash = true;
375 }
376 }
377 out.trim_matches('-').to_string()
378}
379
380pub mod liveness {
391 use super::*;
392
393 pub fn alive_path(issues_dir: &Path, session_id: &str) -> PathBuf {
395 issues_dir.join(".alive").join(session_id)
396 }
397
398 pub fn acquire(issues_dir: &Path, session_id: &str) -> io::Result<AliveGuard> {
405 let dir = issues_dir.join(".alive");
406 fs::create_dir_all(&dir)?;
407 let path = dir.join(session_id);
408 let file = OpenOptions::new()
409 .write(true)
410 .create(true)
411 .truncate(false)
412 .open(&path)?;
413 let fd = file.as_raw_fd();
414 let rc = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
416 if rc != 0 {
417 let err = io::Error::last_os_error();
418 return Err(err);
420 }
421 Ok(AliveGuard { _file: file, path })
422 }
423
424 pub fn is_session_alive(issues_dir: &Path, session_id: &str) -> bool {
427 let path = alive_path(issues_dir, session_id);
428 if !path.exists() {
429 return false;
430 }
431 let Ok(file) = OpenOptions::new().read(true).write(true).open(&path) else {
434 return false;
435 };
436 let fd = file.as_raw_fd();
437 let rc = unsafe { libc::flock(fd, libc::LOCK_SH | libc::LOCK_NB) };
438 if rc == 0 {
439 unsafe {
441 libc::flock(fd, libc::LOCK_UN);
442 }
443 false
444 } else {
445 true
447 }
448 }
449
450 #[derive(Debug)]
452 pub struct AliveGuard {
453 _file: fs::File,
454 path: PathBuf,
455 }
456
457 impl AliveGuard {
458 pub fn path(&self) -> &Path {
459 &self.path
460 }
461 }
462
463 impl Drop for AliveGuard {
464 fn drop(&mut self) {
465 let _ = fs::remove_file(&self.path);
467 }
468 }
469
470 #[cfg(test)]
471 mod tests {
472 use super::*;
473
474 #[test]
475 fn acquire_then_alive() {
476 let tmp = tempfile::tempdir().unwrap();
477 let dir = tmp.path().to_path_buf();
478 let sid = "s1";
479 let _g = acquire(&dir, sid).unwrap();
480 assert!(is_session_alive(&dir, sid));
481 drop(_g);
482 assert!(!is_session_alive(&dir, sid));
483 }
484
485 #[test]
486 fn second_acquire_fails_while_held() {
487 let tmp = tempfile::tempdir().unwrap();
488 let dir = tmp.path().to_path_buf();
489 let sid = "s2";
490 let g = acquire(&dir, sid).unwrap();
491 let second = acquire(&dir, sid);
492 assert!(second.is_err(), "second acquire should fail while held");
493 assert!(is_session_alive(&dir, sid));
494 drop(g);
495 assert!(acquire(&dir, sid).is_ok(), "after drop, acquire succeeds");
496 }
497 }
498}
499
500#[derive(Debug, Default, Clone)]
508struct Cache {
509 open_count: usize,
511 latest_open_title: Option<String>,
513 dir_mtime: Option<std::time::SystemTime>,
514}
515
516struct Inner {
518 issues_dir: PathBuf,
519 cache: Cache,
520}
521
522impl std::fmt::Debug for Inner {
523 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
524 f.debug_struct("Inner")
525 .field("issues_dir", &self.issues_dir)
526 .finish()
527 }
528}
529
530#[derive(Clone, Debug)]
537pub struct FileIssueStore {
538 inner: Arc<RwLock<Inner>>,
539}
540
541impl FileIssueStore {
542 pub fn open(issues_dir: PathBuf) -> Result<Self> {
544 Ok(Self {
545 inner: Arc::new(RwLock::new(Inner {
546 issues_dir,
547 cache: Cache::default(),
548 })),
549 })
550 }
551
552 pub fn open_from_cwd(start: &Path) -> Result<Self> {
554 Self::open(issues_dir(start))
555 }
556
557 pub fn issues_dir(&self) -> PathBuf {
559 self.inner.read().issues_dir.clone()
560 }
561
562 pub fn open_count(&self) -> usize {
565 self.refresh_if_stale();
566 self.inner.read().cache.open_count
567 }
568
569 pub fn latest_open_title(&self) -> Option<String> {
573 self.refresh_if_stale();
574 self.inner.read().cache.latest_open_title.clone()
575 }
576
577 pub fn has_any(&self) -> bool {
580 self.refresh_if_stale();
581 let dir = self.inner.read().issues_dir.clone();
582 fs::read_dir(&dir)
583 .map(|rd| {
584 rd.filter_map(|e| e.ok())
585 .any(|e| e.path().extension().and_then(|x| x.to_str()) == Some("md"))
586 })
587 .unwrap_or(false)
588 }
589
590 fn refresh_if_stale(&self) {
592 let dir = self.inner.read().issues_dir.clone();
593 let cur_dir_mtime = fs::metadata(&dir).and_then(|m| m.modified()).ok();
594 let needs = {
595 let g = self.inner.read();
596 match (g.cache.dir_mtime, cur_dir_mtime) {
597 (None, _) => true, (Some(_), None) => false, (Some(cached), Some(cur)) => cached != cur,
600 }
601 };
602 if !needs {
603 return;
604 }
605 let mut open_count = 0;
607 let mut latest_open_title: Option<String> = None;
608 let mut latest_open_updated: Option<chrono::DateTime<chrono::Utc>> = None;
609 if let Ok(rd) = fs::read_dir(&dir) {
610 for entry in rd.flatten() {
611 let p = entry.path();
612 if p.extension().and_then(|x| x.to_str()) != Some("md") {
613 continue;
614 }
615 if let Ok(raw) = fs::read_to_string(&p)
618 && let Ok(issue) = parse_issue(&raw, None)
619 && issue.meta.status == Status::Open
620 {
621 open_count += 1;
622 if issue.meta.updated_at
623 > latest_open_updated.unwrap_or(chrono::DateTime::<chrono::Utc>::MIN_UTC)
624 {
625 latest_open_updated = Some(issue.meta.updated_at);
626 latest_open_title = Some(issue.meta.title);
627 }
628 }
629 }
630 }
631 let mut g = self.inner.write();
632 g.cache = Cache {
633 open_count,
634 latest_open_title,
635 dir_mtime: cur_dir_mtime,
636 };
637 }
638
639 pub fn invalidate(&self) {
641 self.inner.write().cache = Cache::default();
642 }
643
644 pub fn list(&self, filter: &IssueFilter) -> Result<Vec<Issue>> {
648 self.refresh_if_stale();
649 let dir = self.inner.read().issues_dir.clone();
650 let mut out = Vec::new();
651 if let Ok(rd) = fs::read_dir(&dir) {
652 for entry in rd.flatten() {
653 let p = entry.path();
654 if p.extension().and_then(|x| x.to_str()) != Some("md") {
655 continue;
656 }
657 let raw = fs::read_to_string(&p)?;
658 let issue = parse_issue(&raw, Some(p.clone()))?;
659 if filter.matches(&issue) {
660 out.push(issue);
661 }
662 }
663 }
664 out.sort_by_key(|i| std::cmp::Reverse(i.meta.updated_at));
665 Ok(out)
666 }
667
668 pub fn read(&self, id: u32) -> Result<(Issue, String)> {
671 let path = self.path_for_id(id)?;
672 let raw = fs::read_to_string(&path)
673 .with_context(|| format!("issue #{} not found at {}", id, path.display()))?;
674 let issue = parse_issue(&raw, Some(path))?;
675 Ok((issue, content_hash(&raw)))
676 }
677
678 pub fn next_id(&self) -> Result<u32> {
687 let dir = self.inner.read().issues_dir.clone();
688 fs::create_dir_all(&dir)?;
689 let mut max = 0u32;
690 if let Ok(rd) = fs::read_dir(&dir) {
691 for entry in rd.flatten() {
692 let name = entry.file_name();
693 let name = name.to_string_lossy();
694 let num_str = name.split('-').next().unwrap_or(&name);
695 if let Ok(n) = num_str.trim_end_matches(".md").parse::<u32>() {
696 max = max.max(n);
697 }
698 }
699 }
700 Ok(max + 1)
701 }
702
703 pub fn create(
705 &self,
706 title: String,
707 body: String,
708 priority: Priority,
709 labels: Vec<String>,
710 caller_session: Option<&str>,
711 ) -> Result<Issue> {
712 let id = self.next_id()?;
713 let now = Utc::now();
714 let sessions = caller_session
715 .map(|s| vec![s.to_string()])
716 .unwrap_or_default();
717 let issue = Issue {
718 meta: IssueMeta {
719 id,
720 title,
721 status: Status::Open,
722 priority,
723 labels,
724 assignee: None,
725 created_at: now,
726 updated_at: now,
727 closed_at: None,
728 sessions,
729 assigned_to: None,
730 github: None,
731 },
732 body,
733 path: None,
734 };
735 for _ in 0..4 {
737 let path = self
738 .issues_dir()
739 .join(issue_filename(id, &issue.meta.title));
740 if path.exists() {
741 continue;
743 }
744 let content = serialize_issue(&issue)?;
745 atomic_write(&path, &content)?;
746 self.invalidate();
747 let mut saved = issue.clone();
748 saved.path = Some(path);
749 return Ok(saved);
750 }
751 anyhow::bail!("could not allocate a free issue id after retries");
752 }
753
754 pub async fn update<F>(
763 &self,
764 id: u32,
765 expected_hash: Option<String>,
766 mutator: F,
767 ) -> std::result::Result<Issue, IssueError>
768 where
769 F: FnOnce(Issue) -> std::result::Result<Issue, IssueError> + Send + 'static,
770 {
771 let path = self.path_for_id(id).map_err(IssueError::Other)?;
772 let path_for_closure = path.clone();
773 let store = self.clone();
774 oxi_agent::tools::file_mutation_queue::global_mutation_queue()
776 .with_queue(&path, move || async move {
777 let path = path_for_closure;
778 let raw = fs::read_to_string(&path)?;
779 if let Some(expected) = expected_hash.as_deref()
780 && content_hash(&raw) != expected
781 {
782 return Err(IssueError::Conflict { id });
783 }
784 let issue = parse_issue(&raw, Some(path.clone())).map_err(IssueError::Other)?;
785 let mut new = mutator(issue)?;
786 new.meta.updated_at = Utc::now();
787 let content = serialize_issue(&new).map_err(IssueError::Other)?;
788 atomic_write(&path, &content)?;
789 store.invalidate();
790 Ok(new.with_path(path))
791 })
792 .await
793 }
794
795 pub async fn close(
797 &self,
798 id: u32,
799 caller: &str,
800 expected_hash: Option<String>,
801 ) -> std::result::Result<Issue, IssueError> {
802 let now = Utc::now();
803 let caller = caller.to_string();
804 self.update(id, expected_hash, move |mut issue| {
805 require_owner(&issue, id, &caller)?;
806 issue.meta.status = Status::Closed;
807 issue.meta.closed_at = Some(now);
808 issue.meta.assigned_to = None; Ok(issue)
810 })
811 .await
812 }
813
814 pub async fn start(
820 &self,
821 id: u32,
822 caller: &str,
823 expected_hash: Option<String>,
824 ) -> std::result::Result<Issue, IssueError> {
825 let issues_dir = self.issues_dir();
826 let caller_owned = caller.to_string();
827 self.update(id, expected_hash, move |mut issue| {
828 if let Some(ref a) = issue.meta.assigned_to {
829 if a.session == caller_owned {
830 return Ok(issue);
832 }
833 if liveness::is_session_alive(&issues_dir, &a.session) {
834 return Err(IssueError::Assigned {
835 id,
836 owner: a.session.clone(),
837 acquired_at: a.acquired_at,
838 });
839 }
840 }
842 issue.meta.assigned_to = Some(Assignment {
843 session: caller_owned.clone(),
844 acquired_at: Utc::now(),
845 });
846 if !issue.meta.sessions.contains(&caller_owned) {
848 issue.meta.sessions.push(caller_owned.clone());
849 }
850 Ok(issue)
851 })
852 .await
853 }
854
855 pub async fn release(
857 &self,
858 id: u32,
859 caller: &str,
860 expected_hash: Option<String>,
861 ) -> std::result::Result<Issue, IssueError> {
862 let caller = caller.to_string();
863 self.update(id, expected_hash, move |mut issue| {
864 require_owner(&issue, id, &caller)?;
865 issue.meta.assigned_to = None;
866 Ok(issue)
867 })
868 .await
869 }
870
871 pub async fn link_session(
873 &self,
874 id: u32,
875 session: &str,
876 expected_hash: Option<String>,
877 ) -> std::result::Result<Issue, IssueError> {
878 let session = session.to_string();
879 self.update(id, expected_hash, move |mut issue| {
880 if !issue.meta.sessions.contains(&session) {
881 issue.meta.sessions.push(session);
882 }
883 Ok(issue)
884 })
885 .await
886 }
887
888 fn path_for_id(&self, id: u32) -> Result<PathBuf> {
891 let dir = self.inner.read().issues_dir.clone();
892 if let Ok(rd) = fs::read_dir(&dir) {
894 for entry in rd.flatten() {
895 let name = entry.file_name();
896 let name = name.to_string_lossy();
897 let num_str = name.split('-').next().unwrap_or(&name);
898 if num_str.trim_end_matches(".md").parse::<u32>().ok() == Some(id) {
899 return Ok(entry.path());
900 }
901 }
902 }
903 Err(anyhow::anyhow!(IssueError::NotFound { id }))
904 }
905}
906
907trait WithPath {
909 fn with_path(self, path: PathBuf) -> Self;
910}
911
912impl WithPath for Issue {
913 fn with_path(mut self, path: PathBuf) -> Self {
914 self.path = Some(path);
915 self
916 }
917}
918
919fn require_owner(issue: &Issue, id: u32, caller: &str) -> std::result::Result<(), IssueError> {
921 match &issue.meta.assigned_to {
922 Some(a) if a.session == caller => Ok(()),
923 _ => Err(IssueError::NotAssigned {
924 id,
925 caller: caller.to_string(),
926 }),
927 }
928}
929
930#[derive(Debug, Clone, Default)]
936pub struct IssueFilter {
937 pub status: Option<Status>,
938 pub priority: Option<Priority>,
939 pub label: Option<String>,
940 pub assigned_to_session: Option<String>,
941 pub text: Option<String>,
943}
944
945impl IssueFilter {
946 fn matches(&self, issue: &Issue) -> bool {
947 if let Some(s) = self.status
948 && issue.meta.status != s
949 {
950 return false;
951 }
952 if let Some(p) = self.priority
953 && issue.meta.priority != p
954 {
955 return false;
956 }
957 if let Some(ref label) = self.label
958 && !issue.meta.labels.iter().any(|l| l == label)
959 {
960 return false;
961 }
962 if let Some(ref session) = self.assigned_to_session {
963 let mine = issue
964 .meta
965 .assigned_to
966 .as_ref()
967 .map(|a| &a.session == session)
968 .unwrap_or(false);
969 if !mine {
970 return false;
971 }
972 }
973 if let Some(ref text) = self.text
974 && !issue
975 .meta
976 .title
977 .to_lowercase()
978 .contains(&text.to_lowercase())
979 {
980 return false;
981 }
982 true
983 }
984}
985
986#[cfg(test)]
991mod tests {
992 use super::*;
993
994 fn sample_meta(id: u32, title: &str, priority: Priority) -> IssueMeta {
995 let now = Utc::now();
996 IssueMeta {
997 id,
998 title: title.into(),
999 status: Status::Open,
1000 priority,
1001 labels: vec![],
1002 assignee: None,
1003 created_at: now,
1004 updated_at: now,
1005 closed_at: None,
1006 sessions: vec![],
1007 assigned_to: None,
1008 github: None,
1009 }
1010 }
1011
1012 fn tmp_store() -> (tempfile::TempDir, FileIssueStore) {
1013 let tmp = tempfile::tempdir().unwrap();
1014 let dir = tmp.path().join(".oxi").join("issues");
1015 fs::create_dir_all(&dir).unwrap();
1016 let store = FileIssueStore::open(dir).unwrap();
1017 (tmp, store)
1018 }
1019
1020 #[test]
1021 fn roundtrip_serialization() {
1022 let issue = Issue {
1023 meta: sample_meta(1, "Test", Priority::High),
1024 body: "## Body\n\nHello.".into(),
1025 path: None,
1026 };
1027 let s = serialize_issue(&issue).unwrap();
1028 assert!(s.starts_with("---\n"));
1029 let parsed = parse_issue(&s, None).unwrap();
1030 assert_eq!(parsed.meta.id, 1);
1031 assert_eq!(parsed.meta.title, "Test");
1032 assert_eq!(parsed.meta.priority, Priority::High);
1033 assert!(parsed.body.contains("Hello."));
1034 }
1035
1036 #[tokio::test]
1037 async fn create_read_list() {
1038 let (_tmp, store) = tmp_store();
1039 let created = store
1040 .create(
1041 "Fix bug".into(),
1042 "body".into(),
1043 Priority::High,
1044 vec![],
1045 None,
1046 )
1047 .unwrap();
1048 assert_eq!(created.meta.id, 1);
1049
1050 let (read, hash) = store.read(1).unwrap();
1051 assert_eq!(read.meta.title, "Fix bug");
1052 assert!(!hash.is_empty());
1053
1054 let list = store.list(&IssueFilter::default()).unwrap();
1055 assert_eq!(list.len(), 1);
1056 }
1057
1058 #[tokio::test]
1059 async fn content_hash_detects_conflict() {
1060 let (_tmp, store) = tmp_store();
1061 store
1062 .create("Orig".into(), "b".into(), Priority::Low, vec![], None)
1063 .unwrap();
1064 let (_, hash) = store.read(1).unwrap();
1065
1066 let wrong = Some("deadbeefdeadbeef".to_string());
1068 let err = store
1069 .update(1, wrong, |_| {
1070 Ok(Issue {
1071 meta: sample_meta(1, "x", Priority::Low),
1072 body: "x".into(),
1073 path: None,
1074 })
1075 })
1076 .await
1077 .unwrap_err();
1078 assert!(matches!(err, IssueError::Conflict { id: 1 }));
1079
1080 let _ok = store
1082 .update(1, Some(hash), |mut i| {
1083 i.meta.title = "Updated".into();
1084 Ok(i)
1085 })
1086 .await
1087 .unwrap();
1088 let (read, _) = store.read(1).unwrap();
1089 assert_eq!(read.meta.title, "Updated");
1090 }
1091
1092 #[tokio::test]
1093 async fn start_rejects_live_owner() {
1094 let (_tmp, store) = tmp_store();
1095 store
1096 .create("T".into(), "b".into(), Priority::Low, vec![], None)
1097 .unwrap();
1098 let issues_dir = store.issues_dir();
1099 let _guard_a = liveness::acquire(&issues_dir, "sessionA").unwrap();
1101 let (_, hash) = store.read(1).unwrap();
1103 store.start(1, "sessionA", Some(hash)).await.unwrap();
1104
1105 let (_, hash2) = store.read(1).unwrap();
1107 let err = store.start(1, "sessionB", Some(hash2)).await.unwrap_err();
1108 assert!(matches!(err, IssueError::Assigned { owner, .. } if owner == "sessionA"));
1109 }
1110
1111 #[tokio::test]
1112 async fn start_reclaims_dead_owner() {
1113 let (_tmp, store) = tmp_store();
1114 store
1115 .create("T".into(), "b".into(), Priority::Low, vec![], None)
1116 .unwrap();
1117 let issues_dir = store.issues_dir();
1118
1119 {
1121 let _g = liveness::acquire(&issues_dir, "sessionA").unwrap();
1122 let (_, h) = store.read(1).unwrap();
1123 store.start(1, "sessionA", Some(h)).await.unwrap();
1124 } let (_, hash) = store.read(1).unwrap();
1127 let reclaimed = store.start(1, "sessionB", Some(hash)).await.unwrap();
1128 assert_eq!(
1129 reclaimed.meta.assigned_to.as_ref().unwrap().session,
1130 "sessionB"
1131 );
1132 }
1133
1134 #[tokio::test]
1135 async fn close_requires_owner() {
1136 let (_tmp, store) = tmp_store();
1137 store
1138 .create("T".into(), "b".into(), Priority::Low, vec![], None)
1139 .unwrap();
1140 let (_, hash) = store.read(1).unwrap();
1141 store.start(1, "sessionA", Some(hash)).await.unwrap();
1142
1143 let (_, hash2) = store.read(1).unwrap();
1145 let err = store.close(1, "sessionB", Some(hash2)).await.unwrap_err();
1146 assert!(matches!(err, IssueError::NotAssigned { .. }));
1147
1148 let (_, hash3) = store.read(1).unwrap();
1150 let closed = store.close(1, "sessionA", Some(hash3)).await.unwrap();
1151 assert_eq!(closed.meta.status, Status::Closed);
1152 assert!(closed.meta.assigned_to.is_none());
1153 }
1154
1155 #[test]
1156 fn slugify_basic() {
1157 assert_eq!(slugify("Fix Login Bug!"), "fix-login-bug");
1158 assert_eq!(slugify(" spaces "), "spaces");
1159 assert_eq!(slugify("a__b"), "a-b");
1160 assert_eq!(slugify(""), "");
1161 }
1162
1163 #[test]
1164 fn issue_filename_format() {
1165 assert_eq!(issue_filename(12, "Fix Login"), "0012-fix-login.md");
1166 assert_eq!(issue_filename(1, ""), "0001.md");
1167 }
1168
1169 #[tokio::test]
1170 async fn open_count_caches() {
1171 let (_tmp, store) = tmp_store();
1172 assert_eq!(store.open_count(), 0);
1173 store
1174 .create("A".into(), "b".into(), Priority::Low, vec![], None)
1175 .unwrap();
1176 store
1177 .create("B".into(), "b".into(), Priority::Low, vec![], None)
1178 .unwrap();
1179 assert_eq!(store.open_count(), 2);
1180
1181 let issues_dir = store.issues_dir();
1183 let _guard = liveness::acquire(&issues_dir, "sessionA").unwrap();
1184 let (_, h) = store.read(1).unwrap();
1185 store.start(1, "sessionA", Some(h)).await.unwrap();
1186 let (_, h) = store.read(1).unwrap();
1187 store.close(1, "sessionA", Some(h)).await.unwrap();
1188 store.invalidate();
1189 assert_eq!(store.open_count(), 1);
1190 }
1191
1192 #[tokio::test]
1193 async fn latest_open_title_caches_and_handles_cjk() {
1194 let (_tmp, store) = tmp_store();
1195 assert!(store.latest_open_title().is_none());
1197
1198 let cjk_title =
1203 "버그 수정: 한글 제목도 정상이어야 합니다 — 멀티바이트 인코딩 안전성".to_string();
1204 let cjk_body =
1205 "요약\n\n이 이슈는 한글 본문을 포함합니다. 본문에는 영문과 한글이 섞여 있습니다. "
1206 .repeat(4);
1207 let created = store
1208 .create(cjk_title.clone(), cjk_body, Priority::High, vec![], None)
1209 .unwrap();
1210 assert_eq!(created.meta.title, cjk_title);
1211
1212 let title = store.latest_open_title();
1214 assert_eq!(title.as_deref(), Some(cjk_title.as_str()));
1215
1216 let (read_back, _hash) = store.read(created.meta.id).unwrap();
1218 assert!(read_back.body.contains("한글"));
1219 }
1220}