Skip to main content

tix/storage/
query.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4impl Store {
5    pub fn open(config: &Config) -> Result<Store> {
6        // A relative `issues_dir` is written relative to the **project root** —
7        // the directory holding `tix.toml` — not to the invocation directory
8        // (tix #1584). The root is discovered by walking up from the working
9        // directory, the same walk `Config::project_root` performs for the CLI.
10        //
11        // The resolved directory is kept on the `Store` only; `config.issues_dir`
12        // is left exactly as it was read, so a later `Config::save` (the TUI's
13        // settings writes) round-trips the relative value rather than pinning a
14        // machine-specific absolute path into `tix.toml`.
15        let issues_dir = if config.issues_dir.is_relative() {
16            match Config::project_root(&std::env::current_dir().unwrap_or_default()) {
17                Some(root) => root.join(&config.issues_dir),
18                None => config.issues_dir.clone(),
19            }
20        } else {
21            config.issues_dir.clone()
22        };
23        if !issues_dir.exists() {
24            std::fs::create_dir_all(&issues_dir)?;
25        }
26        Ok(Store {
27            issues_dir,
28            config: config.clone(),
29        })
30    }
31
32    /// Run `f`, undoing every filesystem mutation it made if it returns `Err`.
33    ///
34    /// A command like `tix edit` is several writes in sequence — a reassign that
35    /// moves the file, the merged frontmatter, a `--status` transition, a triage
36    /// stamp — and the writes are not individually atomic with each other. Without
37    /// this, an I/O failure between two of them commits the earlier one and leaves
38    /// the store half-mutated (tix #1302). Wrapping the command in
39    /// [`Store::transaction`] makes the sequence all-or-nothing: on any `Err`, every
40    /// file the command touched is restored to the bytes it had before.
41    ///
42    /// The journal is recorded by the write primitives themselves
43    /// ([`write_file`], the renames in `move_issue_file`, `delete`), so **any**
44    /// store method participates without knowing about transactions — the layer is
45    /// command-level, not a per-method flag.
46    ///
47    /// Nesting: only the outermost call opens a journal, so an inner
48    /// `transaction` joins the outer one and a failure still rolls the whole outer
49    /// unit back. This is what makes it safe to wrap a public command that itself
50    /// calls other transactional commands.
51    ///
52    /// Not covered, stated rather than implied: writes that bypass the primitives
53    /// (a future `fs::write` written by hand) and mutations made after `f` returns.
54    /// The rollback is best-effort per file and the original error is always the
55    /// one returned — a rollback that itself fails must not mask the cause.
56    pub fn transaction<T>(&self, f: impl FnOnce() -> Result<T>) -> Result<T> {
57        if WRITE_JOURNAL.with(|journal| journal.borrow().is_some()) {
58            // Nested: the outermost transaction owns the journal and the rollback.
59            return f();
60        }
61        WRITE_JOURNAL.with(|journal| *journal.borrow_mut() = Some(Vec::new()));
62        let result = f();
63        let entries = WRITE_JOURNAL.with(|journal| journal.borrow_mut().take());
64        if result.is_err() {
65            rollback(entries.unwrap_or_default());
66        }
67        result
68    }
69
70    /// The **active** issue set: every issue file that is not archived.
71    ///
72    /// Archived issues are marked by a dot-prefixed markdown filename
73    /// (`docs/issues/<assignee>/.<file>.md`, issue #1492). They are skipped
74    /// **from the directory entry**, before the file is opened, so the default
75    /// path never parses an archived issue — the performance requirement that
76    /// keeps a repository with thousands of archived issues fast.
77    ///
78    /// Use [`Store::list_all`] when an archived issue must still be reachable.
79    pub fn list(&self) -> Result<Vec<Issue>> {
80        self.scan(false)
81    }
82
83    /// The **exhaustive** issue set: active issues plus archived ones.
84    ///
85    /// Archived issues carry `archived == true`, derived from the leading dot
86    /// of their filename. This is the enumeration for selector resolution and
87    /// number allocation, both of which must see an archived issue; it is
88    /// deliberately *not* the default view.
89    pub fn list_all(&self) -> Result<Vec<Issue>> {
90        self.scan(true)
91    }
92
93    /// Walk the issues tree.
94    ///
95    /// `include_archived == false` is the default path: a dot-prefixed `.md`
96    /// entry is dropped from the directory entry so the file is never read.
97    /// `true` reads them too and marks them with `archived = true`.
98    ///
99    /// The read+parse of every file is the expense of a cold start, so it is
100    /// fanned across threads above [`Store::PARALLEL_SCAN_THRESHOLD`] (see
101    /// [`Store::parse_issue_files`]); the directory walk itself stays serial.
102    pub(in crate::storage) fn scan(&self, include_archived: bool) -> Result<Vec<Issue>> {
103        if !self.issues_dir.exists() {
104            return Ok(Vec::new());
105        }
106        let mut files: Vec<(PathBuf, String, bool)> = Vec::new();
107        Self::collect_issue_files(&self.issues_dir, "", include_archived, &mut files)?;
108        // Deterministic order for both the result and any parse error: the walk
109        // itself is `read_dir`-ordered, which differs between filesystems.
110        files.sort_by(|a, b| a.1.cmp(&b.1));
111        let mut issues = Self::parse_issue_files(&files)?;
112        issues.sort_by(|a, b| a.filename.cmp(&b.filename));
113        Ok(issues)
114    }
115
116    /// Recursive walk used by [`Store::scan`]: any depth, skipping `.tix-tmp-*`
117    /// temp directories, `<slug>-assets/` directories, and hidden directories.
118    ///
119    /// Collects `(full path, relative path, archived)` **without reading a
120    /// file** — the read and parse belong to [`Store::parse_issue_files`], so this
121    /// cheap walk stays single-threaded while the expensive work fans out.
122    pub(in crate::storage) fn collect_issue_files(
123        dir: &Path,
124        prefix: &str,
125        include_archived: bool,
126        out: &mut Vec<(PathBuf, String, bool)>,
127    ) -> Result<()> {
128        for entry in std::fs::read_dir(dir)? {
129            let entry = entry?;
130            let path = entry.path();
131            let name = entry.file_name().to_string_lossy().to_string();
132            if path.is_dir() {
133                if name.starts_with('.')
134                    || name.starts_with(".tix-tmp-")
135                    || name.ends_with("-assets")
136                {
137                    continue;
138                }
139                let child = if prefix.is_empty() {
140                    name
141                } else {
142                    format!("{prefix}/{name}")
143                };
144                Self::collect_issue_files(&path, &child, include_archived, out)?;
145            } else if path.is_file() && name.ends_with(".md") {
146                let archived = name.starts_with('.');
147                if archived && !include_archived {
148                    continue;
149                }
150                let rel = if prefix.is_empty() {
151                    name
152                } else {
153                    format!("{prefix}/{name}")
154                };
155                out.push((path, rel, archived));
156            }
157        }
158        Ok(())
159    }
160
161    /// Read and parse every entry from [`Store::collect_issue_files`], keeping
162    /// the input order.
163    ///
164    /// Below [`Store::PARALLEL_SCAN_THRESHOLD`] files this is a plain serial
165    /// loop. At or above it the work is split across up to
166    /// [`Store::PARALLEL_SCAN_THREADS`] scoped threads; each chunk keeps its
167    /// place, so the result is in the same order as `files` and the first error —
168    /// a parse failure names its file — is deterministic.
169    pub(in crate::storage) fn parse_issue_files(
170        files: &[(PathBuf, String, bool)],
171    ) -> Result<Vec<Issue>> {
172        if files.len() < Self::PARALLEL_SCAN_THRESHOLD {
173            return files
174                .iter()
175                .map(|(path, rel, archived)| Self::read_issue_file(path, rel, *archived))
176                .collect();
177        }
178        let threads = std::thread::available_parallelism()
179            .map(|n| n.get())
180            .unwrap_or(1)
181            .clamp(1, Self::PARALLEL_SCAN_THREADS);
182        if threads < 2 {
183            return files
184                .iter()
185                .map(|(path, rel, archived)| Self::read_issue_file(path, rel, *archived))
186                .collect();
187        }
188        let chunk = files.len().div_ceil(threads);
189        let parts: Vec<Vec<Result<Issue>>> = std::thread::scope(|scope| {
190            let handles: Vec<_> = files
191                .chunks(chunk)
192                .map(|slice| {
193                    scope.spawn(move || {
194                        slice
195                            .iter()
196                            .map(|(path, rel, archived)| {
197                                Self::read_issue_file(path, rel, *archived)
198                            })
199                            .collect::<Vec<_>>()
200                    })
201                })
202                .collect();
203            handles
204                .into_iter()
205                .map(|handle| handle.join().expect("issue-scan worker panicked"))
206                .collect()
207        });
208        let mut issues = Vec::with_capacity(files.len());
209        for part in parts {
210            for issue in part {
211                issues.push(issue?);
212            }
213        }
214        Ok(issues)
215    }
216
217    /// Read one issue file from its full `path` and stamp it with `rel` (the
218    /// store-relative filename) and `archived` (the dot-prefix marker). Shared by
219    /// the serial and parallel scan paths so they cannot diverge.
220    pub(in crate::storage) fn read_issue_file(
221        path: &Path,
222        rel: &str,
223        archived: bool,
224    ) -> Result<Issue> {
225        let contents = std::fs::read_to_string(path)?;
226        let mut issue = Issue::parse(&contents).map_err(|e| match e {
227            TixError::Parse(msg) => TixError::Parse(format!("{rel}: {msg}")),
228            other => other,
229        })?;
230        issue.filename = rel.to_string();
231        issue.archived = archived;
232        Ok(issue)
233    }
234
235    /// The assignee storage folders under the issues directory, sorted.
236    ///
237    /// These are the `docs/issues/<username>/` segments [`Self::scan`] walks —
238    /// the ground truth for who has issues. The reserved segments are excluded:
239    /// `unassigned` is the fallback for "no assignee" and is not a person, and a
240    /// `<slug>-assets/` or `.tix-tmp-*` directory is not a person either. Used by
241    /// the Triage mode's assignee picker (design D6); never creates a directory.
242    pub fn assignee_segments(&self) -> Result<Vec<String>> {
243        let mut segments = Vec::new();
244        if !self.issues_dir.exists() {
245            return Ok(segments);
246        }
247        for entry in std::fs::read_dir(&self.issues_dir)? {
248            let entry = entry?;
249            if !entry.path().is_dir() {
250                continue;
251            }
252            let name = entry.file_name().to_string_lossy().to_string();
253            if name == "unassigned" || name.starts_with(".tix-tmp-") || name.ends_with("-assets") {
254                continue;
255            }
256            segments.push(name);
257        }
258        segments.sort();
259        segments.dedup();
260        Ok(segments)
261    }
262
263    pub fn get(&self, selector: IssueSelector) -> Result<Issue> {
264        match selector {
265            IssueSelector::Number(num) => {
266                let issues = self.list_all()?;
267                issues
268                    .into_iter()
269                    .find(|i| i.number == num)
270                    .ok_or_else(|| TixError::NotFound(format!("issue #{num}")))
271            }
272            IssueSelector::Ref(reference) => {
273                let issues = self.list_all()?;
274                issues
275                    .into_iter()
276                    .find(|issue| crate::id::canonical_ref(issue) == reference)
277                    .ok_or_else(|| TixError::NotFound(format!("issue {reference}")))
278            }
279            IssueSelector::Filename(name) => {
280                validate_filename(&name)?;
281                let path = self.issues_dir.join(&name);
282                if !path.is_file() {
283                    return Err(TixError::NotFound(format!("file {name}")));
284                }
285                let contents = std::fs::read_to_string(&path)?;
286                let mut issue = Issue::parse(&contents)?;
287                issue.filename = name;
288                Ok(issue)
289            }
290            IssueSelector::Slug(slug) => {
291                let issues = self.list_all()?;
292                issues
293                    .into_iter()
294                    .find(|i| slugify(&i.title) == slug)
295                    .ok_or_else(|| TixError::NotFound(format!("slug {slug}")))
296            }
297        }
298    }
299
300    /// Read one issue by its **store-relative** filename (issue #10
301    /// follow-up).
302    ///
303    /// The single-file counterpart of [`Store::scan`], for the incremental
304    /// corpus path: it sets `filename` and `archived` exactly as `scan` does
305    /// (the archived marker is the leading dot of the file name) and applies
306    /// the same parse-error context, so an incrementally updated corpus holds
307    /// byte-identical issues to a full rebuild. It deliberately does **not**
308    /// linear-scan [`IssueSelector`] — the caller already has the relative
309    /// path from the watcher.
310    ///
311    /// A path that is not a file is `NotFound`; the caller decides whether
312    /// that means "removed".
313    pub fn read_issue_at(&self, rel: &Path) -> Result<Issue> {
314        let filename = rel.to_string_lossy().to_string();
315        let path = self.issues_dir.join(rel);
316        if !path.is_file() {
317            return Err(TixError::NotFound(format!("file {filename}")));
318        }
319        let contents = std::fs::read_to_string(&path)?;
320        let mut issue = Issue::parse(&contents).map_err(|e| match e {
321            TixError::Parse(msg) => TixError::Parse(format!("{filename}: {msg}")),
322            other => other,
323        })?;
324        issue.filename = filename;
325        issue.archived = rel
326            .file_name()
327            .and_then(|name| name.to_str())
328            .is_some_and(|name| name.starts_with('.'));
329        Ok(issue)
330    }
331
332    /// Resolve a selector string exactly like the selector-taking commands
333    /// (`parse_selector`) and fetch the issue (D2: "Resolver SHALL match
334    /// `tix edit`/`tix move`").
335    ///
336    /// `pub` because the `--parent` flags (WP03) resolve a parent selector to its
337    /// **number** through this same path, so `#4`, `4` and a title fragment all work
338    /// on a parent exactly as they do on any other selector — and a second
339    /// resolver in the command layer is how two surfaces end up disagreeing about
340    /// which issue a selector names.
341    pub fn resolve_issue(&self, raw: &str) -> Result<Issue> {
342        let selector = crate::commands::parse_selector(self, raw)?;
343        self.get(selector)
344    }
345
346    /// Whether `filename` already sits at the directory its templated fields
347    /// render to. Used to decide whether a mutation may relocate the file: only a
348    /// file already *on* the layout is moved, so a legacy or mismatched path is
349    /// left to `tix layout migrate`.
350    #[must_use]
351    pub fn path_is_laid_out(&self, filename: &str, issue: &Issue) -> bool {
352        split_relative(filename).is_some_and(|(dir, _)| {
353            dir == crate::layout::render_directory(&self.config.layout, issue)
354        })
355    }
356
357    pub fn config_path(&self) -> Result<std::path::PathBuf> {
358        if let Some(root) = Config::project_root(&self.issues_dir) {
359            Ok(root.join("tix.toml"))
360        } else {
361            Err(TixError::Config(
362                "tix.toml not found in any parent directory of issues_dir".into(),
363            ))
364        }
365    }
366}