bibsync 0.3.1__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {bibsync-0.3.1 → bibsync-0.3.2}/CITATION.cff +2 -0
  2. {bibsync-0.3.1 → bibsync-0.3.2}/Cargo.lock +7 -7
  3. {bibsync-0.3.1 → bibsync-0.3.2}/Cargo.toml +1 -1
  4. {bibsync-0.3.1 → bibsync-0.3.2}/PKG-INFO +21 -6
  5. {bibsync-0.3.1 → bibsync-0.3.2}/README.md +20 -5
  6. {bibsync-0.3.1 → bibsync-0.3.2}/docs/api/index.md +15 -1
  7. {bibsync-0.3.1 → bibsync-0.3.2}/docs/user-guide/pre-commit.md +7 -2
  8. {bibsync-0.3.1 → bibsync-0.3.2}/docs/user-guide/usage.md +31 -0
  9. {bibsync-0.3.1 → bibsync-0.3.2}/python/bibsync/__init__.pyi +1 -0
  10. {bibsync-0.3.1 → bibsync-0.3.2}/src/cli.rs +4 -1
  11. {bibsync-0.3.1 → bibsync-0.3.2}/src/lib.rs +415 -46
  12. {bibsync-0.3.1 → bibsync-0.3.2}/src/python.rs +11 -0
  13. {bibsync-0.3.1 → bibsync-0.3.2}/tests/cli.rs +41 -1
  14. {bibsync-0.3.1 → bibsync-0.3.2}/tests/python/test_bindings.py +1 -0
  15. {bibsync-0.3.1 → bibsync-0.3.2}/.gitignore +0 -0
  16. {bibsync-0.3.1 → bibsync-0.3.2}/.markdownlint.yaml +0 -0
  17. {bibsync-0.3.1 → bibsync-0.3.2}/.pre-commit-config.yaml +0 -0
  18. {bibsync-0.3.1 → bibsync-0.3.2}/.pre-commit-hooks.yaml +0 -0
  19. {bibsync-0.3.1 → bibsync-0.3.2}/.prettierrc +0 -0
  20. {bibsync-0.3.1 → bibsync-0.3.2}/.typos.toml +0 -0
  21. {bibsync-0.3.1 → bibsync-0.3.2}/CODE_OF_CONDUCT.md +0 -0
  22. {bibsync-0.3.1 → bibsync-0.3.2}/CONTRIBUTING.md +0 -0
  23. {bibsync-0.3.1 → bibsync-0.3.2}/LICENSE +0 -0
  24. {bibsync-0.3.1 → bibsync-0.3.2}/SECURITY.md +0 -0
  25. {bibsync-0.3.1 → bibsync-0.3.2}/SUPPORT.md +0 -0
  26. {bibsync-0.3.1 → bibsync-0.3.2}/benches/greeting.rs +0 -0
  27. {bibsync-0.3.1 → bibsync-0.3.2}/cliff.toml +0 -0
  28. {bibsync-0.3.1 → bibsync-0.3.2}/deny.toml +0 -0
  29. {bibsync-0.3.1 → bibsync-0.3.2}/docs/contributing.md +0 -0
  30. {bibsync-0.3.1 → bibsync-0.3.2}/docs/index.md +0 -0
  31. {bibsync-0.3.1 → bibsync-0.3.2}/docs/security.md +0 -0
  32. {bibsync-0.3.1 → bibsync-0.3.2}/docs/user-guide/examples.md +0 -0
  33. {bibsync-0.3.1 → bibsync-0.3.2}/docs/user-guide/installation.md +0 -0
  34. {bibsync-0.3.1 → bibsync-0.3.2}/docs/user-guide/providers.md +0 -0
  35. {bibsync-0.3.1 → bibsync-0.3.2}/examples/README.md +0 -0
  36. {bibsync-0.3.1 → bibsync-0.3.2}/examples/greet.rs +0 -0
  37. {bibsync-0.3.1 → bibsync-0.3.2}/examples/inspire-main.bib +0 -0
  38. {bibsync-0.3.1 → bibsync-0.3.2}/examples/inspire-main.tex +0 -0
  39. {bibsync-0.3.1 → bibsync-0.3.2}/examples/main.bib +0 -0
  40. {bibsync-0.3.1 → bibsync-0.3.2}/examples/main.tex +0 -0
  41. {bibsync-0.3.1 → bibsync-0.3.2}/pyproject.toml +0 -0
  42. {bibsync-0.3.1 → bibsync-0.3.2}/python/bibsync/__init__.py +0 -0
  43. {bibsync-0.3.1 → bibsync-0.3.2}/python/bibsync/__main__.py +0 -0
  44. {bibsync-0.3.1 → bibsync-0.3.2}/python/bibsync/py.typed +0 -0
  45. {bibsync-0.3.1 → bibsync-0.3.2}/renovate.json +0 -0
  46. {bibsync-0.3.1 → bibsync-0.3.2}/rust-toolchain.toml +0 -0
  47. {bibsync-0.3.1 → bibsync-0.3.2}/scripts/pre-commit-bibsync +0 -0
  48. {bibsync-0.3.1 → bibsync-0.3.2}/setup_repo.sh +0 -0
  49. {bibsync-0.3.1 → bibsync-0.3.2}/src/main.rs +0 -0
  50. {bibsync-0.3.1 → bibsync-0.3.2}/tests/examples.rs +0 -0
  51. {bibsync-0.3.1 → bibsync-0.3.2}/uv.lock +0 -0
  52. {bibsync-0.3.1 → bibsync-0.3.2}/zensical.toml +0 -0
@@ -1,5 +1,7 @@
1
1
  cff-version: 1.2.0
2
2
  title: "bibsync: A Rust package to automatically resolve, synchronize, and validate LaTeX citations across BibTeX databases"
3
+ version: "0.3.2"
4
+ date-released: "2026-05-29"
3
5
  license: "BSD-3-Clause"
4
6
  type: software
5
7
  authors:
@@ -124,7 +124,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
124
124
 
125
125
  [[package]]
126
126
  name = "bibsync"
