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::store::fs_util::atomic_write;
12use crate::store::issues::error::IssueError;
13use crate::store::issues::filter::IssueFilter;
14use crate::store::issues::liveness;
15use crate::store::issues::serialize::{
16 content_hash, issue_filename, issues_dir, parse_issue, serialize_issue,
17};
18use crate::store::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,
48 pub locked_open_count: usize,
49 pub top_priority: Option<Priority>,
50 pub top_free_priority: Option<Priority>,
53 pub latest_open_title: Option<String>,
54}
55
56impl IssueSummary {
57 pub fn is_empty(&self) -> bool {
58 self.open_count == 0
59 }
60}
61
62struct Inner {
64 issues_dir: PathBuf,
65 cache: Cache,
66}
67
68impl Cache {
69 fn empty() -> Self {
70 Self {
71 open_count: 0,
72 latest_open_title: None,
73 locked_open_count: 0,
74 top_priority: None,
75 top_free_priority: None,
76 dir_mtime: None,
77 }
78 }
79}
80
81impl std::fmt::Debug for Inner {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 f.debug_struct("Inner")
84 .field("issues_dir", &self.issues_dir)
85 .finish()
86 }
87}
88
89#[derive(Clone, Debug)]
96pub struct FileIssueStore {
97 inner: Arc<RwLock<Inner>>,
98}
99
100impl FileIssueStore {
101 pub fn open(issues_dir: PathBuf) -> Result<Self> {
103 if let Err(e) = liveness::reap_orphans(&issues_dir) {
107 tracing::warn!(error = %e, "issue liveness reap failed (non-fatal)");
108 }
109 Ok(Self {
110 inner: Arc::new(RwLock::new(Inner {
111 issues_dir,
112 cache: Cache::default(),
113 })),
114 })
115 }
116
117 pub fn open_from_cwd(start: &Path) -> Result<Self> {
119 Self::open(issues_dir(start))
120 }
121
122 pub fn issues_dir(&self) -> PathBuf {
124 self.inner.read().issues_dir.clone()
125 }
126
127 pub fn open_count(&self) -> usize {
130 self.refresh_if_stale();
131 self.inner.read().cache.open_count
132 }
133
134 pub fn latest_open_title(&self) -> Option<String> {
138 self.refresh_if_stale();
139 self.inner.read().cache.latest_open_title.clone()
140 }
141
142 pub fn summary(&self) -> IssueSummary {
145 self.refresh_if_stale();
146 let g = self.inner.read();
147 IssueSummary {
148 open_count: g.cache.open_count,
149 locked_open_count: g.cache.locked_open_count,
150 top_priority: g.cache.top_priority,
151 top_free_priority: g.cache.top_free_priority,
152 latest_open_title: g.cache.latest_open_title.clone(),
153 }
154 }
155
156 pub fn top_free_priority(&self) -> Option<Priority> {
162 self.refresh_if_stale();
163 self.inner.read().cache.top_free_priority
164 }
165
166 pub fn has_any(&self) -> bool {
169 self.refresh_if_stale();
170 let dir = self.inner.read().issues_dir.clone();
171 fs::read_dir(&dir)
172 .map(|rd| {
173 rd.filter_map(|e| e.ok())
174 .any(|e| e.path().extension().and_then(|x| x.to_str()) == Some("md"))
175 })
176 .unwrap_or(false)
177 }
178
179 fn refresh_if_stale(&self) {
181 let dir = self.inner.read().issues_dir.clone();
182 let cur_dir_mtime = fs::metadata(&dir).and_then(|m| m.modified()).ok();
183 let needs = {
184 let g = self.inner.read();
185 match (g.cache.dir_mtime, cur_dir_mtime) {
186 (None, _) => true, (Some(_), None) => false, (Some(cached), Some(cur)) => cached != cur,
189 }
190 };
191 if !needs {
192 return;
193 }
194 let mut open_count = 0;
196 let mut locked_open_count = 0;
197 let mut top_priority: Option<Priority> = None;
198 let mut latest_open_title: Option<String> = None;
199 let mut latest_open_updated: Option<chrono::DateTime<chrono::Utc>> = None;
200 let mut top_free_priority: Option<Priority> = None;
201 if let Ok(rd) = fs::read_dir(&dir) {
202 for entry in rd.flatten() {
203 let p = entry.path();
204 if p.extension().and_then(|x| x.to_str()) != Some("md") {
205 continue;
206 }
207 if let Ok(raw) = fs::read_to_string(&p)
210 && let Ok(issue) = parse_issue(&raw, None)
211 && issue.meta.status == Status::Open
212 {
213 open_count += 1;
214 if issue.meta.assigned_to.is_some() {
215 locked_open_count += 1;
216 }
217 top_priority = Some(match top_priority {
219 Some(existing) => existing.max(issue.meta.priority),
220 None => issue.meta.priority,
221 });
222 if issue.meta.updated_at
223 > latest_open_updated.unwrap_or(chrono::DateTime::<chrono::Utc>::MIN_UTC)
224 {
225 latest_open_updated = Some(issue.meta.updated_at);
226 latest_open_title = Some(issue.meta.title);
227 }
228 if issue.meta.assigned_to.is_none() {
230 top_free_priority = Some(match top_free_priority {
231 Some(cur) if cur >= issue.meta.priority => cur,
232 _ => issue.meta.priority,
233 });
234 }
235 }
236 }
237 }
238 let mut g = self.inner.write();
239 g.cache = Cache {
240 open_count,
241 latest_open_title,
242 locked_open_count,
243 top_priority,
244 top_free_priority,
245 dir_mtime: cur_dir_mtime,
246 };
247 }
248
249 pub fn invalidate(&self) {
251 self.inner.write().cache = Cache::default();
252 }
253
254 pub fn list(&self, filter: &IssueFilter) -> Result<Vec<Issue>> {
258 self.refresh_if_stale();
259 let dir = self.inner.read().issues_dir.clone();
260 let mut out = Vec::new();
261 if let Ok(rd) = fs::read_dir(&dir) {
262 for entry in rd.flatten() {
263 let p = entry.path();
264 if p.extension().and_then(|x| x.to_str()) != Some("md") {
265 continue;
266 }
267 let raw = fs::read_to_string(&p)?;
268 let issue = parse_issue(&raw, Some(p.clone()))?;
269 if filter.matches(&issue) {
270 out.push(issue);
271 }
272 }
273 }
274 out.sort_by_key(|i| std::cmp::Reverse(i.meta.updated_at));
275 Ok(out)
276 }
277
278 pub fn read(&self, id: u32) -> Result<(Issue, String)> {
281 let path = self.path_for_id(id)?;
282 let raw = fs::read_to_string(&path)
283 .with_context(|| format!("issue #{} not found at {}", id, path.display()))?;
284 let issue = parse_issue(&raw, Some(path))?;
285 Ok((issue, content_hash(&raw)))
286 }
287
288 pub fn next_id(&self) -> Result<u32> {
297 let dir = self.inner.read().issues_dir.clone();
298 fs::create_dir_all(&dir)?;
299 let mut max = 0u32;
300 if let Ok(rd) = fs::read_dir(&dir) {
301 for entry in rd.flatten() {
302 let name = entry.file_name();
303 let name = name.to_string_lossy();
304 let num_str = name.split('-').next().unwrap_or(&name);
305 if let Ok(n) = num_str.trim_end_matches(".md").parse::<u32>() {
306 max = max.max(n);
307 }
308 }
309 }
310 Ok(max + 1)
311 }
312
313 pub fn create(
315 &self,
316 title: String,
317 body: String,
318 priority: Priority,
319 labels: Vec<String>,
320 caller_session: Option<&str>,
321 ) -> Result<Issue> {
322 let id = self.next_id()?;
323 let now = Utc::now();
324 let sessions = caller_session
325 .map(|s| vec![s.to_string()])
326 .unwrap_or_default();
327 let issue = Issue {
328 meta: IssueMeta {
329 id,
330 title,
331 status: Status::Open,
332 priority,
333 labels,
334 assignee: None,
335 created_at: now,
336 updated_at: now,
337 closed_at: None,
338 sessions,
339 assigned_to: None,
340 github: None,
341 },
342 body,
343 path: None,
344 };
345 for _ in 0..4 {
347 let path = self
348 .issues_dir()
349 .join(issue_filename(id, &issue.meta.title));
350 if path.exists() {
351 continue;
353 }
354 let content = serialize_issue(&issue)?;
355 atomic_write(&path, &content)?;
356 self.invalidate();
357 let mut saved = issue.clone();
358 saved.path = Some(path);
359 return Ok(saved);
360 }
361 anyhow::bail!("could not allocate a free issue id after retries");
362 }
363
364 pub async fn update<F>(
373 &self,
374 id: u32,
375 expected_hash: Option<String>,
376 mutator: F,
377 ) -> std::result::Result<Issue, IssueError>
378 where
379 F: FnOnce(Issue) -> std::result::Result<Issue, IssueError> + Send + 'static,
380 {
381 let path = self.path_for_id(id).map_err(IssueError::Other)?;
382 let path_for_closure = path.clone();
383 let store = self.clone();
384 oxicode_agent::tools::file_mutation_queue::global_mutation_queue()
386 .with_queue(&path, move || async move {
387 let path = path_for_closure;
388 let raw = fs::read_to_string(&path)?;
389 if let Some(expected) = expected_hash.as_deref()
390 && content_hash(&raw) != expected
391 {
392 return Err(IssueError::Conflict { id });
393 }
394 let before = parse_issue(&raw, Some(path.clone())).map_err(IssueError::Other)?;
395 let before_updated_at = before.meta.updated_at;
396 let before_bytes = serialize_issue(&before).map_err(IssueError::Other)?;
397 let after = mutator(before)?;
398
399 let mut probe = after.clone();
406 probe.meta.updated_at = before_updated_at;
407 let probe_bytes = serialize_issue(&probe).map_err(IssueError::Other)?;
408 if probe_bytes == before_bytes {
409 return Ok(after.with_path(path));
410 }
411
412 let mut final_issue = after;
413 final_issue.meta.updated_at = Utc::now();
414 let content = serialize_issue(&final_issue).map_err(IssueError::Other)?;
415 atomic_write(&path, &content)?;
416 store.invalidate();
417 Ok(final_issue.with_path(path))
418 })
419 .await
420 }
421
422 pub async fn close(
424 &self,
425 id: u32,
426 caller: &str,
427 expected_hash: Option<String>,
428 ) -> std::result::Result<Issue, IssueError> {
429 let now = Utc::now();
430 let caller = caller.to_string();
431 self.update(id, expected_hash, move |mut issue| {
432 require_owner(&issue, id, &caller)?;
433 issue.meta.status = Status::Closed;
434 issue.meta.closed_at = Some(now);
435 issue.meta.assigned_to = None; Ok(issue)
437 })
438 .await
439 }
440
441 pub async fn reopen(
447 &self,
448 id: u32,
449 expected_hash: Option<String>,
450 ) -> std::result::Result<Issue, IssueError> {
451 self.update(id, expected_hash, move |mut issue| {
452 if issue.meta.status == Status::Open {
453 return Ok(issue);
456 }
457 issue.meta.status = Status::Open;
458 issue.meta.closed_at = None;
459 issue.meta.assigned_to = None;
460 Ok(issue)
461 })
462 .await
463 }
464
465 pub async fn start(
471 &self,
472 id: u32,
473 caller: &str,
474 expected_hash: Option<String>,
475 ) -> std::result::Result<Issue, IssueError> {
476 let issues_dir = self.issues_dir();
477 let caller_owned = caller.to_string();
478 self.update(id, expected_hash, move |mut issue| {
479 if let Some(ref a) = issue.meta.assigned_to {
480 if a.session == caller_owned {
481 return Ok(issue);
483 }
484 if liveness::is_session_alive(&issues_dir, &a.session) {
485 return Err(IssueError::Assigned {
486 id,
487 owner: a.session.clone(),
488 acquired_at: a.acquired_at,
489 });
490 }
491 }
493 issue.meta.assigned_to = Some(Assignment {
494 session: caller_owned.clone(),
495 acquired_at: Utc::now(),
496 });
497 if !issue.meta.sessions.contains(&caller_owned) {
499 issue.meta.sessions.push(caller_owned.clone());
500 }
501 Ok(issue)
502 })
503 .await
504 }
505
506 pub async fn release(
508 &self,
509 id: u32,
510 caller: &str,
511 expected_hash: Option<String>,
512 ) -> std::result::Result<Issue, IssueError> {
513 let caller = caller.to_string();
514 self.update(id, expected_hash, move |mut issue| {
515 require_owner(&issue, id, &caller)?;
516 issue.meta.assigned_to = None;
517 Ok(issue)
518 })
519 .await
520 }
521
522 pub async fn link_session(
524 &self,
525 id: u32,
526 session: &str,
527 expected_hash: Option<String>,
528 ) -> std::result::Result<Issue, IssueError> {
529 let session = session.to_string();
530 self.update(id, expected_hash, move |mut issue| {
531 if !issue.meta.sessions.contains(&session) {
532 issue.meta.sessions.push(session);
533 }
534 Ok(issue)
535 })
536 .await
537 }
538
539 pub async fn apply_patch(
552 &self,
553 id: u32,
554 patch: IssuePatch,
555 caller: Option<String>,
556 expected_hash: Option<String>,
557 ) -> std::result::Result<Issue, IssueError> {
558 self.update(id, expected_hash, move |mut issue| {
559 if let Some(caller) = caller.as_deref()
560 && let Some(ref a) = issue.meta.assigned_to
561 && !a.session.is_empty()
562 && a.session != caller
563 {
564 return Err(IssueError::NotAssigned {
565 id,
566 caller: caller.to_string(),
567 });
568 }
569 if let Some(t) = patch.title {
570 issue.meta.title = t;
571 }
572 if let Some(b) = patch.body {
573 issue.body = b;
574 }
575 if let Some(s) = patch.status {
576 issue.meta.status = s;
577 issue.meta.closed_at = match s {
578 Status::Closed => Some(Utc::now()),
579 Status::Open => None, };
581 }
582 if let Some(p) = patch.priority {
583 issue.meta.priority = p;
584 }
585 if let Some(l) = patch.labels {
586 issue.meta.labels = l;
587 }
588 Ok(issue)
589 })
590 .await
591 }
592
593 fn path_for_id(&self, id: u32) -> Result<PathBuf> {
596 let dir = self.inner.read().issues_dir.clone();
597 if let Ok(rd) = fs::read_dir(&dir) {
599 for entry in rd.flatten() {
600 let name = entry.file_name();
601 let name = name.to_string_lossy();
602 let num_str = name.split('-').next().unwrap_or(&name);
603 if num_str.trim_end_matches(".md").parse::<u32>().ok() == Some(id) {
604 return Ok(entry.path());
605 }
606 }
607 }
608 Err(anyhow::anyhow!(IssueError::NotFound { id }))
609 }
610}
611
612trait WithPath {
614 fn with_path(self, path: PathBuf) -> Self;
615}
616
617impl WithPath for Issue {
618 fn with_path(mut self, path: PathBuf) -> Self {
619 self.path = Some(path);
620 self
621 }
622}
623
624fn require_owner(issue: &Issue, id: u32, caller: &str) -> std::result::Result<(), IssueError> {
626 match &issue.meta.assigned_to {
627 Some(a) if a.session == caller => Ok(()),
628 _ => Err(IssueError::NotAssigned {
629 id,
630 caller: caller.to_string(),
631 }),
632 }
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638
639 fn sample_meta(id: u32, title: &str, priority: Priority) -> IssueMeta {
640 let now = Utc::now();
641 IssueMeta {
642 id,
643 title: title.into(),
644 status: Status::Open,
645 priority,
646 labels: vec![],
647 assignee: None,
648 created_at: now,
649 updated_at: now,
650 closed_at: None,
651 sessions: vec![],
652 assigned_to: None,
653 github: None,
654 }
655 }
656
657 fn tmp_store() -> (tempfile::TempDir, FileIssueStore) {
658 let tmp = tempfile::tempdir().unwrap();
659 let dir = tmp.path().join(".oxicode").join("issues");
660 fs::create_dir_all(&dir).unwrap();
661 let store = FileIssueStore::open(dir).unwrap();
662 (tmp, store)
663 }
664
665 #[test]
666 fn roundtrip_serialization() {
667 let issue = Issue {
668 meta: sample_meta(1, "Test", Priority::High),
669 body: "## Body\n\nHello.".into(),
670 path: None,
671 };
672 let s = serialize_issue(&issue).unwrap();
673 assert!(s.starts_with("---\n"));
674 let parsed = parse_issue(&s, None).unwrap();
675 assert_eq!(parsed.meta.id, 1);
676 assert_eq!(parsed.meta.title, "Test");
677 assert_eq!(parsed.meta.priority, Priority::High);
678 assert!(parsed.body.contains("Hello."));
679 }
680
681 #[tokio::test]
682 async fn create_read_list() {
683 let (_tmp, store) = tmp_store();
684 let created = store
685 .create(
686 "Fix bug".into(),
687 "body".into(),
688 Priority::High,
689 vec![],
690 None,
691 )
692 .unwrap();
693 assert_eq!(created.meta.id, 1);
694
695 let (read, hash) = store.read(1).unwrap();
696 assert_eq!(read.meta.title, "Fix bug");
697 assert!(!hash.is_empty());
698
699 let list = store.list(&IssueFilter::default()).unwrap();
700 assert_eq!(list.len(), 1);
701 }
702
703 #[tokio::test]
704 async fn content_hash_detects_conflict() {
705 let (_tmp, store) = tmp_store();
706 store
707 .create("Orig".into(), "b".into(), Priority::Low, vec![], None)
708 .unwrap();
709 let (_, hash) = store.read(1).unwrap();
710
711 let wrong = Some("deadbeefdeadbeef".to_string());
713 let err = store
714 .update(1, wrong, |_| {
715 Ok(Issue {
716 meta: sample_meta(1, "x", Priority::Low),
717 body: "x".into(),
718 path: None,
719 })
720 })
721 .await
722 .unwrap_err();
723 assert!(matches!(err, IssueError::Conflict { id: 1 }));
724
725 let _ok = store
727 .update(1, Some(hash), |mut i| {
728 i.meta.title = "Updated".into();
729 Ok(i)
730 })
731 .await
732 .unwrap();
733 let (read, _) = store.read(1).unwrap();
734 assert_eq!(read.meta.title, "Updated");
735 }
736
737 #[tokio::test]
738 async fn start_rejects_live_owner() {
739 let (_tmp, store) = tmp_store();
740 store
741 .create("T".into(), "b".into(), Priority::Low, vec![], None)
742 .unwrap();
743 let issues_dir = store.issues_dir();
744 let _guard_a = liveness::acquire(&issues_dir, "sessionA").unwrap();
746 let (_, hash) = store.read(1).unwrap();
748 store.start(1, "sessionA", Some(hash)).await.unwrap();
749
750 let (_, hash2) = store.read(1).unwrap();
752 let err = store.start(1, "sessionB", Some(hash2)).await.unwrap_err();
753 assert!(matches!(err, IssueError::Assigned { owner, .. } if owner == "sessionA"));
754 }
755
756 #[tokio::test]
757 async fn start_reclaims_dead_owner() {
758 let (_tmp, store) = tmp_store();
759 store
760 .create("T".into(), "b".into(), Priority::Low, vec![], None)
761 .unwrap();
762 let issues_dir = store.issues_dir();
763
764 {
766 let _g = liveness::acquire(&issues_dir, "sessionA").unwrap();
767 let (_, h) = store.read(1).unwrap();
768 store.start(1, "sessionA", Some(h)).await.unwrap();
769 } let (_, hash) = store.read(1).unwrap();
772 let reclaimed = store.start(1, "sessionB", Some(hash)).await.unwrap();
773 assert_eq!(
774 reclaimed.meta.assigned_to.as_ref().unwrap().session,
775 "sessionB"
776 );
777 }
778
779 #[tokio::test]
780 async fn close_requires_owner() {
781 let (_tmp, store) = tmp_store();
782 store
783 .create("T".into(), "b".into(), Priority::Low, vec![], None)
784 .unwrap();
785 let (_, hash) = store.read(1).unwrap();
786 store.start(1, "sessionA", Some(hash)).await.unwrap();
787
788 let (_, hash2) = store.read(1).unwrap();
790 let err = store.close(1, "sessionB", Some(hash2)).await.unwrap_err();
791 assert!(matches!(err, IssueError::NotAssigned { .. }));
792
793 let (_, hash3) = store.read(1).unwrap();
795 let closed = store.close(1, "sessionA", Some(hash3)).await.unwrap();
796 assert_eq!(closed.meta.status, Status::Closed);
797 assert!(closed.meta.assigned_to.is_none());
798 }
799
800 #[tokio::test]
801 async fn reopen_flips_closed_to_open() {
802 let (_tmp, store) = tmp_store();
803 let issues_dir = store.issues_dir();
804 let _guard = crate::store::issues::liveness::acquire(&issues_dir, "tui").unwrap();
805 store
806 .create("T".into(), "b".into(), Priority::Low, vec![], None)
807 .unwrap();
808 let (_, h) = store.read(1).unwrap();
810 store.start(1, "tui", Some(h)).await.unwrap();
811 let (_, h) = store.read(1).unwrap();
812 store.close(1, "tui", Some(h)).await.unwrap();
813 let (_, h) = store.read(1).unwrap();
815 let reopened = store.reopen(1, Some(h)).await.unwrap();
816 assert_eq!(reopened.meta.status, Status::Open);
817 assert!(reopened.meta.closed_at.is_none());
818 assert!(reopened.meta.assigned_to.is_none());
819 }
820
821 #[tokio::test]
822 async fn reopen_is_idempotent_on_already_open() {
823 let (_tmp, store) = tmp_store();
824 store
825 .create("T".into(), "b".into(), Priority::Low, vec![], None)
826 .unwrap();
827 let (_, h) = store.read(1).unwrap();
828 let reopened = store.reopen(1, Some(h)).await.unwrap();
830 assert_eq!(reopened.meta.status, Status::Open);
831 assert!(reopened.meta.closed_at.is_none());
832 }
833
834 #[tokio::test]
835 async fn open_count_caches() {
836 let (_tmp, store) = tmp_store();
837 assert_eq!(store.open_count(), 0);
838 store
839 .create("A".into(), "b".into(), Priority::Low, vec![], None)
840 .unwrap();
841 store
842 .create("B".into(), "b".into(), Priority::Low, vec![], None)
843 .unwrap();
844 assert_eq!(store.open_count(), 2);
845
846 let issues_dir = store.issues_dir();
848 let _guard = liveness::acquire(&issues_dir, "sessionA").unwrap();
849 let (_, h) = store.read(1).unwrap();
850 store.start(1, "sessionA", Some(h)).await.unwrap();
851 let (_, h) = store.read(1).unwrap();
852 store.close(1, "sessionA", Some(h)).await.unwrap();
853 store.invalidate();
854 assert_eq!(store.open_count(), 1);
855 }
856
857 #[tokio::test]
858 async fn summary_reflects_lock_and_priority() {
859 let (_tmp, store) = tmp_store();
860 let issues_dir = store.issues_dir();
861 let _guard = liveness::acquire(&issues_dir, "sessionA").unwrap();
862 store
864 .create("Lowly".into(), "".into(), Priority::Low, vec![], None)
865 .unwrap();
866 store
867 .create("Crit".into(), "".into(), Priority::Critical, vec![], None)
868 .unwrap();
869 store
871 .create("Closed".into(), "".into(), Priority::Medium, vec![], None)
872 .unwrap();
873 let (_, h) = store.read(3).unwrap();
874 store.start(3, "sessionA", Some(h)).await.unwrap();
875 let (_, h) = store.read(3).unwrap();
876 store.close(3, "sessionA", Some(h)).await.unwrap();
877 let (_, h) = store.read(1).unwrap();
879 store.start(1, "sessionA", Some(h)).await.unwrap();
880 store.invalidate();
881
882 let s = store.summary();
883 assert_eq!(s.open_count, 2);
884 assert_eq!(s.locked_open_count, 1);
885 assert_eq!(s.top_priority, Some(Priority::Critical));
886 assert!(s.latest_open_title.is_some());
887 assert!(!s.is_empty());
888 }
889
890 #[tokio::test]
891 async fn summary_empty_when_no_issues() {
892 let (_tmp, store) = tmp_store();
893 let s = store.summary();
894 assert_eq!(s.open_count, 0);
895 assert_eq!(s.locked_open_count, 0);
896 assert!(s.top_priority.is_none());
897 assert!(s.latest_open_title.is_none());
898 assert!(s.is_empty());
899 }
900
901 #[tokio::test]
902 async fn latest_open_title_caches_and_handles_cjk() {
903 let (_tmp, store) = tmp_store();
904 assert!(store.latest_open_title().is_none());
906
907 let cjk_title =
912 "버그 수정: 한글 제목도 정상이어야 합니다 — 멀티바이트 인코딩 안전성".to_string();
913 let cjk_body =
914 "요약\n\n이 이슈는 한글 본문을 포함합니다. 본문에는 영문과 한글이 섞여 있습니다. "
915 .repeat(4);
916 let created = store
917 .create(cjk_title.clone(), cjk_body, Priority::High, vec![], None)
918 .unwrap();
919 assert_eq!(created.meta.title, cjk_title);
920
921 let title = store.latest_open_title();
923 assert_eq!(title.as_deref(), Some(cjk_title.as_str()));
924
925 let (read_back, _hash) = store.read(created.meta.id).unwrap();
927 assert!(read_back.body.contains("한글"));
928 }
929
930 #[tokio::test]
940 async fn start_with_distinct_live_owners_collides() {
941 let (_tmp, store) = tmp_store();
946 let issues_dir = store.issues_dir();
947 store
948 .create("T".into(), "b".into(), Priority::Low, vec![], None)
949 .unwrap();
950
951 let _guard_a = liveness::acquire(&issues_dir, "proc-A").unwrap();
953 let (_, h) = store.read(1).unwrap();
954 store.start(1, "proc-A", Some(h)).await.unwrap();
955
956 let _guard_b = liveness::acquire(&issues_dir, "proc-B").unwrap();
958 let (_, h2) = store.read(1).unwrap();
959 let err = store.start(1, "proc-B", Some(h2)).await.unwrap_err();
960 assert!(
961 matches!(err, IssueError::Assigned { ref owner, .. } if owner == "proc-A"),
962 "a second distinct live owner must be rejected, got: {err:?}"
963 );
964 }
965
966 #[tokio::test]
967 async fn empty_session_assignment_is_immediately_reclaimable_documentation() {
968 let (_tmp, store) = tmp_store();
977 store
978 .create("T".into(), "b".into(), Priority::Low, vec![], None)
979 .unwrap();
980 let issues_dir = store.issues_dir();
981
982 let (_, h) = store.read(1).unwrap();
984 store.start(1, "", Some(h)).await.unwrap();
985
986 assert!(
988 !liveness::is_session_alive(&issues_dir, ""),
989 "no flock can be held under the empty string"
990 );
991
992 let _guard_c = liveness::acquire(&issues_dir, "proc-C").unwrap();
996 let (_, h2) = store.read(1).unwrap();
997 let reclaimed = store.start(1, "proc-C", Some(h2)).await.unwrap();
998 assert_eq!(
999 reclaimed.meta.assigned_to.as_ref().unwrap().session,
1000 "proc-C",
1001 "empty-string assignment is reclaimable — this is the #13 bug shape"
1002 );
1003 }
1004
1005 #[tokio::test]
1008 async fn reopen_clears_closed_at() {
1009 let (_tmp, store) = tmp_store();
1012 store
1013 .create("T".into(), "b".into(), Priority::Low, vec![], None)
1014 .unwrap();
1015 let (_, h) = store.read(1).unwrap();
1016 store.start(1, "proc-X", Some(h)).await.unwrap();
1017 let (_, h) = store.read(1).unwrap();
1018 store.close(1, "proc-X", Some(h)).await.unwrap();
1019 let (closed, _) = store.read(1).unwrap();
1020 assert_eq!(closed.meta.status, Status::Closed);
1021 assert!(closed.meta.closed_at.is_some());
1022
1023 let (_, h) = store.read(1).unwrap();
1024 store.reopen(1, Some(h)).await.unwrap();
1025 let (reopened, _) = store.read(1).unwrap();
1026 assert_eq!(reopened.meta.status, Status::Open);
1027 assert!(
1028 reopened.meta.closed_at.is_none(),
1029 "reopen must clear closed_at (#4)"
1030 );
1031 }
1032
1033 #[tokio::test]
1034 async fn apply_patch_status_open_clears_closed_at() {
1035 let (_tmp, store) = tmp_store();
1037 store
1038 .create("T".into(), "b".into(), Priority::Low, vec![], None)
1039 .unwrap();
1040 let (_, h) = store.read(1).unwrap();
1041 store.start(1, "proc-X", Some(h)).await.unwrap();
1042 let (_, h) = store.read(1).unwrap();
1043 store.close(1, "proc-X", Some(h)).await.unwrap();
1044
1045 let (_, h) = store.read(1).unwrap();
1046 store
1047 .apply_patch(
1048 1,
1049 IssuePatch {
1050 status: Some(Status::Open),
1051 ..Default::default()
1052 },
1053 None,
1054 Some(h),
1055 )
1056 .await
1057 .unwrap();
1058 let (after, _) = store.read(1).unwrap();
1059 assert_eq!(after.meta.status, Status::Open);
1060 assert!(
1061 after.meta.closed_at.is_none(),
1062 "apply_patch status=Open must clear closed_at (#4)"
1063 );
1064 }
1065
1066 #[tokio::test]
1067 async fn noop_update_does_not_bump_timestamp() {
1068 let (_tmp, store) = tmp_store();
1071 store
1072 .create("T".into(), "b".into(), Priority::Low, vec![], None)
1073 .unwrap();
1074 let (before, _) = store.read(1).unwrap();
1075 let ts_before = before.meta.updated_at;
1076
1077 let (_, h) = store.read(1).unwrap();
1079 store
1080 .apply_patch(1, IssuePatch::default(), None, Some(h))
1081 .await
1082 .unwrap();
1083 let (after, _) = store.read(1).unwrap();
1084 assert_eq!(
1085 after.meta.updated_at, ts_before,
1086 "no-op update must not bump updated_at (#12)"
1087 );
1088
1089 std::thread::sleep(std::time::Duration::from_millis(5));
1091 let (_, h2) = store.read(1).unwrap();
1092 store
1093 .apply_patch(
1094 1,
1095 IssuePatch {
1096 title: Some("New".into()),
1097 ..Default::default()
1098 },
1099 None,
1100 Some(h2),
1101 )
1102 .await
1103 .unwrap();
1104 let (after2, _) = store.read(1).unwrap();
1105 assert_ne!(
1106 after2.meta.updated_at, ts_before,
1107 "real update must bump updated_at"
1108 );
1109 assert_eq!(after2.meta.title, "New");
1110 }
1111
1112 #[tokio::test]
1113 async fn apply_patch_labels_clear_vs_keep() {
1114 let (_tmp, store) = tmp_store();
1117 store
1118 .create(
1119 "T".into(),
1120 "b".into(),
1121 Priority::Low,
1122 vec!["a".into(), "b".into()],
1123 None,
1124 )
1125 .unwrap();
1126
1127 let (_, h) = store.read(1).unwrap();
1129 store
1130 .apply_patch(
1131 1,
1132 IssuePatch {
1133 priority: Some(Priority::High),
1134 ..Default::default()
1135 },
1136 None,
1137 Some(h),
1138 )
1139 .await
1140 .unwrap();
1141 let (kept, _) = store.read(1).unwrap();
1142 assert_eq!(kept.meta.labels, vec!["a".to_string(), "b".to_string()]);
1143 assert_eq!(kept.meta.priority, Priority::High);
1144
1145 let (_, h) = store.read(1).unwrap();
1147 store
1148 .apply_patch(
1149 1,
1150 IssuePatch {
1151 labels: Some(vec![]),
1152 ..Default::default()
1153 },
1154 None,
1155 Some(h),
1156 )
1157 .await
1158 .unwrap();
1159 let (cleared, _) = store.read(1).unwrap();
1160 assert!(cleared.meta.labels.is_empty(), "Some([]) must clear labels");
1161
1162 let (_, h) = store.read(1).unwrap();
1164 store
1165 .apply_patch(
1166 1,
1167 IssuePatch {
1168 labels: Some(vec!["z".into()]),
1169 ..Default::default()
1170 },
1171 None,
1172 Some(h),
1173 )
1174 .await
1175 .unwrap();
1176 let (replaced, _) = store.read(1).unwrap();
1177 assert_eq!(replaced.meta.labels, vec!["z".to_string()]);
1178 }
1179
1180 #[tokio::test]
1181 async fn apply_patch_enforces_ownership() {
1182 let (_tmp, store) = tmp_store();
1185 store
1186 .create("T".into(), "b".into(), Priority::Low, vec![], None)
1187 .unwrap();
1188 let (_, h) = store.read(1).unwrap();
1189 store.start(1, "proc-A", Some(h)).await.unwrap();
1190
1191 let (_, h) = store.read(1).unwrap();
1193 let err = store
1194 .apply_patch(
1195 1,
1196 IssuePatch {
1197 title: Some("X".into()),
1198 ..Default::default()
1199 },
1200 Some("proc-B".into()),
1201 Some(h),
1202 )
1203 .await
1204 .unwrap_err();
1205 assert!(
1206 matches!(err, IssueError::NotAssigned { ref caller, .. } if caller == "proc-B"),
1207 "non-owner must be rejected, got: {err:?}"
1208 );
1209
1210 let (_, h) = store.read(1).unwrap();
1212 store
1213 .apply_patch(
1214 1,
1215 IssuePatch {
1216 title: Some("X".into()),
1217 ..Default::default()
1218 },
1219 Some("proc-A".into()),
1220 Some(h),
1221 )
1222 .await
1223 .unwrap();
1224 let (patched, _) = store.read(1).unwrap();
1225 assert_eq!(patched.meta.title, "X");
1226 }
1227
1228 #[tokio::test]
1231 async fn top_free_priority_ignores_assigned_and_closed() {
1232 let (_tmp, store) = tmp_store();
1235 store
1236 .create("low".into(), "".into(), Priority::Low, vec![], None)
1237 .unwrap();
1238 store
1239 .create("high".into(), "".into(), Priority::High, vec![], None)
1240 .unwrap();
1241 store
1242 .create(
1243 "critical-assigned".into(),
1244 "".into(),
1245 Priority::Critical,
1246 vec![],
1247 None,
1248 )
1249 .unwrap();
1250 store
1251 .create(
1252 "critical-closed".into(),
1253 "".into(),
1254 Priority::Critical,
1255 vec![],
1256 None,
1257 )
1258 .unwrap();
1259
1260 let (_, h) = store.read(3).unwrap();
1262 store.start(3, "proc", Some(h)).await.unwrap();
1263 let (_, h) = store.read(4).unwrap();
1265 store.start(4, "proc", Some(h)).await.unwrap();
1266 let (_, h) = store.read(4).unwrap();
1267 store.close(4, "proc", Some(h)).await.unwrap();
1268
1269 assert_eq!(store.top_free_priority(), Some(Priority::High));
1271
1272 let (_, h) = store.read(1).unwrap();
1275 store.start(1, "proc", Some(h)).await.unwrap();
1276 let (_, h) = store.read(2).unwrap();
1277 store.start(2, "proc", Some(h)).await.unwrap();
1278 assert_eq!(
1279 store.top_free_priority(),
1280 None,
1281 "no open unassigned issue → None"
1282 );
1283 }
1284}