Skip to main content

oxicode_agent/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::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/// 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    /// Number of open issues.
48    pub open_count: usize,
49    /// Open issues currently assigned to a live session.
50    pub locked_open_count: usize,
51    /// Highest priority among open issues (`None` if none open).
52    pub top_priority: Option<Priority>,
53    /// Highest priority among open + *unassigned* issues (#10). Distinct from
54    /// `top_priority` (overall open max): excludes issues someone works on.
55    pub top_free_priority: Option<Priority>,
56    /// Title of the most recently updated open issue.
57    pub latest_open_title: Option<String>,
58}
59
60impl IssueSummary {
61    /// True when there are no open issues.
62    pub fn is_empty(&self) -> bool {
63        self.open_count == 0
64    }
65}
66
67/// In-memory state for [`FileIssueStore`].
68struct 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/// File-backed issue store.
82///
83/// One instance is shared (via `Arc`) between the TUI indicator, the agent
84/// `issue` tool, and the `oxicode issue` CLI subcommand. All mutations go through
85/// [`FileIssueStore::create`] / [`FileIssueStore::update`] which serialize per-file
86/// content-hash CAS (cross-process / external edits).
87#[derive(Clone, Debug)]
88pub struct FileIssueStore {
89    inner: Arc<RwLock<Inner>>,
90}
91
92impl FileIssueStore {
93    /// Open (or create lazily) the issue store rooted at `issues_dir`.
94    pub fn open(issues_dir: PathBuf) -> Result<Self> {
95        // Best-effort: clear zombie alive-lock files left by crashed/killed
96        // processes (#8). Lazy + idempotent + age-gated; failures are a
97        // warn log only and never block store construction.
98        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    /// Open using project-root discovery from `start` (cwd).
110    pub fn open_from_cwd(start: &Path) -> Result<Self> {
111        Self::open(issues_dir(start))
112    }
113
114    /// The issues directory.
115    pub fn issues_dir(&self) -> PathBuf {
116        self.inner.read().issues_dir.clone()
117    }
118
119    /// Number of open issues, for the status-bar indicator. Refreshes the
120    /// cache if the directory mtime changed. Cheap (O(1) when fresh).
121    pub fn open_count(&self) -> usize {
122        self.refresh_if_stale();
123        self.inner.read().cache.open_count
124    }
125
126    /// Title of the most recently updated open issue, for the status-bar
127    /// indicator. Cached alongside `open_count`, so this is also O(1) on a
128    /// warm cache. Returns `None` if there are no open issues.
129    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    /// Aggregate summary for the footer indicator / panels. Pulled from the
135    /// in-memory cache, so it's cheap (O(1) on a warm cache).
136    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    /// Highest priority among open, *unassigned* issues — the most actionable
149    /// thing a free agent could pick up right now (#10). Distinct from a
150    /// plain "top priority" (overall open max): this excludes issues someone
151    /// is already working on. Returns `None` when no open issue is free.
152    /// Cached alongside [`Self::open_count`]; O(1) on a warm cache.
153    pub fn top_free_priority(&self) -> Option<Priority> {
154        self.refresh_if_stale();
155        self.inner.read().cache.top_free_priority
156    }
157
158    /// True iff the issues directory has any issues at all (suppresses the
159    /// indicator when the project has never used the feature).
160    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    /// Refresh cache if the directory mtime changed (or never loaded).
172    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,        // never loaded
179                (Some(_), None) => false, // can't stat dir; keep cache
180                (Some(cached), Some(cur)) => cached != cur,
181            }
182        };
183        if !needs {
184            return;
185        }
186        // Re-scan.
187        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                // open_count requires parsing frontmatter. For the indicator
200                // we accept the cost — issues are typically few.
201                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                    // Track highest priority (Critical > High > Medium > Low).
210                    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                    // #10: track the max priority among open + unassigned issues.
221                    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    /// Invalidate the cache (force next read to rescan).
242    pub fn invalidate(&self) {
243        self.inner.write().cache = Cache::default();
244    }
245
246    // ── Reads ───────────────────────────────────────────────────────────
247
248    /// List all issues, optionally filtered. Sorted by `updated_at` desc.
249    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    /// Read a single issue by id. Returns the issue and its current content
271    /// hash (for optimistic-concurrency writes).
272    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    // ── Writes ──────────────────────────────────────────────────────────
281
282    /// Allocate the next issue id by scanning existing filenames.
283    ///
284    /// Cross-process allocation races are possible (two sessions create the
285    /// next id simultaneously) but bounded: the loser's `create` write hits
286    /// an existing file and we bump to the next free id. No lock needed for
287    /// correctness, only for avoiding rare retries.
288    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    /// Create a new issue. `caller_session` is linked into `sessions`.
306    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        // Retry a few times in case of id collision with another session.
338        for _ in 0..4 {
339            let path = self
340                .issues_dir()
341                .join(issue_filename(id, &issue.meta.title));
342            if path.exists() {
343                // bump id and retry
344                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    /// Update an issue with optimistic concurrency.
357    ///
358    /// `expected_hash` should be the hash returned by [`FileIssueStore::read`]. If the
359    /// on-disk content changed since, returns [`IssueError::Conflict`].
360    /// `mutator` receives the loaded issue and returns the new state.
361    ///
362    /// All writes go through `file_mutation_queue` for in-process
363    /// serialization, exactly like the `edit` tool.
364    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        // Serialize same-file writes within this process.
377        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                // No-op detection (#12): if the mutator produced no meaningful
392                // change — ignoring `updated_at`, which a real write always
393                // refreshes — skip the write, the timestamp bump, and the cache
394                // invalidate. We compare the *normalized serialized* forms so
395                // key-order/whitespace drift in the on-disk `raw` can't create
396                // false negatives.
397                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    /// Convenience: close an issue (assignee only).
415    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; // closing releases the assignment
428            Ok(issue)
429        })
430        .await
431    }
432
433    /// Reopen a closed issue. No ownership required (reopening doesn't
434    /// assign the issue to anyone; it goes back to the unassigned pool).
435    ///
436    /// Errors with `NotFound` if the id doesn't exist, or with no special
437    /// error if the issue is already open — that case is a no-op.
438    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                // Already open — idempotent no-op so callers can retry
446                // without special-casing.
447                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    /// Try to claim an issue for `caller` (the `start` action).
458    ///
459    /// If already assigned to a *live* session, returns [`IssueError::Assigned`].
460    /// If assigned to a *dead* session (process exited), reclaims and assigns
461    /// to the caller. If free, assigns to the caller.
462    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                    // Already mine; idempotent.
474                    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                // Dead owner — reclaim silently.
484            }
485            issue.meta.assigned_to = Some(Assignment {
486                session: caller_owned.clone(),
487                acquired_at: Utc::now(),
488            });
489            // Link the session.
490            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    /// Release an assignment (the `release` action). Caller must be the owner.
499    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    /// Link a session to an issue (append-only; idempotent).
515    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    /// Apply a precise [`IssuePatch`] under strict CAS, preserving the existing
532    /// ownership policy.
533    ///
534    /// If `caller` is `Some`, a different *non-empty* assignee blocks the
535    /// update with [`IssueError::NotAssigned`] — identical to the legacy
536    /// `update` tool action. Setting `status = Open` also clears `closed_at`,
537    /// fixing the latent reopen bug (#4: previously `update { status: open }`
538    /// left a stale `closed_at` on a reopened issue). Prefer the dedicated
539    /// [`FileIssueStore::reopen`] for clarity.
540    ///
541    /// No-op patches (nothing meaningful changed) are detected inside
542    /// [`FileIssueStore::update`] and skip the write entirely.
543    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, // reopen clears closed_at (#4)
572                };
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    // ── Path helpers ────────────────────────────────────────────────────
586
587    fn path_for_id(&self, id: u32) -> Result<PathBuf> {
588        let dir = self.inner.read().issues_dir.clone();
589        // Files are named `<id>-<slug>.md`; match by leading id.
590        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
604/// Attach a path to an issue (builder convenience).
605trait 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
616/// Check `caller` owns the issue's assignment, else [`IssueError::NotAssigned`].
617fn 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        // External edit (different hash) → wrong expected_hash → conflict.
704        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        // Correct hash → succeeds.
718        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        // Owner session A acquires a live lock.
737        let _guard_a = liveness::acquire(&issues_dir, "sessionA").unwrap();
738        // Manually assign to A.
739        let (_, hash) = store.read(1).unwrap();
740        store.start(1, "sessionA", Some(hash)).await.unwrap();
741
742        // B tries to start → rejected (A is alive).
743        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        // A acquires, then "dies" (drop guard).
757        {
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        } // guard dropped → A is "dead"
762
763        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        // B can't close.
781        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        // A can.
786        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        // Close it.
801        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        // Reopen.
806        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        // Already open — reopen returns the issue unchanged.
821        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        // Start as owner A, then close → count drops to 1.
839        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        // Two opens: one Low (assigned to A), one Critical (free).
855        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        // Plus one closed Medium (should be ignored).
862        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        // Assign #1 to A.
870        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        // No issues yet — latest_open_title is None.
897        assert!(store.latest_open_title().is_none());
898
899        // Create an issue with a CJK title and body. The title must survive
900        // round-trip through the cache and read() without panic on multi-byte
901        // boundaries. (Regression test for the byte-slice panic in
902        // `first_line_preview` / `truncate_for_footer`.)
903        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        // Cache populates from read_dir.
914        let title = store.latest_open_title();
915        assert_eq!(title.as_deref(), Some(cjk_title.as_str()));
916
917        // read() must not panic on multi-byte UTF-8 in the body.
918        let (read_back, _hash) = store.read(created.meta.id).unwrap();
919        assert!(read_back.body.contains("한글"));
920    }
921
922    // ── Phase 0 (defect #13) regression coverage ───────────────────────────
923    //
924    // Before #13 was fixed, `ToolContext.session_id` was always `None`, so the
925    // `issue` tool called `start(id, "", hash)`. An assignment under the empty
926    // string is never "alive" (no `.alive/` file named `""`), so any other
927    // caller immediately reclaimed it — the headline ownership feature was
928    // silently inert for the agent path. These tests pin the post-fix invariants
929    // at the store layer so the regression cannot return silently.
930
931    #[tokio::test]
932    async fn start_with_distinct_live_owners_collides() {
933        // Two DIFFERENT live sessions both try to start the same issue. With
934        // real session identities (the post-#13 world), the second MUST see
935        // `Assigned` — proving the liveness check is now meaningful for the
936        // agent path, not just for the TUI panel.
937        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        // Session A is live and claims the issue.
944        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        // Session B is ALSO live (different flock file) and tries to start.
949        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        // Documents the EXACT pre-#13 bug shape at the store layer so that if
961        // `start(id, "", hash)` ever reappears in a caller, this test loudly
962        // explains why it's wrong: an assignment under "" has no flock holder,
963        // so `is_session_alive("")` is false and ANY caller reclaims it.
964        //
965        // (This is intentionally a documentation test, not a behavior change —
966        // the store is policy-free. The fix lives in the agent/tool wiring,
967        // covered by oxicode-agent's `session_id_wiring_tests`.)
968        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        // Caller "" (the pre-#13 agent default) claims the issue.
975        let (_, h) = store.read(1).unwrap();
976        store.start(1, "", Some(h)).await.unwrap();
977
978        // Nobody holds a flock named "", so the assignment is NOT alive...
979        assert!(
980            !liveness::is_session_alive(&issues_dir, ""),
981            "no flock can be held under the empty string"
982        );
983
984        // ...and any real caller reclaims it without contention. This is the
985        // silent-ownership-bypass bug that #13 fixes by ensuring agents never
986        // use "" as their caller id.
987        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    // ── Phase 2 regression coverage (#2 #3 #4 #9 #12) ────────────────────
998
999    #[tokio::test]
1000    async fn reopen_clears_closed_at() {
1001        // #4: reopening must clear `closed_at`. The legacy `update { status:
1002        // open }` left a stale `closed_at` on a reopened issue.
1003        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        // #4 via the apply_patch path too: status -> Open clears closed_at.
1028        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        // #12: a patch that changes nothing meaningful must not write, must
1061        // not bump updated_at, must not invalidate the cache.
1062        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        // Empty patch → no-op.
1070        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        // A real change DOES bump it (and updates the field).
1082        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        // #3: absent vs [] must be distinguishable. None=keep, Some([])=clear,
1107        // Some([x])=replace.
1108        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        // Omit labels (None) → keep, while another field changes.
1120        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        // labels: Some([]) → clear.
1138        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        // labels: Some([x]) → replace.
1155        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        // Hardening keeps the legacy ownership policy: a different non-empty
1175        // assignee blocks the update. apply_patch must reject a non-owner.
1176        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        // proc-B cannot patch.
1184        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        // proc-A (the owner) succeeds.
1203        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    // ── Phase 4: top_free_priority (#10) ──
1221
1222    #[tokio::test]
1223    async fn top_free_priority_ignores_assigned_and_closed() {
1224        // Highest priority among OPEN + UNASSIGNED issues only. A critical
1225        // issue that's assigned or closed must not be reported as "free".
1226        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        // Assign critical-assigned (free → assign).
1253        let (_, h) = store.read(3).unwrap();
1254        store.start(3, "proc", Some(h)).await.unwrap();
1255        // Close critical-closed.
1256        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        // The top FREE priority is High (the two criticals are assigned/closed).
1262        assert_eq!(store.top_free_priority(), Some(Priority::High));
1263
1264        // Release everything and nothing is left free with higher than Low/High...
1265        // (sanity: when all open free issues are gone, returns None.)
1266        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}