127
- version = "0.3.1"
127
+ version = "0.3.2"
128
128
  dependencies = [
129
129
  "assert_cmd",
130
130
  "clap",
@@ -141,9 +141,9 @@ dependencies = [
141
141
 
142
142
  [[package]]
143
143
  name = "bitflags"
144
- version = "2.11.1"
144
+ version = "2.12.0"
145
145
  source = "registry+https://github.com/rust-lang/crates.io-index"
146
- checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
146
+ checksum = "2c61cd05405eb1d0f3a4660f802bad76ece84b6e722426342ba5dd511f724e97"
147
147
 
148
148
  [[package]]
149
149
  name = "bstr"
@@ -170,9 +170,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
170
170
 
171
171
  [[package]]
172
172
  name = "cc"
173
- version = "1.2.62"
173
+ version = "1.2.63"
174
174
  source = "registry+https://github.com/rust-lang/crates.io-index"
175
- checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
175
+ checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
176
176
  dependencies = [
177
177
  "find-msvc-tools",
178
178
  "jobserver",
@@ -1445,9 +1445,9 @@ dependencies = [
1445
1445
 
1446
1446
  [[package]]
1447
1447
  name = "shlex"
1448
- version = "1.3.0"
1448
+ version = "2.0.1"
1449
1449
  source = "registry+https://github.com/rust-lang/crates.io-index"
1450
- checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
1450
+ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
1451
1451
 
1452
1452
  [[package]]
1453
1453
  name = "simd_cesu8"
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "bibsync"
3
- version = "0.3.1"
3
+ version = "0.3.2"
4
4
  edition = "2024"
5
5
  rust-version = "1.85"
6
6
  authors = ["Isaac C. F. Wong"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bibsync
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Science/Research
@@ -65,6 +65,11 @@ NASA ADS and/or InspireHEP, rewrites provider BibTeX entries so the citekey stay
65
65
  the key used in TeX, and reports whether the output `.bib` file is current. With
66
66
  `--fix`, it writes the merged bibliography.
67
67
 
68
+ When a citekey cannot be resolved, the command prints the key together with a
69
+ reason and the likely fix. For example, an unsupported citekey is reported as an
70
+ identifier-format problem, while an arXiv ID or DOI that the provider cannot find
71
+ is reported as a provider miss.
72
+
68
73
  ## Installation
69
74
 
70
75
  Install the Rust CLI from crates.io:
@@ -112,7 +117,7 @@ For convenience, you can also use:
112
117
  @software{wong2026bibsync,
113
118
  author = {Wong, Isaac C. F.},
114
119
  title = {bibsync: A Rust package to automatically resolve, synchronize, and validate LaTeX citations across BibTeX databases},
115
- version = {v0.3.1},
120
+ version = {v0.3.2},
116
121
  year = {2026},
117
122
  month = may,
118
123
  doi = {10.5281/zenodo.20422622},
@@ -193,6 +198,12 @@ To update a bibliography in place, pass a single `.bib` file:
193
198
  bibsync --fix references.bib --force-regenerate
194
199
  ```
195
200
 
201
+ Existing input files are validated before resolution. A missing single `.bib`
202
+ input, `--other` bibliography, or `--ignore-file` is reported as an error with
203
+ the path that could not be read. Existing bibliography files are also parsed
204
+ strictly, so malformed BibTeX reports the file and the approximate failing
205
+ entry instead of being treated as an empty or partial bibliography.
206
+
196
207
  ### Update Behavior
197
208
 
198
209
  By default `bibsync` leaves published entries untouched. Only entries that look
@@ -244,6 +255,10 @@ bibsync --fix --refresh-cache main.tex -o references.bib
244
255
 
245
256
  Override the cache location with `--cache-dir DIR`.
246
257
 
258
+ If a cache file is corrupt, `bibsync` reports the exact cache path and asks you
259
+ to refresh or remove the bad cache entry. Provider request failures include the
260
+ provider and citekey or batch being resolved.
261
+
247
262
  ## Pre-commit
248
263
 
249
264
  The repository includes `.pre-commit-hooks.yaml`, so other projects can use
@@ -254,7 +269,7 @@ Use the pre-built binary hook for faster installs:
254
269
  ```yaml
255
270
  repos:
256
271
  - repo: https://github.com/isaac-cf-wong/bibsync
257
- rev: v0.3.1
272
+ rev: v0.3.2
258
273
  hooks:
259
274
  - id: bibsync-bin
260
275
  args: [--cache, --provider, inspire, --output, references.bib]
@@ -269,7 +284,7 @@ installation:
269
284
  ```yaml
270
285
  repos:
271
286
  - repo: https://github.com/isaac-cf-wong/bibsync
272
- rev: v0.3.1
287
+ rev: v0.3.2
273
288
  hooks:
274
289
  - id: bibsync
275
290
  args: [--provider, inspire, --output, references.bib]
@@ -281,7 +296,7 @@ writing changes. To let the hook update files, add `--fix` to the hook args:
281
296
  ```yaml
282
297
  repos:
283
298
  - repo: https://github.com/isaac-cf-wong/bibsync
284
- rev: v0.3.1
299
+ rev: v0.3.2
285
300
  hooks:
286
301
  - id: bibsync-bin
287
302
  args: [--fix, --cache, --provider, inspire, --output, references.bib]
@@ -292,7 +307,7 @@ To skip manually curated entries, add `--ignore-file`:
292
307
  ```yaml
293
308
  repos:
294
309
  - repo: https://github.com/isaac-cf-wong/bibsync
295
- rev: v0.3.1
310
+ rev: v0.3.2
296
311
  hooks:
297
312
  - id: bibsync-bin
298
313
  args:
@@ -38,6 +38,11 @@ NASA ADS and/or InspireHEP, rewrites provider BibTeX entries so the citekey stay
38
38
  the key used in TeX, and reports whether the output `.bib` file is current. With
39
39
  `--fix`, it writes the merged bibliography.
40
40
 
41
+ When a citekey cannot be resolved, the command prints the key together with a
42
+ reason and the likely fix. For example, an unsupported citekey is reported as an
43
+ identifier-format problem, while an arXiv ID or DOI that the provider cannot find
44
+ is reported as a provider miss.
45
+
41
46
  ## Installation
42
47
 
43
48
  Install the Rust CLI from crates.io:
@@ -85,7 +90,7 @@ For convenience, you can also use:
85
90
  @software{wong2026bibsync,
86
91
  author = {Wong, Isaac C. F.},
87
92
  title = {bibsync: A Rust package to automatically resolve, synchronize, and validate LaTeX citations across BibTeX databases},
88
- version = {v0.3.1},
93
+ version = {v0.3.2},
89
94
  year = {2026},
90
95
  month = may,
91
96
  doi = {10.5281/zenodo.20422622},
@@ -166,6 +171,12 @@ To update a bibliography in place, pass a single `.bib` file:
166
171
  bibsync --fix references.bib --force-regenerate
167
172
  ```
168
173
 
174
+ Existing input files are validated before resolution. A missing single `.bib`
175
+ input, `--other` bibliography, or `--ignore-file` is reported as an error with
176
+ the path that could not be read. Existing bibliography files are also parsed
177
+ strictly, so malformed BibTeX reports the file and the approximate failing
178
+ entry instead of being treated as an empty or partial bibliography.
179
+
169
180
  ### Update Behavior
170
181
 
171
182
  By default `bibsync` leaves published entries untouched. Only entries that look
@@ -217,6 +228,10 @@ bibsync --fix --refresh-cache main.tex -o references.bib
217
228
 
218
229
  Override the cache location with `--cache-dir DIR`.
219
230
 
231
+ If a cache file is corrupt, `bibsync` reports the exact cache path and asks you
232
+ to refresh or remove the bad cache entry. Provider request failures include the
233
+ provider and citekey or batch being resolved.
234
+
220
235
  ## Pre-commit
221
236
 
222
237
  The repository includes `.pre-commit-hooks.yaml`, so other projects can use
@@ -227,7 +242,7 @@ Use the pre-built binary hook for faster installs:
227
242
  ```yaml
228
243
  repos:
229
244
  - repo: https://github.com/isaac-cf-wong/bibsync
230
- rev: v0.3.1
245
+ rev: v0.3.2
231
246
  hooks:
232
247
  - id: bibsync-bin
233
248
  args: [--cache, --provider, inspire, --output, references.bib]
@@ -242,7 +257,7 @@ installation:
242
257
  ```yaml
243
258
  repos:
244
259
  - repo: https://github.com/isaac-cf-wong/bibsync
245
- rev: v0.3.1
260
+ rev: v0.3.2
246
261
  hooks:
247
262
  - id: bibsync
248
263
  args: [--provider, inspire, --output, references.bib]
@@ -254,7 +269,7 @@ writing changes. To let the hook update files, add `--fix` to the hook args:
254
269
  ```yaml
255
270
  repos:
256
271
  - repo: https://github.com/isaac-cf-wong/bibsync
257
- rev: v0.3.1
272
+ rev: v0.3.2
258
273
  hooks:
259
274
  - id: bibsync-bin
260
275
  args: [--fix, --cache, --provider, inspire, --output, references.bib]
@@ -265,7 +280,7 @@ To skip manually curated entries, add `--ignore-file`:
265
280
  ```yaml
266
281
  repos:
267
282
  - repo: https://github.com/isaac-cf-wong/bibsync
268
- rev: v0.3.1
283
+ rev: v0.3.2
269
284
  hooks:
270
285
  - id: bibsync-bin
271
286
  args:
@@ -54,11 +54,15 @@ behavior.
54
54
  - `output`: explicit output bibliography file.
55
55
  - `other_bibliographies`: read-only bibliography sources.
56
56
  - `provider`: `auto`, NASA ADS, or InspireHEP.
57
- - `update_existing`: refresh existing entries when possible.
57
+ - `update_mode`: controls whether existing entries are refreshed.
58
58
  - `force_regenerate`: rewrite existing entries from provider output.
59
59
  - `merge_other`: copy matching read-only entries into the output file.
60
60
  - `backup`: create a `.bak` file before overwriting.
61
61
  - `check`: compare only and do not write.
62
+ - `cache`: read and write provider responses from the local cache.
63
+ - `refresh_cache`: bypass cache reads and update cached provider responses.
64
+ - `cache_dir`: override the cache location.
65
+ - `ignore_file`: citekeys to skip during resolution.
62
66
 
63
67
  `SyncReport` describes the outcome:
64
68
 
@@ -67,9 +71,19 @@ behavior.
67
71
  - `existing`: citekeys already present.
68
72
  - `found_in_other`: citekeys found in read-only bibliographies.
69
73
  - `unresolved`: citekeys that could not be resolved.
74
+ - `unresolved_details`: per-citekey diagnostics explaining whether resolution
75
+ failed because the key is not a supported identifier or because the provider
76
+ returned no match.
70
77
  - `changed`: whether the output would change.
71
78
  - `check_mode`: whether the run was check-only.
72
79
 
80
+ `sync_files` validates existing input files before resolution. Missing TeX
81
+ inputs, missing single-file `.bib` inputs, missing `other_bibliographies`,
82
+ missing `ignore_file`, malformed existing BibTeX, corrupt cache JSON, and
83
+ provider request failures are returned as `BibsyncError` values with file,
84
+ provider, and key context where available. A missing output bibliography is
85
+ allowed when `output` points to the file that should be created.
86
+
73
87
  ## Providers
74
88
 
75
89
  The built-in providers are:
@@ -115,9 +115,14 @@ bibsync --fix --cache --provider inspire --output references.bib main.tex
115
115
  Then review and commit the changed bibliography.
116
116
 
117
117
  If the hook reports unresolved citekeys, either correct the citekey or choose a
118
- provider that supports that identifier type. For example, ADS bibcodes require
119
- NASA ADS:
118
+ provider that supports that identifier type. The hook output includes a reason
119
+ for each unresolved key, such as an unsupported identifier format or a provider
120
+ miss. For example, ADS bibcodes require NASA ADS:
120
121
 
121
122
  ```shell
122
123
  bibsync --fix --cache --provider ads --output references.bib main.tex
123
124
  ```
125
+
126
+ Missing `--ignore-file` or `--other` paths and malformed existing BibTeX are
127
+ reported as input errors with the affected path. Fix those files or hook
128
+ arguments before rerunning the hook.
@@ -21,6 +21,13 @@ reports whether `references.bib` is current. It does not write changes unless
21
21
  bibsync --fix main.tex -o references.bib
22
22
  ```
23
23
 
24
+ When a required citekey cannot be resolved, `bibsync` prints a diagnostic for
25
+ each key. Unsupported citekeys are reported as identifier-format problems, and
26
+ identifier-like keys that the selected provider cannot find are reported as
27
+ provider misses. Those messages are meant to be actionable in automation: fix the
28
+ citekey, choose a provider that supports it, add the entry manually, or add the
29
+ key to an ignore file.
30
+
24
31
  ## Citekey Style
25
32
 
26
33
  The most reliable workflow is to cite by identifier:
@@ -101,6 +108,10 @@ Additional read-only bibliographies can be passed with `--other`:
101
108
  bibsync main.tex -o references.bib --other shared.bib software.bib
102
109
  ```
103
110
 
111
+ Every file passed with `--other` must already exist and contain valid BibTeX.
112
+ If a path is wrong or a file is malformed, `bibsync` stops with an error naming
113
+ that file instead of silently ignoring it.
114
+
104
115
  By default, entries found in those read-only files are not copied into the main
105
116
  output file. This is useful when a project deliberately keeps shared references
106
117
  or software citations in separate files.
@@ -132,6 +143,9 @@ bibsync --fix main.tex -o references.bib --ignore-file .bibsyncignore
132
143
  Ignored citekeys are never sent to any provider and their existing bib entries
133
144
  are never modified.
134
145
 
146
+ The ignore file must exist when `--ignore-file` is passed. A misspelled ignore
147
+ path is reported as a missing input file.
148
+
135
149
  ## Updating A BibTeX File
136
150
 
137
151
  Passing a single `.bib` file uses the existing keys in that bibliography as the
@@ -148,6 +162,10 @@ scanning TeX files. Add `--fix` to refresh the file:
148
162
  bibsync --fix references.bib
149
163
  ```
150
164
 
165
+ The `.bib` file must exist in this mode. Existing output bibliographies are also
166
+ parsed before resolution; malformed BibTeX reports the file and approximate
167
+ entry location so the bibliography can be corrected before running again.
168
+
151
169
  Use `--force-regenerate` to rewrite all existing entries from provider output:
152
170
 
153
171
  ```shell
@@ -167,6 +185,14 @@ The command exits with a non-zero status when the bibliography would change or
167
185
  when a required citekey cannot be resolved. This is the mode used by the
168
186
  pre-commit hook.
169
187
 
188
+ Unresolved citekeys are printed with reasons. For example:
189
+
190
+ ```text
191
+ unresolved:
192
+ Smith2024: unsupported identifier format; use an arXiv ID, DOI, or ADS bibcode, or add the entry to the bibliography or ignore file
193
+ 2404.14498: provider returned no matching BibTeX entry; check the citekey, choose a provider that supports it, or add the entry manually
194
+ ```
195
+
170
196
  Use `--fix` to write the calculated bibliography:
171
197
 
172
198
  ```shell
@@ -205,6 +231,11 @@ Override the cache location with `--cache-dir`:
205
231
  bibsync --cache --cache-dir .bibsync-cache main.tex -o references.bib
206
232
  ```
207
233
 
234
+ If a cached JSON file is corrupt, `bibsync` reports the exact cache path. Remove
235
+ that file or rerun with `--refresh-cache` to rebuild the provider response.
236
+ Network and provider failures include the provider and citekey or batch being
237
+ resolved.
238
+
208
239
  ## Backups
209
240
 
210
241
  When `bibsync` writes over an existing bibliography, it creates a `.bak` file by
@@ -8,6 +8,7 @@ class SyncReport(TypedDict):
8
8
  existing: list[str]
9
9
  found_in_other: list[str]
10
10
  unresolved: list[str]
11
+ unresolved_details: list[dict[str, str]]
11
12
  changed: bool
12
13
  check_mode: bool
13
14
 
@@ -165,7 +165,10 @@ where
165
165
  );
166
166
  }
167
167
  if !report.unresolved.is_empty() {
168
- println!("unresolved: {}", report.unresolved.join(", "));
168
+ println!("unresolved:");
169
+ for detail in &report.unresolved_details {
170
+ println!(" {}: {}", detail.key, detail.reason);
171
+ }
169
172
  }
170
173
  if report.changed {
171
174
  if report.check_mode {
@@ -36,21 +36,57 @@ pub enum BibsyncError {
36
36
  /// The underlying I/O error.
37
37
  source: io::Error,
38
38
  },
39
+ /// An expected input file was not found.
40
+ #[error("{role} not found: {path}")]
41
+ MissingInput {
42
+ /// File path associated with the failure.
43
+ path: PathBuf,
44
+ /// User-facing description of the missing input.
45
+ role: &'static str,
46
+ },
39
47
  /// An HTTP request failed.
40
48
  #[error("HTTP request failed: {0}")]
41
49
  Http(#[from] reqwest::Error),
50
+ /// A provider request failed while resolving a specific key.
51
+ #[error("{provider} request failed while resolving {key}: {source}")]
52
+ ProviderRequest {
53
+ /// Provider name.
54
+ provider: &'static str,
55
+ /// Citekey or identifier being resolved.
56
+ key: String,
57
+ /// The underlying request error.
58
+ source: reqwest::Error,
59
+ },
42
60
  /// NASA ADS was selected without an API token.
43
61
  #[error("NASA ADS requires ADS_API_TOKEN")]
44
62
  MissingAdsToken,
45
63
  /// An HTTP header value could not be built.
46
64
  #[error("invalid HTTP header value: {0}")]
47
65
  InvalidHeader(#[from] reqwest::header::InvalidHeaderValue),
48
- /// JSON cache serialization or parsing failed.
49
- #[error("JSON error: {0}")]
50
- Json(#[from] serde_json::Error),
66
+ /// JSON cache parsing failed.
67
+ #[error("{path}: invalid JSON cache file: {source}")]
68
+ Json {
69
+ /// Cache file path associated with the failure.
70
+ path: PathBuf,
71
+ /// The underlying JSON error.
72
+ source: serde_json::Error,
73
+ },
74
+ /// JSON cache serialization failed.
75
+ #[error("could not serialize JSON cache record: {0}")]
76
+ JsonSerialization(#[from] serde_json::Error),
51
77
  /// An input file did not point to any BibTeX output file.
52
- #[error("could not identify a bibliography file; pass --output")]
78
+ #[error(
79
+ "could not identify a bibliography file; pass --output or add a \\bibliography{{...}} command"
80
+ )]
53
81
  MissingOutput,
82
+ /// A bibliography file could not be parsed.
83
+ #[error("{path}: invalid BibTeX: {message}")]
84
+ InvalidBibtex {
85
+ /// File path associated with the failure.
86
+ path: PathBuf,
87
+ /// User-facing parse diagnostic.
88
+ message: String,
89
+ },
54
90
  /// A provider returned an invalid BibTeX payload.
55
91
  #[error("{provider} did not return a usable BibTeX entry for {key}")]
56
92
  InvalidProviderBibtex {
@@ -149,12 +185,59 @@ pub struct SyncReport {
149
185
  pub found_in_other: Vec<String>,
150
186
  /// Citekeys that could not be resolved.
151
187
  pub unresolved: Vec<String>,
188
+ /// Detailed diagnostics for citekeys that could not be resolved.
189
+ pub unresolved_details: Vec<UnresolvedCitation>,
152
190
  /// Whether the output file content changed.
153
191
  pub changed: bool,
154
192
  /// Whether changes were only reported because check mode was enabled.
155
193
  pub check_mode: bool,
156
194
  }
157
195
 
196
+ /// Diagnostic for one citekey that could not be resolved.
197
+ #[derive(Clone, Debug, Eq, PartialEq)]
198
+ pub struct UnresolvedCitation {
199
+ /// Citekey or identifier that could not be resolved.
200
+ pub key: String,
201
+ /// Reason the key could not be resolved automatically.
202
+ pub reason: UnresolvedReason,
203
+ }
204
+
205
+ /// Reason a citekey could not be resolved.
206
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
207
+ pub enum UnresolvedReason {
208
+ /// The key is not an arXiv ID, DOI, or ADS bibcode.
209
+ UnsupportedIdentifier,
210
+ /// The selected provider did not return a matching entry.
211
+ ProviderNoMatch,
212
+ }
213
+
214
+ impl UnresolvedReason {
215
+ fn explanation(self) -> &'static str {
216
+ match self {
217
+ Self::UnsupportedIdentifier => {
218
+ "unsupported identifier format; use an arXiv ID, DOI, or ADS bibcode, or add the entry to the bibliography or ignore file"
219
+ }
220
+ Self::ProviderNoMatch => {
221
+ "provider returned no matching BibTeX entry; check the citekey, choose a provider that supports it, or add the entry manually"
222
+ }
223
+ }
224
+ }
225
+ }
226
+
227
+ impl std::fmt::Display for UnresolvedReason {
228
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229
+ formatter.write_str(self.explanation())
230
+ }
231
+ }
232
+
233
+ impl SyncReport {
234
+ fn push_unresolved(&mut self, key: String, reason: UnresolvedReason) {
235
+ self.unresolved.push(key.clone());
236
+ self.unresolved_details
237
+ .push(UnresolvedCitation { key, reason });
238
+ }
239
+ }
240
+
158
241
  /// A resolved BibTeX entry.
159
242
  #[derive(Clone, Debug, Eq, PartialEq)]
160
243
  pub struct ResolvedEntry {
@@ -241,7 +324,7 @@ pub fn sync_files_with_provider(
241
324
  let bib_update_mode = files.len() == 1 && has_extension(&files[0], "bib");
242
325
  let (keys, output, discovered_other) = if bib_update_mode {
243
326
  let output = files[0].clone();
244
- let bib = Bibliography::read_optional(&output)?;
327
+ let bib = Bibliography::read_existing(&output, "bibliography input")?;
245
328
  (bib.keys(), output, Vec::new())
246
329
  } else {
247
330
  let tex_scan = scan_tex_files(files)?;
@@ -268,10 +351,10 @@ pub fn sync_files_with_provider(
268
351
  };
269
352
 
270
353
  let original = read_to_string_optional(&output)?;
271
- let mut bibliography = Bibliography::parse(&original);
354
+ let mut bibliography = Bibliography::parse_path(&output, &original)?;
272
355
  let mut other_bibliography = Bibliography::default();
273
356
  for path in &other_paths {
274
- other_bibliography.merge(Bibliography::read_optional(path)?);
357
+ other_bibliography.merge(Bibliography::read_existing(path, "read-only bibliography")?);
275
358
  }
276
359
 
277
360
  let ignore_set = if let Some(ref path) = options.ignore_file {
@@ -326,7 +409,7 @@ pub fn sync_files_with_provider(
326
409
  if exists {
327
410
  report.existing.push(key);
328
411
  } else {
329
- report.unresolved.push(key);
412
+ report.push_unresolved(key, UnresolvedReason::UnsupportedIdentifier);
330
413
  }
331
414
  continue;
332
415
  }
@@ -358,7 +441,7 @@ pub fn sync_files_with_provider(
358
441
  if exists {
359
442
  report.existing.push(key.clone());
360
443
  } else {
361
- report.unresolved.push(key.clone());
444
+ report.push_unresolved(key.clone(), UnresolvedReason::ProviderNoMatch);
362
445
  }
363
446
  continue;
364
447
  };
@@ -413,6 +496,9 @@ pub fn sync_files_with_provider(
413
496
  report.existing.sort();
414
497
  report.found_in_other.sort();
415
498
  report.unresolved.sort();
499
+ report
500
+ .unresolved_details
501
+ .sort_by(|left, right| left.key.cmp(&right.key));
416
502
  Ok(report)
417
503
  }
418
504
 
@@ -556,27 +642,38 @@ struct Bibliography {
556
642
  }
557
643
 
558
644
  impl Bibliography {
559
- fn read_optional(path: &Path) -> Result<Self> {
560
- Ok(Self::parse(&read_to_string_optional(path)?))
645
+ fn read_existing(path: &Path, role: &'static str) -> Result<Self> {
646
+ Self::parse_path(path, &read_to_string_existing(path, role)?)
647
+ }
648
+
649
+ fn parse_path(path: &Path, input: &str) -> Result<Self> {
650
+ Self::try_parse(input).map_err(|message| BibsyncError::InvalidBibtex {
651
+ path: path.to_owned(),
652
+ message,
653
+ })
561
654
  }
562
655
 
563
- fn parse(input: &str) -> Self {
656
+ fn try_parse(input: &str) -> std::result::Result<Self, String> {
564
657
  let mut bibliography = Self::default();
565
658
  let mut first_entry_start = None;
566
- for segment in split_bib_entries(input) {
659
+ for segment in split_bib_entries(input)? {
567
660
  if first_entry_start.is_none() {
568
661
  first_entry_start = input.find(segment);
569
662
  }
570
- if let Some(entry) = BibEntry::parse(segment) {
571
- bibliography.entries.insert(entry.key.clone(), entry);
572
- }
663
+ let entry = BibEntry::parse(segment).ok_or_else(|| {
664
+ format!(
665
+ "could not parse entry starting near line {}",
666
+ line_number(input, input.find(segment).unwrap_or(0))
667
+ )
668
+ })?;
669
+ bibliography.entries.insert(entry.key.clone(), entry);
573
670
  }
574
671
  if let Some(index) = first_entry_start {
575
672
  input[..index].trim().clone_into(&mut bibliography.preamble);
576
673
  } else {
577
674
  input.trim().clone_into(&mut bibliography.preamble);
578
675
  }
579
- bibliography
676
+ Ok(bibliography)
580
677
  }
581
678
 
582
679
  fn contains(&self, key: &str) -> bool {
@@ -617,16 +714,16 @@ impl std::fmt::Display for Bibliography {
617
714
  }
618
715
  }
619
716
 
620
- fn split_bib_entries(input: &str) -> Vec<&str> {
717
+ fn split_bib_entries(input: &str) -> std::result::Result<Vec<&str>, String> {
621
718
  let mut entries = Vec::new();
622
719
  let bytes = input.as_bytes();
623
720
  let mut index = 0;
624
721
  while let Some(relative_at) = input[index..].find('@') {
625
722
  let start = index + relative_at;
626
- let Some(relative_open) = input[start..].find(['{', '(']) else {
627
- break;
723
+ let Some(open) = bib_entry_open(input, start) else {
724
+ index = start + 1;
725
+ continue;
628
726
  };
629
- let open = start + relative_open;
630
727
  let close = if bytes.get(open) == Some(&b'{') {
631
728
  b'}'
632
729
  } else {
@@ -649,11 +746,42 @@ fn split_bib_entries(input: &str) -> Vec<&str> {
649
746
  if let Some(end) = end {
650
747
  entries.push(&input[start..end]);
651
748
  index = end;
749
+ } else {
750
+ return Err(format!(
751
+ "entry starting near line {} is missing a closing '{}'",
752
+ line_number(input, start),
753
+ close as char
754
+ ));
755
+ }
756
+ }
757
+ Ok(entries)
758
+ }
759
+
760
+ fn bib_entry_open(input: &str, at_index: usize) -> Option<usize> {
761
+ let rest = input.get(at_index + 1..)?;
762
+ let mut type_end = 0;
763
+ for (offset, ch) in rest.char_indices() {
764
+ if ch.is_ascii_alphabetic() {
765
+ type_end = offset + ch.len_utf8();
652
766
  } else {
653
767
  break;
654
768
  }
655
769
  }
656
- entries
770
+ if type_end == 0 {
771
+ return None;
772
+ }
773
+ let after_type = &rest[type_end..];
774
+ let whitespace = after_type.len() - after_type.trim_start().len();
775
+ let open = at_index + 1 + type_end + whitespace;
776
+ matches!(input.as_bytes().get(open), Some(b'{' | b'(')).then_some(open)
777
+ }
778
+
779
+ fn line_number(input: &str, byte_index: usize) -> usize {
780
+ input[..byte_index.min(input.len())]
781
+ .bytes()
782
+ .filter(|byte| *byte == b'\n')
783
+ .count()
784
+ + 1
657
785
  }
658
786
 
659
787
  fn indent_body(body: &str) -> String {
@@ -699,12 +827,28 @@ fn read_to_string_optional(path: &Path) -> Result<String> {
699
827
  }
700
828
  }
701
829
 
830
+ fn read_to_string_existing(path: &Path, role: &'static str) -> Result<String> {
831
+ match fs::read_to_string(path) {
832
+ Ok(content) => Ok(content),
833
+ Err(source) if source.kind() == io::ErrorKind::NotFound => {
834
+ Err(BibsyncError::MissingInput {
835
+ path: path.to_owned(),
836
+ role,
837
+ })
838
+ }
839
+ Err(source) => Err(BibsyncError::Io {
840
+ path: path.to_owned(),
841
+ source,
842
+ }),
843
+ }
844
+ }
845
+
702
846
  fn is_supported_identifier(key: &str) -> bool {
703
847
  is_arxiv_id(key) || is_doi(key) || is_ads_bibcode(key)
704
848
  }
705
849
 
706
850
  fn load_ignore_set(path: &Path) -> Result<BTreeSet<String>> {
707
- let content = read_to_string_optional(path)?;
851
+ let content = read_to_string_existing(path, "ignore file")?;
708
852
  Ok(content
709
853
  .lines()
710
854
  .map(str::trim)
@@ -962,7 +1106,14 @@ fn cache_store(root: &Path, key: &str, entry: &ResolvedEntry) -> Result<()> {
962
1106
 
963
1107
  fn read_json_optional<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<Option<T>> {
964
1108
  match fs::read_to_string(path) {
965
- Ok(content) => Ok(Some(serde_json::from_str(&content)?)),
1109
+ Ok(content) => {
1110
+ serde_json::from_str(&content)
1111
+ .map(Some)
1112
+ .map_err(|source| BibsyncError::Json {
1113
+ path: path.to_owned(),
1114
+ source,
1115
+ })
1116
+ }
966
1117
  Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(None),
967
1118
  Err(source) => Err(BibsyncError::Io {
968
1119
  path: path.to_owned(),
@@ -1038,6 +1189,18 @@ fn provider_name_from_slug(slug: &str) -> &'static str {
1038
1189
  }
1039
1190
  }
1040
1191
 
1192
+ fn provider_request<T>(
1193
+ provider: &'static str,
1194
+ key: impl Into<String>,
1195
+ result: std::result::Result<T, reqwest::Error>,
1196
+ ) -> Result<T> {
1197
+ result.map_err(|source| BibsyncError::ProviderRequest {
1198
+ provider,
1199
+ key: key.into(),
1200
+ source,
1201
+ })
1202
+ }
1203
+
1041
1204
  fn unix_timestamp() -> u64 {
1042
1205
  std::time::SystemTime::now()
1043
1206
  .duration_since(std::time::UNIX_EPOCH)
@@ -1117,14 +1280,15 @@ impl AdsProvider {
1117
1280
  return Ok(Some(key.to_owned()));
1118
1281
  }
1119
1282
  let query = format!("identifier:\"{key}\"");
1120
- let response: AdsSearchResponse = self
1283
+ let response = self
1121
1284
  .client
1122
1285
  .get("https://api.adsabs.harvard.edu/v1/search/query")
1123
1286
  .headers(self.headers()?)
1124
1287
  .query(&[("q", query.as_str()), ("fl", "bibcode"), ("rows", "1")])
1125
- .send()?
1126
- .error_for_status()?
1127
- .json()?;
1288
+ .send();
1289
+ let response = provider_request(self.name(), key, response)?;
1290
+ let response = provider_request(self.name(), key, response.error_for_status())?;
1291
+ let response: AdsSearchResponse = provider_request(self.name(), key, response.json())?;
1128
1292
  Ok(response
1129
1293
  .response
1130
1294
  .docs
@@ -1133,18 +1297,25 @@ impl AdsProvider {
1133
1297
  }
1134
1298
 
1135
1299
  fn export_bibtex_many(&self, bibcodes: &[String]) -> Result<BTreeMap<String, String>> {
1136
- let response: AdsExportResponse = self
1300
+ let key = if bibcodes.is_empty() {
1301
+ "empty ADS export batch".to_owned()
1302
+ } else {
1303
+ bibcodes.join(", ")
1304
+ };
1305
+ let response = self
1137
1306
  .client
1138
1307
  .post("https://api.adsabs.harvard.edu/v1/export/bibtex")
1139
1308
  .headers(self.headers()?)
1140
1309
  .json(&json!({ "bibcode": bibcodes }))
1141
- .send()?
1142
- .error_for_status()?
1143
- .json()?;
1310
+ .send();
1311
+ let response = provider_request(self.name(), key.clone(), response)?;
1312
+ let response = provider_request(self.name(), key.clone(), response.error_for_status())?;
1313
+ let response: AdsExportResponse = provider_request(self.name(), key, response.json())?;
1144
1314
  let Some(export) = nonempty(&response.export) else {
1145
1315
  return Ok(BTreeMap::new());
1146
1316
  };
1147
1317
  Ok(split_bib_entries(&export)
1318
+ .unwrap_or_default()
1148
1319
  .into_iter()
1149
1320
  .filter_map(|entry| {
1150
1321
  let parsed = BibEntry::parse(entry)?;
@@ -1266,15 +1437,16 @@ impl BibliographyProvider for InspireProvider {
1266
1437
  } else {
1267
1438
  return Ok(None);
1268
1439
  };
1269
- let bibtex = self
1440
+ let response = self
1270
1441
  .client
1271
1442
  .get("https://inspirehep.net/api/literature")
1272
1443
  .header(USER_AGENT, "bibsync/0.1")
1273
1444
  .header(ACCEPT, "application/x-bibtex")
1274
1445
  .query(&[("q", query.as_str()), ("format", "bibtex"), ("size", "1")])
1275
- .send()?
1276
- .error_for_status()?
1277
- .text()?;
1446
+ .send();
1447
+ let response = provider_request(self.name(), key, response)?;
1448
+ let response = provider_request(self.name(), key, response.error_for_status())?;
1449
+ let bibtex = provider_request(self.name(), key, response.text())?;
1278
1450
  let Some(bibtex) = nonempty(&bibtex) else {
1279
1451
  return Ok(None);
1280
1452
  };
@@ -1305,7 +1477,12 @@ impl BibliographyProvider for InspireProvider {
1305
1477
  return Ok(BTreeMap::new());
1306
1478
  }
1307
1479
 
1308
- let response: InspireSearchResponse = self
1480
+ let batch_key = if keys.is_empty() {
1481
+ "empty InspireHEP batch".to_owned()
1482
+ } else {
1483
+ keys.join(", ")
1484
+ };
1485
+ let response = self
1309
1486
  .client
1310
1487
  .get("https://inspirehep.net/api/literature")
1311
1488
  .header(USER_AGENT, "bibsync/0.1")
@@ -1313,9 +1490,12 @@ impl BibliographyProvider for InspireProvider {
1313
1490
  ("q", query_parts.join(" OR ").as_str()),
1314
1491
  ("size", keys.len().to_string().as_str()),
1315
1492
  ])
1316
- .send()?
1317
- .error_for_status()?
1318
- .json()?;
1493
+ .send();
1494
+ let response = provider_request(self.name(), batch_key.clone(), response)?;
1495
+ let response =
1496
+ provider_request(self.name(), batch_key.clone(), response.error_for_status())?;
1497
+ let response: InspireSearchResponse =
1498
+ provider_request(self.name(), batch_key, response.json())?;
1319
1499
 
1320
1500
  let mut record_by_key = BTreeMap::new();
1321
1501
  for hit in response.hits.hits {
@@ -1334,15 +1514,16 @@ impl BibliographyProvider for InspireProvider {
1334
1514
 
1335
1515
  let mut resolved = BTreeMap::new();
1336
1516
  for (key, record_id) in record_by_key {
1337
- let bibtex = self
1517
+ let response = self
1338
1518
  .client
1339
1519
  .get(format!("https://inspirehep.net/api/literature/{record_id}"))
1340
1520
  .header(USER_AGENT, "bibsync/0.1")
1341
1521
  .header(ACCEPT, "application/x-bibtex")
1342
1522
  .query(&[("format", "bibtex")])
1343
- .send()?
1344
- .error_for_status()?
1345
- .text()?;
1523
+ .send();
1524
+ let response = provider_request(self.name(), key.clone(), response)?;
1525
+ let response = provider_request(self.name(), key.clone(), response.error_for_status())?;
1526
+ let bibtex = provider_request(self.name(), key.clone(), response.text())?;
1346
1527
  let Some(bibtex) = nonempty(&bibtex) else {
1347
1528
  continue;
1348
1529
  };
@@ -1437,8 +1618,8 @@ pub fn pre_commit_hook_manifest(ignore_file: Option<&Path>) -> String {
1437
1618
  #[cfg(test)]
1438
1619
  mod tests {
1439
1620
  use super::{
1440
- BibliographyProvider, ProviderChoice, ResolvedEntry, SyncOptions, citation_keys,
1441
- sync_files_with_provider,
1621
+ BibliographyProvider, ProviderChoice, ResolvedEntry, SyncOptions, UnresolvedCitation,
1622
+ citation_keys, sync_files_with_provider,
1442
1623
  };
1443
1624
  use std::cell::Cell;
1444
1625
  use std::collections::BTreeMap;
@@ -1623,4 +1804,192 @@ mod tests {
1623
1804
  assert_eq!(second.len(), 1);
1624
1805
  assert_eq!(calls.get(), 1);
1625
1806
  }
1807
+
1808
+ #[test]
1809
+ fn single_bib_update_requires_existing_input() {
1810
+ let dir = tempdir().expect("tempdir");
1811
+ let missing = dir.path().join("missing.bib");
1812
+ let provider = FakeProvider {
1813
+ entries: BTreeMap::new(),
1814
+ };
1815
+
1816
+ let error = sync_files_with_provider(
1817
+ std::slice::from_ref(&missing),
1818
+ &SyncOptions::default(),
1819
+ &provider,
1820
+ )
1821
+ .expect_err("missing single bibliography should fail");
1822
+
1823
+ assert!(error.to_string().contains("bibliography input not found"));
1824
+ assert!(error.to_string().contains(&missing.display().to_string()));
1825
+ }
1826
+
1827
+ #[test]
1828
+ fn other_bibliography_requires_existing_input() {
1829
+ let dir = tempdir().expect("tempdir");
1830
+ let tex = dir.path().join("main.tex");
1831
+ let bib = dir.path().join("refs.bib");
1832
+ let other = dir.path().join("shared.bib");
1833
+ std::fs::write(&tex, "\\cite{2404.14498}").expect("write tex");
1834
+ let provider = FakeProvider {
1835
+ entries: BTreeMap::new(),
1836
+ };
1837
+
1838
+ let error = sync_files_with_provider(
1839
+ &[tex],
1840
+ &SyncOptions {
1841
+ output: Some(bib),
1842
+ other_bibliographies: vec![other.clone()],
1843
+ ..SyncOptions::default()
1844
+ },
1845
+ &provider,
1846
+ )
1847
+ .expect_err("missing other bibliography should fail");
1848
+
1849
+ assert!(
1850
+ error
1851
+ .to_string()
1852
+ .contains("read-only bibliography not found")
1853
+ );
1854
+ assert!(error.to_string().contains(&other.display().to_string()));
1855
+ }
1856
+
1857
+ #[test]
1858
+ fn ignore_file_requires_existing_input() {
1859
+ let dir = tempdir().expect("tempdir");
1860
+ let tex = dir.path().join("main.tex");
1861
+ let bib = dir.path().join("refs.bib");
1862
+ let ignore = dir.path().join(".bibsyncignore");
1863
+ std::fs::write(&tex, "\\cite{NotAnIdentifier}").expect("write tex");
1864
+ let provider = FakeProvider {
1865
+ entries: BTreeMap::new(),
1866
+ };
1867
+
1868
+ let error = sync_files_with_provider(
1869
+ &[tex],
1870
+ &SyncOptions {
1871
+ output: Some(bib),
1872
+ ignore_file: Some(ignore.clone()),
1873
+ ..SyncOptions::default()
1874
+ },
1875
+ &provider,
1876
+ )
1877
+ .expect_err("missing ignore file should fail");
1878
+
1879
+ assert!(error.to_string().contains("ignore file not found"));
1880
+ assert!(error.to_string().contains(&ignore.display().to_string()));
1881
+ }
1882
+
1883
+ #[test]
1884
+ fn malformed_bibtex_reports_file_and_parse_error() {
1885
+ let dir = tempdir().expect("tempdir");
1886
+ let bib = dir.path().join("refs.bib");
1887
+ std::fs::write(&bib, "@article{broken,\n title = {Missing close}\n")
1888
+ .expect("write malformed bib");
1889
+ let provider = FakeProvider {
1890
+ entries: BTreeMap::new(),
1891
+ };
1892
+
1893
+ let error = sync_files_with_provider(
1894
+ std::slice::from_ref(&bib),
1895
+ &SyncOptions::default(),
1896
+ &provider,
1897
+ )
1898
+ .expect_err("malformed BibTeX should fail");
1899
+
1900
+ assert!(error.to_string().contains("invalid BibTeX"));
1901
+ assert!(error.to_string().contains("missing a closing"));
1902
+ assert!(error.to_string().contains(&bib.display().to_string()));
1903
+ }
1904
+
1905
+ #[test]
1906
+ fn malformed_output_bibtex_reports_file_and_parse_error() {
1907
+ let dir = tempdir().expect("tempdir");
1908
+ let tex = dir.path().join("main.tex");
1909
+ let bib = dir.path().join("refs.bib");
1910
+ std::fs::write(&tex, "\\cite{NotAnIdentifier}").expect("write tex");
1911
+ std::fs::write(&bib, "@article{broken,\n title = {Missing close}\n")
1912
+ .expect("write malformed bib");
1913
+ let provider = FakeProvider {
1914
+ entries: BTreeMap::new(),
1915
+ };
1916
+
1917
+ let error = sync_files_with_provider(
1918
+ &[tex],
1919
+ &SyncOptions {
1920
+ output: Some(bib.clone()),
1921
+ ..SyncOptions::default()
1922
+ },
1923
+ &provider,
1924
+ )
1925
+ .expect_err("malformed output BibTeX should fail");
1926
+
1927
+ assert!(error.to_string().contains("invalid BibTeX"));
1928
+ assert!(error.to_string().contains("missing a closing"));
1929
+ assert!(error.to_string().contains(&bib.display().to_string()));
1930
+ }
1931
+
1932
+ #[test]
1933
+ fn unresolved_details_distinguish_unsupported_and_provider_miss() {
1934
+ let dir = tempdir().expect("tempdir");
1935
+ let tex = dir.path().join("main.tex");
1936
+ let bib = dir.path().join("refs.bib");
1937
+ std::fs::write(&tex, "\\cite{NotAnIdentifier,2404.14498}").expect("write tex");
1938
+ let provider = FakeProvider {
1939
+ entries: BTreeMap::new(),
1940
+ };
1941
+
1942
+ let report = sync_files_with_provider(
1943
+ &[tex],
1944
+ &SyncOptions {
1945
+ output: Some(bib),
1946
+ ..SyncOptions::default()
1947
+ },
1948
+ &provider,
1949
+ )
1950
+ .expect("sync");
1951
+
1952
+ assert_eq!(report.unresolved, vec!["2404.14498", "NotAnIdentifier"]);
1953
+ assert_eq!(
1954
+ report.unresolved_details,
1955
+ vec![
1956
+ UnresolvedCitation {
1957
+ key: "2404.14498".to_owned(),
1958
+ reason: super::UnresolvedReason::ProviderNoMatch,
1959
+ },
1960
+ UnresolvedCitation {
1961
+ key: "NotAnIdentifier".to_owned(),
1962
+ reason: super::UnresolvedReason::UnsupportedIdentifier,
1963
+ },
1964
+ ]
1965
+ );
1966
+ }
1967
+
1968
+ #[test]
1969
+ fn corrupt_cache_json_reports_cache_path() {
1970
+ let dir = tempdir().expect("tempdir");
1971
+ let mapping = super::mapping_path(dir.path(), "inspire", "arxiv", "2404.14498");
1972
+ std::fs::create_dir_all(mapping.parent().expect("mapping parent"))
1973
+ .expect("create cache dir");
1974
+ std::fs::write(&mapping, "{").expect("write corrupt cache");
1975
+ let provider = CountingProvider {
1976
+ calls: Rc::new(Cell::new(0)),
1977
+ entries: BTreeMap::new(),
1978
+ };
1979
+ let cached = super::CachedProvider {
1980
+ inner: Box::new(provider),
1981
+ config: super::CacheConfig {
1982
+ enabled: true,
1983
+ refresh: false,
1984
+ root: dir.path().to_owned(),
1985
+ },
1986
+ };
1987
+
1988
+ let error = cached
1989
+ .resolve_many(&["2404.14498".to_owned()])
1990
+ .expect_err("corrupt cache should fail");
1991
+
1992
+ assert!(error.to_string().contains("invalid JSON cache file"));
1993
+ assert!(error.to_string().contains(&mapping.display().to_string()));
1994
+ }
1626
1995
  }
@@ -94,6 +94,17 @@ fn sync_files_py<'py>(
94
94
  dict.set_item("existing", report.existing)?;
95
95
  dict.set_item("found_in_other", report.found_in_other)?;
96
96
  dict.set_item("unresolved", report.unresolved)?;
97
+ let unresolved_details = report
98
+ .unresolved_details
99
+ .into_iter()
100
+ .map(|detail| {
101
+ let item = PyDict::new(py);
102
+ item.set_item("key", detail.key)?;
103
+ item.set_item("reason", detail.reason.to_string())?;
104
+ Ok(item)
105
+ })
106
+ .collect::<PyResult<Vec<_>>>()?;
107
+ dict.set_item("unresolved_details", unresolved_details)?;
97
108
  dict.set_item("changed", report.changed)?;
98
109
  dict.set_item("check_mode", report.check_mode)?;
99
110
  Ok(dict)
@@ -43,7 +43,47 @@ fn cli_defaults_to_check_mode() {
43
43
  .arg(&tex)
44
44
  .assert()
45
45
  .failure()
46
- .stdout(predicate::str::contains("unresolved: NotAnIdentifier"));
46
+ .stdout(predicate::str::contains("unresolved:"))
47
+ .stdout(predicate::str::contains(
48
+ "NotAnIdentifier: unsupported identifier format",
49
+ ));
50
+ }
51
+
52
+ #[test]
53
+ fn cli_reports_missing_ignore_file() {
54
+ let dir = tempdir().expect("tempdir");
55
+ let tex = dir.path().join("main.tex");
56
+ let bib = dir.path().join("refs.bib");
57
+ let ignore = dir.path().join(".bibsyncignore");
58
+ std::fs::write(&tex, "\\cite{NotAnIdentifier}").expect("write tex");
59
+
60
+ let mut command = Command::cargo_bin("bibsync").expect("binary exists");
61
+ command
62
+ .arg("--output")
63
+ .arg(&bib)
64
+ .arg("--ignore-file")
65
+ .arg(&ignore)
66
+ .arg(&tex)
67
+ .assert()
68
+ .failure()
69
+ .stderr(predicate::str::contains("ignore file not found"))
70
+ .stderr(predicate::str::contains(ignore.display().to_string()));
71
+ }
72
+
73
+ #[test]
74
+ fn cli_reports_malformed_bibtex() {
75
+ let dir = tempdir().expect("tempdir");
76
+ let bib = dir.path().join("refs.bib");
77
+ std::fs::write(&bib, "@article{broken,\n title = {Missing close}\n")
78
+ .expect("write malformed bib");
79
+
80
+ let mut command = Command::cargo_bin("bibsync").expect("binary exists");
81
+ command
82
+ .arg(&bib)
83
+ .assert()
84
+ .failure()
85
+ .stderr(predicate::str::contains("invalid BibTeX"))
86
+ .stderr(predicate::str::contains("missing a closing"));
47
87
  }
48
88
 
49
89
  #[test]
@@ -27,6 +27,7 @@ def test_sync_files_reports_existing_complete_bib_entry(tmp_path: Path) -> None:
27
27
  "existing": ["smith2024"],
28
28
  "found_in_other": [],
29
29
  "unresolved": [],
30
+ "unresolved_details": [],
30
31
  "changed": False,
31
32
  "check_mode": True,
32
33
  }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes