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// ============================================================================
220// Serialization — markdown + YAML frontmatter
221// ============================================================================
222
223/// Atomically write content to a file by first writing to a temp file,
224/// then renaming it. Same pattern as `store::session::atomic_write`.
225fn atomic_write(path: &Path, content: &str) -> std::io::Result<()> {
226    let tmp_path = path.with_extension(format!("tmp.{}", std::process::id()));
227    std::fs::write(&tmp_path, content)?;
228    std::fs::rename(&tmp_path, path)?;
229    Ok(())
230}
231
232const FRONTMATTER_DELIM: &str = "---";
233
234/// Parse a markdown-with-frontmatter file into an [`Issue`].
235///
236/// Format:
237/// ```text
238/// ---
239/// <yaml>
240/// ---
241/// <markdown body>
242/// ```
243///
244/// A missing closing delimiter is treated as "the rest is body". Missing
245/// frontmatter entirely yields an empty meta (caller decides whether that's
246/// an error).
247pub fn parse_issue(raw: &str, path: Option<PathBuf>) -> Result<Issue> {
248    let raw = raw.strip_prefix('\u{feff}').unwrap_or(raw);
249
250    // Split off the opening delimiter.
251    // No leading frontmatter delimiter → synthesize an empty meta and treat
252    // the whole input as body.
253    let after_open = match raw.strip_prefix(FRONTMATTER_DELIM) {
254        Some(rest) => rest,
255        None => {
256            return Ok(Issue {
257                meta: empty_meta(),
258                body: raw.to_string(),
259                path,
260            });
261        }
262    };
263
264    // Robust line-based scan for the closing `---` delimiter. Everything
265    // between the opening and closing lines is YAML; everything after is body.
266    let mut yaml = String::new();
267    let mut body = String::new();
268    let mut closed = false;
269    for line in after_open.split_inclusive('\n') {
270        if !closed && line.trim_end() == FRONTMATTER_DELIM {
271            closed = true;
272            continue;
273        }
274        if !closed {
275            yaml.push_str(line);
276        } else {
277            body.push_str(line);
278        }
279    }
280
281    let meta: IssueMeta =
282        serde_yaml::from_str(&yaml).context("failed to parse issue frontmatter")?;
283    Ok(Issue { meta, body, path })
284}
285
286/// Serialize an issue back to the markdown-with-frontmatter form.
287pub fn serialize_issue(issue: &Issue) -> Result<String> {
288    let yaml = serde_yaml::to_string(&issue.meta).context("failed to serialize frontmatter")?;
289    // serde_yaml emits a trailing newline; the `---` document markers are
290    // *not* added by serde_yaml, so we wrap manually.
291    let body = if issue.body.is_empty() {
292        String::new()
293    } else if issue.body.ends_with('\n') {
294        issue.body.clone()
295    } else {
296        format!("{}\n", issue.body)
297    };
298    Ok(format!(
299        "{open}\n{yaml}{close}\n{body}",
300        open = FRONTMATTER_DELIM,
301        close = FRONTMATTER_DELIM
302    ))
303}
304
305/// Compute a content hash used for optimistic concurrency (same idea as the
306/// `edit` tool's `expected_hash`). Uses the std default hasher for zero deps.
307pub fn content_hash(raw: &str) -> String {
308    let mut hasher = std::collections::hash_map::DefaultHasher::new();
309    raw.hash(&mut hasher);
310    format!("{:016x}", hasher.finish())
311}
312
313// ============================================================================
314// Project-root discovery
315// ============================================================================
316
317/// Walk up from `start` looking for a `.oxi/` directory. Returns the path to
318/// `<root>/.oxi/issues`. If no `.oxi/` exists, returns `<start>/.oxi/issues`
319/// (lazily created on first write).
320///
321/// Mirrors the walk in `Settings::find_project_settings`.
322pub fn issues_dir(start: &Path) -> PathBuf {
323    let mut dir = start.to_path_buf();
324    loop {
325        if dir.join(".oxi").is_dir() {
326            return dir.join(".oxi").join("issues");
327        }
328        if !dir.pop() {
329            break;
330        }
331    }
332    start.join(".oxi").join("issues")
333}
334
335/// Filename for an issue: zero-padded 4-digit id + slugified title.
336pub fn issue_filename(id: u32, title: &str) -> String {
337    let slug = slugify(title);
338    if slug.is_empty() {
339        format!("{:04}.md", id)
340    } else {
341        format!("{:04}-{}.md", id, slug)
342    }
343}
344
345/// Construct an empty placeholder meta (used when a file has no frontmatter).
346fn empty_meta() -> IssueMeta {
347    let now = Utc::now();
348    IssueMeta {
349        id: 0,
350        title: String::new(),
351        status: Status::default(),
352        priority: Priority::default(),
353        labels: vec![],
354        assignee: None,
355        created_at: now,
356        updated_at: now,
357        closed_at: None,
358        sessions: vec![],
359        assigned_to: None,
360        github: None,
361    }
362}
363
364/// Slugify a title for use in a filename: lowercase, [a-z0-9-] only.
365fn slugify(s: &str) -> String {
366    let mut out = String::new();
367    let mut prev_dash = false;
368    for c in s.chars() {
369        if c.is_ascii_alphanumeric() {
370            out.push(c.to_ascii_lowercase());
371            prev_dash = false;
372        } else if !prev_dash {
373            out.push('-');
374            prev_dash = true;
375        }
376    }
377    out.trim_matches('-').to_string()
378}
379
380// ============================================================================
381// Liveness — process-held advisory locks (no wall-clock expiry)
382// ============================================================================
383
384/// Process-liveness tracking via OS advisory locks.
385///
386/// Each session holds an exclusive `flock` on `.oxi/issues/.alive/<session_id>`.
387/// The lock is released by the OS when the process exits (including crashes
388/// and `kill -9`). This lets us answer "is session X still alive?" without
389/// any wall-clock timeout, PID-recycling heuristics, or heartbeats.
390pub mod liveness {
391    use super::*;
392
393    /// Path of the alive-lock file for `session_id` under `issues_dir`.
394    pub fn alive_path(issues_dir: &Path, session_id: &str) -> PathBuf {
395        issues_dir.join(".alive").join(session_id)
396    }
397
398    /// Try to acquire (and hold) an exclusive advisory lock for `session_id`.
399    ///
400    /// The returned [`AliveGuard`] releases the lock when dropped — so callers
401    /// must keep it alive for the whole session. Opening with write+create and
402    /// calling `flock(LOCK_EX | LOCK_NB)` is atomic enough for our purposes:
403    /// failure to acquire means another live process holds it.
404    pub fn acquire(issues_dir: &Path, session_id: &str) -> io::Result<AliveGuard> {
405        let dir = issues_dir.join(".alive");
406        fs::create_dir_all(&dir)?;
407        let path = dir.join(session_id);
408        let file = OpenOptions::new()
409            .write(true)
410            .create(true)
411            .truncate(false)
412            .open(&path)?;
413        let fd = file.as_raw_fd();
414        // SAFETY: flock(2) on a valid fd. LOCK_NB = non-blocking.
415        let rc = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
416        if rc != 0 {
417            let err = io::Error::last_os_error();
418            // EWOULDBLOCK/EAGAIN = held by another process.
419            return Err(err);
420        }
421        Ok(AliveGuard { _file: file, path })
422    }
423
424    /// Returns `true` iff a live process currently holds the alive-lock for
425    /// `session_id`. Used to decide whether an [`Assignment`] is still valid.
426    pub fn is_session_alive(issues_dir: &Path, session_id: &str) -> bool {
427        let path = alive_path(issues_dir, session_id);
428        if !path.exists() {
429            return false;
430        }
431        // Try to acquire a *shared* lock non-blockingly. If we can't, someone
432        // holds an exclusive lock → alive. If we can, no one holds it → dead.
433        let Ok(file) = OpenOptions::new().read(true).write(true).open(&path) else {
434            return false;
435        };
436        let fd = file.as_raw_fd();
437        let rc = unsafe { libc::flock(fd, libc::LOCK_SH | libc::LOCK_NB) };
438        if rc == 0 {
439            // We got a shared lock — nobody is holding exclusive. Release.
440            unsafe {
441                libc::flock(fd, libc::LOCK_UN);
442            }
443            false
444        } else {
445            // Couldn't acquire — an exclusive holder is alive.
446            true
447        }
448    }
449
450    /// RAII guard for an acquired alive-lock.
451    #[derive(Debug)]
452    pub struct AliveGuard {
453        _file: fs::File,
454        path: PathBuf,
455    }
456
457    impl AliveGuard {
458        pub fn path(&self) -> &Path {
459            &self.path
460        }
461    }
462
463    impl Drop for AliveGuard {
464        fn drop(&mut self) {
465            // Drop closes the fd → OS releases the lock. Best-effort unlink.
466            let _ = fs::remove_file(&self.path);
467        }
468    }
469
470    #[cfg(test)]
471    mod tests {
472        use super::*;
473
474        #[test]
475        fn acquire_then_alive() {
476            let tmp = tempfile::tempdir().unwrap();
477            let dir = tmp.path().to_path_buf();
478            let sid = "s1";
479            let _g = acquire(&dir, sid).unwrap();
480            assert!(is_session_alive(&dir, sid));
481            drop(_g);
482            assert!(!is_session_alive(&dir, sid));
483        }
484
485        #[test]
486        fn second_acquire_fails_while_held() {
487            let tmp = tempfile::tempdir().unwrap();
488            let dir = tmp.path().to_path_buf();
489            let sid = "s2";
490            let g = acquire(&dir, sid).unwrap();
491            let second = acquire(&dir, sid);
492            assert!(second.is_err(), "second acquire should fail while held");
493            assert!(is_session_alive(&dir, sid));
494            drop(g);
495            assert!(acquire(&dir, sid).is_ok(), "after drop, acquire succeeds");
496        }
497    }
498}
499
500// ============================================================================
501// Store
502// ============================================================================
503
504/// Cached directory listing, so the status-bar indicator doesn't readdir the
505/// issues dir every render frame. `dir_mtime` is the single invalidation
506/// signal; per-file mtimes aren't tracked (CAS uses content-hash on writes).
507#[derive(Debug, Default, Clone)]
508struct Cache {
509    /// `open` issue count (the number shown in the status bar).
510    open_count: usize,
511    /// Title of the most recently updated open issue (for the indicator).
512    latest_open_title: Option<String>,
513    dir_mtime: Option<std::time::SystemTime>,
514}
515
516/// In-memory state for [`FileIssueStore`].
517struct Inner {
518    issues_dir: PathBuf,
519    cache: Cache,
520}
521
522impl std::fmt::Debug for Inner {
523    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
524        f.debug_struct("Inner")
525            .field("issues_dir", &self.issues_dir)
526            .finish()
527    }
528}
529
530/// File-backed issue store.
531///
532/// One instance is shared (via `Arc`) between the TUI indicator, the agent
533/// `issue` tool, and the `oxi issue` CLI subcommand. All mutations go through
534/// [`FileIssueStore::write`] which serializes per-file (in-process) and uses
535/// content-hash CAS (cross-process / external edits).
536#[derive(Clone, Debug)]
537pub struct FileIssueStore {
538    inner: Arc<RwLock<Inner>>,
539}
540
541impl FileIssueStore {
542    /// Open (or create lazily) the issue store rooted at `issues_dir`.
543    pub fn open(issues_dir: PathBuf) -> Result<Self> {
544        Ok(Self {
545            inner: Arc::new(RwLock::new(Inner {
546                issues_dir,
547                cache: Cache::default(),
548            })),
549        })
550    }
551
552    /// Open using project-root discovery from `start` (cwd).
553    pub fn open_from_cwd(start: &Path) -> Result<Self> {
554        Self::open(issues_dir(start))
555    }
556
557    /// The issues directory.
558    pub fn issues_dir(&self) -> PathBuf {
559        self.inner.read().issues_dir.clone()
560    }
561
562    /// Number of open issues, for the status-bar indicator. Refreshes the
563    /// cache if the directory mtime changed. Cheap (O(1) when fresh).
564    pub fn open_count(&self) -> usize {
565        self.refresh_if_stale();
566        self.inner.read().cache.open_count
567    }
568
569    /// Title of the most recently updated open issue, for the status-bar
570    /// indicator. Cached alongside `open_count`, so this is also O(1) on a
571    /// warm cache. Returns `None` if there are no open issues.
572    pub fn latest_open_title(&self) -> Option<String> {
573        self.refresh_if_stale();
574        self.inner.read().cache.latest_open_title.clone()
575    }
576
577    /// True iff the issues directory has any issues at all (suppresses the
578    /// indicator when the project has never used the feature).
579    pub fn has_any(&self) -> bool {
580        self.refresh_if_stale();
581        let dir = self.inner.read().issues_dir.clone();
582        fs::read_dir(&dir)
583            .map(|rd| {
584                rd.filter_map(|e| e.ok())
585                    .any(|e| e.path().extension().and_then(|x| x.to_str()) == Some("md"))
586            })
587            .unwrap_or(false)
588    }
589
590    /// Refresh cache if the directory mtime changed (or never loaded).
591    fn refresh_if_stale(&self) {
592        let dir = self.inner.read().issues_dir.clone();
593        let cur_dir_mtime = fs::metadata(&dir).and_then(|m| m.modified()).ok();
594        let needs = {
595            let g = self.inner.read();
596            match (g.cache.dir_mtime, cur_dir_mtime) {
597                (None, _) => true,        // never loaded
598                (Some(_), None) => false, // can't stat dir; keep cache
599                (Some(cached), Some(cur)) => cached != cur,
600            }
601        };
602        if !needs {
603            return;
604        }
605        // Re-scan.
606        let mut open_count = 0;
607        let mut latest_open_title: Option<String> = None;
608        let mut latest_open_updated: Option<chrono::DateTime<chrono::Utc>> = None;
609        if let Ok(rd) = fs::read_dir(&dir) {
610            for entry in rd.flatten() {
611                let p = entry.path();
612                if p.extension().and_then(|x| x.to_str()) != Some("md") {
613                    continue;
614                }
615                // open_count requires parsing frontmatter. For the indicator
616                // we accept the cost — issues are typically few.
617                if let Ok(raw) = fs::read_to_string(&p)
618                    && let Ok(issue) = parse_issue(&raw, None)
619                    && issue.meta.status == Status::Open
620                {
621                    open_count += 1;
622                    if issue.meta.updated_at
623                        > latest_open_updated.unwrap_or(chrono::DateTime::<chrono::Utc>::MIN_UTC)
624                    {
625                        latest_open_updated = Some(issue.meta.updated_at);
626                        latest_open_title = Some(issue.meta.title);
627                    }
628                }
629            }
630        }
631        let mut g = self.inner.write();
632        g.cache = Cache {
633            open_count,
634            latest_open_title,
635            dir_mtime: cur_dir_mtime,
636        };
637    }
638
639    /// Invalidate the cache (force next read to rescan).
640    pub fn invalidate(&self) {
641        self.inner.write().cache = Cache::default();
642    }
643
644    // ── Reads ───────────────────────────────────────────────────────────
645
646    /// List all issues, optionally filtered. Sorted by `updated_at` desc.
647    pub fn list(&self, filter: &IssueFilter) -> Result<Vec<Issue>> {
648        self.refresh_if_stale();
649        let dir = self.inner.read().issues_dir.clone();
650        let mut out = Vec::new();
651        if let Ok(rd) = fs::read_dir(&dir) {
652            for entry in rd.flatten() {
653                let p = entry.path();
654                if p.extension().and_then(|x| x.to_str()) != Some("md") {
655                    continue;
656                }
657                let raw = fs::read_to_string(&p)?;
658                let issue = parse_issue(&raw, Some(p.clone()))?;
659                if filter.matches(&issue) {
660                    out.push(issue);
661                }
662            }
663        }
664        out.sort_by_key(|i| std::cmp::Reverse(i.meta.updated_at));
665        Ok(out)
666    }
667
668    /// Read a single issue by id. Returns the issue and its current content
669    /// hash (for optimistic-concurrency writes).
670    pub fn read(&self, id: u32) -> Result<(Issue, String)> {
671        let path = self.path_for_id(id)?;
672        let raw = fs::read_to_string(&path)
673            .with_context(|| format!("issue #{} not found at {}", id, path.display()))?;
674        let issue = parse_issue(&raw, Some(path))?;
675        Ok((issue, content_hash(&raw)))
676    }
677
678    // ── Writes ──────────────────────────────────────────────────────────
679
680    /// Allocate the next issue id by scanning existing filenames.
681    ///
682    /// Cross-process allocation races are possible (two sessions create the
683    /// next id simultaneously) but bounded: the loser's `create` write hits
684    /// an existing file and we bump to the next free id. No lock needed for
685    /// correctness, only for avoiding rare retries.
686    pub fn next_id(&self) -> Result<u32> {
687        let dir = self.inner.read().issues_dir.clone();
688        fs::create_dir_all(&dir)?;
689        let mut max = 0u32;
690        if let Ok(rd) = fs::read_dir(&dir) {
691            for entry in rd.flatten() {
692                let name = entry.file_name();
693                let name = name.to_string_lossy();
694                let num_str = name.split('-').next().unwrap_or(&name);
695                if let Ok(n) = num_str.trim_end_matches(".md").parse::<u32>() {
696                    max = max.max(n);
697                }
698            }
699        }
700        Ok(max + 1)
701    }
702
703    /// Create a new issue. `caller_session` is linked into `sessions`.
704    pub fn create(
705        &self,
706        title: String,
707        body: String,
708        priority: Priority,
709        labels: Vec<String>,
710        caller_session: Option<&str>,
711    ) -> Result<Issue> {
712        let id = self.next_id()?;
713        let now = Utc::now();
714        let sessions = caller_session
715            .map(|s| vec![s.to_string()])
716            .unwrap_or_default();
717        let issue = Issue {
718            meta: IssueMeta {
719                id,
720                title,
721                status: Status::Open,
722                priority,
723                labels,
724                assignee: None,
725                created_at: now,
726                updated_at: now,
727                closed_at: None,
728                sessions,
729                assigned_to: None,
730                github: None,
731            },
732            body,
733            path: None,
734        };
735        // Retry a few times in case of id collision with another session.
736        for _ in 0..4 {
737            let path = self
738                .issues_dir()
739                .join(issue_filename(id, &issue.meta.title));
740            if path.exists() {
741                // bump id and retry
742                continue;
743            }
744            let content = serialize_issue(&issue)?;
745            atomic_write(&path, &content)?;
746            self.invalidate();
747            let mut saved = issue.clone();
748            saved.path = Some(path);
749            return Ok(saved);
750        }
751        anyhow::bail!("could not allocate a free issue id after retries");
752    }
753
754    /// Update an issue with optimistic concurrency.
755    ///
756    /// `expected_hash` should be the hash returned by [`read`]. If the
757    /// on-disk content changed since, returns [`IssueError::Conflict`].
758    /// `mutator` receives the loaded issue and returns the new state.
759    ///
760    /// All writes go through `file_mutation_queue` for in-process
761    /// serialization, exactly like the `edit` tool.
762    pub async fn update<F>(
763        &self,
764        id: u32,
765        expected_hash: Option<String>,
766        mutator: F,
767    ) -> std::result::Result<Issue, IssueError>
768    where
769        F: FnOnce(Issue) -> std::result::Result<Issue, IssueError> + Send + 'static,
770    {
771        let path = self.path_for_id(id).map_err(IssueError::Other)?;
772        let path_for_closure = path.clone();
773        let store = self.clone();
774        // Serialize same-file writes within this process.
775        oxi_agent::tools::file_mutation_queue::global_mutation_queue()
776            .with_queue(&path, move || async move {
777                let path = path_for_closure;
778                let raw = fs::read_to_string(&path)?;
779                if let Some(expected) = expected_hash.as_deref()
780                    && content_hash(&raw) != expected
781                {
782                    return Err(IssueError::Conflict { id });
783                }
784                let issue = parse_issue(&raw, Some(path.clone())).map_err(IssueError::Other)?;
785                let mut new = mutator(issue)?;
786                new.meta.updated_at = Utc::now();
787                let content = serialize_issue(&new).map_err(IssueError::Other)?;
788                atomic_write(&path, &content)?;
789                store.invalidate();
790                Ok(new.with_path(path))
791            })
792            .await
793    }
794
795    /// Convenience: close an issue (assignee only).
796    pub async fn close(
797        &self,
798        id: u32,
799        caller: &str,
800        expected_hash: Option<String>,
801    ) -> std::result::Result<Issue, IssueError> {
802        let now = Utc::now();
803        let caller = caller.to_string();
804        self.update(id, expected_hash, move |mut issue| {
805            require_owner(&issue, id, &caller)?;
806            issue.meta.status = Status::Closed;
807            issue.meta.closed_at = Some(now);
808            issue.meta.assigned_to = None; // closing releases the assignment
809            Ok(issue)
810        })
811        .await
812    }
813
814    /// Try to claim an issue for `caller` (the `start` action).
815    ///
816    /// If already assigned to a *live* session, returns [`IssueError::Assigned`].
817    /// If assigned to a *dead* session (process exited), reclaims and assigns
818    /// to the caller. If free, assigns to the caller.
819    pub async fn start(
820        &self,
821        id: u32,
822        caller: &str,
823        expected_hash: Option<String>,
824    ) -> std::result::Result<Issue, IssueError> {
825        let issues_dir = self.issues_dir();
826        let caller_owned = caller.to_string();
827        self.update(id, expected_hash, move |mut issue| {
828            if let Some(ref a) = issue.meta.assigned_to {
829                if a.session == caller_owned {
830                    // Already mine; idempotent.
831                    return Ok(issue);
832                }
833                if liveness::is_session_alive(&issues_dir, &a.session) {
834                    return Err(IssueError::Assigned {
835                        id,
836                        owner: a.session.clone(),
837                        acquired_at: a.acquired_at,
838                    });
839                }
840                // Dead owner — reclaim silently.
841            }
842            issue.meta.assigned_to = Some(Assignment {
843                session: caller_owned.clone(),
844                acquired_at: Utc::now(),
845            });
846            // Link the session.
847            if !issue.meta.sessions.contains(&caller_owned) {
848                issue.meta.sessions.push(caller_owned.clone());
849            }
850            Ok(issue)
851        })
852        .await
853    }
854
855    /// Release an assignment (the `release` action). Caller must be the owner.
856    pub async fn release(
857        &self,
858        id: u32,
859        caller: &str,
860        expected_hash: Option<String>,
861    ) -> std::result::Result<Issue, IssueError> {
862        let caller = caller.to_string();
863        self.update(id, expected_hash, move |mut issue| {
864            require_owner(&issue, id, &caller)?;
865            issue.meta.assigned_to = None;
866            Ok(issue)
867        })
868        .await
869    }
870
871    /// Link a session to an issue (append-only; idempotent).
872    pub async fn link_session(
873        &self,
874        id: u32,
875        session: &str,
876        expected_hash: Option<String>,
877    ) -> std::result::Result<Issue, IssueError> {
878        let session = session.to_string();
879        self.update(id, expected_hash, move |mut issue| {
880            if !issue.meta.sessions.contains(&session) {
881                issue.meta.sessions.push(session);
882            }
883            Ok(issue)
884        })
885        .await
886    }
887
888    // ── Path helpers ────────────────────────────────────────────────────
889
890    fn path_for_id(&self, id: u32) -> Result<PathBuf> {
891        let dir = self.inner.read().issues_dir.clone();
892        // Files are named `<id>-<slug>.md`; match by leading id.
893        if let Ok(rd) = fs::read_dir(&dir) {
894            for entry in rd.flatten() {
895                let name = entry.file_name();
896                let name = name.to_string_lossy();
897                let num_str = name.split('-').next().unwrap_or(&name);
898                if num_str.trim_end_matches(".md").parse::<u32>().ok() == Some(id) {
899                    return Ok(entry.path());
900                }
901            }
902        }
903        Err(anyhow::anyhow!(IssueError::NotFound { id }))
904    }
905}
906
907/// Attach a path to an issue (builder convenience).
908trait WithPath {
909    fn with_path(self, path: PathBuf) -> Self;
910}
911
912impl WithPath for Issue {
913    fn with_path(mut self, path: PathBuf) -> Self {
914        self.path = Some(path);
915        self
916    }
917}
918
919/// Check `caller` owns the issue's assignment, else [`IssueError::NotAssigned`].
920fn require_owner(issue: &Issue, id: u32, caller: &str) -> std::result::Result<(), IssueError> {
921    match &issue.meta.assigned_to {
922        Some(a) if a.session == caller => Ok(()),
923        _ => Err(IssueError::NotAssigned {
924            id,
925            caller: caller.to_string(),
926        }),
927    }
928}
929
930// ============================================================================
931// Filter
932// ============================================================================
933
934/// Filter for `list`. All fields optional (None = no constraint).
935#[derive(Debug, Clone, Default)]
936pub struct IssueFilter {
937    pub status: Option<Status>,
938    pub priority: Option<Priority>,
939    pub label: Option<String>,
940    pub assigned_to_session: Option<String>,
941    /// Text substring match on title (case-insensitive).
942    pub text: Option<String>,
943}
944
945impl IssueFilter {
946    fn matches(&self, issue: &Issue) -> bool {
947        if let Some(s) = self.status
948            && issue.meta.status != s
949        {
950            return false;
951        }
952        if let Some(p) = self.priority
953            && issue.meta.priority != p
954        {
955            return false;
956        }
957        if let Some(ref label) = self.label
958            && !issue.meta.labels.iter().any(|l| l == label)
959        {
960            return false;
961        }
962        if let Some(ref session) = self.assigned_to_session {
963            let mine = issue
964                .meta
965                .assigned_to
966                .as_ref()
967                .map(|a| &a.session == session)
968                .unwrap_or(false);
969            if !mine {
970                return false;
971            }
972        }
973        if let Some(ref text) = self.text
974            && !issue
975                .meta
976                .title
977                .to_lowercase()
978                .contains(&text.to_lowercase())
979        {
980            return false;
981        }
982        true
983    }
984}
985
986// ============================================================================
987// Tests
988// ============================================================================
989
990#[cfg(test)]
991mod tests {
992    use super::*;
993
994    fn sample_meta(id: u32, title: &str, priority: Priority) -> IssueMeta {
995        let now = Utc::now();
996        IssueMeta {
997            id,
998            title: title.into(),
999            status: Status::Open,
1000            priority,
1001            labels: vec![],
1002            assignee: None,
1003            created_at: now,
1004            updated_at: now,
1005            closed_at: None,
1006            sessions: vec![],
1007            assigned_to: None,
1008            github: None,
1009        }
1010    }
1011
1012    fn tmp_store() -> (tempfile::TempDir, FileIssueStore) {
1013        let tmp = tempfile::tempdir().unwrap();
1014        let dir = tmp.path().join(".oxi").join("issues");
1015        fs::create_dir_all(&dir).unwrap();
1016        let store = FileIssueStore::open(dir).unwrap();
1017        (tmp, store)
1018    }
1019
1020    #[test]
1021    fn roundtrip_serialization() {
1022        let issue = Issue {
1023            meta: sample_meta(1, "Test", Priority::High),
1024            body: "## Body\n\nHello.".into(),
1025            path: None,
1026        };
1027        let s = serialize_issue(&issue).unwrap();
1028        assert!(s.starts_with("---\n"));
1029        let parsed = parse_issue(&s, None).unwrap();
1030        assert_eq!(parsed.meta.id, 1);
1031        assert_eq!(parsed.meta.title, "Test");
1032        assert_eq!(parsed.meta.priority, Priority::High);
1033        assert!(parsed.body.contains("Hello."));
1034    }
1035
1036    #[tokio::test]
1037    async fn create_read_list() {
1038        let (_tmp, store) = tmp_store();
1039        let created = store
1040            .create(
1041                "Fix bug".into(),
1042                "body".into(),
1043                Priority::High,
1044                vec![],
1045                None,
1046            )
1047            .unwrap();
1048        assert_eq!(created.meta.id, 1);
1049
1050        let (read, hash) = store.read(1).unwrap();
1051        assert_eq!(read.meta.title, "Fix bug");
1052        assert!(!hash.is_empty());
1053
1054        let list = store.list(&IssueFilter::default()).unwrap();
1055        assert_eq!(list.len(), 1);
1056    }
1057
1058    #[tokio::test]
1059    async fn content_hash_detects_conflict() {
1060        let (_tmp, store) = tmp_store();
1061        store
1062            .create("Orig".into(), "b".into(), Priority::Low, vec![], None)
1063            .unwrap();
1064        let (_, hash) = store.read(1).unwrap();
1065
1066        // External edit (different hash) → wrong expected_hash → conflict.
1067        let wrong = Some("deadbeefdeadbeef".to_string());
1068        let err = store
1069            .update(1, wrong, |_| {
1070                Ok(Issue {
1071                    meta: sample_meta(1, "x", Priority::Low),
1072                    body: "x".into(),
1073                    path: None,
1074                })
1075            })
1076            .await
1077            .unwrap_err();
1078        assert!(matches!(err, IssueError::Conflict { id: 1 }));
1079
1080        // Correct hash → succeeds.
1081        let _ok = store
1082            .update(1, Some(hash), |mut i| {
1083                i.meta.title = "Updated".into();
1084                Ok(i)
1085            })
1086            .await
1087            .unwrap();
1088        let (read, _) = store.read(1).unwrap();
1089        assert_eq!(read.meta.title, "Updated");
1090    }
1091
1092    #[tokio::test]
1093    async fn start_rejects_live_owner() {
1094        let (_tmp, store) = tmp_store();
1095        store
1096            .create("T".into(), "b".into(), Priority::Low, vec![], None)
1097            .unwrap();
1098        let issues_dir = store.issues_dir();
1099        // Owner session A acquires a live lock.
1100        let _guard_a = liveness::acquire(&issues_dir, "sessionA").unwrap();
1101        // Manually assign to A.
1102        let (_, hash) = store.read(1).unwrap();
1103        store.start(1, "sessionA", Some(hash)).await.unwrap();
1104
1105        // B tries to start → rejected (A is alive).
1106        let (_, hash2) = store.read(1).unwrap();
1107        let err = store.start(1, "sessionB", Some(hash2)).await.unwrap_err();
1108        assert!(matches!(err, IssueError::Assigned { owner, .. } if owner == "sessionA"));
1109    }
1110
1111    #[tokio::test]
1112    async fn start_reclaims_dead_owner() {
1113        let (_tmp, store) = tmp_store();
1114        store
1115            .create("T".into(), "b".into(), Priority::Low, vec![], None)
1116            .unwrap();
1117        let issues_dir = store.issues_dir();
1118
1119        // A acquires, then "dies" (drop guard).
1120        {
1121            let _g = liveness::acquire(&issues_dir, "sessionA").unwrap();
1122            let (_, h) = store.read(1).unwrap();
1123            store.start(1, "sessionA", Some(h)).await.unwrap();
1124        } // guard dropped → A is "dead"
1125
1126        let (_, hash) = store.read(1).unwrap();
1127        let reclaimed = store.start(1, "sessionB", Some(hash)).await.unwrap();
1128        assert_eq!(
1129            reclaimed.meta.assigned_to.as_ref().unwrap().session,
1130            "sessionB"
1131        );
1132    }
1133
1134    #[tokio::test]
1135    async fn close_requires_owner() {
1136        let (_tmp, store) = tmp_store();
1137        store
1138            .create("T".into(), "b".into(), Priority::Low, vec![], None)
1139            .unwrap();
1140        let (_, hash) = store.read(1).unwrap();
1141        store.start(1, "sessionA", Some(hash)).await.unwrap();
1142
1143        // B can't close.
1144        let (_, hash2) = store.read(1).unwrap();
1145        let err = store.close(1, "sessionB", Some(hash2)).await.unwrap_err();
1146        assert!(matches!(err, IssueError::NotAssigned { .. }));
1147
1148        // A can.
1149        let (_, hash3) = store.read(1).unwrap();
1150        let closed = store.close(1, "sessionA", Some(hash3)).await.unwrap();
1151        assert_eq!(closed.meta.status, Status::Closed);
1152        assert!(closed.meta.assigned_to.is_none());
1153    }
1154
1155    #[test]
1156    fn slugify_basic() {
1157        assert_eq!(slugify("Fix Login Bug!"), "fix-login-bug");
1158        assert_eq!(slugify("   spaces   "), "spaces");
1159        assert_eq!(slugify("a__b"), "a-b");
1160        assert_eq!(slugify(""), "");
1161    }
1162
1163    #[test]
1164    fn issue_filename_format() {
1165        assert_eq!(issue_filename(12, "Fix Login"), "0012-fix-login.md");
1166        assert_eq!(issue_filename(1, ""), "0001.md");
1167    }
1168
1169    #[tokio::test]
1170    async fn open_count_caches() {
1171        let (_tmp, store) = tmp_store();
1172        assert_eq!(store.open_count(), 0);
1173        store
1174            .create("A".into(), "b".into(), Priority::Low, vec![], None)
1175            .unwrap();
1176        store
1177            .create("B".into(), "b".into(), Priority::Low, vec![], None)
1178            .unwrap();
1179        assert_eq!(store.open_count(), 2);
1180
1181        // Start as owner A, then close → count drops to 1.
1182        let issues_dir = store.issues_dir();
1183        let _guard = liveness::acquire(&issues_dir, "sessionA").unwrap();
1184        let (_, h) = store.read(1).unwrap();
1185        store.start(1, "sessionA", Some(h)).await.unwrap();
1186        let (_, h) = store.read(1).unwrap();
1187        store.close(1, "sessionA", Some(h)).await.unwrap();
1188        store.invalidate();
1189        assert_eq!(store.open_count(), 1);
1190    }
1191
1192    #[tokio::test]
1193    async fn latest_open_title_caches_and_handles_cjk() {
1194        let (_tmp, store) = tmp_store();
1195        // No issues yet — latest_open_title is None.
1196        assert!(store.latest_open_title().is_none());
1197
1198        // Create an issue with a CJK title and body. The title must survive
1199        // round-trip through the cache and read() without panic on multi-byte
1200        // boundaries. (Regression test for the byte-slice panic in
1201        // `first_line_preview` / `truncate_for_footer`.)
1202        let cjk_title =
1203            "버그 수정: 한글 제목도 정상이어야 합니다 — 멀티바이트 인코딩 안전성".to_string();
1204        let cjk_body =
1205            "요약\n\n이 이슈는 한글 본문을 포함합니다. 본문에는 영문과 한글이 섞여 있습니다. "
1206                .repeat(4);
1207        let created = store
1208            .create(cjk_title.clone(), cjk_body, Priority::High, vec![], None)
1209            .unwrap();
1210        assert_eq!(created.meta.title, cjk_title);
1211
1212        // Cache populates from read_dir.
1213        let title = store.latest_open_title();
1214        assert_eq!(title.as_deref(), Some(cjk_title.as_str()));
1215
1216        // read() must not panic on multi-byte UTF-8 in the body.
1217        let (read_back, _hash) = store.read(created.meta.id).unwrap();
1218        assert!(read_back.body.contains("한글"));
1219    }
1220}