1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
#[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(),
))
}
}
}