Skip to main content

oxi/store/
issues.rs

1//! Local issue tracking system — GitHub-style issues stored as markdown files.
2//!
3//! Issues live in `.oxi/issues/` at the project root (discovered by walking
4//! up from the current directory until `.oxi/` is found, mirroring
5//! `Settings::find_project_settings`). Each issue is a single markdown file
6//! with a YAML frontmatter block holding structured metadata, followed by a
7//! free-form markdown body:
8//!
9//! ```markdown
10//! ---
11//! id: 12
12//! title: "Fix login bug"
13//! status: open
14//! priority: high
15//! labels: [bug, auth]
16//! assignee: null
17//! created_at: 2026-06-17T10:30:00Z
18//! updated_at: 2026-06-17T14:20:00Z
19//! closed_at: null
20//! sessions: [abc123, def456]
21//! assigned_to: null
22//! github: null
23//! ---
24//!
25//! Free-form markdown body...
26//! ```
27//!
28//! # Design decisions
29//!
30//! - **Why not a `StateStore` port?** Issues are *documents* that humans open
31//!   in `$EDITOR`, commit to git, and diff. `StateStore` is an opaque KV/append
32//!   blob. Different workload, different storage shape. This mirrors how
33//!   `store/session.rs` and `store/settings.rs` coexist with the SDK ports.
34//! - **Optimistic concurrency (content-hash CAS).** Mutations take an optional
35//!   `content_hash` captured at the last read. The write is rejected if the
36//!   on-disk content has changed since. This is the exact pattern used by the
37//!   `edit` tool (`oxi-agent/src/tools/edit.rs`), so external edits (e.g.
38//!   someone editing the file in vim) are detected without any locking.
39//! - **Atomic writes** via temp+rename (same pattern as `store/session.rs`).
40//! - **Assignment is process-liveness based, not time based.** An assigned
41//!   issue records the owning session id. Whether that session is still alive
42//!   is determined by an OS-held advisory lock on
43//!   `.oxi/issues/.alive/<session_id>` (see [`liveness`]). When the owning
44//!   process exits — including `kill -9`, crash, or terminal close — the OS
45//!   releases the lock and the assignment becomes stale and reclaimable.
46//!   No wall-clock expiry, no heartbeats, no zombie assignments.
47//! - **Per-file write serialization** uses the agent's `file_mutation_queue`
48//!   for in-process concurrency. Cross-process races are bounded by the
49//!   content-hash CAS: the loser gets a "retry" response, the same semantics
50//!   as the `edit` tool.
51
52use std::fs::{self, OpenOptions};
53use std::hash::{Hash, Hasher};
54use std::io;
55use std::os::unix::io::AsRawFd;
56use std::path::{Path, PathBuf};
57use std::sync::Arc;
58
59use anyhow::{Context, Result};
60use chrono::{DateTime, Utc};
61use parking_lot::RwLock;
62use serde::{Deserialize, Serialize};
63
64// ============================================================================
65// Errors
66// ============================================================================
67
68/// Errors returned by issue operations.
69///
70/// Kept as a small typed enum (per AGENTS.md: application crate uses anyhow
71/// broadly, but these specific variants are useful to distinguish for the
72/// agent tool layer and tests).
73#[derive(Debug, thiserror::Error)]
74pub enum IssueError {
75    /// `content_hash` supplied did not match the current on-disk content.
76    /// The caller should re-read and retry.
77    #[error("issue #{id} was modified since last read; re-read and retry")]
78    Conflict { id: u32 },
79
80    /// Another live session holds the assignment for this issue.
81    #[error("issue #{id} is currently being worked on by session {owner}")]
82    Assigned {
83        id: u32,
84        owner: String,
85        acquired_at: DateTime<Utc>,
86    },
87
88    /// The caller does not hold the assignment required for this mutation.
89    #[error("issue #{id} is not assigned to session {caller}; run `start` first")]
90    NotAssigned { id: u32, caller: String },
91
92    /// Issue id not found.
93    #[error("issue #{id} not found")]
94    NotFound { id: u32 },
95
96    #[error(transparent)]
97    Io(#[from] io::Error),
98
99    #[error(transparent)]
100    Other(#[from] anyhow::Error),
101}
102
103// ============================================================================
104// Domain types
105// ============================================================================
106
107/// Issue status.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
109#[serde(rename_all = "lowercase")]
110pub enum Status {
111    #[default]
112    Open,
113    Closed,
114}
115
116impl std::fmt::Display for Status {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        match self {
119            Self::Open => write!(f, "open"),
120            Self::Closed => write!(f, "closed"),
121        }
122    }
123}
124
125/// Issue priority. Ordered low → critical for sorting.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
127#[serde(rename_all = "lowercase")]
128pub enum Priority {
129    Low,
130    #[default]
131    Medium,
132    High,
133    Critical,
134}
135
136impl std::fmt::Display for Priority {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        match self {
139            Self::Low => write!(f, "low"),
140            Self::Medium => write!(f, "medium"),
141            Self::High => write!(f, "high"),
142            Self::Critical => write!(f, "critical"),
143        }
144    }
145}
146
147/// Who currently owns the work on an issue.
148///
149/// `None` means the issue is free. `Some` means a session has claimed it via
150/// `start`. Validity of an assignment is determined by process liveness (see
151/// [`liveness::is_session_alive`]) — there is no expiry timestamp.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153pub struct Assignment {
154    /// Owning session id (from `ToolContext.session_id`).
155    pub session: String,
156    /// When the assignment was acquired. Informational only — *not* used for
157    /// expiry decisions. Expiry is governed by process liveness.
158    pub acquired_at: DateTime<Utc>,
159}
160
161/// A reference to a synced GitHub issue. Populated only after Phase 6 sync.
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub struct GithubRef {
164    pub repo: String,
165    pub number: u64,
166    pub url: String,
167}
168
169/// YAML frontmatter for an issue.
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct IssueMeta {
172    pub id: u32,
173    pub title: String,
174    #[serde(default)]
175    pub status: Status,
176    #[serde(default)]
177    pub priority: Priority,
178    #[serde(default)]
179    pub labels: Vec<String>,
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub assignee: Option<String>,
182    pub created_at: DateTime<Utc>,
183    pub updated_at: DateTime<Utc>,
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub closed_at: Option<DateTime<Utc>>,
186    /// Session ids linked to this issue (worked-on or referencing sessions).
187    #[serde(default)]
188    pub sessions: Vec<String>,
189    /// Current assignment (liveness-gated). `None` = free.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub assigned_to: Option<Assignment>,
192    /// 🔜 Phase 6: GitHub sync mapping.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub github: Option<GithubRef>,
195}
196
197/// An in-memory issue: metadata + markdown body + the file path it came from.
198#[derive(Debug, Clone)]
199pub struct Issue {
200    pub meta: IssueMeta,
201    /// Raw markdown body (everything after the `---` frontmatter block).
202    pub body: String,
203    /// Path to the source file (None for unsaved/in-memory issues).
204    pub path: Option<PathBuf>,
205}
206
207impl Issue {
208    /// Combined status badge for list rendering: `🔒 open`.
209    pub fn list_badge(&self) -> String {
210        let lock = if self.meta.assigned_to.is_some() {
211            "🔒 "
212        } else {
213            ""
214        };
215        format!("{}{}", lock, self.meta.status)
216    }
217}
218
219/// A precise update payload for [`FileIssueStore::apply_patch`].
220///
221/// Every field is `Option`: `None` = keep the existing value, `Some` = replace
222/// it. `labels` is the only field with a meaningful empty state —
223/// `Some(vec![])` clears all labels while `None` keeps them. This resolves
224/// defect #3: through the tool schema, "field absent" vs `[]` were previously
225/// indistinguishable, so labels could never be cleared without resending the
226/// full set.
227///
228/// Used by the `issue` tool's `update` action (via [`FileIssueStore::apply_patch`])
229/// and is the recommended mutation surface for callers that want precise
230/// keep-vs-replace semantics.
231#[derive(Debug, Clone, Default)]
232pub struct IssuePatch {
233    /// Replace the title.
234    pub title: Option<String>,
235    /// Replace the markdown body.
236    pub body: Option<String>,
237    /// Replace the status. Setting [`Status::Open`] also clears `closed_at`
238    /// (see [`FileIssueStore::apply_patch`], which fixes the latent reopen bug #4).
239    pub status: Option<Status>,
240    /// Replace the priority.
241    pub priority: Option<Priority>,
242    /// Replace the labels wholesale. `Some(vec![])` clears all labels.
243    pub labels: Option<Vec<String>>,
244}
245
246// ============================================================================
247// Serialization — markdown + YAML frontmatter
248// ============================================================================
249
250use super::fs_util::atomic_write;
251
252const FRONTMATTER_DELIM: &str = "---";
253
254/// Parse a markdown-with-frontmatter file into an [`Issue`].
255///
256/// Format:
257/// ```text
258/// ---
259/// <yaml>
260/// ---
261/// <markdown body>
262/// ```
263///
264/// A missing closing delimiter is treated as "the rest is body". Missing
265/// frontmatter entirely yields an empty meta (caller decides whether that's
266/// an error).
267pub fn parse_issue(raw: &str, path: Option<PathBuf>) -> Result<Issue> {
268    let raw = raw.strip_prefix('\u{feff}').unwrap_or(raw);
269
270    // Split off the opening delimiter.
271    // No leading frontmatter delimiter → synthesize an empty meta and treat
272    // the whole input as body.
273    let after_open = match raw.strip_prefix(FRONTMATTER_DELIM) {
274        Some(rest) => rest,
275        None => {
276            return Ok(Issue {
277                meta: empty_meta(),
278                body: raw.to_string(),
279                path,
280            });
281        }
282    };
283
284    // Robust line-based scan for the closing `---` delimiter. Everything
285    // between the opening and closing lines is YAML; everything after is body.
286    let mut yaml = String::new();
287    let mut body = String::new();
288    let mut closed = false;
289    for line in after_open.split_inclusive('\n') {
290        if !closed && line.trim_end() == FRONTMATTER_DELIM {
291            closed = true;
292            continue;
293        }
294        if !closed {
295            yaml.push_str(line);
296        } else {
297            body.push_str(line);
298        }
299    }
300
301    let meta: IssueMeta =
302        serde_yaml::from_str(&yaml).context("failed to parse issue frontmatter")?;
303    Ok(Issue { meta, body, path })
304}
305
306/// Serialize an issue back to the markdown-with-frontmatter form.
307pub fn serialize_issue(issue: &Issue) -> Result<String> {
308    let yaml = serde_yaml::to_string(&issue.meta).context("failed to serialize frontmatter")?;
309    // serde_yaml emits a trailing newline; the `---` document markers are
310    // *not* added by serde_yaml, so we wrap manually.
311    let body = if issue.body.is_empty() {
312        String::new()
313    } else if issue.body.ends_with('\n') {
314        issue.body.clone()
315    } else {
316        format!("{}\n", issue.body)
317    };
318    Ok(format!(
319        "{open}\n{yaml}{close}\n{body}",
320        open = FRONTMATTER_DELIM,
321        close = FRONTMATTER_DELIM
322    ))
323}
324
325/// Compute a content hash used for optimistic concurrency (same idea as the
326/// `edit` tool's `expected_hash`). Uses the std default hasher for zero deps.
327pub fn content_hash(raw: &str) -> String {
328    let mut hasher = std::collections::hash_map::DefaultHasher::new();
329    raw.hash(&mut hasher);
330    format!("{:016x}", hasher.finish())
331}
332
333// ============================================================================
334// Project-root discovery
335// ============================================================================
336
337/// Walk up from `start` looking for a `.oxi/` directory. Returns the path to
338/// `<root>/.oxi/issues`. If no `.oxi/` exists, returns `<start>/.oxi/issues`
339/// (lazily created on first write).
340///
341/// Mirrors the walk in `Settings::find_project_settings`.
342pub fn issues_dir(start: &Path) -> PathBuf {
343    let mut dir = start.to_path_buf();
344    loop {
345        if dir.join(".oxi").is_dir() {
346            return dir.join(".oxi").join("issues");
347        }
348        if !dir.pop() {
349            break;
350        }
351    }
352    start.join(".oxi").join("issues")
353}
354
355/// Filename for an issue: zero-padded 4-digit id + slugified title.
356pub fn issue_filename(id: u32, title: &str) -> String {
357    let slug = slugify(title);
358    if slug.is_empty() {
359        format!("{:04}.md", id)
360    } else {
361        format!("{:04}-{}.md", id, slug)
362    }
363}
364
365/// Construct an empty placeholder meta (used when a file has no frontmatter).
366fn empty_meta() -> IssueMeta {
367    let now = Utc::now();
368    IssueMeta {
369        id: 0,
370        title: String::new(),
371        status: Status::default(),
372        priority: Priority::default(),
373        labels: vec![],
374        assignee: None,
375        created_at: now,
376        updated_at: now,
377        closed_at: None,
378        sessions: vec![],
379        assigned_to: None,
380        github: None,
381    }
382}
383
384/// Slugify a title for use in a filename: lowercase, [a-z0-9-] only.
385fn slugify(s: &str) -> String {
386    let mut out = String::new();
387    let mut prev_dash = false;
388    for c in s.chars() {
389        if c.is_ascii_alphanumeric() {
390            out.push(c.to_ascii_lowercase());
391            prev_dash = false;
392        } else if !prev_dash {
393            out.push('-');
394            prev_dash = true;
395        }
396    }
397    out.trim_matches('-').to_string()
398}
399
400// ============================================================================
401// Liveness — process-held advisory locks (no wall-clock expiry)
402// ============================================================================
403
404/// Process-liveness tracking via OS advisory locks.
405///
406/// Each session holds an exclusive `flock` on `.oxi/issues/.alive/<session_id>`.
407/// The lock is released by the OS when the process exits (including crashes
408/// and `kill -9`). This lets us answer "is session X still alive?" without
409/// any wall-clock timeout, PID-recycling heuristics, or heartbeats.
410pub mod liveness {
411    use super::*;
412
413    /// Single source of truth for the liveness identity used by the TUI
414    /// (and any in-TUI operations: agent tool, `/issue` slash command, panel).
415    ///
416    /// Invariant: in TUI mode, [`crate::App::ownership_session_id`] MUST equal
417    /// this constant. The TUI panel's
418    /// `crate::tui::overlay::IssuesPanelOverlay::session_id()` references it,
419    /// and the agent's `ToolContext.session_id` is set from it, so the flock
420    /// acquired by `App` is the same one the panel and agent use to check
421    /// `is_session_alive`. Keep the two in sync.
422    pub const TUI_OWNERSHIP_ID: &str = "tui";
423
424    /// Path of the alive-lock file for `session_id` under `issues_dir`.
425    pub fn alive_path(issues_dir: &Path, session_id: &str) -> PathBuf {
426        issues_dir.join(".alive").join(session_id)
427    }
428
429    /// Try to acquire (and hold) an exclusive advisory lock for `session_id`.
430    ///
431    /// The returned [`AliveGuard`] releases the lock when dropped — so callers
432    /// must keep it alive for the whole session. Opening with write+create and
433    /// calling `flock(LOCK_EX | LOCK_NB)` is atomic enough for our purposes:
434    /// failure to acquire means another live process holds it.
435    pub fn acquire(issues_dir: &Path, session_id: &str) -> io::Result<AliveGuard> {
436        let dir = issues_dir.join(".alive");
437        fs::create_dir_all(&dir)?;
438        let path = dir.join(session_id);
439        let file = OpenOptions::new()
440            .write(true)
441            .create(true)
442            .truncate(false)
443            .open(&path)?;
444        let fd = file.as_raw_fd();
445        // Failure (EWOULDBLOCK/EAGAIN) means another live process holds it.
446        try_flock_exclusive(fd)?;
447        Ok(AliveGuard { _file: file, path })
448    }
449
450    /// Returns `true` iff a live process currently holds the alive-lock for
451    /// `session_id`. Used to decide whether an [`Assignment`] is still valid.
452    pub fn is_session_alive(issues_dir: &Path, session_id: &str) -> bool {
453        let path = alive_path(issues_dir, session_id);
454        if !path.exists() {
455            return false;
456        }
457        // Try to acquire a *shared* lock non-blockingly. If we can't, someone
458        // holds an exclusive lock → alive. If we can, no one holds it → dead.
459        let Ok(file) = OpenOptions::new().read(true).write(true).open(&path) else {
460            return false;
461        };
462        let fd = file.as_raw_fd();
463        // Ok = nobody holds exclusive (dead); Err = held by a live process (alive).
464        probe_flock_shared(fd).is_err()
465    }
466
467    // ── flock helpers (#11: centralize the two unsafe call sites) ────────
468    //
469    // Both take a raw fd that the caller obtained from a live `File` via
470    // `as_raw_fd()`, so fd validity is guaranteed by construction. Naming
471    // them (with SAFETY docs) keeps the `unsafe` surface to these two spots
472    // instead of being scattered through the liveness logic.
473
474    /// Try a non-blocking exclusive flock on `fd`.
475    ///
476    /// `Ok` on success; `Err` on contention (`EWOULDBLOCK`/`EAGAIN` — another
477    /// live process holds it) or any other OS error.
478    ///
479    /// `fd` must be a valid open file descriptor.
480    fn try_flock_exclusive(fd: i32) -> io::Result<()> {
481        // SAFETY: `fd` is a valid, owned descriptor (caller passes
482        // `File::as_raw_fd()` from a live `File`). `LOCK_NB` never blocks.
483        let rc = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
484        if rc == 0 {
485            Ok(())
486        } else {
487            Err(io::Error::last_os_error())
488        }
489    }
490
491    /// Probe liveness by attempting a non-blocking shared flock.
492    ///
493    /// `Ok` if no one holds an exclusive lock (we acquired and released a
494    /// shared one); `Err` if someone holds exclusive (a live process).
495    ///
496    /// `fd` must be a valid open file descriptor.
497    fn probe_flock_shared(fd: i32) -> io::Result<()> {
498        // SAFETY: `fd` is a valid, owned descriptor (see `try_flock_exclusive`).
499        let rc = unsafe { libc::flock(fd, libc::LOCK_SH | libc::LOCK_NB) };
500        if rc == 0 {
501            // SAFETY: releasing the shared lock we just acquired on a valid fd.
502            unsafe { libc::flock(fd, libc::LOCK_UN) };
503            Ok(())
504        } else {
505            Err(io::Error::last_os_error())
506        }
507    }
508
509    // ── Orphan reaping (#8) ─────────────────────────────────────────────
510
511    /// Minimum age (seconds) a dead alive-lock file must reach before reaping.
512    ///
513    /// The age gate is the TOCTOU mitigation: a reaper checks `is_session_alive`,
514    /// and a process could acquire the lock in the gap before `remove_file`.
515    /// Only reaping files older than this threshold leaves a wide margin for
516    /// any session that is actively starting up, while still clearing the
517    /// steady-state accumulation of zombies from crashed/killed processes.
518    pub const ORPHAN_AGE_SECS: u64 = 3600; // 1 hour
519
520    /// Best-effort, idempotent cleanup of dead alive-lock files under
521    /// `<issues_dir>/.alive/`.
522    ///
523    /// Two guards keep it safe:
524    /// 1. **Holder check** — files whose session still holds an exclusive flock
525    ///    ([`is_session_alive`] → `true`) are never touched.
526    /// 2. **Age gate** — even dead files younger than [`ORPHAN_AGE_SECS`] are
527    ///    skipped, so a process racing to acquire can't lose its lock file.
528    ///
529    /// Returns the number of files removed. Missing `.alive/` is `Ok(0)`.
530    pub fn reap_orphans(issues_dir: &Path) -> io::Result<usize> {
531        let dir = issues_dir.join(".alive");
532        let rd = match fs::read_dir(&dir) {
533            Ok(rd) => rd,
534            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
535            Err(e) => return Err(e),
536        };
537        let now = std::time::SystemTime::now();
538        let mut removed = 0;
539        for entry in rd.flatten() {
540            let sid = entry.file_name();
541            let sid = sid.to_string_lossy();
542            if is_session_alive(issues_dir, &sid) {
543                continue; // (1) someone holds it — never reap
544            }
545            let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
546            let age = now.duration_since(mtime).map(|d| d.as_secs()).unwrap_or(0);
547            if age < ORPHAN_AGE_SECS {
548                continue; // (2) too young — TOCTOU margin
549            }
550            if fs::remove_file(entry.path()).is_ok() {
551                removed += 1;
552            }
553        }
554        Ok(removed)
555    }
556
557    /// RAII guard for an acquired alive-lock.
558    #[derive(Debug)]
559    pub struct AliveGuard {
560        _file: fs::File,
561        path: PathBuf,
562    }
563
564    impl AliveGuard {
565        pub fn path(&self) -> &Path {
566            &self.path
567        }
568    }
569
570    impl Drop for AliveGuard {
571        fn drop(&mut self) {
572            // Drop closes the fd → OS releases the lock. Best-effort unlink.
573            let _ = fs::remove_file(&self.path);
574        }
575    }
576
577    #[cfg(test)]
578    mod tests {
579        use super::*;
580
581        #[test]
582        fn acquire_then_alive() {
583            let tmp = tempfile::tempdir().unwrap();
584            let dir = tmp.path().to_path_buf();
585            let sid = "s1";
586            let _g = acquire(&dir, sid).unwrap();
587            assert!(is_session_alive(&dir, sid));
588            drop(_g);
589            assert!(!is_session_alive(&dir, sid));
590        }
591
592        #[test]
593        fn second_acquire_fails_while_held() {
594            let tmp = tempfile::tempdir().unwrap();
595            let dir = tmp.path().to_path_buf();
596            let sid = "s2";
597            let g = acquire(&dir, sid).unwrap();
598            let second = acquire(&dir, sid);
599            assert!(second.is_err(), "second acquire should fail while held");
600            assert!(is_session_alive(&dir, sid));
601            drop(g);
602            assert!(acquire(&dir, sid).is_ok(), "after drop, acquire succeeds");
603        }
604
605        // ── Phase 4: orphan reap (#8) ──
606
607        /// Helper: backdate a file's mtime by `secs` so it crosses the age gate.
608        fn backdate(path: &std::path::Path, secs: u64) {
609            use std::fs::FileTimes;
610            let then = std::time::SystemTime::now() - std::time::Duration::from_secs(secs);
611            let f = std::fs::File::open(path)
612                .or_else(|_| {
613                    std::fs::OpenOptions::new()
614                        .read(true)
615                        .write(true)
616                        .create(true)
617                        .truncate(false) // open-or-create without erasing (clippy::suspicious_open_options)
618                        .open(path)
619                })
620                .unwrap();
621            f.set_times(FileTimes::new().set_modified(then)).unwrap();
622        }
623
624        #[test]
625        fn reap_idempotent() {
626            let tmp = tempfile::tempdir().unwrap();
627            let dir = tmp.path().to_path_buf();
628            // No `.alive/` at all.
629            assert_eq!(reap_orphans(&dir).unwrap(), 0);
630            fs::create_dir_all(dir.join(".alive")).unwrap();
631            // Empty dir, repeated calls stay at 0.
632            assert_eq!(reap_orphans(&dir).unwrap(), 0);
633            assert_eq!(reap_orphans(&dir).unwrap(), 0);
634        }
635
636        #[test]
637        fn reap_skips_recent_dead_files() {
638            // A dead (unheld) orphan younger than ORPHAN_AGE_SECS must be
639            // preserved — the age gate is the TOCTOU mitigation.
640            let tmp = tempfile::tempdir().unwrap();
641            let dir = tmp.path().to_path_buf();
642            fs::create_dir_all(dir.join(".alive")).unwrap();
643            let recent = dir.join(".alive").join("dead-recent");
644            fs::write(&recent, b"").unwrap();
645            // mtime ~ now.
646            assert_eq!(reap_orphans(&dir).unwrap(), 0);
647            assert!(
648                recent.exists(),
649                "recent dead orphan must be preserved by the age gate"
650            );
651        }
652
653        #[test]
654        fn reap_removes_old_dead_and_keeps_alive() {
655            let tmp = tempfile::tempdir().unwrap();
656            let dir = tmp.path().to_path_buf();
657            // A genuinely live lock — must never be reaped.
658            let _g_live = acquire(&dir, "alive-session").unwrap();
659            // An old dead orphan (no holder, mtime > threshold).
660            fs::create_dir_all(dir.join(".alive")).unwrap();
661            let old = dir.join(".alive").join("dead-old");
662            fs::write(&old, b"").unwrap();
663            backdate(&old, ORPHAN_AGE_SECS + 60);
664
665            let removed = reap_orphans(&dir).unwrap();
666            assert_eq!(removed, 1, "only the old dead orphan should be reaped");
667            assert!(!old.exists(), "old dead orphan must be removed");
668            // Live holder is still alive and its file untouched.
669            assert!(
670                is_session_alive(&dir, "alive-session"),
671                "live lock must survive reap"
672            );
673        }
674    }
675}
676
677// ============================================================================
678// Store
679// ============================================================================
680
681/// Cached directory listing, so the status-bar indicator doesn't readdir the
682/// issues dir every render frame. `dir_mtime` is the single invalidation
683/// signal; per-file mtimes aren't tracked (CAS uses content-hash on writes).
684#[derive(Debug, Default, Clone)]
685struct Cache {
686    /// `open` issue count (the number shown in the status bar).
687    open_count: usize,
688    /// Title of the most recently updated open issue (for the indicator).
689    latest_open_title: Option<String>,
690    /// Number of currently-assigned (locked) open issues. Computed at the
691    /// same time as `open_count` so the indicator can show "3 open · 1 🔒".
692    locked_open_count: usize,
693    /// Highest priority among open issues (None if no open issues).
694    /// Used for the priority dot in the footer indicator.
695    top_priority: Option<Priority>,
696    /// Highest priority among open AND *unassigned* issues — the "most
697    /// actionable thing right now" signal (#10). `None` when no open issue is
698    /// free. Distinct from `top_priority` (overall open max): this excludes
699    /// issues someone is already working on.
700    top_free_priority: Option<Priority>,
701    dir_mtime: Option<std::time::SystemTime>,
702}
703
704/// Summary view exposed for UI consumers (footer indicator, panel header).
705/// Cheap to construct — values come straight from the in-memory cache.
706#[derive(Debug, Clone)]
707pub struct IssueSummary {
708    pub open_count: usize,
709    pub locked_open_count: usize,
710    pub top_priority: Option<Priority>,
711    /// Highest priority among open + *unassigned* issues (#10). Distinct from
712    /// `top_priority` (overall open max): excludes issues someone works on.
713    pub top_free_priority: Option<Priority>,
714    pub latest_open_title: Option<String>,
715}
716
717impl IssueSummary {
718    pub fn is_empty(&self) -> bool {
719        self.open_count == 0
720    }
721}
722
723/// In-memory state for [`FileIssueStore`].
724struct Inner {
725    issues_dir: PathBuf,
726    cache: Cache,
727}
728
729impl Cache {
730    fn empty() -> Self {
731        Self {
732            open_count: 0,
733            latest_open_title: None,
734            locked_open_count: 0,
735            top_priority: None,
736            top_free_priority: None,
737            dir_mtime: None,
738        }
739    }
740}
741
742impl std::fmt::Debug for Inner {
743    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
744        f.debug_struct("Inner")
745            .field("issues_dir", &self.issues_dir)
746            .finish()
747    }
748}
749
750/// File-backed issue store.
751///
752/// One instance is shared (via `Arc`) between the TUI indicator, the agent
753/// `issue` tool, and the `oxi issue` CLI subcommand. All mutations go through
754/// [`FileIssueStore::create`] / [`FileIssueStore::update`] which serialize per-file
755/// content-hash CAS (cross-process / external edits).
756#[derive(Clone, Debug)]
757pub struct FileIssueStore {
758    inner: Arc<RwLock<Inner>>,
759}
760
761impl FileIssueStore {
762    /// Open (or create lazily) the issue store rooted at `issues_dir`.
763    pub fn open(issues_dir: PathBuf) -> Result<Self> {
764        // Best-effort: clear zombie alive-lock files left by crashed/killed
765        // processes (#8). Lazy + idempotent + age-gated; failures are a
766        // warn log only and never block store construction.
767        if let Err(e) = liveness::reap_orphans(&issues_dir) {
768            tracing::warn!(error = %e, "issue liveness reap failed (non-fatal)");
769        }
770        Ok(Self {
771            inner: Arc::new(RwLock::new(Inner {
772                issues_dir,
773                cache: Cache::default(),
774            })),
775        })
776    }
777
778    /// Open using project-root discovery from `start` (cwd).
779    pub fn open_from_cwd(start: &Path) -> Result<Self> {
780        Self::open(issues_dir(start))
781    }
782
783    /// The issues directory.
784    pub fn issues_dir(&self) -> PathBuf {
785        self.inner.read().issues_dir.clone()
786    }
787
788    /// Number of open issues, for the status-bar indicator. Refreshes the
789    /// cache if the directory mtime changed. Cheap (O(1) when fresh).
790    pub fn open_count(&self) -> usize {
791        self.refresh_if_stale();
792        self.inner.read().cache.open_count
793    }
794
795    /// Title of the most recently updated open issue, for the status-bar
796    /// indicator. Cached alongside `open_count`, so this is also O(1) on a
797    /// warm cache. Returns `None` if there are no open issues.
798    pub fn latest_open_title(&self) -> Option<String> {
799        self.refresh_if_stale();
800        self.inner.read().cache.latest_open_title.clone()
801    }
802
803    /// Aggregate summary for the footer indicator / panels. Pulled from the
804    /// in-memory cache, so it's cheap (O(1) on a warm cache).
805    pub fn summary(&self) -> IssueSummary {
806        self.refresh_if_stale();
807        let g = self.inner.read();
808        IssueSummary {
809            open_count: g.cache.open_count,
810            locked_open_count: g.cache.locked_open_count,
811            top_priority: g.cache.top_priority,
812            top_free_priority: g.cache.top_free_priority,
813            latest_open_title: g.cache.latest_open_title.clone(),
814        }
815    }
816
817    /// Highest priority among open, *unassigned* issues — the most actionable
818    /// thing a free agent could pick up right now (#10). Distinct from a
819    /// plain "top priority" (overall open max): this excludes issues someone
820    /// is already working on. Returns `None` when no open issue is free.
821    /// Cached alongside [`Self::open_count`]; O(1) on a warm cache.
822    pub fn top_free_priority(&self) -> Option<Priority> {
823        self.refresh_if_stale();
824        self.inner.read().cache.top_free_priority
825    }
826
827    /// True iff the issues directory has any issues at all (suppresses the
828    /// indicator when the project has never used the feature).
829    pub fn has_any(&self) -> bool {
830        self.refresh_if_stale();
831        let dir = self.inner.read().issues_dir.clone();
832        fs::read_dir(&dir)
833            .map(|rd| {
834                rd.filter_map(|e| e.ok())
835                    .any(|e| e.path().extension().and_then(|x| x.to_str()) == Some("md"))
836            })
837            .unwrap_or(false)
838    }
839
840    /// Refresh cache if the directory mtime changed (or never loaded).
841    fn refresh_if_stale(&self) {
842        let dir = self.inner.read().issues_dir.clone();
843        let cur_dir_mtime = fs::metadata(&dir).and_then(|m| m.modified()).ok();
844        let needs = {
845            let g = self.inner.read();
846            match (g.cache.dir_mtime, cur_dir_mtime) {
847                (None, _) => true,        // never loaded
848                (Some(_), None) => false, // can't stat dir; keep cache
849                (Some(cached), Some(cur)) => cached != cur,
850            }
851        };
852        if !needs {
853            return;
854        }
855        // Re-scan.
856        let mut open_count = 0;
857        let mut locked_open_count = 0;
858        let mut top_priority: Option<Priority> = None;
859        let mut latest_open_title: Option<String> = None;
860        let mut latest_open_updated: Option<chrono::DateTime<chrono::Utc>> = None;
861        let mut top_free_priority: Option<Priority> = None;
862        if let Ok(rd) = fs::read_dir(&dir) {
863            for entry in rd.flatten() {
864                let p = entry.path();
865                if p.extension().and_then(|x| x.to_str()) != Some("md") {
866                    continue;
867                }
868                // open_count requires parsing frontmatter. For the indicator
869                // we accept the cost — issues are typically few.
870                if let Ok(raw) = fs::read_to_string(&p)
871                    && let Ok(issue) = parse_issue(&raw, None)
872                    && issue.meta.status == Status::Open
873                {
874                    open_count += 1;
875                    if issue.meta.assigned_to.is_some() {
876                        locked_open_count += 1;
877                    }
878                    // Track highest priority (Critical > High > Medium > Low).
879                    top_priority = Some(match top_priority {
880                        Some(existing) => existing.max(issue.meta.priority),
881                        None => issue.meta.priority,
882                    });
883                    if issue.meta.updated_at
884                        > latest_open_updated.unwrap_or(chrono::DateTime::<chrono::Utc>::MIN_UTC)
885                    {
886                        latest_open_updated = Some(issue.meta.updated_at);
887                        latest_open_title = Some(issue.meta.title);
888                    }
889                    // #10: track the max priority among open + unassigned issues.
890                    if issue.meta.assigned_to.is_none() {
891                        top_free_priority = Some(match top_free_priority {
892                            Some(cur) if cur >= issue.meta.priority => cur,
893                            _ => issue.meta.priority,
894                        });
895                    }
896                }
897            }
898        }
899        let mut g = self.inner.write();
900        g.cache = Cache {
901            open_count,
902            latest_open_title,
903            locked_open_count,
904            top_priority,
905            top_free_priority,
906            dir_mtime: cur_dir_mtime,
907        };
908    }
909
910    /// Invalidate the cache (force next read to rescan).
911    pub fn invalidate(&self) {
912        self.inner.write().cache = Cache::default();
913    }
914
915    // ── Reads ───────────────────────────────────────────────────────────
916
917    /// List all issues, optionally filtered. Sorted by `updated_at` desc.
918    pub fn list(&self, filter: &IssueFilter) -> Result<Vec<Issue>> {
919        self.refresh_if_stale();
920        let dir = self.inner.read().issues_dir.clone();
921        let mut out = Vec::new();
922        if let Ok(rd) = fs::read_dir(&dir) {
923            for entry in rd.flatten() {
924                let p = entry.path();
925                if p.extension().and_then(|x| x.to_str()) != Some("md") {
926                    continue;
927                }
928                let raw = fs::read_to_string(&p)?;
929                let issue = parse_issue(&raw, Some(p.clone()))?;
930                if filter.matches(&issue) {
931                    out.push(issue);
932                }
933            }
934        }
935        out.sort_by_key(|i| std::cmp::Reverse(i.meta.updated_at));
936        Ok(out)
937    }
938
939    /// Read a single issue by id. Returns the issue and its current content
940    /// hash (for optimistic-concurrency writes).
941    pub fn read(&self, id: u32) -> Result<(Issue, String)> {
942        let path = self.path_for_id(id)?;
943        let raw = fs::read_to_string(&path)
944            .with_context(|| format!("issue #{} not found at {}", id, path.display()))?;
945        let issue = parse_issue(&raw, Some(path))?;
946        Ok((issue, content_hash(&raw)))
947    }
948
949    // ── Writes ──────────────────────────────────────────────────────────
950
951    /// Allocate the next issue id by scanning existing filenames.
952    ///
953    /// Cross-process allocation races are possible (two sessions create the
954    /// next id simultaneously) but bounded: the loser's `create` write hits
955    /// an existing file and we bump to the next free id. No lock needed for
956    /// correctness, only for avoiding rare retries.
957    pub fn next_id(&self) -> Result<u32> {
958        let dir = self.inner.read().issues_dir.clone();
959        fs::create_dir_all(&dir)?;
960        let mut max = 0u32;
961        if let Ok(rd) = fs::read_dir(&dir) {
962            for entry in rd.flatten() {
963                let name = entry.file_name();
964                let name = name.to_string_lossy();
965                let num_str = name.split('-').next().unwrap_or(&name);
966                if let Ok(n) = num_str.trim_end_matches(".md").parse::<u32>() {
967                    max = max.max(n);
968                }
969            }
970        }
971        Ok(max + 1)
972    }
973
974    /// Create a new issue. `caller_session` is linked into `sessions`.
975    pub fn create(
976        &self,
977        title: String,
978        body: String,
979        priority: Priority,
980        labels: Vec<String>,
981        caller_session: Option<&str>,
982    ) -> Result<Issue> {
983        let id = self.next_id()?;
984        let now = Utc::now();
985        let sessions = caller_session
986            .map(|s| vec![s.to_string()])
987            .unwrap_or_default();
988        let issue = Issue {
989            meta: IssueMeta {
990                id,
991                title,
992                status: Status::Open,
993                priority,
994                labels,
995                assignee: None,
996                created_at: now,
997                updated_at: now,
998                closed_at: None,
999                sessions,
1000                assigned_to: None,
1001                github: None,
1002            },
1003            body,
1004            path: None,
1005        };
1006        // Retry a few times in case of id collision with another session.
1007        for _ in 0..4 {
1008            let path = self
1009                .issues_dir()
1010                .join(issue_filename(id, &issue.meta.title));
1011            if path.exists() {
1012                // bump id and retry
1013                continue;
1014            }
1015            let content = serialize_issue(&issue)?;
1016            atomic_write(&path, &content)?;
1017            self.invalidate();
1018            let mut saved = issue.clone();
1019            saved.path = Some(path);
1020            return Ok(saved);
1021        }
1022        anyhow::bail!("could not allocate a free issue id after retries");
1023    }
1024
1025    /// Update an issue with optimistic concurrency.
1026    ///
1027    /// `expected_hash` should be the hash returned by [`FileIssueStore::read`]. If the
1028    /// on-disk content changed since, returns [`IssueError::Conflict`].
1029    /// `mutator` receives the loaded issue and returns the new state.
1030    ///
1031    /// All writes go through `file_mutation_queue` for in-process
1032    /// serialization, exactly like the `edit` tool.
1033    pub async fn update<F>(
1034        &self,
1035        id: u32,
1036        expected_hash: Option<String>,
1037        mutator: F,
1038    ) -> std::result::Result<Issue, IssueError>
1039    where
1040        F: FnOnce(Issue) -> std::result::Result<Issue, IssueError> + Send + 'static,
1041    {
1042        let path = self.path_for_id(id).map_err(IssueError::Other)?;
1043        let path_for_closure = path.clone();
1044        let store = self.clone();
1045        // Serialize same-file writes within this process.
1046        oxi_agent::tools::file_mutation_queue::global_mutation_queue()
1047            .with_queue(&path, move || async move {
1048                let path = path_for_closure;
1049                let raw = fs::read_to_string(&path)?;
1050                if let Some(expected) = expected_hash.as_deref()
1051                    && content_hash(&raw) != expected
1052                {
1053                    return Err(IssueError::Conflict { id });
1054                }
1055                let before = parse_issue(&raw, Some(path.clone())).map_err(IssueError::Other)?;
1056                let before_updated_at = before.meta.updated_at;
1057                let before_bytes = serialize_issue(&before).map_err(IssueError::Other)?;
1058                let after = mutator(before)?;
1059
1060                // No-op detection (#12): if the mutator produced no meaningful
1061                // change — ignoring `updated_at`, which a real write always
1062                // refreshes — skip the write, the timestamp bump, and the cache
1063                // invalidate. We compare the *normalized serialized* forms so
1064                // key-order/whitespace drift in the on-disk `raw` can't create
1065                // false negatives.
1066                let mut probe = after.clone();
1067                probe.meta.updated_at = before_updated_at;
1068                let probe_bytes = serialize_issue(&probe).map_err(IssueError::Other)?;
1069                if probe_bytes == before_bytes {
1070                    return Ok(after.with_path(path));
1071                }
1072
1073                let mut final_issue = after;
1074                final_issue.meta.updated_at = Utc::now();
1075                let content = serialize_issue(&final_issue).map_err(IssueError::Other)?;
1076                atomic_write(&path, &content)?;
1077                store.invalidate();
1078                Ok(final_issue.with_path(path))
1079            })
1080            .await
1081    }
1082
1083    /// Convenience: close an issue (assignee only).
1084    pub async fn close(
1085        &self,
1086        id: u32,
1087        caller: &str,
1088        expected_hash: Option<String>,
1089    ) -> std::result::Result<Issue, IssueError> {
1090        let now = Utc::now();
1091        let caller = caller.to_string();
1092        self.update(id, expected_hash, move |mut issue| {
1093            require_owner(&issue, id, &caller)?;
1094            issue.meta.status = Status::Closed;
1095            issue.meta.closed_at = Some(now);
1096            issue.meta.assigned_to = None; // closing releases the assignment
1097            Ok(issue)
1098        })
1099        .await
1100    }
1101
1102    /// Reopen a closed issue. No ownership required (reopening doesn't
1103    /// assign the issue to anyone; it goes back to the unassigned pool).
1104    ///
1105    /// Errors with `NotFound` if the id doesn't exist, or with no special
1106    /// error if the issue is already open — that case is a no-op.
1107    pub async fn reopen(
1108        &self,
1109        id: u32,
1110        expected_hash: Option<String>,
1111    ) -> std::result::Result<Issue, IssueError> {
1112        self.update(id, expected_hash, move |mut issue| {
1113            if issue.meta.status == Status::Open {
1114                // Already open — idempotent no-op so callers can retry
1115                // without special-casing.
1116                return Ok(issue);
1117            }
1118            issue.meta.status = Status::Open;
1119            issue.meta.closed_at = None;
1120            issue.meta.assigned_to = None;
1121            Ok(issue)
1122        })
1123        .await
1124    }
1125
1126    /// Try to claim an issue for `caller` (the `start` action).
1127    ///
1128    /// If already assigned to a *live* session, returns [`IssueError::Assigned`].
1129    /// If assigned to a *dead* session (process exited), reclaims and assigns
1130    /// to the caller. If free, assigns to the caller.
1131    pub async fn start(
1132        &self,
1133        id: u32,
1134        caller: &str,
1135        expected_hash: Option<String>,
1136    ) -> std::result::Result<Issue, IssueError> {
1137        let issues_dir = self.issues_dir();
1138        let caller_owned = caller.to_string();
1139        self.update(id, expected_hash, move |mut issue| {
1140            if let Some(ref a) = issue.meta.assigned_to {
1141                if a.session == caller_owned {
1142                    // Already mine; idempotent.
1143                    return Ok(issue);
1144                }
1145                if liveness::is_session_alive(&issues_dir, &a.session) {
1146                    return Err(IssueError::Assigned {
1147                        id,
1148                        owner: a.session.clone(),
1149                        acquired_at: a.acquired_at,
1150                    });
1151                }
1152                // Dead owner — reclaim silently.
1153            }
1154            issue.meta.assigned_to = Some(Assignment {
1155                session: caller_owned.clone(),
1156                acquired_at: Utc::now(),
1157            });
1158            // Link the session.
1159            if !issue.meta.sessions.contains(&caller_owned) {
1160                issue.meta.sessions.push(caller_owned.clone());
1161            }
1162            Ok(issue)
1163        })
1164        .await
1165    }
1166
1167    /// Release an assignment (the `release` action). Caller must be the owner.
1168    pub async fn release(
1169        &self,
1170        id: u32,
1171        caller: &str,
1172        expected_hash: Option<String>,
1173    ) -> std::result::Result<Issue, IssueError> {
1174        let caller = caller.to_string();
1175        self.update(id, expected_hash, move |mut issue| {
1176            require_owner(&issue, id, &caller)?;
1177            issue.meta.assigned_to = None;
1178            Ok(issue)
1179        })
1180        .await
1181    }
1182
1183    /// Link a session to an issue (append-only; idempotent).
1184    pub async fn link_session(
1185        &self,
1186        id: u32,
1187        session: &str,
1188        expected_hash: Option<String>,
1189    ) -> std::result::Result<Issue, IssueError> {
1190        let session = session.to_string();
1191        self.update(id, expected_hash, move |mut issue| {
1192            if !issue.meta.sessions.contains(&session) {
1193                issue.meta.sessions.push(session);
1194            }
1195            Ok(issue)
1196        })
1197        .await
1198    }
1199
1200    /// Apply a precise [`IssuePatch`] under strict CAS, preserving the existing
1201    /// ownership policy.
1202    ///
1203    /// If `caller` is `Some`, a different *non-empty* assignee blocks the
1204    /// update with [`IssueError::NotAssigned`] — identical to the legacy
1205    /// `update` tool action. Setting `status = Open` also clears `closed_at`,
1206    /// fixing the latent reopen bug (#4: previously `update { status: open }`
1207    /// left a stale `closed_at` on a reopened issue). Prefer the dedicated
1208    /// [`FileIssueStore::reopen`] for clarity.
1209    ///
1210    /// No-op patches (nothing meaningful changed) are detected inside
1211    /// [`FileIssueStore::update`] and skip the write entirely.
1212    pub async fn apply_patch(
1213        &self,
1214        id: u32,
1215        patch: IssuePatch,
1216        caller: Option<String>,
1217        expected_hash: Option<String>,
1218    ) -> std::result::Result<Issue, IssueError> {
1219        self.update(id, expected_hash, move |mut issue| {
1220            if let Some(caller) = caller.as_deref()
1221                && let Some(ref a) = issue.meta.assigned_to
1222                && !a.session.is_empty()
1223                && a.session != caller
1224            {
1225                return Err(IssueError::NotAssigned {
1226                    id,
1227                    caller: caller.to_string(),
1228                });
1229            }
1230            if let Some(t) = patch.title {
1231                issue.meta.title = t;
1232            }
1233            if let Some(b) = patch.body {
1234                issue.body = b;
1235            }
1236            if let Some(s) = patch.status {
1237                issue.meta.status = s;
1238                issue.meta.closed_at = match s {
1239                    Status::Closed => Some(Utc::now()),
1240                    Status::Open => None, // reopen clears closed_at (#4)
1241                };
1242            }
1243            if let Some(p) = patch.priority {
1244                issue.meta.priority = p;
1245            }
1246            if let Some(l) = patch.labels {
1247                issue.meta.labels = l;
1248            }
1249            Ok(issue)
1250        })
1251        .await
1252    }
1253
1254    // ── Path helpers ────────────────────────────────────────────────────
1255
1256    fn path_for_id(&self, id: u32) -> Result<PathBuf> {
1257        let dir = self.inner.read().issues_dir.clone();
1258        // Files are named `<id>-<slug>.md`; match by leading id.
1259        if let Ok(rd) = fs::read_dir(&dir) {
1260            for entry in rd.flatten() {
1261                let name = entry.file_name();
1262                let name = name.to_string_lossy();
1263                let num_str = name.split('-').next().unwrap_or(&name);
1264                if num_str.trim_end_matches(".md").parse::<u32>().ok() == Some(id) {
1265                    return Ok(entry.path());
1266                }
1267            }
1268        }
1269        Err(anyhow::anyhow!(IssueError::NotFound { id }))
1270    }
1271}
1272
1273/// Attach a path to an issue (builder convenience).
1274trait WithPath {
1275    fn with_path(self, path: PathBuf) -> Self;
1276}
1277
1278impl WithPath for Issue {
1279    fn with_path(mut self, path: PathBuf) -> Self {
1280        self.path = Some(path);
1281        self
1282    }
1283}
1284
1285/// Check `caller` owns the issue's assignment, else [`IssueError::NotAssigned`].
1286fn require_owner(issue: &Issue, id: u32, caller: &str) -> std::result::Result<(), IssueError> {
1287    match &issue.meta.assigned_to {
1288        Some(a) if a.session == caller => Ok(()),
1289        _ => Err(IssueError::NotAssigned {
1290            id,
1291            caller: caller.to_string(),
1292        }),
1293    }
1294}
1295
1296// ============================================================================
1297// Filter
1298// ============================================================================
1299
1300/// Filter for `list`. All fields optional (None = no constraint).
1301#[derive(Debug, Clone, Default)]
1302pub struct IssueFilter {
1303    pub status: Option<Status>,
1304    pub priority: Option<Priority>,
1305    pub label: Option<String>,
1306    pub assigned_to_session: Option<String>,
1307    /// Text substring match on title (case-insensitive).
1308    pub text: Option<String>,
1309}
1310
1311impl IssueFilter {
1312    fn matches(&self, issue: &Issue) -> bool {
1313        if let Some(s) = self.status
1314            && issue.meta.status != s
1315        {
1316            return false;
1317        }
1318        if let Some(p) = self.priority
1319            && issue.meta.priority != p
1320        {
1321            return false;
1322        }
1323        if let Some(ref label) = self.label
1324            && !issue.meta.labels.iter().any(|l| l == label)
1325        {
1326            return false;
1327        }
1328        if let Some(ref session) = self.assigned_to_session {
1329            let mine = issue
1330                .meta
1331                .assigned_to
1332                .as_ref()
1333                .map(|a| &a.session == session)
1334                .unwrap_or(false);
1335            if !mine {
1336                return false;
1337            }
1338        }
1339        if let Some(ref text) = self.text
1340            && !issue
1341                .meta
1342                .title
1343                .to_lowercase()
1344                .contains(&text.to_lowercase())
1345        {
1346            return false;
1347        }
1348        true
1349    }
1350}
1351
1352// ============================================================================
1353// Tests
1354// ============================================================================
1355
1356#[cfg(test)]
1357mod tests {
1358    use super::*;
1359
1360    fn sample_meta(id: u32, title: &str, priority: Priority) -> IssueMeta {
1361        let now = Utc::now();
1362        IssueMeta {
1363            id,
1364            title: title.into(),
1365            status: Status::Open,
1366            priority,
1367            labels: vec![],
1368            assignee: None,
1369            created_at: now,
1370            updated_at: now,
1371            closed_at: None,
1372            sessions: vec![],
1373            assigned_to: None,
1374            github: None,
1375        }
1376    }
1377
1378    fn tmp_store() -> (tempfile::TempDir, FileIssueStore) {
1379        let tmp = tempfile::tempdir().unwrap();
1380        let dir = tmp.path().join(".oxi").join("issues");
1381        fs::create_dir_all(&dir).unwrap();
1382        let store = FileIssueStore::open(dir).unwrap();
1383        (tmp, store)
1384    }
1385
1386    #[test]
1387    fn roundtrip_serialization() {
1388        let issue = Issue {
1389            meta: sample_meta(1, "Test", Priority::High),
1390            body: "## Body\n\nHello.".into(),
1391            path: None,
1392        };
1393        let s = serialize_issue(&issue).unwrap();
1394        assert!(s.starts_with("---\n"));
1395        let parsed = parse_issue(&s, None).unwrap();
1396        assert_eq!(parsed.meta.id, 1);
1397        assert_eq!(parsed.meta.title, "Test");
1398        assert_eq!(parsed.meta.priority, Priority::High);
1399        assert!(parsed.body.contains("Hello."));
1400    }
1401
1402    #[tokio::test]
1403    async fn create_read_list() {
1404        let (_tmp, store) = tmp_store();
1405        let created = store
1406            .create(
1407                "Fix bug".into(),
1408                "body".into(),
1409                Priority::High,
1410                vec![],
1411                None,
1412            )
1413            .unwrap();
1414        assert_eq!(created.meta.id, 1);
1415
1416        let (read, hash) = store.read(1).unwrap();
1417        assert_eq!(read.meta.title, "Fix bug");
1418        assert!(!hash.is_empty());
1419
1420        let list = store.list(&IssueFilter::default()).unwrap();
1421        assert_eq!(list.len(), 1);
1422    }
1423
1424    #[tokio::test]
1425    async fn content_hash_detects_conflict() {
1426        let (_tmp, store) = tmp_store();
1427        store
1428            .create("Orig".into(), "b".into(), Priority::Low, vec![], None)
1429            .unwrap();
1430        let (_, hash) = store.read(1).unwrap();
1431
1432        // External edit (different hash) → wrong expected_hash → conflict.
1433        let wrong = Some("deadbeefdeadbeef".to_string());
1434        let err = store
1435            .update(1, wrong, |_| {
1436                Ok(Issue {
1437                    meta: sample_meta(1, "x", Priority::Low),
1438                    body: "x".into(),
1439                    path: None,
1440                })
1441            })
1442            .await
1443            .unwrap_err();
1444        assert!(matches!(err, IssueError::Conflict { id: 1 }));
1445
1446        // Correct hash → succeeds.
1447        let _ok = store
1448            .update(1, Some(hash), |mut i| {
1449                i.meta.title = "Updated".into();
1450                Ok(i)
1451            })
1452            .await
1453            .unwrap();
1454        let (read, _) = store.read(1).unwrap();
1455        assert_eq!(read.meta.title, "Updated");
1456    }
1457
1458    #[tokio::test]
1459    async fn start_rejects_live_owner() {
1460        let (_tmp, store) = tmp_store();
1461        store
1462            .create("T".into(), "b".into(), Priority::Low, vec![], None)
1463            .unwrap();
1464        let issues_dir = store.issues_dir();
1465        // Owner session A acquires a live lock.
1466        let _guard_a = liveness::acquire(&issues_dir, "sessionA").unwrap();
1467        // Manually assign to A.
1468        let (_, hash) = store.read(1).unwrap();
1469        store.start(1, "sessionA", Some(hash)).await.unwrap();
1470
1471        // B tries to start → rejected (A is alive).
1472        let (_, hash2) = store.read(1).unwrap();
1473        let err = store.start(1, "sessionB", Some(hash2)).await.unwrap_err();
1474        assert!(matches!(err, IssueError::Assigned { owner, .. } if owner == "sessionA"));
1475    }
1476
1477    #[tokio::test]
1478    async fn start_reclaims_dead_owner() {
1479        let (_tmp, store) = tmp_store();
1480        store
1481            .create("T".into(), "b".into(), Priority::Low, vec![], None)
1482            .unwrap();
1483        let issues_dir = store.issues_dir();
1484
1485        // A acquires, then "dies" (drop guard).
1486        {
1487            let _g = liveness::acquire(&issues_dir, "sessionA").unwrap();
1488            let (_, h) = store.read(1).unwrap();
1489            store.start(1, "sessionA", Some(h)).await.unwrap();
1490        } // guard dropped → A is "dead"
1491
1492        let (_, hash) = store.read(1).unwrap();
1493        let reclaimed = store.start(1, "sessionB", Some(hash)).await.unwrap();
1494        assert_eq!(
1495            reclaimed.meta.assigned_to.as_ref().unwrap().session,
1496            "sessionB"
1497        );
1498    }
1499
1500    #[tokio::test]
1501    async fn close_requires_owner() {
1502        let (_tmp, store) = tmp_store();
1503        store
1504            .create("T".into(), "b".into(), Priority::Low, vec![], None)
1505            .unwrap();
1506        let (_, hash) = store.read(1).unwrap();
1507        store.start(1, "sessionA", Some(hash)).await.unwrap();
1508
1509        // B can't close.
1510        let (_, hash2) = store.read(1).unwrap();
1511        let err = store.close(1, "sessionB", Some(hash2)).await.unwrap_err();
1512        assert!(matches!(err, IssueError::NotAssigned { .. }));
1513
1514        // A can.
1515        let (_, hash3) = store.read(1).unwrap();
1516        let closed = store.close(1, "sessionA", Some(hash3)).await.unwrap();
1517        assert_eq!(closed.meta.status, Status::Closed);
1518        assert!(closed.meta.assigned_to.is_none());
1519    }
1520
1521    #[tokio::test]
1522    async fn reopen_flips_closed_to_open() {
1523        let (_tmp, store) = tmp_store();
1524        let issues_dir = store.issues_dir();
1525        let _guard = crate::store::issues::liveness::acquire(&issues_dir, "tui").unwrap();
1526        store
1527            .create("T".into(), "b".into(), Priority::Low, vec![], None)
1528            .unwrap();
1529        // Close it.
1530        let (_, h) = store.read(1).unwrap();
1531        store.start(1, "tui", Some(h)).await.unwrap();
1532        let (_, h) = store.read(1).unwrap();
1533        store.close(1, "tui", Some(h)).await.unwrap();
1534        // Reopen.
1535        let (_, h) = store.read(1).unwrap();
1536        let reopened = store.reopen(1, Some(h)).await.unwrap();
1537        assert_eq!(reopened.meta.status, Status::Open);
1538        assert!(reopened.meta.closed_at.is_none());
1539        assert!(reopened.meta.assigned_to.is_none());
1540    }
1541
1542    #[tokio::test]
1543    async fn reopen_is_idempotent_on_already_open() {
1544        let (_tmp, store) = tmp_store();
1545        store
1546            .create("T".into(), "b".into(), Priority::Low, vec![], None)
1547            .unwrap();
1548        let (_, h) = store.read(1).unwrap();
1549        // Already open — reopen returns the issue unchanged.
1550        let reopened = store.reopen(1, Some(h)).await.unwrap();
1551        assert_eq!(reopened.meta.status, Status::Open);
1552        assert!(reopened.meta.closed_at.is_none());
1553    }
1554
1555    #[test]
1556    fn slugify_basic() {
1557        assert_eq!(slugify("Fix Login Bug!"), "fix-login-bug");
1558        assert_eq!(slugify("   spaces   "), "spaces");
1559        assert_eq!(slugify("a__b"), "a-b");
1560        assert_eq!(slugify(""), "");
1561    }
1562
1563    #[test]
1564    fn issue_filename_format() {
1565        assert_eq!(issue_filename(12, "Fix Login"), "0012-fix-login.md");
1566        assert_eq!(issue_filename(1, ""), "0001.md");
1567    }
1568
1569    #[tokio::test]
1570    async fn open_count_caches() {
1571        let (_tmp, store) = tmp_store();
1572        assert_eq!(store.open_count(), 0);
1573        store
1574            .create("A".into(), "b".into(), Priority::Low, vec![], None)
1575            .unwrap();
1576        store
1577            .create("B".into(), "b".into(), Priority::Low, vec![], None)
1578            .unwrap();
1579        assert_eq!(store.open_count(), 2);
1580
1581        // Start as owner A, then close → count drops to 1.
1582        let issues_dir = store.issues_dir();
1583        let _guard = liveness::acquire(&issues_dir, "sessionA").unwrap();
1584        let (_, h) = store.read(1).unwrap();
1585        store.start(1, "sessionA", Some(h)).await.unwrap();
1586        let (_, h) = store.read(1).unwrap();
1587        store.close(1, "sessionA", Some(h)).await.unwrap();
1588        store.invalidate();
1589        assert_eq!(store.open_count(), 1);
1590    }
1591
1592    #[tokio::test]
1593    async fn summary_reflects_lock_and_priority() {
1594        let (_tmp, store) = tmp_store();
1595        let issues_dir = store.issues_dir();
1596        let _guard = liveness::acquire(&issues_dir, "sessionA").unwrap();
1597        // Two opens: one Low (assigned to A), one Critical (free).
1598        store
1599            .create("Lowly".into(), "".into(), Priority::Low, vec![], None)
1600            .unwrap();
1601        store
1602            .create("Crit".into(), "".into(), Priority::Critical, vec![], None)
1603            .unwrap();
1604        // Plus one closed Medium (should be ignored).
1605        store
1606            .create("Closed".into(), "".into(), Priority::Medium, vec![], None)
1607            .unwrap();
1608        let (_, h) = store.read(3).unwrap();
1609        store.start(3, "sessionA", Some(h)).await.unwrap();
1610        let (_, h) = store.read(3).unwrap();
1611        store.close(3, "sessionA", Some(h)).await.unwrap();
1612        // Assign #1 to A.
1613        let (_, h) = store.read(1).unwrap();
1614        store.start(1, "sessionA", Some(h)).await.unwrap();
1615        store.invalidate();
1616
1617        let s = store.summary();
1618        assert_eq!(s.open_count, 2);
1619        assert_eq!(s.locked_open_count, 1);
1620        assert_eq!(s.top_priority, Some(Priority::Critical));
1621        assert!(s.latest_open_title.is_some());
1622        assert!(!s.is_empty());
1623    }
1624
1625    #[tokio::test]
1626    async fn summary_empty_when_no_issues() {
1627        let (_tmp, store) = tmp_store();
1628        let s = store.summary();
1629        assert_eq!(s.open_count, 0);
1630        assert_eq!(s.locked_open_count, 0);
1631        assert!(s.top_priority.is_none());
1632        assert!(s.latest_open_title.is_none());
1633        assert!(s.is_empty());
1634    }
1635
1636    #[tokio::test]
1637    async fn latest_open_title_caches_and_handles_cjk() {
1638        let (_tmp, store) = tmp_store();
1639        // No issues yet — latest_open_title is None.
1640        assert!(store.latest_open_title().is_none());
1641
1642        // Create an issue with a CJK title and body. The title must survive
1643        // round-trip through the cache and read() without panic on multi-byte
1644        // boundaries. (Regression test for the byte-slice panic in
1645        // `first_line_preview` / `truncate_for_footer`.)
1646        let cjk_title =
1647            "버그 수정: 한글 제목도 정상이어야 합니다 — 멀티바이트 인코딩 안전성".to_string();
1648        let cjk_body =
1649            "요약\n\n이 이슈는 한글 본문을 포함합니다. 본문에는 영문과 한글이 섞여 있습니다. "
1650                .repeat(4);
1651        let created = store
1652            .create(cjk_title.clone(), cjk_body, Priority::High, vec![], None)
1653            .unwrap();
1654        assert_eq!(created.meta.title, cjk_title);
1655
1656        // Cache populates from read_dir.
1657        let title = store.latest_open_title();
1658        assert_eq!(title.as_deref(), Some(cjk_title.as_str()));
1659
1660        // read() must not panic on multi-byte UTF-8 in the body.
1661        let (read_back, _hash) = store.read(created.meta.id).unwrap();
1662        assert!(read_back.body.contains("한글"));
1663    }
1664
1665    // ── Phase 0 (defect #13) regression coverage ───────────────────────────
1666    //
1667    // Before #13 was fixed, `ToolContext.session_id` was always `None`, so the
1668    // `issue` tool called `start(id, "", hash)`. An assignment under the empty
1669    // string is never "alive" (no `.alive/` file named `""`), so any other
1670    // caller immediately reclaimed it — the headline ownership feature was
1671    // silently inert for the agent path. These tests pin the post-fix invariants
1672    // at the store layer so the regression cannot return silently.
1673
1674    #[tokio::test]
1675    async fn start_with_distinct_live_owners_collides() {
1676        // Two DIFFERENT live sessions both try to start the same issue. With
1677        // real session identities (the post-#13 world), the second MUST see
1678        // `Assigned` — proving the liveness check is now meaningful for the
1679        // agent path, not just for the TUI panel.
1680        let (_tmp, store) = tmp_store();
1681        let issues_dir = store.issues_dir();
1682        store
1683            .create("T".into(), "b".into(), Priority::Low, vec![], None)
1684            .unwrap();
1685
1686        // Session A is live and claims the issue.
1687        let _guard_a = liveness::acquire(&issues_dir, "proc-A").unwrap();
1688        let (_, h) = store.read(1).unwrap();
1689        store.start(1, "proc-A", Some(h)).await.unwrap();
1690
1691        // Session B is ALSO live (different flock file) and tries to start.
1692        let _guard_b = liveness::acquire(&issues_dir, "proc-B").unwrap();
1693        let (_, h2) = store.read(1).unwrap();
1694        let err = store.start(1, "proc-B", Some(h2)).await.unwrap_err();
1695        assert!(
1696            matches!(err, IssueError::Assigned { ref owner, .. } if owner == "proc-A"),
1697            "a second distinct live owner must be rejected, got: {err:?}"
1698        );
1699    }
1700
1701    #[tokio::test]
1702    async fn empty_session_assignment_is_immediately_reclaimable_documentation() {
1703        // Documents the EXACT pre-#13 bug shape at the store layer so that if
1704        // `start(id, "", hash)` ever reappears in a caller, this test loudly
1705        // explains why it's wrong: an assignment under "" has no flock holder,
1706        // so `is_session_alive("")` is false and ANY caller reclaims it.
1707        //
1708        // (This is intentionally a documentation test, not a behavior change —
1709        // the store is policy-free. The fix lives in the agent/tool wiring,
1710        // covered by oxi-agent's `session_id_wiring_tests`.)
1711        let (_tmp, store) = tmp_store();
1712        store
1713            .create("T".into(), "b".into(), Priority::Low, vec![], None)
1714            .unwrap();
1715        let issues_dir = store.issues_dir();
1716
1717        // Caller "" (the pre-#13 agent default) claims the issue.
1718        let (_, h) = store.read(1).unwrap();
1719        store.start(1, "", Some(h)).await.unwrap();
1720
1721        // Nobody holds a flock named "", so the assignment is NOT alive...
1722        assert!(
1723            !liveness::is_session_alive(&issues_dir, ""),
1724            "no flock can be held under the empty string"
1725        );
1726
1727        // ...and any real caller reclaims it without contention. This is the
1728        // silent-ownership-bypass bug that #13 fixes by ensuring agents never
1729        // use "" as their caller id.
1730        let _guard_c = liveness::acquire(&issues_dir, "proc-C").unwrap();
1731        let (_, h2) = store.read(1).unwrap();
1732        let reclaimed = store.start(1, "proc-C", Some(h2)).await.unwrap();
1733        assert_eq!(
1734            reclaimed.meta.assigned_to.as_ref().unwrap().session,
1735            "proc-C",
1736            "empty-string assignment is reclaimable — this is the #13 bug shape"
1737        );
1738    }
1739
1740    // ── Phase 2 regression coverage (#2 #3 #4 #9 #12) ────────────────────
1741
1742    #[tokio::test]
1743    async fn reopen_clears_closed_at() {
1744        // #4: reopening must clear `closed_at`. The legacy `update { status:
1745        // open }` left a stale `closed_at` on a reopened issue.
1746        let (_tmp, store) = tmp_store();
1747        store
1748            .create("T".into(), "b".into(), Priority::Low, vec![], None)
1749            .unwrap();
1750        let (_, h) = store.read(1).unwrap();
1751        store.start(1, "proc-X", Some(h)).await.unwrap();
1752        let (_, h) = store.read(1).unwrap();
1753        store.close(1, "proc-X", Some(h)).await.unwrap();
1754        let (closed, _) = store.read(1).unwrap();
1755        assert_eq!(closed.meta.status, Status::Closed);
1756        assert!(closed.meta.closed_at.is_some());
1757
1758        let (_, h) = store.read(1).unwrap();
1759        store.reopen(1, Some(h)).await.unwrap();
1760        let (reopened, _) = store.read(1).unwrap();
1761        assert_eq!(reopened.meta.status, Status::Open);
1762        assert!(
1763            reopened.meta.closed_at.is_none(),
1764            "reopen must clear closed_at (#4)"
1765        );
1766    }
1767
1768    #[tokio::test]
1769    async fn apply_patch_status_open_clears_closed_at() {
1770        // #4 via the apply_patch path too: status -> Open clears closed_at.
1771        let (_tmp, store) = tmp_store();
1772        store
1773            .create("T".into(), "b".into(), Priority::Low, vec![], None)
1774            .unwrap();
1775        let (_, h) = store.read(1).unwrap();
1776        store.start(1, "proc-X", Some(h)).await.unwrap();
1777        let (_, h) = store.read(1).unwrap();
1778        store.close(1, "proc-X", Some(h)).await.unwrap();
1779
1780        let (_, h) = store.read(1).unwrap();
1781        store
1782            .apply_patch(
1783                1,
1784                IssuePatch {
1785                    status: Some(Status::Open),
1786                    ..Default::default()
1787                },
1788                None,
1789                Some(h),
1790            )
1791            .await
1792            .unwrap();
1793        let (after, _) = store.read(1).unwrap();
1794        assert_eq!(after.meta.status, Status::Open);
1795        assert!(
1796            after.meta.closed_at.is_none(),
1797            "apply_patch status=Open must clear closed_at (#4)"
1798        );
1799    }
1800
1801    #[tokio::test]
1802    async fn noop_update_does_not_bump_timestamp() {
1803        // #12: a patch that changes nothing meaningful must not write, must
1804        // not bump updated_at, must not invalidate the cache.
1805        let (_tmp, store) = tmp_store();
1806        store
1807            .create("T".into(), "b".into(), Priority::Low, vec![], None)
1808            .unwrap();
1809        let (before, _) = store.read(1).unwrap();
1810        let ts_before = before.meta.updated_at;
1811
1812        // Empty patch → no-op.
1813        let (_, h) = store.read(1).unwrap();
1814        store
1815            .apply_patch(1, IssuePatch::default(), None, Some(h))
1816            .await
1817            .unwrap();
1818        let (after, _) = store.read(1).unwrap();
1819        assert_eq!(
1820            after.meta.updated_at, ts_before,
1821            "no-op update must not bump updated_at (#12)"
1822        );
1823
1824        // A real change DOES bump it (and updates the field).
1825        std::thread::sleep(std::time::Duration::from_millis(5));
1826        let (_, h2) = store.read(1).unwrap();
1827        store
1828            .apply_patch(
1829                1,
1830                IssuePatch {
1831                    title: Some("New".into()),
1832                    ..Default::default()
1833                },
1834                None,
1835                Some(h2),
1836            )
1837            .await
1838            .unwrap();
1839        let (after2, _) = store.read(1).unwrap();
1840        assert_ne!(
1841            after2.meta.updated_at, ts_before,
1842            "real update must bump updated_at"
1843        );
1844        assert_eq!(after2.meta.title, "New");
1845    }
1846
1847    #[tokio::test]
1848    async fn apply_patch_labels_clear_vs_keep() {
1849        // #3: absent vs [] must be distinguishable. None=keep, Some([])=clear,
1850        // Some([x])=replace.
1851        let (_tmp, store) = tmp_store();
1852        store
1853            .create(
1854                "T".into(),
1855                "b".into(),
1856                Priority::Low,
1857                vec!["a".into(), "b".into()],
1858                None,
1859            )
1860            .unwrap();
1861
1862        // Omit labels (None) → keep, while another field changes.
1863        let (_, h) = store.read(1).unwrap();
1864        store
1865            .apply_patch(
1866                1,
1867                IssuePatch {
1868                    priority: Some(Priority::High),
1869                    ..Default::default()
1870                },
1871                None,
1872                Some(h),
1873            )
1874            .await
1875            .unwrap();
1876        let (kept, _) = store.read(1).unwrap();
1877        assert_eq!(kept.meta.labels, vec!["a".to_string(), "b".to_string()]);
1878        assert_eq!(kept.meta.priority, Priority::High);
1879
1880        // labels: Some([]) → clear.
1881        let (_, h) = store.read(1).unwrap();
1882        store
1883            .apply_patch(
1884                1,
1885                IssuePatch {
1886                    labels: Some(vec![]),
1887                    ..Default::default()
1888                },
1889                None,
1890                Some(h),
1891            )
1892            .await
1893            .unwrap();
1894        let (cleared, _) = store.read(1).unwrap();
1895        assert!(cleared.meta.labels.is_empty(), "Some([]) must clear labels");
1896
1897        // labels: Some([x]) → replace.
1898        let (_, h) = store.read(1).unwrap();
1899        store
1900            .apply_patch(
1901                1,
1902                IssuePatch {
1903                    labels: Some(vec!["z".into()]),
1904                    ..Default::default()
1905                },
1906                None,
1907                Some(h),
1908            )
1909            .await
1910            .unwrap();
1911        let (replaced, _) = store.read(1).unwrap();
1912        assert_eq!(replaced.meta.labels, vec!["z".to_string()]);
1913    }
1914
1915    #[tokio::test]
1916    async fn apply_patch_enforces_ownership() {
1917        // Hardening keeps the legacy ownership policy: a different non-empty
1918        // assignee blocks the update. apply_patch must reject a non-owner.
1919        let (_tmp, store) = tmp_store();
1920        store
1921            .create("T".into(), "b".into(), Priority::Low, vec![], None)
1922            .unwrap();
1923        let (_, h) = store.read(1).unwrap();
1924        store.start(1, "proc-A", Some(h)).await.unwrap();
1925
1926        // proc-B cannot patch.
1927        let (_, h) = store.read(1).unwrap();
1928        let err = store
1929            .apply_patch(
1930                1,
1931                IssuePatch {
1932                    title: Some("X".into()),
1933                    ..Default::default()
1934                },
1935                Some("proc-B".into()),
1936                Some(h),
1937            )
1938            .await
1939            .unwrap_err();
1940        assert!(
1941            matches!(err, IssueError::NotAssigned { ref caller, .. } if caller == "proc-B"),
1942            "non-owner must be rejected, got: {err:?}"
1943        );
1944
1945        // proc-A (the owner) succeeds.
1946        let (_, h) = store.read(1).unwrap();
1947        store
1948            .apply_patch(
1949                1,
1950                IssuePatch {
1951                    title: Some("X".into()),
1952                    ..Default::default()
1953                },
1954                Some("proc-A".into()),
1955                Some(h),
1956            )
1957            .await
1958            .unwrap();
1959        let (patched, _) = store.read(1).unwrap();
1960        assert_eq!(patched.meta.title, "X");
1961    }
1962
1963    // ── Phase 4: top_free_priority (#10) ──
1964
1965    #[tokio::test]
1966    async fn top_free_priority_ignores_assigned_and_closed() {
1967        // Highest priority among OPEN + UNASSIGNED issues only. A critical
1968        // issue that's assigned or closed must not be reported as "free".
1969        let (_tmp, store) = tmp_store();
1970        store
1971            .create("low".into(), "".into(), Priority::Low, vec![], None)
1972            .unwrap();
1973        store
1974            .create("high".into(), "".into(), Priority::High, vec![], None)
1975            .unwrap();
1976        store
1977            .create(
1978                "critical-assigned".into(),
1979                "".into(),
1980                Priority::Critical,
1981                vec![],
1982                None,
1983            )
1984            .unwrap();
1985        store
1986            .create(
1987                "critical-closed".into(),
1988                "".into(),
1989                Priority::Critical,
1990                vec![],
1991                None,
1992            )
1993            .unwrap();
1994
1995        // Assign critical-assigned (free → assign).
1996        let (_, h) = store.read(3).unwrap();
1997        store.start(3, "proc", Some(h)).await.unwrap();
1998        // Close critical-closed.
1999        let (_, h) = store.read(4).unwrap();
2000        store.start(4, "proc", Some(h)).await.unwrap();
2001        let (_, h) = store.read(4).unwrap();
2002        store.close(4, "proc", Some(h)).await.unwrap();
2003
2004        // The top FREE priority is High (the two criticals are assigned/closed).
2005        assert_eq!(store.top_free_priority(), Some(Priority::High));
2006
2007        // Release everything and nothing is left free with higher than Low/High...
2008        // (sanity: when all open free issues are gone, returns None.)
2009        let (_, h) = store.read(1).unwrap();
2010        store.start(1, "proc", Some(h)).await.unwrap();
2011        let (_, h) = store.read(2).unwrap();
2012        store.start(2, "proc", Some(h)).await.unwrap();
2013        assert_eq!(
2014            store.top_free_priority(),
2015            None,
2016            "no open unassigned issue → None"
2017        );
2018    }
2019}