1use std::fs;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use anyhow::{Context, Result};
8use chrono::Utc;
9use parking_lot::RwLock;
10
11use crate::issues::atomic_write;
12use crate::issues::error::IssueError;
13use crate::issues::filter::IssueFilter;
14use crate::issues::liveness;
15use crate::issues::serialize::{
16 content_hash, issue_filename, issues_dir, parse_issue, serialize_issue,
17};
18use crate::issues::types::{Assignment, Issue, IssueMeta, IssuePatch, Priority, Status};
19
20#[derive(Debug, Default, Clone)]
24struct Cache {
25 open_count: usize,
27 latest_open_title: Option<String>,
29 locked_open_count: usize,
32 top_priority: Option<Priority>,
35 top_free_priority: Option<Priority>,
40 dir_mtime: Option<std::time::SystemTime>,
41}
42
43#[derive(Debug, Clone)]
46pub struct IssueSummary {
47 pub open_count: usize,
49 pub locked_open_count: usize,
51 pub top_priority: Option<Priority>,
53 pub top_free_priority: Option<Priority>,
56 pub latest_open_title: Option<String>,
58}
59
60impl IssueSummary {
61 pub fn is_empty(&self) -> bool {
63 self.open_count == 0
64 }
65}
66
67struct Inner {
69 issues_dir: PathBuf,
70 cache: Cache,
71}
72
73impl std::fmt::Debug for Inner {
74 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75 f.debug_struct("Inner")
76 .field("issues_dir", &self.issues_dir)
77 .finish()
78 }
79}
80
81#[derive(Clone, Debug)]
88pub struct FileIssueStore {
89 inner: Arc<RwLock<Inner>>,
90}
91
92impl FileIssueStore {
93 pub fn open(issues_dir: PathBuf) -> Result<Self> {
95 if let Err(e) = liveness::reap_orphans(&issues_dir) {
99 tracing::warn!(error = %e, "issue liveness reap failed (non-fatal)");
100 }
101 Ok(Self {
102 inner: Arc::new(RwLock::new(Inner {
103 issues_dir,
104 cache: Cache::default(),
105 })),
106 })
107 }
108
109 pub fn open_from_cwd(start: &Path) -> Result<Self> {
111 Self::open(issues_dir(start))
112 }
113
114 pub fn issues_dir(&self) -> PathBuf {
116 self.inner.read().issues_dir.clone()
117 }
118
119 pub fn open_count(&self) -> usize {
122 self.refresh_if_stale();
123 self.inner.read().cache.open_count
124 }
125
126 pub fn latest_open_title(&self) -> Option<String> {
130 self.refresh_if_stale();
131 self.inner.read().cache.latest_open_title.clone()
132 }
133
134 pub fn summary(&self) -> IssueSummary {
137 self.refresh_if_stale();
138 let g = self.inner.read();
139 IssueSummary {
140 open_count: g.cache.open_count,
141 locked_open_count: g.cache.locked_open_count,
142 top_priority: g.cache.top_priority,
143 top_free_priority: g.cache.top_free_priority,
144 latest_open_title: g.cache.latest_open_title.clone(),
145 }
146 }
147
148 pub fn top_free_priority(&self) -> Option<Priority> {
154 self.refresh_if_stale();
155 self.inner.read().cache.top_free_priority
156 }
157
158 pub fn has_any(&self) -> bool {
161 self.refresh_if_stale();
162 let dir = self.inner.read().issues_dir.clone();
163 fs::read_dir(&dir)
164 .map(|rd| {
165 rd.filter_map(|e| e.ok())
166 .any(|e| e.path().extension().and_then(|x| x.to_str()) == Some("md"))
167 })
168 .unwrap_or(false)
169 }
170
171 fn refresh_if_stale(&self) {
173 let dir = self.inner.read().issues_dir.clone();
174 let cur_dir_mtime = fs::metadata(&dir).and_then(|m| m.modified()).ok();
175 let needs = {
176 let g = self.inner.read();
177 match (g.cache.dir_mtime, cur_dir_mtime) {
178 (None, _) => true, (Some(_), None) => false, (Some(cached), Some(cur)) => cached != cur,
181 }
182 };
183 if !needs {
184 return;
185 }
186 let mut open_count = 0;
188 let mut locked_open_count = 0;
189 let mut top_priority: Option<Priority> = None;
190 let mut latest_open_title: Option<String> = None;
191 let mut latest_open_updated: Option<chrono::DateTime<chrono::Utc>> = None;
192 let mut top_free_priority: Option<Priority> = None;
193 if let Ok(rd) = fs::read_dir(&dir) {
194 for entry in rd.flatten() {
195 let p = entry.path();
196 if p.extension().and_then(|x| x.to_str()) != Some("md") {
197 continue;
198 }
199 if let Ok(raw) = fs::read_to_string(&p)
202 && let Ok(issue) = parse_issue(&raw, None)
203 && issue.meta.status == Status::Open
204 {
205 open_count += 1;
206 if issue.meta.assigned_to.is_some() {
207 locked_open_count += 1;
208 }
209 top_priority = Some(match top_priority {
211 Some(existing) => existing.max(issue.meta.priority),
212 None => issue.meta.priority,
213 });
214 if issue.meta.updated_at
215 > latest_open_updated.unwrap_or(chrono::DateTime::<chrono::Utc>::MIN_UTC)
216 {
217 latest_open_updated = Some(issue.meta.updated_at);
218 latest_open_title = Some(issue.meta.title);
219 }
220 if issue.meta.assigned_to.is_none() {
222 top_free_priority = Some(match top_free_priority {
223 Some(cur) if cur >= issue.meta.priority => cur,
224 _ => issue.meta.priority,
225 });
226 }
227 }
228 }
229 }
230 let mut g = self.inner.write();
231 g.cache = Cache {
232 open_count,
233 latest_open_title,
234 locked_open_count,
235 top_priority,
236 top_free_priority,
237 dir_mtime: cur_dir_mtime,
238 };
239 }
240
241 pub fn invalidate(&self) {
243 self.inner.write().cache = Cache::default();
244 }
245
246 pub fn list(&self, filter: &IssueFilter) -> Result<Vec<Issue>> {
250 self.refresh_if_stale();
251 let dir = self.inner.read().issues_dir.clone();
252 let mut out = Vec::new();
253 if let Ok(rd) = fs::read_dir(&dir) {
254 for entry in rd.flatten() {
255 let p = entry.path();
256 if p.extension().and_then(|x| x.to_str()) != Some("md") {
257 continue;
258 }
259 let raw = fs::read_to_string(&p)?;
260 let issue = parse_issue(&raw, Some(p.clone()))?;
261 if filter.matches(&issue) {
262 out.push(issue);
263 }
264 }
265 }
266 out.sort_by_key(|i| std::cmp::Reverse(i.meta.updated_at));
267 Ok(out)
268 }
269
270 pub fn read(&self, id: u32) -> Result<(Issue, String)> {
273 let path = self.path_for_id(id)?;
274 let raw = fs::read_to_string(&path)
275 .with_context(|| format!("issue #{} not found at {}", id, path.display()))?;
276 let issue = parse_issue(&raw, Some(path))?;
277 Ok((issue, content_hash(&raw)))
278 }
279
280 pub fn next_id(&self) -> Result<u32> {
289 let dir = self.inner.read().issues_dir.clone();
290 fs::create_dir_all(&dir)?;
291 let mut max = 0u32;
292 if let Ok(rd) = fs::read_dir(&dir) {
293 for entry in rd.flatten() {
294 let name = entry.file_name();
295 let name = name.to_string_lossy();
296 let num_str = name.split('-').next().unwrap_or(&name);
297 if let Ok(n) = num_str.trim_end_matches(".md").parse::<u32>() {
298 max = max.max(n);
299 }
300 }
301 }
302 Ok(max + 1)
303 }
304
305 pub fn create(
307 &self,
308 title: String,
309 body: String,
310 priority: Priority,
311 labels: Vec<String>,
312 caller_session: Option<&str>,
313 ) -> Result<Issue> {
314 let id = self.next_id()?;
315 let now = Utc::now();
316 let sessions = caller_session
317 .map(|s| vec![s.to_string()])
318 .unwrap_or_default();
319 let issue = Issue {
320 meta: IssueMeta {
321 id,
322 title,
323 status: Status::Open,
324 priority,
325 labels,
326 assignee: None,
327 created_at: now,
328 updated_at: now,
329 closed_at: None,
330 sessions,
331 assigned_to: None,
332 github: None,
333 },
334 body,
335 path: None,
336 };
337 for _ in 0..4 {
339 let path = self
340 .issues_dir()
341 .join(issue_filename(id, &issue.meta.title));
342 if path.exists() {
343 continue;
345 }
346 let content = serialize_issue(&issue)?;
347 atomic_write(&path, &content)?;
348 self.invalidate();
349 let mut saved = issue.clone();
350 saved.path = Some(path);
351 return Ok(saved);
352 }
353 anyhow::bail!("could not allocate a free issue id after retries");
354 }
355
356 pub async fn update<F>(
365 &self,
366 id: u32,
367 expected_hash: Option<String>,
368 mutator: F,
369 ) -> std::result::Result<Issue, IssueError>
370 where
371 F: FnOnce(Issue) -> std::result::Result<Issue, IssueError> + Send + 'static,
372 {
373 let path = self.path_for_id(id).map_err(IssueError::Other)?;
374 let path_for_closure = path.clone();
375 let store = self.clone();
376 crate::tools::file_mutation_queue::global_mutation_queue()
378 .with_queue(&path, move || async move {
379 let path = path_for_closure;
380 let raw = fs::read_to_string(&path)?;
381 if let Some(expected) = expected_hash.as_deref()
382 && content_hash(&raw) != expected
383 {
384 return Err(IssueError::Conflict { id });
385 }
386 let before = parse_issue(&raw, Some(path.clone())).map_err(IssueError::Other)?;
387 let before_updated_at = before.meta.updated_at;
388 let before_bytes = serialize_issue(&before).map_err(IssueError::Other)?;
389 let after = mutator(before)?;
390
391 let mut probe = after.clone();
398 probe.meta.updated_at = before_updated_at;
399 let probe_bytes = serialize_issue(&probe).map_err(IssueError::Other)?;
400 if probe_bytes == before_bytes {
401 return Ok(after.with_path(path));
402 }
403
404 let mut final_issue = after;
405 final_issue.meta.updated_at = Utc::now();
406 let content = serialize_issue(&final_issue).map_err(IssueError::Other)?;
407 atomic_write(&path, &content)?;
408 store.invalidate();
409 Ok(final_issue.with_path(path))
410 })
411 .await
412 }
413
414 pub async fn close(
416 &self,
417 id: u32,
418 caller: &str,
419 expected_hash: Option<String>,
420 ) -> std::result::Result<Issue, IssueError> {
421 let now = Utc::now();
422 let caller = caller.to_string();
423 self.update(id, expected_hash, move |mut issue| {
424 require_owner(&issue, id, &caller)?;
425 issue.meta.status = Status::Closed;
426 issue.meta.closed_at = Some(now);
427 issue.meta.assigned_to = None; Ok(issue)
429 })
430 .await
431 }
432
433 pub async fn reopen(
439 &self,
440 id: u32,
441 expected_hash: Option<String>,
442 ) -> std::result::Result<Issue, IssueError> {
443 self.update(id, expected_hash, move |mut issue| {
444 if issue.meta.status == Status::Open {
445 return Ok(issue);
448 }
449 issue.meta.status = Status::Open;
450 issue.meta.closed_at = None;
451 issue.meta.assigned_to = None;
452 Ok(issue)
453 })
454 .await
455 }
456
457 pub async fn start(
463 &self,
464 id: u32,
465 caller: &str,
466 expected_hash: Option<String>,
467 ) -> std::result::Result<Issue, IssueError> {
468 let issues_dir = self.issues_dir();
469 let caller_owned = caller.to_string();
470 self.update(id, expected_hash, move |mut issue| {
471 if let Some(ref a) = issue.meta.assigned_to {
472 if a.session == caller_owned {
473 return Ok(issue);
475 }
476 if liveness::is_session_alive(&issues_dir, &a.session) {
477 return Err(IssueError::Assigned {
478 id,
479 owner: a.session.clone(),
480 acquired_at: a.acquired_at,
481 });
482 }
483 }
485 issue.meta.assigned_to = Some(Assignment {
486 session: caller_owned.clone(),
487 acquired_at: Utc::now(),
488 });
489 if !issue.meta.sessions.contains(&caller_owned) {
491 issue.meta.sessions.push(caller_owned.clone());
492 }
493 Ok(issue)
494 })
495 .await
496 }
497
498 pub async fn release(
500 &self,
501 id: u32,
502 caller: &str,
503 expected_hash: Option<String>,
504 ) -> std::result::Result<Issue, IssueError> {
505 let caller = caller.to_string();
506 self.update(id, expected_hash, move |mut issue| {
507 require_owner(&issue, id, &caller)?;
508 issue.meta.assigned_to = None;
509 Ok(issue)
510 })
511 .await
512 }
513
514 pub async fn link_session(
516 &self,
517 id: u32,
518 session: &str,
519 expected_hash: Option<String>,
520 ) -> std::result::Result<Issue, IssueError> {
521 let session = session.to_string();
522 self.update(id, expected_hash, move |mut issue| {
523 if !issue.meta.sessions.contains(&session) {
524 issue.meta.sessions.push(session);
525 }
526 Ok(issue)
527 })
528 .await
529 }
530
531 pub async fn apply_patch(
544 &self,
545 id: u32,
546 patch: IssuePatch,
547 caller: Option<String>,
548 expected_hash: Option<String>,
549 ) -> std::result::Result<Issue, IssueError> {
550 self.update(id, expected_hash, move |mut issue| {
551 if let Some(caller) = caller.as_deref()
552 && let Some(ref a) = issue.meta.assigned_to
553 && !a.session.is_empty()
554 && a.session != caller
555 {
556 return Err(IssueError::NotAssigned {
557 id,
558 caller: caller.to_string(),
559 });
560 }
561 if let Some(t) = patch.title {
562 issue.meta.title = t;
563 }
564 if let Some(b) = patch.body {
565 issue.body = b;
566 }
567 if let Some(s) = patch.status {
568 issue.meta.status = s;
569 issue.meta.closed_at = match s {
570 Status::Closed => Some(Utc::now()),
571 Status::Open => None, };
573 }
574 if let Some(p) = patch.priority {
575 issue.meta.priority = p;
576 }
577 if let Some(l) = patch.labels {
578 issue.meta.labels = l;
579 }
580 Ok(issue)
581 })
582 .await
583 }
584
585 fn path_for_id(&self, id: u32) -> Result<PathBuf> {
588 let dir = self.inner.read().issues_dir.clone();
589 if let Ok(rd) = fs::read_dir(&dir) {
591 for entry in rd.flatten() {
592 let name = entry.file_name();
593 let name = name.to_string_lossy();
594 let num_str = name.split('-').next().unwrap_or(&name);
595 if num_str.trim_end_matches(".md").parse::<u32>().ok() == Some(id) {
596 return Ok(entry.path());
597 }
598 }
599 }
600 Err(anyhow::anyhow!(IssueError::NotFound { id }))
601 }
602}
603
604trait WithPath {
606 fn with_path(self, path: PathBuf) -> Self;
607}
608
609impl WithPath for Issue {
610 fn with_path(mut self, path: PathBuf) -> Self {
611 self.path = Some(path);
612 self
613 }
614}
615
616fn require_owner(issue: &Issue, id: u32, caller: &str) -> std::result::Result<(), IssueError> {
618 match &issue.meta.assigned_to {
619 Some(a) if a.session == caller => Ok(()),
620 _ => Err(IssueError::NotAssigned {
621 id,
622 caller: caller.to_string(),
623 }),
624 }
625}
626
627#[cfg(test)]
628mod tests {
629 use super::*;
630
631 fn sample_meta(id: u32, title: &str, priority: Priority) -> IssueMeta {
632 let now = Utc::now();
633 IssueMeta {
634 id,
635 title: title.into(),
636 status: Status::Open,
637 priority,
638 labels: vec![],
639 assignee: None,
640 created_at: now,
641 updated_at: now,
642 closed_at: None,
643 sessions: vec![],
644 assigned_to: None,
645 github: None,
646 }
647 }
648
649 fn tmp_store() -> (tempfile::TempDir, FileIssueStore) {
650 let tmp = tempfile::tempdir().unwrap();
651 let dir = tmp.path().join(".oxicode").join("issues");
652 fs::create_dir_all(&dir).unwrap();
653 let store = FileIssueStore::open(dir).unwrap();
654 (tmp, store)
655 }
656
657 #[test]
658 fn roundtrip_serialization() {
659 let issue = Issue {
660 meta: sample_meta(1, "Test", Priority::High),
661 body: "## Body\n\nHello.".into(),
662 path: None,
663 };
664 let s = serialize_issue(&issue).unwrap();
665 assert!(s.starts_with("---\n"));
666 let parsed = parse_issue(&s, None).unwrap();
667 assert_eq!(parsed.meta.id, 1);
668 assert_eq!(parsed.meta.title, "Test");
669 assert_eq!(parsed.meta.priority, Priority::High);
670 assert!(parsed.body.contains("Hello."));
671 }
672
673 #[tokio::test]
674 async fn create_read_list() {
675 let (_tmp, store) = tmp_store();
676 let created = store
677 .create(
678 "Fix bug".into(),
679 "body".into(),
680 Priority::High,
681 vec![],
682 None,
683 )
684 .unwrap();
685 assert_eq!(created.meta.id, 1);
686
687 let (read, hash) = store.read(1).unwrap();
688 assert_eq!(read.meta.title, "Fix bug");
689 assert!(!hash.is_empty());
690
691 let list = store.list(&IssueFilter::default()).unwrap();
692 assert_eq!(list.len(), 1);
693 }
694
695 #[tokio::test]
696 async fn content_hash_detects_conflict() {
697 let (_tmp, store) = tmp_store();
698 store
699 .create("Orig".into(), "b".into(), Priority::Low, vec![], None)
700 .unwrap();
701 let (_, hash) = store.read(1).unwrap();
702
703 let wrong = Some("deadbeefdeadbeef".to_string());
705 let err = store
706 .update(1, wrong, |_| {
707 Ok(Issue {
708 meta: sample_meta(1, "x", Priority::Low),
709 body: "x".into(),
710 path: None,
711 })
712 })
713 .await
714 .unwrap_err();
715 assert!(matches!(err, IssueError::Conflict { id: 1 }));
716
717 let _ok = store
719 .update(1, Some(hash), |mut i| {
720 i.meta.title = "Updated".into();
721 Ok(i)
722 })
723 .await
724 .unwrap();
725 let (read, _) = store.read(1).unwrap();
726 assert_eq!(read.meta.title, "Updated");
727 }
728
729 #[tokio::test]
730 async fn start_rejects_live_owner() {
731 let (_tmp, store) = tmp_store();
732 store
733 .create("T".into(), "b".into(), Priority::Low, vec![], None)
734 .unwrap();
735 let issues_dir = store.issues_dir();
736 let _guard_a = liveness::acquire(&issues_dir, "sessionA").unwrap();
738 let (_, hash) = store.read(1).unwrap();
740 store.start(1, "sessionA", Some(hash)).await.unwrap();
741
742 let (_, hash2) = store.read(1).unwrap();
744 let err = store.start(1, "sessionB", Some(hash2)).await.unwrap_err();
745 assert!(matches!(err, IssueError::Assigned { owner, .. } if owner == "sessionA"));
746 }
747
748 #[tokio::test]
749 async fn start_reclaims_dead_owner() {
750 let (_tmp, store) = tmp_store();
751 store
752 .create("T".into(), "b".into(), Priority::Low, vec![], None)
753 .unwrap();
754 let issues_dir = store.issues_dir();
755
756 {
758 let _g = liveness::acquire(&issues_dir, "sessionA").unwrap();
759 let (_, h) = store.read(1).unwrap();
760 store.start(1, "sessionA", Some(h)).await.unwrap();
761 } let (_, hash) = store.read(1).unwrap();
764 let reclaimed = store.start(1, "sessionB", Some(hash)).await.unwrap();
765 assert_eq!(
766 reclaimed.meta.assigned_to.as_ref().unwrap().session,
767 "sessionB"
768 );
769 }
770
771 #[tokio::test]
772 async fn close_requires_owner() {
773 let (_tmp, store) = tmp_store();
774 store
775 .create("T".into(), "b".into(), Priority::Low, vec![], None)
776 .unwrap();
777 let (_, hash) = store.read(1).unwrap();
778 store.start(1, "sessionA", Some(hash)).await.unwrap();
779
780 let (_, hash2) = store.read(1).unwrap();
782 let err = store.close(1, "sessionB", Some(hash2)).await.unwrap_err();
783 assert!(matches!(err, IssueError::NotAssigned { .. }));
784
785 let (_, hash3) = store.read(1).unwrap();
787 let closed = store.close(1, "sessionA", Some(hash3)).await.unwrap();
788 assert_eq!(closed.meta.status, Status::Closed);
789 assert!(closed.meta.assigned_to.is_none());
790 }
791
792 #[tokio::test]
793 async fn reopen_flips_closed_to_open() {
794 let (_tmp, store) = tmp_store();
795 let issues_dir = store.issues_dir();
796 let _guard = crate::issues::liveness::acquire(&issues_dir, "tui").unwrap();
797 store
798 .create("T".into(), "b".into(), Priority::Low, vec![], None)
799 .unwrap();
800 let (_, h) = store.read(1).unwrap();
802 store.start(1, "tui", Some(h)).await.unwrap();
803 let (_, h) = store.read(1).unwrap();
804 store.close(1, "tui", Some(h)).await.unwrap();
805 let (_, h) = store.read(1).unwrap();
807 let reopened = store.reopen(1, Some(h)).await.unwrap();
808 assert_eq!(reopened.meta.status, Status::Open);
809 assert!(reopened.meta.closed_at.is_none());
810 assert!(reopened.meta.assigned_to.is_none());
811 }
812
813 #[tokio::test]
814 async fn reopen_is_idempotent_on_already_open() {
815 let (_tmp, store) = tmp_store();
816 store
817 .create("T".into(), "b".into(), Priority::Low, vec![], None)
818 .unwrap();
819 let (_, h) = store.read(1).unwrap();
820 let reopened = store.reopen(1, Some(h)).await.unwrap();
822 assert_eq!(reopened.meta.status, Status::Open);
823 assert!(reopened.meta.closed_at.is_none());
824 }
825
826 #[tokio::test]
827 async fn open_count_caches() {
828 let (_tmp, store) = tmp_store();
829 assert_eq!(store.open_count(), 0);
830 store
831 .create("A".into(), "b".into(), Priority::Low, vec![], None)
832 .unwrap();
833 store
834 .create("B".into(), "b".into(), Priority::Low, vec![], None)
835 .unwrap();
836 assert_eq!(store.open_count(), 2);
837
838 let issues_dir = store.issues_dir();
840 let _guard = liveness::acquire(&issues_dir, "sessionA").unwrap();
841 let (_, h) = store.read(1).unwrap();
842 store.start(1, "sessionA", Some(h)).await.unwrap();
843 let (_, h) = store.read(1).unwrap();
844 store.close(1, "sessionA", Some(h)).await.unwrap();
845 store.invalidate();
846 assert_eq!(store.open_count(), 1);
847 }
848
849 #[tokio::test]
850 async fn summary_reflects_lock_and_priority() {
851 let (_tmp, store) = tmp_store();
852 let issues_dir = store.issues_dir();
853 let _guard = liveness::acquire(&issues_dir, "sessionA").unwrap();
854 store
856 .create("Lowly".into(), "".into(), Priority::Low, vec![], None)
857 .unwrap();
858 store
859 .create("Crit".into(), "".into(), Priority::Critical, vec![], None)
860 .unwrap();
861 store
863 .create("Closed".into(), "".into(), Priority::Medium, vec![], None)
864 .unwrap();
865 let (_, h) = store.read(3).unwrap();
866 store.start(3, "sessionA", Some(h)).await.unwrap();
867 let (_, h) = store.read(3).unwrap();
868 store.close(3, "sessionA", Some(h)).await.unwrap();
869 let (_, h) = store.read(1).unwrap();
871 store.start(1, "sessionA", Some(h)).await.unwrap();
872 store.invalidate();
873
874 let s = store.summary();
875 assert_eq!(s.open_count, 2);
876 assert_eq!(s.locked_open_count, 1);
877 assert_eq!(s.top_priority, Some(Priority::Critical));
878 assert!(s.latest_open_title.is_some());
879 assert!(!s.is_empty());
880 }
881
882 #[tokio::test]
883 async fn summary_empty_when_no_issues() {
884 let (_tmp, store) = tmp_store();
885 let s = store.summary();
886 assert_eq!(s.open_count, 0);
887 assert_eq!(s.locked_open_count, 0);
888 assert!(s.top_priority.is_none());
889 assert!(s.latest_open_title.is_none());
890 assert!(s.is_empty());
891 }
892
893 #[tokio::test]
894 async fn latest_open_title_caches_and_handles_cjk() {
895 let (_tmp, store) = tmp_store();
896 assert!(store.latest_open_title().is_none());
898
899 let cjk_title =
904 "버그 수정: 한글 제목도 정상이어야 합니다 — 멀티바이트 인코딩 안전성".to_string();
905 let cjk_body =
906 "요약\n\n이 이슈는 한글 본문을 포함합니다. 본문에는 영문과 한글이 섞여 있습니다. "
907 .repeat(4);
908 let created = store
909 .create(cjk_title.clone(), cjk_body, Priority::High, vec![], None)
910 .unwrap();
911 assert_eq!(created.meta.title, cjk_title);
912
913 let title = store.latest_open_title();
915 assert_eq!(title.as_deref(), Some(cjk_title.as_str()));
916
917 let (read_back, _hash) = store.read(created.meta.id).unwrap();
919 assert!(read_back.body.contains("한글"));
920 }
921
922 #[tokio::test]
932 async fn start_with_distinct_live_owners_collides() {
933 let (_tmp, store) = tmp_store();
938 let issues_dir = store.issues_dir();
939 store
940 .create("T".into(), "b".into(), Priority::Low, vec![], None)
941 .unwrap();
942
943 let _guard_a = liveness::acquire(&issues_dir, "proc-A").unwrap();
945 let (_, h) = store.read(1).unwrap();
946 store.start(1, "proc-A", Some(h)).await.unwrap();
947
948 let _guard_b = liveness::acquire(&issues_dir, "proc-B").unwrap();
950 let (_, h2) = store.read(1).unwrap();
951 let err = store.start(1, "proc-B", Some(h2)).await.unwrap_err();
952 assert!(
953 matches!(err, IssueError::Assigned { ref owner, .. } if owner == "proc-A"),
954 "a second distinct live owner must be rejected, got: {err:?}"
955 );
956 }
957
958 #[tokio::test]
959 async fn empty_session_assignment_is_immediately_reclaimable_documentation() {
960 let (_tmp, store) = tmp_store();
969 store
970 .create("T".into(), "b".into(), Priority::Low, vec![], None)
971 .unwrap();
972 let issues_dir = store.issues_dir();
973
974 let (_, h) = store.read(1).unwrap();
976 store.start(1, "", Some(h)).await.unwrap();
977
978 assert!(
980 !liveness::is_session_alive(&issues_dir, ""),
981 "no flock can be held under the empty string"
982 );
983
984 let _guard_c = liveness::acquire(&issues_dir, "proc-C").unwrap();
988 let (_, h2) = store.read(1).unwrap();
989 let reclaimed = store.start(1, "proc-C", Some(h2)).await.unwrap();
990 assert_eq!(
991 reclaimed.meta.assigned_to.as_ref().unwrap().session,
992 "proc-C",
993 "empty-string assignment is reclaimable — this is the #13 bug shape"
994 );
995 }
996
997 #[tokio::test]
1000 async fn reopen_clears_closed_at() {
1001 let (_tmp, store) = tmp_store();
1004 store
1005 .create("T".into(), "b".into(), Priority::Low, vec![], None)
1006 .unwrap();
1007 let (_, h) = store.read(1).unwrap();
1008 store.start(1, "proc-X", Some(h)).await.unwrap();
1009 let (_, h) = store.read(1).unwrap();
1010 store.close(1, "proc-X", Some(h)).await.unwrap();
1011 let (closed, _) = store.read(1).unwrap();
1012 assert_eq!(closed.meta.status, Status::Closed);
1013 assert!(closed.meta.closed_at.is_some());
1014
1015 let (_, h) = store.read(1).unwrap();
1016 store.reopen(1, Some(h)).await.unwrap();
1017 let (reopened, _) = store.read(1).unwrap();
1018 assert_eq!(reopened.meta.status, Status::Open);
1019 assert!(
1020 reopened.meta.closed_at.is_none(),
1021 "reopen must clear closed_at (#4)"
1022 );
1023 }
1024
1025 #[tokio::test]
1026 async fn apply_patch_status_open_clears_closed_at() {
1027 let (_tmp, store) = tmp_store();
1029 store
1030 .create("T".into(), "b".into(), Priority::Low, vec![], None)
1031 .unwrap();
1032 let (_, h) = store.read(1).unwrap();
1033 store.start(1, "proc-X", Some(h)).await.unwrap();
1034 let (_, h) = store.read(1).unwrap();
1035 store.close(1, "proc-X", Some(h)).await.unwrap();
1036
1037 let (_, h) = store.read(1).unwrap();
1038 store
1039 .apply_patch(
1040 1,
1041 IssuePatch {
1042 status: Some(Status::Open),
1043 ..Default::default()
1044 },
1045 None,
1046 Some(h),
1047 )
1048 .await
1049 .unwrap();
1050 let (after, _) = store.read(1).unwrap();
1051 assert_eq!(after.meta.status, Status::Open);
1052 assert!(
1053 after.meta.closed_at.is_none(),
1054 "apply_patch status=Open must clear closed_at (#4)"
1055 );
1056 }
1057
1058 #[tokio::test]
1059 async fn noop_update_does_not_bump_timestamp() {
1060 let (_tmp, store) = tmp_store();
1063 store
1064 .create("T".into(), "b".into(), Priority::Low, vec![], None)
1065 .unwrap();
1066 let (before, _) = store.read(1).unwrap();
1067 let ts_before = before.meta.updated_at;
1068
1069 let (_, h) = store.read(1).unwrap();
1071 store
1072 .apply_patch(1, IssuePatch::default(), None, Some(h))
1073 .await
1074 .unwrap();
1075 let (after, _) = store.read(1).unwrap();
1076 assert_eq!(
1077 after.meta.updated_at, ts_before,
1078 "no-op update must not bump updated_at (#12)"
1079 );
1080
1081 std::thread::sleep(std::time::Duration::from_millis(5));
1083 let (_, h2) = store.read(1).unwrap();
1084 store
1085 .apply_patch(
1086 1,
1087 IssuePatch {
1088 title: Some("New".into()),
1089 ..Default::default()
1090 },
1091 None,
1092 Some(h2),
1093 )
1094 .await
1095 .unwrap();
1096 let (after2, _) = store.read(1).unwrap();
1097 assert_ne!(
1098 after2.meta.updated_at, ts_before,
1099 "real update must bump updated_at"
1100 );
1101 assert_eq!(after2.meta.title, "New");
1102 }
1103
1104 #[tokio::test]
1105 async fn apply_patch_labels_clear_vs_keep() {
1106 let (_tmp, store) = tmp_store();
1109 store
1110 .create(
1111 "T".into(),
1112 "b".into(),
1113 Priority::Low,
1114 vec!["a".into(), "b".into()],
1115 None,
1116 )
1117 .unwrap();
1118
1119 let (_, h) = store.read(1).unwrap();
1121 store
1122 .apply_patch(
1123 1,
1124 IssuePatch {
1125 priority: Some(Priority::High),
1126 ..Default::default()
1127 },
1128 None,
1129 Some(h),
1130 )
1131 .await
1132 .unwrap();
1133 let (kept, _) = store.read(1).unwrap();
1134 assert_eq!(kept.meta.labels, vec!["a".to_string(), "b".to_string()]);
1135 assert_eq!(kept.meta.priority, Priority::High);
1136
1137 let (_, h) = store.read(1).unwrap();
1139 store
1140 .apply_patch(
1141 1,
1142 IssuePatch {
1143 labels: Some(vec![]),
1144 ..Default::default()
1145 },
1146 None,
1147 Some(h),
1148 )
1149 .await
1150 .unwrap();
1151 let (cleared, _) = store.read(1).unwrap();
1152 assert!(cleared.meta.labels.is_empty(), "Some([]) must clear labels");
1153
1154 let (_, h) = store.read(1).unwrap();
1156 store
1157 .apply_patch(
1158 1,
1159 IssuePatch {
1160 labels: Some(vec!["z".into()]),
1161 ..Default::default()
1162 },
1163 None,
1164 Some(h),
1165 )
1166 .await
1167 .unwrap();
1168 let (replaced, _) = store.read(1).unwrap();
1169 assert_eq!(replaced.meta.labels, vec!["z".to_string()]);
1170 }
1171
1172 #[tokio::test]
1173 async fn apply_patch_enforces_ownership() {
1174 let (_tmp, store) = tmp_store();
1177 store
1178 .create("T".into(), "b".into(), Priority::Low, vec![], None)
1179 .unwrap();
1180 let (_, h) = store.read(1).unwrap();
1181 store.start(1, "proc-A", Some(h)).await.unwrap();
1182
1183 let (_, h) = store.read(1).unwrap();
1185 let err = store
1186 .apply_patch(
1187 1,
1188 IssuePatch {
1189 title: Some("X".into()),
1190 ..Default::default()
1191 },
1192 Some("proc-B".into()),
1193 Some(h),
1194 )
1195 .await
1196 .unwrap_err();
1197 assert!(
1198 matches!(err, IssueError::NotAssigned { ref caller, .. } if caller == "proc-B"),
1199 "non-owner must be rejected, got: {err:?}"
1200 );
1201
1202 let (_, h) = store.read(1).unwrap();
1204 store
1205 .apply_patch(
1206 1,
1207 IssuePatch {
1208 title: Some("X".into()),
1209 ..Default::default()
1210 },
1211 Some("proc-A".into()),
1212 Some(h),
1213 )
1214 .await
1215 .unwrap();
1216 let (patched, _) = store.read(1).unwrap();
1217 assert_eq!(patched.meta.title, "X");
1218 }
1219
1220 #[tokio::test]
1223 async fn top_free_priority_ignores_assigned_and_closed() {
1224 let (_tmp, store) = tmp_store();
1227 store
1228 .create("low".into(), "".into(), Priority::Low, vec![], None)
1229 .unwrap();
1230 store
1231 .create("high".into(), "".into(), Priority::High, vec![], None)
1232 .unwrap();
1233 store
1234 .create(
1235 "critical-assigned".into(),
1236 "".into(),
1237 Priority::Critical,
1238 vec![],
1239 None,
1240 )
1241 .unwrap();
1242 store
1243 .create(
1244 "critical-closed".into(),
1245 "".into(),
1246 Priority::Critical,
1247 vec![],
1248 None,
1249 )
1250 .unwrap();
1251
1252 let (_, h) = store.read(3).unwrap();
1254 store.start(3, "proc", Some(h)).await.unwrap();
1255 let (_, h) = store.read(4).unwrap();
1257 store.start(4, "proc", Some(h)).await.unwrap();
1258 let (_, h) = store.read(4).unwrap();
1259 store.close(4, "proc", Some(h)).await.unwrap();
1260
1261 assert_eq!(store.top_free_priority(), Some(Priority::High));
1263
1264 let (_, h) = store.read(1).unwrap();
1267 store.start(1, "proc", Some(h)).await.unwrap();
1268 let (_, h) = store.read(2).unwrap();
1269 store.start(2, "proc", Some(h)).await.unwrap();
1270 assert_eq!(
1271 store.top_free_priority(),
1272 None,
1273 "no open unassigned issue → None"
1274 );
1275 }
1276}