tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
#[allow(clippy::wildcard_imports)]
use super::*;

impl Store {
    pub fn open(config: &Config) -> Result<Store> {
        // A relative `issues_dir` is written relative to the **project root** —
        // the directory holding `tix.toml` — not to the invocation directory
        // (tix #1584). The root is discovered by walking up from the working
        // directory, the same walk `Config::project_root` performs for the CLI.
        //
        // The resolved directory is kept on the `Store` only; `config.issues_dir`
        // is left exactly as it was read, so a later `Config::save` (the TUI's
        // settings writes) round-trips the relative value rather than pinning a
        // machine-specific absolute path into `tix.toml`.
        let issues_dir = if config.issues_dir.is_relative() {
            match Config::project_root(&std::env::current_dir().unwrap_or_default()) {
                Some(root) => root.join(&config.issues_dir),
                None => config.issues_dir.clone(),
            }
        } else {
            config.issues_dir.clone()
        };
        if !issues_dir.exists() {
            std::fs::create_dir_all(&issues_dir)?;
        }
        Ok(Store {
            issues_dir,
            config: config.clone(),
        })
    }

    /// Run `f`, undoing every filesystem mutation it made if it returns `Err`.
    ///
    /// A command like `tix edit` is several writes in sequence — a reassign that
    /// moves the file, the merged frontmatter, a `--status` transition, a triage
    /// stamp — and the writes are not individually atomic with each other. Without
    /// this, an I/O failure between two of them commits the earlier one and leaves
    /// the store half-mutated (tix #1302). Wrapping the command in
    /// [`Store::transaction`] makes the sequence all-or-nothing: on any `Err`, every
    /// file the command touched is restored to the bytes it had before.
    ///
    /// The journal is recorded by the write primitives themselves
    /// ([`write_file`], the renames in `move_issue_file`, `delete`), so **any**
    /// store method participates without knowing about transactions — the layer is
    /// command-level, not a per-method flag.
    ///
    /// Nesting: only the outermost call opens a journal, so an inner
    /// `transaction` joins the outer one and a failure still rolls the whole outer
    /// unit back. This is what makes it safe to wrap a public command that itself
    /// calls other transactional commands.
    ///
    /// Not covered, stated rather than implied: writes that bypass the primitives
    /// (a future `fs::write` written by hand) and mutations made after `f` returns.
    /// The rollback is best-effort per file and the original error is always the
    /// one returned — a rollback that itself fails must not mask the cause.
    pub fn transaction<T>(&self, f: impl FnOnce() -> Result<T>) -> Result<T> {
        if WRITE_JOURNAL.with(|journal| journal.borrow().is_some()) {
            // Nested: the outermost transaction owns the journal and the rollback.
            return f();
        }
        WRITE_JOURNAL.with(|journal| *journal.borrow_mut() = Some(Vec::new()));
        let result = f();
        let entries = WRITE_JOURNAL.with(|journal| journal.borrow_mut().take());
        if result.is_err() {
            rollback(entries.unwrap_or_default());
        }
        result
    }

    /// The **active** issue set: every issue file that is not archived.
    ///
    /// Archived issues are marked by a dot-prefixed markdown filename
    /// (`docs/issues/<assignee>/.<file>.md`, issue #1492). They are skipped
    /// **from the directory entry**, before the file is opened, so the default
    /// path never parses an archived issue — the performance requirement that
    /// keeps a repository with thousands of archived issues fast.
    ///
    /// Use [`Store::list_all`] when an archived issue must still be reachable.
    pub fn list(&self) -> Result<Vec<Issue>> {
        self.scan(false)
    }

    /// The **exhaustive** issue set: active issues plus archived ones.
    ///
    /// Archived issues carry `archived == true`, derived from the leading dot
    /// of their filename. This is the enumeration for selector resolution and
    /// number allocation, both of which must see an archived issue; it is
    /// deliberately *not* the default view.
    pub fn list_all(&self) -> Result<Vec<Issue>> {
        self.scan(true)
    }

    /// Walk the issues tree.
    ///
    /// `include_archived == false` is the default path: a dot-prefixed `.md`
    /// entry is dropped from the directory entry so the file is never read.
    /// `true` reads them too and marks them with `archived = true`.
    ///
    /// The read+parse of every file is the expense of a cold start, so it is
    /// fanned across threads above [`Store::PARALLEL_SCAN_THRESHOLD`] (see
    /// [`Store::parse_issue_files`]); the directory walk itself stays serial.
    pub(in crate::storage) fn scan(&self, include_archived: bool) -> Result<Vec<Issue>> {
        if !self.issues_dir.exists() {
            return Ok(Vec::new());
        }
        let mut files: Vec<(PathBuf, String, bool)> = Vec::new();
        Self::collect_issue_files(&self.issues_dir, "", include_archived, &mut files)?;
        // Deterministic order for both the result and any parse error: the walk
        // itself is `read_dir`-ordered, which differs between filesystems.
        files.sort_by(|a, b| a.1.cmp(&b.1));
        let mut issues = Self::parse_issue_files(&files)?;
        issues.sort_by(|a, b| a.filename.cmp(&b.filename));
        Ok(issues)
    }

