Skip to main content

oxicode/store/issues/
store.rs

1//! File-backed issue store + cached summary view.
2
3use 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/// Cached directory listing, so the status-bar indicator doesn't readdir the
21/// issues dir every render frame. `dir_mtime` is the single invalidation
22/// signal; per-file mtimes aren't tracked (CAS uses content-hash on writes).
23#[derive(Debug, Default, Clone)]
24struct Cache {
25    /// `open` issue count (the number shown in the status bar).
26    open_count: usize,
27    /// Title of the most recently updated open issue (for the indicator).
28    latest_open_title: Option<String>,
29    /// Number of currently-assigned (locked) open issues. Computed at the
30    /// same time as `open_count` so the indicator can show "3 open · 1 ▣".
31    locked_open_count: usize,
32    /// Highest priority among open issues (None if no open issues).
33    /// Used for the priority dot in the footer indicator.
34    top_priority: Option<Priority>,
35    /// Highest priority among open AND *unassigned* issues — the "most
36    /// actionable thing right now" signal (#10). `None` when no open issue is
37    /// free. Distinct from `top_priority` (overall open max): this excludes
38    /// issues someone is already working on.
39    top_free_priority: Option<Priority>,
40    dir_mtime: Option<std::time::SystemTime>,
41}
42
43/// Summary view exposed for UI consumers (footer indicator, panel header).
44/// Cheap to construct — values come straight from the in-memory cache.
45#[derive(Debug, Clone)]
46pub struct IssueSummary {
47    pub open_count: usize,
48    pub locked_open_count: usize,
49    pub top_priority: Option<Priority>,
50    /// Highest priority among open + *unassigned* issues (#10). Distinct from
51    /// `top_priority` (overall open max): excludes issues someone works on.
52    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
62/// In-memory state for [`FileIssueStore`].
63struct 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/// File-backed issue store.
90///
91/// One instance is shared (via `Arc`) between the TUI indicator, the agent
92/// `issue` tool, and the `oxicode issue` CLI subcommand. All mutations go through
93/// [`FileIssueStore::create`] / [`FileIssueStore::update`] which serialize per-file
94/// content-hash CAS (cross-process / external edits).
95#[derive(Clone, Debug)]
96pub struct FileIssueStore {
97    inner: Arc<RwLock<Inner>>,
98}
99
100impl FileIssueStore {
101    /// Open (or create lazily) the issue store rooted at `issues_dir`.
102    pub fn open(issues_dir: PathBuf) -> Result<Self> {
103        // Best-effort: clear zombie alive-lock files left by crashed/killed
104        // processes (#8). Lazy + idempotent + age-gated; failures are a
105        // warn log only and never block store construction.
106        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    /// Open using project-root discovery from `start` (cwd).
118    pub fn open_from_cwd(start: &Path) -> Result<Self> {
119        Self::open(issues_dir(start))
120    }
121
122    /// The issues directory.
123    pub fn issues_dir(&self) -> PathBuf {
124        self.inner.read().issues_dir.clone()
125    }
126
127    /// Number of open issues, for the status-bar indicator. Refreshes the
128    /// cache if the directory mtime changed. Cheap (O(1) when fresh).
129    pub fn open_count(&self) -> usize {
130        self.refresh_if_stale();
131        self.inner.read().cache.open_count
132    }
133
134    /// Title of the most recently updated open issue, for the status-bar
135    /// indicator. Cached alongside `open_count`, so this is also O(1) on a
136    /// warm cache. Returns `None` if there are no open issues.
137    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    /// Aggregate summary for the footer indicator / panels. Pulled from the
143    /// in-memory cache, so it's cheap (O(1) on a warm cache).
144    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    /// Highest priority among open, *unassigned* issues — the most actionable
157    /// thing a free agent could pick up right now (#10). Distinct from a
158    /// plain "top priority" (overall open max): this excludes issues someone
159    /// is already working on. Returns `None` when no open issue is free.
160    /// Cached alongside [`Self::open_count`]; O(1) on a warm cache.
161    pub fn top_free_priority(&self) -> Option<Priority> {
162        self.refresh_if_stale();
163        self.inner.read().cache.top_free_priority
164    }
165
166    /// True iff the issues directory has any issues at all (suppresses the
167    /// indicator when the project has never used the feature).
168    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    /// Refresh cache if the directory mtime changed (or never loaded).
180    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,        // never loaded
187                (Some(_), None) => false, // can't stat dir; keep cache
188                (Some(cached), Some(cur)) => cached != cur,
189            }
190        };
191        if !needs {
192            return;
193        }
194        // Re-scan.
195        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                // open_count requires parsing frontmatter. For the indicator
208                // we accept the cost — issues are typically few.
209                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                    // Track highest priority (Critical > High > Medium > Low).
218                    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                    // #10: track the max priority among open + unassigned issues.
229                    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    /// Invalidate the cache (force next read to rescan).
250    pub fn invalidate(&self) {
251        self.inner.write().cache = Cache::default();
252    }
253
254    // ── Reads ───────────────────────────────────────────────────────────
255
256    /// List all issues, optionally filtered. Sorted by `updated_at` desc.
257    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    /// Read a single issue by id. Returns the issue and its current content
279    /// hash (for optimistic-concurrency writes).
280    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    // ── Writes ──────────────────────────────────────────────────────────
289
290    /// Allocate the next issue id by scanning existing filenames.
291    ///
292    /// Cross-process allocation races are possible (two sessions create the
293    /// next id simultaneously) but bounded: the loser's `create` write hits
294    /// an existing file and we bump to the next free id. No lock needed for
295    /// correctness, only for avoiding rare retries.
296    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    /// Create a new issue. `caller_session` is linked into `sessions`.
314    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        // Retry a few times in case of id collision with another session.
346        for _ in 0..4 {
347            let path = self
348                .issues_dir()
349                .join(issue_filename(id, &issue.meta.title));
350            if path.exists() {
351                // bump id and retry
352                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    /// Update an issue with optimistic concurrency.
365    ///
366    /// `expected_hash` should be the hash returned by [`FileIssueStore::read`]. If the
367    /// on-disk content changed since, returns [`IssueError::Conflict`].
368    /// `mutator` receives the loaded issue and returns the new state.
369    ///
370    /// All writes go through `file_mutation_queue` for in-process
371    /// serialization, exactly like the `edit` tool.
372    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        // Serialize same-file writes within this process.
385        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                // No-op detection (#12): if the mutator produced no meaningful
400                // change — ignoring `updated_at`, which a real write always
401                // refreshes — skip the write, the timestamp bump, and the cache
402                // invalidate. We compare the *normalized serialized* forms so
403                // key-order/whitespace drift in the on-disk `raw` can't create
404                // false negatives.
405                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    /// Convenience: close an issue (assignee only).
423    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; // closing releases the assignment
436            Ok(issue)
437        })
438        .await
439    }
440
441    /// Reopen a closed issue. No ownership required (reopening doesn't
442    /// assign the issue to anyone; it goes back to the unassigned pool).
443    ///
444    /// Errors with `NotFound` if the id doesn't exist, or with no special
445    /// error if the issue is already open — that case is a no-op.
446    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                // Already open — idempotent no-op so callers can retry
454                // without special-casing.
455                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    /// Try to claim an issue for `caller` (the `start` action).
466    ///
467    /// If already assigned to a *live* session, returns [`IssueError::Assigned`].
468    /// If assigned to a *dead* session (process exited), reclaims and assigns
469    /// to the caller. If free, assigns to the caller.
470    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                    // Already mine; idempotent.
482                    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                // Dead owner — reclaim silently.
492            }
493            issue.meta.assigned_to = Some(Assignment {
494                session: caller_owned.clone(),
495                acquired_at: Utc::now(),
496            });
497            // Link the session.
498            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    /// Release an assignment (the `release` action). Caller must be the owner.
507    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    /// Link a session to an issue (append-only; idempotent).
523    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    /// Apply a precise [`IssuePatch`] under strict CAS, preserving the existing
540    /// ownership policy.
541    ///
542    /// If `caller` is `Some`, a different *non-empty* assignee blocks the
543    /// update with [`IssueError::NotAssigned`] — identical to the legacy
544    /// `update` tool action. Setting `status = Open` also clears `closed_at`,
545    /// fixing the latent reopen bug (#4: previously `update { status: open }`
546    /// left a stale `closed_at` on a reopened issue). Prefer the dedicated
547    /// [`FileIssueStore::reopen`] for clarity.
548    ///
549    /// No-op patches (nothing meaningful changed) are detected inside
550    /// [`FileIssueStore::update`] and skip the write entirely.
551    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, // reopen clears closed_at (#4)
580                };
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    // ── Path helpers ────────────────────────────────────────────────────
594
595    fn path_for_id(&self, id: u32) -> Result<PathBuf> {
596        let dir = self.inner.read().issues_dir.clone();
597        // Files are named `<id>-<slug>.md`; match by leading id.
598        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
612/// Attach a path to an issue (builder convenience).
613trait 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
624/// Check `caller` owns the issue's assignment, else [`IssueError::NotAssigned`].
625fn 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        // External edit (different hash) → wrong expected_hash → conflict.
712        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        // Correct hash → succeeds.
726        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        // Owner session A acquires a live lock.
745        let _guard_a = liveness::acquire(&issues_dir, "sessionA").unwrap();
746        // Manually assign to A.
747        let (_, hash) = store.read(1).unwrap();
748        store.start(1, "sessionA", Some(hash)).await.unwrap();
749
750        // B tries to start → rejected (A is alive).
751        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        // A acquires, then "dies" (drop guard).
765        {
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        } // guard dropped → A is "dead"
770
771        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        // B can't close.
789        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        // A can.
794        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        // Close it.
809        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        // Reopen.
814        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        // Already open — reopen returns the issue unchanged.
829        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        // Start as owner A, then close → count drops to 1.
847        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        // Two opens: one Low (assigned to A), one Critical (free).
863        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        // Plus one closed Medium (should be ignored).
870        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        // Assign #1 to A.
878        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        // No issues yet — latest_open_title is None.
905        assert!(store.latest_open_title().is_none());
906
907        // Create an issue with a CJK title and body. The title must survive
908        // round-trip through the cache and read() without panic on multi-byte
909        // boundaries. (Regression test for the byte-slice panic in
910        // `first_line_preview` / `truncate_for_footer`.)
911        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        // Cache populates from read_dir.
922        let title = store.latest_open_title();
923        assert_eq!(title.as_deref(), Some(cjk_title.as_str()));
924
925        // read() must not panic on multi-byte UTF-8 in the body.
926        let (read_back, _hash) = store.read(created.meta.id).unwrap();
927        assert!(read_back.body.contains("한글"));
928    }
929
930    // ── Phase 0 (defect #13) regression coverage ───────────────────────────
931    //
932    // Before #13 was fixed, `ToolContext.session_id` was always `None`, so the
933    // `issue` tool called `start(id, "", hash)`. An assignment under the empty
934    // string is never "alive" (no `.alive/` file named `""`), so any other
935    // caller immediately reclaimed it — the headline ownership feature was
936    // silently inert for the agent path. These tests pin the post-fix invariants
937    // at the store layer so the regression cannot return silently.
938
939    #[tokio::test]
940    async fn start_with_distinct_live_owners_collides() {
941        // Two DIFFERENT live sessions both try to start the same issue. With
942        // real session identities (the post-#13 world), the second MUST see
943        // `Assigned` — proving the liveness check is now meaningful for the
944        // agent path, not just for the TUI panel.
945        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        // Session A is live and claims the issue.
952        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        // Session B is ALSO live (different flock file) and tries to start.
957        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        // Documents the EXACT pre-#13 bug shape at the store layer so that if
969        // `start(id, "", hash)` ever reappears in a caller, this test loudly
970        // explains why it's wrong: an assignment under "" has no flock holder,
971        // so `is_session_alive("")` is false and ANY caller reclaims it.
972        //
973        // (This is intentionally a documentation test, not a behavior change —
974        // the store is policy-free. The fix lives in the agent/tool wiring,
975        // covered by oxicode-agent's `session_id_wiring_tests`.)
976        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        // Caller "" (the pre-#13 agent default) claims the issue.
983        let (_, h) = store.read(1).unwrap();
984        store.start(1, "", Some(h)).await.unwrap();
985
986        // Nobody holds a flock named "", so the assignment is NOT alive...
987        assert!(
988            !liveness::is_session_alive(&issues_dir, ""),
989            "no flock can be held under the empty string"
990        );
991
992        // ...and any real caller reclaims it without contention. This is the
993        // silent-ownership-bypass bug that #13 fixes by ensuring agents never
994        // use "" as their caller id.
995        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    // ── Phase 2 regression coverage (#2 #3 #4 #9 #12) ────────────────────
1006
1007    #[tokio::test]
1008    async fn reopen_clears_closed_at() {
1009        // #4: reopening must clear `closed_at`. The legacy `update { status:
1010        // open }` left a stale `closed_at` on a reopened issue.
1011        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        // #4 via the apply_patch path too: status -> Open clears closed_at.
1036        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        // #12: a patch that changes nothing meaningful must not write, must
1069        // not bump updated_at, must not invalidate the cache.
1070        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        // Empty patch → no-op.
1078        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        // A real change DOES bump it (and updates the field).
1090        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        // #3: absent vs [] must be distinguishable. None=keep, Some([])=clear,
1115        // Some([x])=replace.
1116        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        // Omit labels (None) → keep, while another field changes.
1128        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        // labels: Some([]) → clear.
1146        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        // labels: Some([x]) → replace.
1163        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        // Hardening keeps the legacy ownership policy: a different non-empty
1183        // assignee blocks the update. apply_patch must reject a non-owner.
1184        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        // proc-B cannot patch.
1192        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        // proc-A (the owner) succeeds.
1211        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    // ── Phase 4: top_free_priority (#10) ──
1229
1230    #[tokio::test]
1231    async fn top_free_priority_ignores_assigned_and_closed() {
1232        // Highest priority among OPEN + UNASSIGNED issues only. A critical
1233        // issue that's assigned or closed must not be reported as "free".
1234        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        // Assign critical-assigned (free → assign).
1261        let (_, h) = store.read(3).unwrap();
1262        store.start(3, "proc", Some(h)).await.unwrap();
1263        // Close critical-closed.
1264        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        // The top FREE priority is High (the two criticals are assigned/closed).
1270        assert_eq!(store.top_free_priority(), Some(Priority::High));
1271
1272        // Release everything and nothing is left free with higher than Low/High...
1273        // (sanity: when all open free issues are gone, returns None.)
1274        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}