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}