    /// Recursive walk used by [`Store::scan`]: any depth, skipping `.tix-tmp-*`
    /// temp directories, `<slug>-assets/` directories, and hidden directories.
    ///
    /// Collects `(full path, relative path, archived)` **without reading a
    /// file** — the read and parse belong to [`Store::parse_issue_files`], so this
    /// cheap walk stays single-threaded while the expensive work fans out.
    pub(in crate::storage) fn collect_issue_files(
        dir: &Path,
        prefix: &str,
        include_archived: bool,
        out: &mut Vec<(PathBuf, String, bool)>,
    ) -> Result<()> {
        for entry in std::fs::read_dir(dir)? {
            let entry = entry?;
            let path = entry.path();
            let name = entry.file_name().to_string_lossy().to_string();
            if path.is_dir() {
                if name.starts_with('.')
                    || name.starts_with(".tix-tmp-")
                    || name.ends_with("-assets")
                {
                    continue;
                }
                let child = if prefix.is_empty() {
                    name
                } else {
                    format!("{prefix}/{name}")
                };
                Self::collect_issue_files(&path, &child, include_archived, out)?;
            } else if path.is_file() && name.ends_with(".md") {
                let archived = name.starts_with('.');
                if archived && !include_archived {
                    continue;
                }
                let rel = if prefix.is_empty() {
                    name
                } else {
                    format!("{prefix}/{name}")
                };
                out.push((path, rel, archived));
            }
        }
        Ok(())
    }

    /// Read and parse every entry from [`Store::collect_issue_files`], keeping
    /// the input order.
    ///
    /// Below [`Store::PARALLEL_SCAN_THRESHOLD`] files this is a plain serial
    /// loop. At or above it the work is split across up to
    /// [`Store::PARALLEL_SCAN_THREADS`] scoped threads; each chunk keeps its
    /// place, so the result is in the same order as `files` and the first error —
    /// a parse failure names its file — is deterministic.
    pub(in crate::storage) fn parse_issue_files(
        files: &[(PathBuf, String, bool)],
    ) -> Result<Vec<Issue>> {
        if files.len() < Self::PARALLEL_SCAN_THRESHOLD {
            return files
                .iter()
                .map(|(path, rel, archived)| Self::read_issue_file(path, rel, *archived))
                .collect();
        }
        let threads = std::thread::available_parallelism()
            .map(|n| n.get())
            .unwrap_or(1)
            .clamp(1, Self::PARALLEL_SCAN_THREADS);
        if threads < 2 {
            return files
                .iter()
                .map(|(path, rel, archived)| Self::read_issue_file(path, rel, *archived))
                .collect();
        }
        let chunk = files.len().div_ceil(threads);
        let parts: Vec<Vec<Result<Issue>>> = std::thread::scope(|scope| {
            let handles: Vec<_> = files
                .chunks(chunk)
                .map(|slice| {
                    scope.spawn(move || {
                        slice
                            .iter()
                            .map(|(path, rel, archived)| {
                                Self::read_issue_file(path, rel, *archived)
                            })
                            .collect::<Vec<_>>()
                    })
                })
                .collect();
            handles
                .into_iter()
                .map(|handle| handle.join().expect("issue-scan worker panicked"))
                .collect()
        });
        let mut issues = Vec::with_capacity(files.len());
        for part in parts {
            for issue in part {
                issues.push(issue?);
            }
        }
        Ok(issues)
    }

    /// Read one issue file from its full `path` and stamp it with `rel` (the
    /// store-relative filename) and `archived` (the dot-prefix marker). Shared by
    /// the serial and parallel scan paths so they cannot diverge.
    pub(in crate::storage) fn read_issue_file(
        path: &Path,
        rel: &str,
        archived: bool,
    ) -> Result<Issue> {
        let contents = std::fs::read_to_string(path)?;
        let mut issue = Issue::parse(&contents).map_err(|e| match e {
            TixError::Parse(msg) => TixError::Parse(format!("{rel}: {msg}")),
            other => other,
        })?;
        issue.filename = rel.to_string();
        issue.archived = archived;
        Ok(issue)
    }

    /// The assignee storage folders under the issues directory, sorted.
    ///
    /// These are the `docs/issues/<username>/` segments [`Self::scan`] walks —
    /// the ground truth for who has issues. The reserved segments are excluded:
    /// `unassigned` is the fallback for "no assignee" and is not a person, and a
    /// `<slug>-assets/` or `.tix-tmp-*` directory is not a person either. Used by
    /// the Triage mode's assignee picker (design D6); never creates a directory.
    pub fn assignee_segments(&self) -> Result<Vec<String>> {
        let mut segments = Vec::new();
        if !self.issues_dir.exists() {
            return Ok(segments);
        }
        for entry in std::fs::read_dir(&self.issues_dir)? {
            let entry = entry?;
            if !entry.path().is_dir() {
                continue;
            }
            let name = entry.file_name().to_string_lossy().to_string();
            if name == "unassigned" || name.starts_with(".tix-tmp-") || name.ends_with("-assets") {
                continue;
            }
            segments.push(name);
        }
        segments.sort();
        segments.dedup();
        Ok(segments)
    }

    pub fn get(&self, selector: IssueSelector) -> Result<Issue> {
        match selector {
            IssueSelector::Number(num) => {
                let issues = self.list_all()?;
                issues
                    .into_iter()
                    .find(|i| i.number == num)
                    .ok_or_else(|| TixError::NotFound(format!("issue #{num}")))
            }
            IssueSelector::Filename(name) => {
                validate_filename(&name)?;
                let path = self.issues_dir.join(&name);
                if !path.is_file() {
                    return Err(TixError::NotFound(format!("file {name}")));
                }
                let contents = std::fs::read_to_string(&path)?;
                let mut issue = Issue::parse(&contents)?;
                issue.filename = name;
                Ok(issue)
            }
            IssueSelector::Slug(slug) => {
                let issues = self.list_all()?;
                issues
                    .into_iter()
                    .find(|i| slugify(&i.title) == slug)
                    .ok_or_else(|| TixError::NotFound(format!("slug {slug}")))
            }
        }
    }

    /// Read one issue by its **store-relative** filename (issue #10
    /// follow-up).
    ///
    /// The single-file counterpart of [`Store::scan`], for the incremental
    /// corpus path: it sets `filename` and `archived` exactly as `scan` does
    /// (the archived marker is the leading dot of the file name) and applies
    /// the same parse-error context, so an incrementally updated corpus holds
    /// byte-identical issues to a full rebuild. It deliberately does **not**
    /// linear-scan [`IssueSelector`] — the caller already has the relative
    /// path from the watcher.
    ///
    /// A path that is not a file is `NotFound`; the caller decides whether
    /// that means "removed".
    pub fn read_issue_at(&self, rel: &Path) -> Result<Issue> {
        let filename = rel.to_string_lossy().to_string();
        let path = self.issues_dir.join(rel);
        if !path.is_file() {
            return Err(TixError::NotFound(format!("file {filename}")));
        }
        let contents = std::fs::read_to_string(&path)?;
        let mut issue = Issue::parse(&contents).map_err(|e| match e {
            TixError::Parse(msg) => TixError::Parse(format!("{filename}: {msg}")),
            other => other,
        })?;
        issue.filename = filename;
        issue.archived = rel
            .file_name()
            .and_then(|name| name.to_str())
            .is_some_and(|name| name.starts_with('.'));
        Ok(issue)
    }

    /// Resolve a selector string exactly like the selector-taking commands
    /// (`parse_selector`) and fetch the issue (D2: "Resolver SHALL match
    /// `tix edit`/`tix move`").
    ///
    /// `pub` because the `--parent` flags (WP03) resolve a parent selector to its
    /// **number** through this same path, so `#4`, `4` and a title fragment all work
    /// on a parent exactly as they do on any other selector — and a second
    /// resolver in the command layer is how two surfaces end up disagreeing about
    /// which issue a selector names.
    pub fn resolve_issue(&self, raw: &str) -> Result<Issue> {
        let selector = crate::commands::parse_selector(self, raw)?;
        self.get(selector)
    }

    /// Whether `filename` already sits at the directory its templated fields
    /// render to. Used to decide whether a mutation may relocate the file: only a
    /// file already *on* the layout is moved, so a legacy or mismatched path is
    /// left to `tix layout migrate`.
    #[must_use]
    pub fn path_is_laid_out(&self, filename: &str, issue: &Issue) -> bool {
        split_relative(filename).is_some_and(|(dir, _)| {
            dir == crate::layout::render_directory(&self.config.layout, issue)
        })
    }

    pub fn config_path(&self) -> Result<std::path::PathBuf> {
        if let Some(root) = Config::project_root(&self.issues_dir) {
            Ok(root.join("tix.toml"))
        } else {
            Err(TixError::Config(
                "tix.toml not found in any parent directory of issues_dir".into(),
            ))
        }
    }
}