Repository navigation
Expand file tree
/
Copy pathcargo-i18n_20260908.html
More file actions
920 lines (857 loc) · 162 KB
/
Copy pathcargo-i18n_20260908.html
File metadata and controls
920 lines (857 loc) · 162 KB
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
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Code Audit — Interactive</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{color-scheme:dark}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f172a;color:#e5e7eb;font-size:14px;line-height:1.5}
.app{max-width:1100px;margin:0 auto;padding:1.5rem 1rem}
.top{display:flex;justify-content:space-between;align-items:flex-start;gap:1rem;margin-bottom:1rem;flex-wrap:wrap}
h1{font-size:1.5rem;font-weight:700;color:#f1f5f9}
.meta{font-size:.8rem;color:#6b7280;margin-top:.2rem}
.top-right{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}
.project-info{margin-bottom:1rem;padding:.75rem .9rem;background:#1e293b;border:1px solid #334155;border-radius:8px}
.project-info .pi-name{font-size:.95rem;font-weight:700;color:#f1f5f9}
.project-info .pi-desc{font-size:.82rem;color:#d1d5db;margin-top:.25rem}
.project-info .pi-row{display:flex;flex-wrap:wrap;gap:.4rem .9rem;margin-top:.45rem;font-size:.78rem;color:#9ca3af}
.project-info .pi-row .pi-k{color:#6b7280}
.project-info a{color:#60a5fa;text-decoration:none}
.project-info a:hover{text-decoration:underline}
.project-info code{font-family:"JetBrains Mono","Fira Code",Consolas,monospace;font-size:.74rem;color:#93c5fd}
.pi-repo-btn{display:inline-flex;align-items:center;gap:.3rem;padding:.18rem .5rem;background:#0f172a;border:1px solid #3b82f6;border-radius:6px;color:#60a5fa !important;font-weight:600}
.pi-repo-btn:hover{background:#1d283a;text-decoration:none !important}
#search{background:#1e293b;border:1px solid #334155;color:#e5e7eb;padding:.4rem .75rem;border-radius:6px;font-size:.875rem;width:220px;outline:none}
#search:focus{border-color:#3b82f6}
.group-label{font-size:.8rem;color:#9ca3af;display:flex;align-items:center;gap:.4rem}
select{background:#1e293b;border:1px solid #334155;color:#e5e7eb;padding:.35rem .6rem;border-radius:6px;font-size:.8rem;outline:none;cursor:pointer}
select:focus{border-color:#3b82f6}
.filterbar{display:flex;flex-direction:column;gap:.4rem;margin-bottom:.6rem;padding:.6rem .75rem;background:#1e293b;border:1px solid #334155;border-radius:8px}
.filter-row{display:flex;flex-wrap:wrap;gap:.3rem;align-items:center}
.filter-lbl{font-size:.65rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#4b5563;white-space:nowrap;margin-right:.25rem}
.filter-toggle{font-size:.65rem;font-weight:600;padding:.18rem .5rem;border-radius:4px;border:1px solid #334155;background:#0f172a;color:#9ca3af;cursor:pointer;margin-left:auto;white-space:nowrap;letter-spacing:.04em}
.filter-toggle:hover{background:#1e293b;color:#e5e7eb;border-color:#4b5563}
.badge{font-size:.68rem;font-weight:700;padding:.18rem .5rem;border-radius:4px;border:1px solid transparent;cursor:pointer;letter-spacing:.03em;transition:opacity .12s,filter .12s;user-select:none}
.badge.off{opacity:.28;filter:grayscale(.5)}
.sev-critical{background:#450a0a;color:#fca5a5;border-color:#991b1b}
.sev-high {background:#431407;color:#fdba74;border-color:#9a3412}
.sev-medium {background:#422006;color:#fcd34d;border-color:#854d0e}
.sev-low {background:#0c1a3a;color:#93c5fd;border-color:#1d4ed8}
.sev-negligible{background:#1e293b;color:#6b7280;border-color:#334155}
.type-badge{background:#0c1e33;color:#7dd3fc;border-color:#0369a1}
.diff-very-easy{background:#052e16;color:#86efac;border-color:#16a34a}
.diff-easy{background:#064e3b;color:#6ee7b7;border-color:#059669}
.diff-medium{background:#1e293b;color:#9ca3af;border-color:#4b5563}
.diff-hard{background:#2e1065;color:#c4b5fd;border-color:#7c3aed}
.st-open{background:#1e293b;color:#4b5563;border-color:#334155}
.st-fixed{background:#052e16;color:#4ade80;border-color:#16a34a}
.st-wont{background:#431407;color:#fdba74;border-color:#9a3412}
.st-fp{background:#2e1065;color:#c4b5fd;border-color:#7c3aed}
#progress-wrap{display:flex;align-items:center;gap:.75rem;margin-bottom:.6rem;flex-wrap:wrap}
#progress-bar{flex:1;min-width:120px;height:7px;background:#1e293b;border-radius:4px;overflow:hidden;border:1px solid #334155}
#progress-fill{height:100%;background:#22c55e;border-radius:4px;transition:width .35s ease;width:0}
#progress-text{font-size:.75rem;color:#6b7280;white-space:nowrap}
.action-btn{font-size:.72rem;padding:.25rem .6rem;border-radius:5px;border:1px solid #334155;background:#1e293b;color:#9ca3af;cursor:pointer;white-space:nowrap}
.action-btn:hover{background:#334155;color:#e5e7eb}
.action-btn.active{background:#1d4ed8;color:#fff;border-color:#2563eb}
.action-btn.active:hover{background:#2563eb}
.search-wrap{display:flex;align-items:center;gap:.35rem}
#hidden-bar{margin-bottom:.6rem}
.hidden-inner{display:flex;flex-wrap:wrap;gap:.35rem;align-items:center}
.hidden-lbl{font-size:.72rem;color:#4b5563}
.show-btn{font-size:.7rem;padding:.15rem .45rem;border-radius:4px;background:#1e293b;color:#9ca3af;border:1px solid #334155;cursor:pointer}
.show-btn:hover{background:#334155;color:#e5e7eb}
.show-all-btn{font-size:.7rem;padding:.15rem .45rem;border-radius:4px;background:#2563eb;color:#fff;border:none;cursor:pointer}
.show-all-btn:hover{background:#1d4ed8}
.group-section{margin-bottom:1.5rem}
.group-header{display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem;padding-bottom:.35rem;border-bottom:1px solid #334155}
.group-title{font-size:.95rem;font-weight:700;color:#d1d5db}
.group-title.all-done{color:#4ade80}
.group-count{font-size:.72rem;color:#4b5563;background:#1e293b;padding:.1rem .4rem;border-radius:10px;white-space:nowrap}
.icon-btn{background:transparent;border:none;color:#334155;cursor:pointer;font-size:.85rem;padding:.05rem .2rem;line-height:1}
.icon-btn:hover{color:#6b7280}
.hide-btn{margin-left:auto;font-size:.68rem;padding:.12rem .45rem;border-radius:4px;background:transparent;color:#334155;border:1px solid #1e293b;cursor:pointer}
.hide-btn:hover{background:#1e293b;color:#6b7280}
.card{background:#1e293b;border:1px solid #334155;border-radius:8px;margin-bottom:.6rem;overflow:hidden;transition:opacity .15s}
.card.resolved{opacity:.55;border-left:3px solid #16a34a}
.card.resolved:hover{opacity:.85}
.card-header{display:flex;align-items:center;gap:.5rem;padding:.45rem .85rem;background:#162032;border-bottom:1px solid #334155;flex-wrap:wrap;cursor:pointer;user-select:none}
.card-header:hover{background:#1a2840}
.card-arrow{font-size:.65rem;color:#334155;flex-shrink:0}
.card-id{font-size:.75rem;font-weight:700;color:#6b7280;font-family:monospace;letter-spacing:.04em}
.card-loc{font-size:.72rem;color:#6b7280;font-family:"JetBrains Mono","Fira Code",Consolas,monospace;letter-spacing:.02em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
.status-btn{margin-left:auto;font-size:.68rem;font-weight:600;padding:.15rem .5rem;border-radius:4px;border:1px solid transparent;cursor:pointer;white-space:nowrap;transition:filter .1s}
.status-btn:hover{filter:brightness(1.25)}
.copy-btn{font-size:.68rem;font-weight:600;padding:.15rem .5rem;border-radius:4px;border:1px solid #334155;background:#0f172a;color:#9ca3af;cursor:pointer;white-space:nowrap;transition:background .12s,color .12s}
.copy-btn:hover{background:#334155;color:#e5e7eb}
.card-body{padding:.85rem .9rem}
.field-lbl{font-size:.85rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:#7dd3fc;margin:1rem 0 .35rem;padding-bottom:.18rem;border-bottom:1px solid #1e293b}
.field-lbl:first-child{margin-top:0}
.desc,.fix-txt{font-size:.875rem;color:#d1d5db;line-height:1.55}
.desc p,.fix-txt p{margin:.4rem 0}
.desc p:first-child,.fix-txt p:first-child{margin-top:0}
.desc p:last-child,.fix-txt p:last-child{margin-bottom:0}
.loc-block{margin:.3rem 0 .6rem}
.loc-ref{font-size:.8rem;color:#38bdf8;font-family:"JetBrains Mono","Fira Code",Consolas,monospace;font-weight:600;margin-bottom:.22rem}
a.loc-link{color:inherit;text-decoration:none}
a.loc-link:hover{text-decoration:underline}
.loc-lines{color:#4b5563;font-weight:400}
pre{background:#0f172a;color:#cdd6f4;border-radius:5px;padding:.6rem .8rem;overflow-x:auto;white-space:pre;font-size:.8rem;line-height:1.55;font-family:"JetBrains Mono","Fira Code",Consolas,monospace;border:1px solid #1e293b;max-height:320px}
.desc pre.md-code,.fix-txt pre.md-code{margin:.5rem 0;font-size:.82rem;color:#e5e7eb;background:#0b1224;border:1px solid #1e293b;max-height:none}
code.md-inline{font-family:"JetBrains Mono","Fira Code",Consolas,monospace;font-size:.86em;background:#0b1224;color:#fca5a5;border:1px solid #1e293b;border-radius:3px;padding:.04rem .32rem;white-space:pre-wrap;word-break:break-word}
.not-found{font-size:.8rem;color:#334155;font-style:italic}
mark{background:#854d0e;color:#fef9c3;border-radius:2px;padding:0 .1rem}
.no-results{text-align:center;color:#334155;padding:3rem 1rem;font-size:.9rem}
</style>
</head>
<body>
<script type="application/json" id="D">[
{"id":"PANIC_1","type":"panic","type_label":"Panic","severity":"high","difficulty":"medium","description":"In load_languages, when a non-fallback language has no language files, the code logs a debug message but does NOT return early (the early return only happens for the fallback language). Execution falls through to `files.into_iter().map(...).collect()` which produces an empty inner Vec for that language. Later, line 591 calls `language_bundles.first().expect(...)` on every per-language group, which panics for the empty group. Thus loading any requested language whose .ftl file is missing (while the fallback exists) crashes the application instead of degrading gracefully.","fix":"Filter out empty per-language bundle groups before building `language_map`/`language_bundles`, or skip pushing a group when its files are empty. For example, collect into the outer Vec only when the inner Vec is non-empty, and adjust the `indices` computation accordingly so it does not assume `language_bundles.len() == load_language_ids.len()`.","locations":[{"ref":"i18n-embed/src/fluent.rs:551-556","code":" 551 | if files.is_empty() {\n 552 | log::debug!(target:\"i18n_embed::fluent\", \"Unable to find language file: \\\"{0}\\\" for language: \\\"{1}\\\"\", path, language);\n 553 | if language == &self.fallback_language {\n 554 | return Err(I18nEmbedError::LanguageNotAvailable(path, language.clone()));\n 555 | }\n 556 | }","lines":6,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/fluent.rs#L551-L556"},{"ref":"i18n-embed/src/fluent.rs:586-595","code":" 586 | language_map: language_bundles\n 587 | .iter()\n 588 | .enumerate()\n 589 | .map(|(i, language_bundles)| {\n 590 | (\n 591 | language_bundles.first().expect(\"Expect there to be at least bundle in a set of bundles per language\").language.clone(),\n 592 | i\n 593 | )\n 594 | })\n 595 | .collect(),","lines":10,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/fluent.rs#L586-L595"}]},
{"id":"PANIC_2","type":"panic","type_label":"Panic","severity":"high","difficulty":"medium","description":"The `path` argument is defined without any `value_parser`, so clap stores it as the default type `String`. At line 161 it is then read with `get_one::<PathBuf>(\"path\")`. In clap v4, `get_one::<T>` panics (\"Mismatch between definition and access\") when the requested type does not match the type produced by the value parser. As a result, whenever a user actually passes `--path <dir>`, the program panics instead of localizing that crate. The bug is masked when `--path` is omitted because `get_one` returns `None` without attempting a downcast, so the `.unwrap_or_else(|| \".\")` fallback hides it in the common case and in tests.","fix":"Add `.value_parser(clap::value_parser!(std::path::PathBuf))` to the `path` arg definition, or read it as `get_one::<String>(\"path\")` and convert to `PathBuf` afterwards.","locations":[{"ref":"src/main.rs:112-119","code":" 112 | .arg(Arg::new(\"path\")\n 113 | .help(\n 114 | // The help message for the `--path` command line argument.\n 115 | tr!(\"Path to the crate you want to localize (if not the current directory). The crate needs to contain \\\"i18n.toml\\\" in its root.\")\n 116 | )\n 117 | .long(\"path\")\n 118 | .num_args(1)\n 119 | )","lines":8,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/src/main.rs#L112-L119"},{"ref":"src/main.rs:160-163","code":" 160 | let path = i18n_matches\n 161 | .get_one::<PathBuf>(\"path\")\n 162 | .map(ToOwned::to_owned)\n 163 | .unwrap_or_else(|| PathBuf::from(\".\"));","lines":4,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/src/main.rs#L160-L163"}]},
{"id":"PANIC_3","type":"panic","type_label":"Panic","severity":"medium","difficulty":"easy","description":"`run_msgfmt` uses `.expect(\"mo file path is not valid utf-8\")` and `.expect(\"po file path is not valid utf-8\")` to convert paths to `&str`, whereas every other command builder in this module gracefully returns a `PathError::not_valid_utf8`. On systems with non-UTF-8 paths this panics instead of producing the intended translated error.","fix":"Replace the `.expect(...)` calls with `.ok_or_else(|| PathError::not_valid_utf8(...))?` matching the other functions.","locations":[{"ref":"i18n-build/src/gettext_impl/mod.rs:355-366","code":" 355 | let msgfmt_arg_output_file = format!(\n 356 | \"--output-file={}\",\n 357 | mo_file_path\n 358 | .to_str()\n 359 | .expect(\"mo file path is not valid utf-8\")\n 360 | );\n 361 | let mut msgfmt_args = vec![\n 362 | msgfmt_arg_output_file.as_str(),\n 363 | po_file_path\n 364 | .to_str()\n 365 | .expect(\"po file path is not valid utf-8\"),\n 366 | ];","lines":12,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-build/src/gettext_impl/mod.rs#L355-L366"}]},
{"id":"PANIC_4","type":"panic","type_label":"Panic","severity":"medium","difficulty":"medium","description":"In `run`, `crt.path.canonicalize().unwrap()` and `config_crate.path.canonicalize().unwrap()` will panic if either path does not exist or is inaccessible (e.g. a misconfigured subcrate path, permission error, or broken symlink). Since these are user/config-supplied paths, this turns a recoverable filesystem error into a hard panic with no context.","fix":"Propagate the error with `?` and a context message (map canonicalize errors into a PathError) instead of unwrapping.","locations":[{"ref":"i18n-build/src/gettext_impl/mod.rs:441-442","code":" 441 | let prepend_crate_path =\n 442 | crt.path.canonicalize().unwrap() != config_crate.path.canonicalize().unwrap();","lines":2,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-build/src/gettext_impl/mod.rs#L441-L442"}]},
{"id":"PANIC_5","type":"panic","type_label":"Panic","severity":"medium","difficulty":"easy","description":"load_languages for gettext calls `Catalog::parse(...).expect(\"could not parse the catalog\")`. A corrupt or malformed .mo file (which can be supplied by embedded/external assets) will panic the whole program instead of producing a recoverable I18nEmbedError. The fluent loader, by contrast, handles parse errors gracefully by logging.","fix":"Map the parse error into an I18nEmbedError (or log and fall back to the source language) instead of using `.expect`.","locations":[{"ref":"i18n-embed/src/gettext.rs:119-120","code":" 119 | let catalog = gettext_system::Catalog::parse(&**file).expect(\"could not parse the catalog\");\n 120 | tr::internal::set_translator(self.module, catalog);","lines":2,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/gettext.rs#L119-L120"}]},
{"id":"PANIC_6","type":"panic","type_label":"Panic","severity":"medium","difficulty":"easy","description":"available_languages calls .expect(\"path should be valid utf-8\") on path components obtained from filenames produced by an I18nAssets implementation. A custom or filesystem-backed asset source can legitimately produce a path whose component is not valid UTF-8 (the FileSystemAssets impl walks the real file system), which will panic the whole language-discovery path instead of skipping the file.","fix":"Replace `.expect(...)` with a graceful skip: use `s.to_str().map(str::to_string)` and treat None as a non-matching entry (return None) rather than panicking.","locations":[{"ref":"i18n-embed/src/lib.rs:681-694","code":" 681 | let locale: Option<String> = match components.first() {\n 682 | Some(Component::Normal(s)) => {\n 683 | Some(s.to_str().expect(\"path should be valid utf-8\").to_string())\n 684 | }\n 685 | _ => None,\n 686 | };\n 687 | \n 688 | let language_file_name: Option<String> =\n 689 | components.get(1).and_then(|component| match component {\n 690 | Component::Normal(s) => {\n 691 | Some(s.to_str().expect(\"path should be valid utf-8\").to_string())\n 692 | }\n 693 | _ => None,\n 694 | });","lines":14,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/lib.rs#L681-L694"}]},
{"id":"MEM_1","type":"memory_leak","type_label":"Memory Leak","severity":"low","difficulty":"easy","description":"`String::leak` is used three times (fallback locale and every available-language string) to obtain `'static str` for clap. These allocations are intentionally leaked and never freed. For a one-shot CLI this is harmless, but it is an unusual pattern: the leak scales with the number of available translations and would matter if this bootstrap code were ever reused in a long-running context.","fix":"Prefer building the clap Command without `'static` leaks - e.g. keep the owned `Vec<String>` alive in a local and use clap APIs that accept owned values, or accept the leak but document why it is safe here with a one-line comment.","locations":[{"ref":"src/main.rs:89-95","code":" 89 | let fallback_locale: &'static str =\n 90 | String::leak(language_loader().fallback_language().to_string());\n 91 | let available_languages = available_languages(&*cargo_i18n_localizer_rc)?;\n 92 | let available_languages_slice: Vec<&'static str> = available_languages\n 93 | .into_iter()\n 94 | .map(|l| String::leak(l) as &str)\n 95 | .collect();","lines":7,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/src/main.rs#L89-L95"}]},
{"id":"LOGIC_1","type":"logic_error","type_label":"Logic Error","severity":"high","difficulty":"medium","description":"`WhyNotCrate::Workspace` is never constructed anywhere. `Crate::from` only ever returns `WhyNotCrate::NoCargoToml`, and parses the Cargo.toml into `RawCrate` which requires `package.name`/`package.version`. When pointed at a workspace-root Cargo.toml (which has a `[workspace]` table but no `[package]`), `Crate::from` fails with `CannotDeserializeToml` instead of the intended `NotACrate(_, WhyNotCrate::Workspace)`. As a result `find_parent` never recognises a workspace parent, so the `NotACrate(path, WhyNotCrate::Workspace)` match arm in `find_parent` is dead and parent resolution emits a misleading error log for the common case of a crate living directly under a workspace root.","fix":"In `Crate::from`, before deserializing into `RawCrate`, detect a workspace root (e.g. a parsed `[workspace]` table with no `[package]`) and return `NotACrate(path_into, WhyNotCrate::Workspace)`. Then the existing `find_parent` arm becomes reachable and the workspace parent is handled gracefully.","locations":[{"ref":"i18n-config/src/lib.rs:101-122","code":" 101 | let cargo_path = path_into.join(\"Cargo.toml\");\n 102 | \n 103 | if !cargo_path.exists() {\n 104 | return Err(I18nConfigError::NotACrate(\n 105 | path_into,\n 106 | WhyNotCrate::NoCargoToml,\n 107 | ));\n 108 | }\n 109 | \n 110 | let toml_str = read_to_string(cargo_path.clone()).map_err(|err| {\n 111 | I18nConfigError::CannotReadFile(cargo_path.clone(), std::env::current_dir(), err)\n 112 | })?;\n 113 | \n 114 | let cargo_toml: RawCrate = basic_toml::from_str(&toml_str)\n 115 | .map_err(|err| I18nConfigError::CannotDeserializeToml(cargo_path.clone(), err))?;\n 116 | \n 117 | let full_config_file_path = path_into.join(&config_file_path_into);\n 118 | let i18n_config = if full_config_file_path.exists() {\n 119 | Some(I18nConfig::from_file(&full_config_file_path)?)\n 120 | } else {\n 121 | None\n 122 | };","lines":22,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-config/src/lib.rs#L101-L122"},{"ref":"i18n-config/src/lib.rs:255-267","code":" 255 | I18nConfigError::NotACrate(path, WhyNotCrate::Workspace) => {\n 256 | debug!(\"The parent of {0} at path {1:?} is a workspace\", self, path);\n 257 | }\n 258 | I18nConfigError::NotACrate(path, WhyNotCrate::NoCargoToml) => {\n 259 | debug!(\"The parent of {0} at path {1:?} is not a valid crate with a Cargo.toml\", self, path);\n 260 | }\n 261 | _ => {\n 262 | error!(\n 263 | \"Error occurred while attempting to resolve parent of {0}: {1}\",\n 264 | self, err\n 265 | );\n 266 | }\n 267 | }","lines":13,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-config/src/lib.rs#L255-L267"}]},
{"id":"LOGIC_2","type":"logic_error","type_label":"Logic Error","severity":"high","difficulty":"medium","description":"FileSystemAssets::filenames_iter yields only the bare final path component (DirEntry::file_name returns e.g. \"domain.ftl\"), discarding the locale directory. LanguageLoader::available_languages in lib.rs relies on the filename having at least two path components ({locale}/{file}) to extract the locale from components.first() and the file name from components.get(1). With only a single component, components.get(1) is always None, so no language file is ever matched and no available languages are discovered from a FileSystemAssets source (only the manually-inserted fallback remains). This breaks language discovery for the filesystem-assets feature.","fix":"Yield the path relative to base_dir instead of just the file name, e.g. `f.path().strip_prefix(&self.base_dir).ok()?.to_str()` (with the same UTF-8 handling), so the returned string preserves the {locale}/{domain} structure that available_languages parses.","locations":[{"ref":"i18n-embed/src/assets.rs:239-268","code":" 239 | fn filenames_iter(&self) -> Box<dyn Iterator<Item = String>> {\n 240 | Box::new(\n 241 | walkdir::WalkDir::new(&self.base_dir)\n 242 | .into_iter()\n 243 | .filter_map(|f| match f {\n 244 | Ok(f) => {\n 245 | if f.file_type().is_file() {\n 246 | match f.file_name().to_str() {\n 247 | Some(filename) => Some(filename.to_string()),\n 248 | None => {\n 249 | log::error!(\n 250 | target: \"i18n_embed::assets\", \n 251 | \"Filename {:?} is not valid UTF-8.\", \n 252 | f.file_name());\n 253 | None\n 254 | }\n 255 | }\n 256 | } else {\n 257 | None\n 258 | }\n 259 | }\n 260 | Err(err) => {\n 261 | log::error!(\n 262 | target: \"i18n_embed::assets\", \n 263 | \"Unexpected error while gathering localization asset filenames: {}\", \n 264 | err);\n 265 | None\n 266 | }\n 267 | }),\n 268 | )","lines":30,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/assets.rs#L239-L268"},{"ref":"i18n-embed/src/lib.rs:674-711","code":" 674 | let mut language_strings: Vec<String> = i18n_assets\n 675 | .filenames_iter()\n 676 | .filter_map(|filename| {\n 677 | let path: &Path = Path::new(&filename);\n 678 | \n 679 | let components: Vec<Component<'_>> = path.components().collect();\n 680 | \n 681 | let locale: Option<String> = match components.first() {\n 682 | Some(Component::Normal(s)) => {\n 683 | Some(s.to_str().expect(\"path should be valid utf-8\").to_string())\n 684 | }\n 685 | _ => None,\n 686 | };\n 687 | \n 688 | let language_file_name: Option<String> =\n 689 | components.get(1).and_then(|component| match component {\n 690 | Component::Normal(s) => {\n 691 | Some(s.to_str().expect(\"path should be valid utf-8\").to_string())\n 692 | }\n 693 | _ => None,\n 694 | });\n 695 | \n 696 | match language_file_name {\n 697 | Some(language_file_name) => {\n 698 | debug!(\n 699 | \"Searching for available languages, found language file: \\\"{0}\\\"\",\n 700 | &filename\n 701 | );\n 702 | if language_file_name == self.language_file_name() {\n 703 | locale\n 704 | } else {\n 705 | None\n 706 | }\n 707 | }\n 708 | None => None,\n 709 | }\n 710 | })\n 711 | .collect();","lines":38,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/lib.rs#L674-L711"}]},
{"id":"LOGIC_3","type":"logic_error","type_label":"Logic Error","severity":"high","difficulty":"medium","description":"get_args_fluent honors the currently-selected language subset by iterating only over `current_languages.indices`, but get_attr_args_fluent, has, has_attr, with_fluent_message, with_fluent_message_and_bundle and with_message_iter all iterate over the full `language_config.language_bundles`, ignoring `current_languages.indices`. After calling `select_languages`/`select_languages_negotiate` to narrow the active languages, these methods still search languages that were deselected, returning translations/results from languages the caller explicitly excluded. The behavior of `get` and the attribute/has/with family is therefore inconsistent.","fix":"Make all lookup methods iterate via `current_languages.indices` the same way `get_args_fluent` does (map each index to `language_config.language_bundles[idx]`), so selection is respected uniformly.","locations":[{"ref":"i18n-embed/src/fluent.rs:156-161","code":" 156 | inner\n 157 | .current_languages\n 158 | .indices\n 159 | .iter()\n 160 | .map(|&idx| &language_config.language_bundles[idx])\n 161 | .flat_map(|language_bundles| language_bundles.iter())","lines":6,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/fluent.rs#L156-L161"},{"ref":"i18n-embed/src/fluent.rs:231-232","code":" 231 | language_config.language_bundles.iter()\n 232 | .flat_map(|language_bundles| language_bundles.iter())","lines":2,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/fluent.rs#L231-L232"},{"ref":"i18n-embed/src/fluent.rs:294-297","code":" 294 | .language_bundles\n 295 | .iter()\n 296 | .flat_map(|language_bundles| language_bundles.iter())\n 297 | .any(|language_bundle| language_bundle.bundle.has_message(message_id))","lines":4,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/fluent.rs#L294-L297"},{"ref":"i18n-embed/src/fluent.rs:313-316","code":" 313 | .language_bundles\n 314 | .iter()\n 315 | .flat_map(|bundles| bundles.iter())\n 316 | .find_map(|bundle| {","lines":4,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/fluent.rs#L313-L316"},{"ref":"i18n-embed/src/fluent.rs:338-341","code":" 338 | .language_bundles\n 339 | .iter()\n 340 | .flat_map(|language_bundles| language_bundles.iter())\n 341 | .find_map(|language_bundle| language_bundle.bundle.get_message(message_id))","lines":4,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/fluent.rs#L338-L341"},{"ref":"i18n-embed/src/fluent.rs:367-370","code":" 367 | .language_bundles\n 368 | .iter()\n 369 | .flat_map(|language_bundles| language_bundles.iter())\n 370 | .find_map(|language_bundle| {","lines":4,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/fluent.rs#L367-L370"}]},
{"id":"LOGIC_4","type":"logic_error","type_label":"Logic Error","severity":"high","difficulty":"easy","description":"has_attr uses `find_map` which returns on the FIRST bundle whose `get_message` returns Some, mapping to whether that single bundle also has the attribute. If an earlier-priority language contains the message but lacks the attribute while a later language has both the message and the attribute, the function incorrectly returns false. This directly contradicts the documented contract ('Returns true if at least one available instance was found').","fix":"Use `.any(|bundle| bundle.bundle.get_message(message_id).map_or(false, |m| m.get_attribute(attribute_id).is_some()))` so all bundles are scanned and the first that has both message and attribute returns true.","locations":[{"ref":"i18n-embed/src/fluent.rs:308-323","code":" 308 | pub fn has_attr(&self, message_id: &str, attribute_id: &str) -> bool {\n 309 | self.inner\n 310 | .load()\n 311 | .language_config\n 312 | .read()\n 313 | .language_bundles\n 314 | .iter()\n 315 | .flat_map(|bundles| bundles.iter())\n 316 | .find_map(|bundle| {\n 317 | bundle\n 318 | .bundle\n 319 | .get_message(message_id)\n 320 | .map(|message| message.get_attribute(attribute_id).is_some())\n 321 | })\n 322 | .unwrap_or(false)\n 323 | }","lines":16,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/fluent.rs#L308-L323"}]},
{"id":"LOGIC_5","type":"logic_error","type_label":"Logic Error","severity":"medium","difficulty":"medium","description":"`run_msgcat` detects when the output path is also an input (`output_in_input`) and writes to an interim `.pot.tmp` to avoid msgcat reading and truncating the same file. But when `output_in_input` is false it writes straight to `output_pot_path`; the temp-file safety only triggers on exact `PathBuf` equality (`input_path_ref == output_pot_path.as_ref()`). Differing-but-equivalent paths (relative vs absolute, trailing components, symlinks) bypass the guard and msgcat can read a file it is simultaneously truncating, producing a corrupted/empty pot file.","fix":"Compare canonicalized paths, or always write to an interim temp file then rename, regardless of detected overlap.","locations":[{"ref":"i18n-build/src/gettext_impl/mod.rs:162-180","code":" 162 | let mut output_in_input = false;\n 163 | for input_path in input_pot_paths_iter {\n 164 | let input_path_ref = input_path.as_ref();\n 165 | input_pot_paths_strings.push(input_path_ref.to_string_lossy().to_string());\n 166 | msgcat_args.push(Box::from(input_path_ref.as_os_str()));\n 167 | output_in_input |= input_path_ref == output_pot_path.as_ref();\n 168 | }\n 169 | \n 170 | info!(\n 171 | \"Concatenating pot files {0:?} with `msgcat` into \\\"{1}\\\"\",\n 172 | input_pot_paths_strings,\n 173 | output_pot_path.as_ref().to_string_lossy()\n 174 | );\n 175 | \n 176 | let interim_output_pot_path = if output_in_input {\n 177 | output_pot_path.as_ref().with_extension(\"pot.tmp\")\n 178 | } else {\n 179 | output_pot_path.as_ref().to_path_buf()\n 180 | };","lines":19,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-build/src/gettext_impl/mod.rs#L162-L180"}]},
{"id":"LOGIC_6","type":"logic_error","type_label":"Logic Error","severity":"medium","difficulty":"medium","description":"`run` calls `config_crate.gettext_config_or_err()` three times (lines 395, 399, 464) and `crt.gettext_config_or_err()` again at 464, re-fetching/re-validating config redundantly. More importantly line 395-397 uses `.expect(\"expected gettext config to be present\")` and line 387-393 uses `panic!` for an `active_config` returning None - these are reachable panics on misconfigured crates (a crate selected for the build process whose active config lacks a gettext section) rather than propagated errors.","fix":"Propagate these as errors with `?`/context instead of panicking, and cache the gettext_config in a local variable.","locations":[{"ref":"i18n-build/src/gettext_impl/mod.rs:387-399","code":" 387 | let (config_crate, _i18n_config) = crt.active_config()?.unwrap_or_else(|| {\n 388 | panic!(\n 389 | \"expected that there would be an active config for the crate: \\\"{0}\\\" at \\\"{1}\\\"\",\n 390 | crt.name,\n 391 | crt.path.to_string_lossy()\n 392 | )\n 393 | });\n 394 | \n 395 | let gettext_config = config_crate\n 396 | .gettext_config_or_err()\n 397 | .expect(\"expected gettext config to be present\");\n 398 | \n 399 | let do_xtr = config_crate.gettext_config_or_err()?.xtr.unwrap_or(true);","lines":13,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-build/src/gettext_impl/mod.rs#L387-L399"}]},
{"id":"LOGIC_7","type":"logic_error","type_label":"Logic Error","severity":"medium","difficulty":"medium","description":"The blanket `impl<T: RustEmbed> I18nAssets for T` overrides subscribe_changed (lines 47-53) to silently ignore the `changed` callback and return a no-op Watcher, identical to the trait default. This means DefaultLocalizer::with_autoreload and any autoreload wiring appears to succeed for a RustEmbed source but never fires a reload. While documented as optional, overriding the method to a no-op (instead of relying on the default) is dead and misleading: it suggests deliberate behavior while doing nothing useful, and the `#[allow(unused_variables)]` masks that `changed` is dropped.","fix":"Remove the redundant override and rely on the trait default, or document explicitly at the impl that RustEmbed embeds are static and cannot be watched.","locations":[{"ref":"i18n-embed/src/assets.rs:47-53","code":" 47 | #[allow(unused_variables)]\n 48 | fn subscribe_changed(\n 49 | &self,\n 50 | changed: std::sync::Arc<dyn Fn() + Send + Sync + 'static>,\n 51 | ) -> Result<Box<dyn Watcher + Send + Sync + 'static>, I18nEmbedError> {\n 52 | Ok(Box::new(()))\n 53 | }","lines":7,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/assets.rs#L47-L53"}]},
{"id":"LOGIC_8","type":"logic_error","type_label":"Logic Error","severity":"medium","difficulty":"medium","description":"select_languages builds `current_languages.languages` from exactly the requested `languages` (line 473) but builds `indices` from the requested languages PLUS the appended fallback (lines 463-468). The two collections are therefore not 1:1 in length and ordering when the fallback is not among the requested languages. Any future code (or the existing logging that indexes `languages.first()`) that assumes `languages[i]` corresponds to `indices[i]` will be wrong; the fallback bundle is searchable via indices but invisible in `languages`.","fix":"Keep `languages` and `indices` aligned: either also append the fallback language to `current_languages.languages`, or track the fallback separately, so the invariant 'languages[i] is the language loaded at indices[i]' holds.","locations":[{"ref":"i18n-embed/src/fluent.rs:463-475","code":" 463 | let indices = languages\n 464 | .iter()\n 465 | .map(|lang| lang.as_ref())\n 466 | .chain(fallback_language)\n 467 | .filter_map(|lang| config_lock.language_map.get(lang.as_ref()))\n 468 | .cloned()\n 469 | .collect();\n 470 | FluentLanguageLoader {\n 471 | inner: ArcSwap::new(Arc::new(FluentLanguageLoaderInner {\n 472 | current_languages: CurrentLanguages {\n 473 | languages: languages.iter().map(|lang| lang.as_ref().clone()).collect(),\n 474 | indices,\n 475 | },","lines":13,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/fluent.rs#L463-L475"}]},
{"id":"LOGIC_9","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"easy","description":"`run_msgcat` builds the interim path with `with_extension(\"pot.tmp\")`. `with_extension` replaces the existing extension, so `module.pot` becomes `module.pot.tmp` as intended, but a path like `dir.v2/module` (no extension on the file but a dot earlier) is unaffected; more importantly the literal extension \"pot.tmp\" contains a dot which Path treats as a single extension - harmless here but fragile. The bigger issue: the temp file is created in the same directory and is not cleaned up if `msgcat.join()` fails (early return leaves the `.pot.tmp` behind).","fix":"Use a uniquely named temp file and ensure it is removed on the error path (e.g. via a guard) before returning.","locations":[{"ref":"i18n-build/src/gettext_impl/mod.rs:176-205","code":" 176 | let interim_output_pot_path = if output_in_input {\n 177 | output_pot_path.as_ref().with_extension(\"pot.tmp\")\n 178 | } else {\n 179 | output_pot_path.as_ref().to_path_buf()\n 180 | };\n 181 | \n 182 | util::create_dir_all_if_not_exists(\n 183 | interim_output_pot_path\n 184 | .parent()\n 185 | .expect(\"expected there to be a parent to the interim output pot path\"),\n 186 | )?;\n 187 | \n 188 | util::remove_file_if_exists(&interim_output_pot_path)?;\n 189 | \n 190 | let output_pot_file = File::create(&interim_output_pot_path)\n 191 | .map_err(|e| PathError::cannot_create_file(&interim_output_pot_path, e))?;\n 192 | \n 193 | let msgcat_command_name = \"msgcat\";\n 194 | let msgcat = Exec::cmd(msgcat_command_name)\n 195 | .args(msgcat_args.as_slice())\n 196 | .stdout(output_pot_file);\n 197 | \n 198 | debug!(\"Running command: {0:?}\", msgcat);\n 199 | \n 200 | msgcat.join().with_context(|| {\n 201 | tr!(\n 202 | \"There was a problem executing the \\\"{0}\\\" command\",\n 203 | msgcat_command_name\n 204 | )\n 205 | })?;","lines":30,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-build/src/gettext_impl/mod.rs#L176-L205"}]},
{"id":"LOGIC_10","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"easy","description":"`cargo_rerun_if_changed` always constructs the not_valid_utf8 error with `PathType::Directory`, even though it is called on individual files (e.g. from `cargo_rerun_if_dir_changed` for every walked entry, including `.rs` files). The path type in the resulting error message will be wrong for files.","fix":"Accept/derive the correct PathType, or determine it from the entry, rather than hardcoding Directory.","locations":[{"ref":"i18n-build/src/watch.rs:13-22","code":" 13 | pub fn cargo_rerun_if_changed(path: &Path) -> Result<(), PathError> {\n 14 | println!(\n 15 | \"cargo:rerun-if-changed={}\",\n 16 | path.to_str().ok_or_else(|| PathError::not_valid_utf8(\n 17 | path,\n 18 | \"rerun build script if file changed\",\n 19 | PathType::Directory,\n 20 | ))?\n 21 | );\n 22 | Ok(())","lines":10,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-build/src/watch.rs#L13-L22"}]},
{"id":"LOGIC_11","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"medium","description":"`active_config` resolves the parent's active config when `extract_to_parent` is true, but does not verify that the parent actually lists this crate as a subcrate (the check done by `find_parent`). A crate can therefore extract into an unrelated parent that happens to be wired up as `parent`, with no validation that the relationship is declared in the parent's i18n config.","fix":"When `extract_to_parent` is active, validate the parent/subcrate relationship (e.g. via the same canonicalized-path check used in `find_parent`) before delegating to the parent's active config, and error out otherwise.","locations":[{"ref":"i18n-config/src/lib.rs:155-189","code":" 155 | pub fn active_config(&self) -> Result<Option<(&'_ Crate, &'_ I18nConfig)>, I18nConfigError> {\n 156 | debug!(\"Resolving active config for {0}\", self);\n 157 | match &self.i18n_config {\n 158 | Some(config) => {\n 159 | if let Some(gettext_config) = &config.gettext {\n 160 | if gettext_config.extract_to_parent {\n 161 | debug!(\"Resolving active config for {0}, extract_to_parent is true, so attempting to obtain parent config.\", self);\n 162 | \n 163 | if self.parent.is_none() {\n 164 | return Err(I18nConfigError::NoParentCrate(\n 165 | self.to_string(),\n 166 | \"the gettext extract_to_parent option is active\".to_string(),\n 167 | ));\n 168 | }\n 169 | \n 170 | return Ok(Some(self.parent_active_config()?.ok_or_else(|| {\n 171 | I18nConfigError::NoParentI18nConfig(\n 172 | self.to_string(),\n 173 | \"the gettext extract_to_parent option is active\".to_string(),\n 174 | )\n 175 | })?));\n 176 | }\n 177 | }\n 178 | \n 179 | Ok(Some((self, config)))\n 180 | }\n 181 | None => {\n 182 | debug!(\n 183 | \"{0} has no i18n config, attempting to obtain parent config instead.\",\n 184 | self\n 185 | );\n 186 | self.parent_active_config()\n 187 | }\n 188 | }\n 189 | }","lines":35,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-config/src/lib.rs#L155-L189"}]},
{"id":"LOGIC_12","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"medium","description":"The `FlArgs` parser distinguishes the HashMap form from the key=value form by forking and testing whether the first token parses as a `syn::ExprAssign`. Any first argument expression containing a top-level assignment (e.g. a HashMap passed as `state.args = compute()` style, or other valid Rust expressions that begin with an assignable path followed by `=`) will be misclassified as key/value pairs. The fork only validates the first `ExprAssign`; the loop at line 88 then re-parses from scratch and could diverge from the lookahead result, producing a confusing 'unable to parse args input' error for otherwise valid input.","fix":"Make the discrimination more robust, e.g. require that all comma-separated items parse as `ident = expr` before committing to KeyValuePairs, and fall back to the HashMap form otherwise; or document the limitation clearly.","locations":[{"ref":"i18n-embed-fl/src/lib.rs:77-147","code":" 77 | if !input.is_empty() {\n 78 | input.parse::<syn::Token![,]>()?;\n 79 | \n 80 | let lookahead = input.fork();\n 81 | if lookahead.parse::<syn::ExprAssign>().is_err() {\n 82 | let hash_map = input.parse()?;\n 83 | return Ok(FlArgs::HashMap(hash_map));\n 84 | }\n 85 | \n 86 | let mut args: Vec<(syn::LitStr, Box<syn::Expr>)> = Vec::new();\n 87 | \n 88 | while let Ok(expr) = input.parse::<syn::ExprAssign>() {\n 89 | let argument_name_ident_opt = match &*expr.left {\n 90 | syn::Expr::Path(path) => path.path.get_ident(),\n 91 | _ => None,\n 92 | };\n 93 | \n 94 | let argument_name_ident = match argument_name_ident_opt {\n 95 | Some(ident) => ident,\n 96 | None => {\n 97 | return Err(syn::Error::new(\n 98 | expr.left.span(),\n 99 | \"fl!() unable to parse argument identifier\",\n 100 | ))\n 101 | }\n 102 | }\n 103 | .clone();\n 104 | \n 105 | let argument_name_string = argument_name_ident.to_string();\n 106 | let argument_name_lit_str =\n 107 | syn::LitStr::new(&argument_name_string, argument_name_ident.span());\n 108 | \n 109 | let argument_value = expr.right;\n 110 | \n 111 | if args\n 112 | .iter()\n 113 | .any(|(key, _value)| argument_name_lit_str == *key)\n 114 | {\n 115 | // There's no Clone implementation by default.\n 116 | let argument_name_lit_str =\n 117 | syn::LitStr::new(&argument_name_string, argument_name_ident.span());\n 118 | return Err(syn::Error::new(\n 119 | argument_name_lit_str.span(),\n 120 | format!(\n 121 | \"fl!() macro contains a duplicate argument `{}`\",\n 122 | argument_name_lit_str.value()\n 123 | ),\n 124 | ));\n 125 | }\n 126 | args.push((argument_name_lit_str, argument_value));\n 127 | \n 128 | // parse the next comma if there is one\n 129 | let _result = input.parse::<syn::Token![,]>();\n 130 | }\n 131 | \n 132 | if args.is_empty() {\n 133 | let span = match input.fork().parse::<syn::Expr>() {\n 134 | Ok(expr) => expr.span(),\n 135 | Err(_) => input.span(),\n 136 | };\n 137 | Err(syn::Error::new(span, \"fl!() unable to parse args input\"))\n 138 | } else {\n 139 | args.sort_by_key(|(s, _)| s.value());\n 140 | Ok(FlArgs::KeyValuePairs {\n 141 | specified_args: args,\n 142 | })\n 143 | }\n 144 | } else {\n 145 | Ok(FlArgs::None)\n 146 | }\n 147 | }","lines":71,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed-fl/src/lib.rs#L77-L147"}]},
{"id":"LOGIC_13","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"easy","description":"In FileSystemAssets::get_files the guard `full_path.is_file() && full_path.exists()` is redundant: Path::is_file already returns false when the path does not exist, so the `&& exists()` check is dead and adds a second stat syscall (a TOCTOU race window also exists between this check and the subsequent std::fs::read, which already handles errors).","fix":"Drop the redundant exists() check (and ideally the pre-check entirely, since std::fs::read already returns an error for missing/non-file paths which is handled below).","locations":[{"ref":"i18n-embed/src/assets.rs:223-225","code":" 223 | if !(full_path.is_file() && full_path.exists()) {\n 224 | return Vec::new();\n 225 | }","lines":3,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/assets.rs#L223-L225"}]},
{"id":"LOGIC_14","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"medium","description":"The `--language` arg sets `default_value(fallback_locale)` while also using `PossibleValuesParser::new(available_languages_slice)`. If the loader's fallback language is ever not among the embedded available languages, clap will reject its own default value and the binary fails on startup with a confusing 'invalid value' error for an argument the user never supplied.","fix":"Ensure the fallback locale is always included in `available_languages_slice` (push it if missing) before wiring it as the default, or skip the default and fall back to the system/fallback language inside the subcommand handler.","locations":[{"ref":"src/main.rs:131-143","code":" 131 | .arg(Arg::new(\"language\")\n 132 | .help(\n 133 | tr!(\n 134 | // The help message for the `-l`, `--language` command line argument.\n 135 | \"Set the language to use for this application. Overrides the language selected automatically by your operating system.\"\n 136 | )\n 137 | )\n 138 | .long(\"language\")\n 139 | .short('l')\n 140 | .num_args(1)\n 141 | .default_value(fallback_locale)\n 142 | .value_parser(PossibleValuesParser::new(available_languages_slice))\n 143 | )","lines":13,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/src/main.rs#L131-L143"}]},
{"id":"CPY_1","type":"copy_paste","type_label":"Copy Paste","severity":"high","difficulty":"easy","description":"`cannot_delete_file` constructs a `PathErrorKind::CannotCreate(PathType::Directory, ...)` instead of `PathErrorKind::CannotDelete(PathType::File, ...)`. This is a copy-paste bug from `cannot_create_dir`. Any failure to delete a file (used by `remove_file_if_exists` / `remove_file_or_error` in util.rs) is reported to the user as \"Cannot create the directory ...\", giving completely misleading error messages.","fix":"Change the kind to `PathErrorKind::CannotDelete(PathType::File, source)`.","locations":[{"ref":"i18n-build/src/error.rs:78-83","code":" 78 | pub fn cannot_delete_file<P: Into<PathBuf>>(path: P, source: io::Error) -> PathError {\n 79 | PathError {\n 80 | path: path.into(),\n 81 | kind: PathErrorKind::CannotCreate(PathType::Directory, source),\n 82 | }\n 83 | }","lines":6,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-build/src/error.rs#L78-L83"}]},
{"id":"CPY_2","type":"copy_paste","type_label":"Copy Paste","severity":"medium","difficulty":"easy","description":"When the optional attribute id is not a string literal, the emitted compile error says `fl!() \"message_id\" should be a literal rust string`, but this branch validates the attribute id, not the message id. The message refers to the wrong argument and will confuse users.","fix":"Change the error text to refer to `attribute_id` (e.g. \"fl!() `attribute_id` should be a literal rust string\").","locations":[{"ref":"i18n-embed-fl/src/lib.rs:508-514","code":" 508 | unexpected_lit => {\n 509 | attr_str = None;\n 510 | emit_error! {\n 511 | unexpected_lit,\n 512 | \"fl!() `message_id` should be a literal rust string\"\n 513 | };\n 514 | None","lines":7,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed-fl/src/lib.rs#L508-L514"}]},
{"id":"CPY_3","type":"copy_paste","type_label":"Copy Paste","severity":"negligible","difficulty":"easy","description":"Several `not_valid_utf8` calls pass an incorrect `for_item` label: in `run_msgmerge` the po file path is labeled \"pot\" (line 309) instead of \"po\". These mislabel the item in the produced error message.","fix":"Use the correct label \"po\" for the po file path.","locations":[{"ref":"i18n-build/src/gettext_impl/mod.rs:308-310","code":" 308 | po_file_path.to_str().ok_or_else(|| {\n 309 | PathError::not_valid_utf8(po_file_path.clone(), \"pot\", PathType::File)\n 310 | })?,","lines":3,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-build/src/gettext_impl/mod.rs#L308-L310"}]},
{"id":"ERR_1","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"medium","difficulty":"medium","description":"`run_command_and_check_success` only checks `output.status.success()`; it captures stdout/stderr via `wait_with_output()` but discards them. When xtr/msginit/msgmerge/msgfmt/msgcat fail, the user gets only \"The \\\"x\\\" command reported that it was unsuccessful.\" with none of the tool's actual error output, making failures very hard to diagnose. Additionally, because stdout is not redirected, child process output is inherited and interleaves with the build.","fix":"Include `String::from_utf8_lossy(&output.stderr)` (and exit code) in the ensure! error message so the underlying tool's diagnostics are surfaced.","locations":[{"ref":"i18n-build/src/util.rs:13-34","code":" 13 | pub fn run_command_and_check_success(command_name: &str, mut command: Command) -> Result<()> {\n 14 | debug!(\"Running command: {0:?}\", &command);\n 15 | let output = command\n 16 | .spawn()\n 17 | .with_context(|| tr!(\"The \\\"{0}\\\" command was unable to start.\", command_name))?\n 18 | .wait_with_output()\n 19 | .with_context(|| {\n 20 | tr!(\n 21 | \"The \\\"{0}\\\" command had a problem waiting for output.\",\n 22 | command_name\n 23 | )\n 24 | })?;\n 25 | \n 26 | ensure!(\n 27 | output.status.success(),\n 28 | tr!(\n 29 | \"The \\\"{0}\\\" command reported that it was unsuccessful.\",\n 30 | command_name\n 31 | )\n 32 | );\n 33 | Ok(())\n 34 | }","lines":22,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-build/src/util.rs#L13-L34"}]},
{"id":"ERR_2","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"medium","difficulty":"medium","description":"`collated_subcrate` swallows real errors as `false`. `gettext_config_or_err()` returning an error (e.g. config present but missing the `gettext` section) is mapped via `.unwrap_or(false)` to mean \"not collated\", indistinguishable from a legitimately-disabled flag. A misconfigured crate silently behaves as if collation is off rather than surfacing the configuration error to the caller.","fix":"Return a `Result<bool, I18nConfigError>` (or at least distinguish the missing-gettext-section error from the flag being false), so callers can detect and report misconfiguration instead of silently disabling collation.","locations":[{"ref":"i18n-config/src/lib.rs:219-236","code":" 219 | pub fn collated_subcrate(&self) -> bool {\n 220 | let parent_extract_to_subcrate = self\n 221 | .parent\n 222 | .map(|parent_crate| {\n 223 | parent_crate\n 224 | .gettext_config_or_err()\n 225 | .map(|parent_gettext_config| parent_gettext_config.collate_extracted_subcrates)\n 226 | .unwrap_or(false)\n 227 | })\n 228 | .unwrap_or(false);\n 229 | \n 230 | let extract_to_parent = self\n 231 | .gettext_config_or_err()\n 232 | .map(|gettext_config| gettext_config.extract_to_parent)\n 233 | .unwrap_or(false);\n 234 | \n 235 | parent_extract_to_subcrate && extract_to_parent\n 236 | }","lines":18,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-config/src/lib.rs#L219-L236"}]},
{"id":"ERR_3","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"medium","difficulty":"easy","description":"`FileSystemAssets::try_new(assets_dir).unwrap()` panics with a generic unwrap message if the configured assets directory does not exist or is inaccessible. Every other config/loading failure in this function uses `abort!` to emit a clean compile error with a helpful hint; this one produces an ugly internal-error-style panic instead.","fix":"Replace `.unwrap()` with `.unwrap_or_else(|err| abort!{...})` emitting a clear message that the fluent assets_dir could not be opened, including the path and a help hint.","locations":[{"ref":"i18n-embed-fl/src/lib.rs:442-443","code":" 442 | let assets_dir = Path::new(&crate_paths.crate_dir).join(fluent_config.assets_dir);\n 443 | let assets = FileSystemAssets::try_new(assets_dir).unwrap();","lines":2,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed-fl/src/lib.rs#L442-L443"}]},
{"id":"ERR_4","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"medium","difficulty":"easy","description":"`config_file_name` and `language` are retrieved with `.expect(...)` justified by the presence of `default_value`. That holds today, but it couples a panic to the arg definitions in a different part of the function; if a default is ever removed the binary panics on user input instead of producing a clean error. For a CLI driven entirely by user-supplied arguments, `.expect` on argument access is fragile error handling.","fix":"Handle the `None` case with a proper anyhow error (e.g. `.context(...)?` after `ok_or_else`) rather than `.expect`, so a missing/removed default degrades to a clean error message and non-zero exit code instead of a panic.","locations":[{"ref":"src/main.rs:148-154","code":" 148 | let config_file_name: &String = i18n_matches\n 149 | .get_one(\"config-file-name\")\n 150 | .expect(\"expected a default config file name to be present\");\n 151 | \n 152 | let language: &String = i18n_matches\n 153 | .get_one(\"language\")\n 154 | .expect(\"expected a default language to be present\");","lines":7,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/src/main.rs#L148-L154"}]},
{"id":"ERR_5","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","difficulty":"easy","description":"In `run`, the `assert!(crt.gettext_config_or_err()?.collate_extracted_subcrates)` (line 464) will abort the process via panic if the invariant is violated by an unexpected config combination, rather than returning a descriptive error. Assertions on config-derived state are inappropriate for a user-facing build tool.","fix":"Replace the assert! with an explicit error return (ensure!/bail!) carrying a clear message.","locations":[{"ref":"i18n-build/src/gettext_impl/mod.rs:463-465","code":" 463 | if !concatenate_crates.is_empty() {\n 464 | assert!(crt.gettext_config_or_err()?.collate_extracted_subcrates);\n 465 | concatenate_crates.insert(0, crt);","lines":3,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-build/src/gettext_impl/mod.rs#L463-L465"}]},
{"id":"ERR_6","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","difficulty":"easy","description":"The doc comment on `remove_file_if_exists` states it returns a `PathError#CannotDelete` \"otherwise\" (i.e. when the file does not exist), but the function returns `Ok(())` when the file does not exist and only errors on an actual delete failure. The comment is misleading about the contract.","fix":"Correct the doc comment to say it succeeds silently when the file is absent and only errors if deletion of an existing file fails.","locations":[{"ref":"i18n-build/src/util.rs:56-64","code":" 56 | /// Remove a file if it exists, otherwise return a [PathError#CannotDelete](PathError#CannotDelete).\n 57 | pub fn remove_file_if_exists<P: AsRef<Path>>(file_path: P) -> Result<(), PathError> {\n 58 | if file_path.as_ref().exists() {\n 59 | remove_file(file_path.as_ref())\n 60 | .map_err(|e| PathError::cannot_delete_file(file_path.as_ref(), e))?;\n 61 | }\n 62 | \n 63 | Ok(())\n 64 | }","lines":9,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-build/src/util.rs#L56-L64"}]},
{"id":"ERR_7","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","difficulty":"medium","description":"Several recoverable, user-facing failures in `fl!()` use `panic!`/`expect` instead of `abort!`/`emit_error!`. Reading Cargo.toml (line 403), CARGO_PKG_NAME (line 405), locating crate paths (line 413), and the load_languages catch-all (line 471) all panic. Panics surface as opaque 'proc macro panicked' messages without spans or help hints, whereas the rest of the function uses proc_macro_error2 for clean diagnostics. The load-language LanguageNotAvailable mismatch panic (lines 453-459) is similarly user-reachable if config is inconsistent.","fix":"Convert these recoverable failures to `abort!`/`emit_error!` with call_site spans and help hints for consistent, user-friendly compile errors.","locations":[{"ref":"i18n-embed-fl/src/lib.rs:403-405","code":" 403 | let manifest = find_crate::Manifest::new().expect(\"Error reading Cargo.toml\");\n 404 | manifest.crate_package().map(|pkg| pkg.name).unwrap_or(\n 405 | std::env::var(\"CARGO_PKG_NAME\").expect(\"Error fetching `CARGO_PKG_NAME` env\"),","lines":3,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed-fl/src/lib.rs#L403-L405"},{"ref":"i18n-embed-fl/src/lib.rs:412-413","code":" 412 | let crate_paths = i18n_config::locate_crate_paths()\n 413 | .unwrap_or_else(|error| panic!(\"fl!() is unable to locate crate paths: {}\", error));","lines":2,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed-fl/src/lib.rs#L412-L413"},{"ref":"i18n-embed-fl/src/lib.rs:453-475","code":" 453 | if fallback_language != language_id {\n 454 | panic!(\n 455 | \"fl!() encountered an unexpected problem, \\\n 456 | the language being loaded (\\\"{0}\\\") is not the \\\n 457 | `fallback_language` (\\\"{1}\\\")\",\n 458 | language_id, fallback_language\n 459 | )\n 460 | }\n 461 | abort! {\n 462 | proc_macro2::Span::call_site(),\n 463 | format!(\n 464 | \"fl!() was unable to load the localization \\\n 465 | file for the `fallback_language` \\\n 466 | (\\\"{fallback_language}\\\"): {file}\"\n 467 | );\n 468 | help = \"Try creating the required fluent localization file.\";\n 469 | }\n 470 | }\n 471 | _ => panic!(\n 472 | \"fl!() had an unexpected problem while \\\n 473 | loading language \\\"{0}\\\": {1}\",\n 474 | fallback_language, err\n 475 | ),","lines":23,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed-fl/src/lib.rs#L453-L475"}]},
{"id":"ERR_8","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","difficulty":"medium","description":"Both `gettext_language_loader!` and `fluent_language_loader!` report all errors via `panic!`/`expect` (manifest read, CARGO_PKG_NAME, missing i18n-embed dependency, locate crate paths, config read, missing section). These produce opaque 'proc macro panicked' diagnostics with no source span. The sibling `fl!` macro uses proc_macro_error2 for clean errors, so the user experience here is inconsistent and worse.","fix":"Adopt proc_macro_error2 (`abort_call_site!`) for these macros as well, emitting spanned compile errors with help hints instead of panicking.","locations":[{"ref":"i18n-embed/i18n-embed-impl/src/lib.rs:16-58","code":" 16 | let manifest = find_crate::Manifest::new().expect(\"Error reading Cargo.toml\");\n 17 | let current_crate_package_name = {\n 18 | manifest.crate_package().map(|pkg| pkg.name).unwrap_or(\n 19 | std::env::var(\"CARGO_PKG_NAME\").expect(\"Error fetching `CARGO_PKG_NAME` env\"),\n 20 | )\n 21 | };\n 22 | \n 23 | // Special case for when this macro is invoked in i18n-embed tests/docs\n 24 | let i18n_embed_crate_name = if current_crate_package_name == \"i18n_embed\" {\n 25 | \"i18n_embed\".to_string()\n 26 | } else {\n 27 | manifest\n 28 | .find(|s| s == \"i18n-embed\")\n 29 | .expect(\"i18n-embed should be an active dependency in your Cargo.toml\")\n 30 | .name\n 31 | };\n 32 | \n 33 | let i18n_embed_crate_ident =\n 34 | syn::Ident::new(&i18n_embed_crate_name, proc_macro2::Span::call_site());\n 35 | \n 36 | let config_file_path = i18n_config::locate_crate_paths()\n 37 | .unwrap_or_else(|error| {\n 38 | panic!(\n 39 | \"gettext_language_loader!() is unable to locate i18n config file: {}\",\n 40 | error\n 41 | )\n 42 | })\n 43 | .i18n_config_file;\n 44 | \n 45 | let config = i18n_config::I18nConfig::from_file(&config_file_path).unwrap_or_else(|err| {\n 46 | panic!(\n 47 | \"gettext_language_loader!() had a problem reading i18n config file {0:?}: {1}\",\n 48 | std::fs::canonicalize(&config_file_path).unwrap_or_else(|_| config_file_path.clone()),\n 49 | err\n 50 | )\n 51 | });\n 52 | \n 53 | if config.gettext.is_none() {\n 54 | panic!(\n 55 | \"gettext_language_loader!() had a problem parsing i18n config file {0:?}: there is no `[gettext]` section\",\n 56 | std::fs::canonicalize(&config_file_path).unwrap_or(config_file_path)\n 57 | )\n 58 | }","lines":43,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/i18n-embed-impl/src/lib.rs#L16-L58"},{"ref":"i18n-embed/i18n-embed-impl/src/lib.rs:90-131","code":" 90 | let manifest = find_crate::Manifest::new().expect(\"Error reading Cargo.toml\");\n 91 | let current_crate_package_name = manifest\n 92 | .crate_package()\n 93 | .map(|pkg| pkg.name)\n 94 | .unwrap_or(std::env::var(\"CARGO_PKG_NAME\").expect(\"Error fetching `CARGO_PKG_NAME` env\"));\n 95 | \n 96 | // Special case for when this macro is invoked in i18n-embed tests/docs\n 97 | let i18n_embed_crate_name = if current_crate_package_name == \"i18n_embed\" {\n 98 | \"i18n_embed\".to_string()\n 99 | } else {\n 100 | manifest\n 101 | .find(|s| s == \"i18n-embed\")\n 102 | .expect(\"i18n-embed should be an active dependency in your Cargo.toml\")\n 103 | .name\n 104 | };\n 105 | \n 106 | let i18n_embed_crate_ident =\n 107 | syn::Ident::new(&i18n_embed_crate_name, proc_macro2::Span::call_site());\n 108 | \n 109 | let config_file_path = i18n_config::locate_crate_paths()\n 110 | .unwrap_or_else(|error| {\n 111 | panic!(\n 112 | \"fluent_language_loader!() is unable to locate i18n config file: {}\",\n 113 | error\n 114 | )\n 115 | })\n 116 | .i18n_config_file;\n 117 | \n 118 | let config = i18n_config::I18nConfig::from_file(&config_file_path).unwrap_or_else(|err| {\n 119 | panic!(\n 120 | \"fluent_language_loader!() had a problem reading i18n config file {0:?}: {1}\",\n 121 | std::fs::canonicalize(&config_file_path).unwrap_or_else(|_| config_file_path.clone()),\n 122 | err\n 123 | )\n 124 | });\n 125 | \n 126 | if config.fluent.is_none() {\n 127 | panic!(\n 128 | \"fluent_language_loader!() had a problem parsing i18n config file {0:?}: there is no `[fluent]` section\",\n 129 | std::fs::canonicalize(&config_file_path).unwrap_or(config_file_path)\n 130 | )\n 131 | }","lines":42,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/i18n-embed-impl/src/lib.rs#L90-L131"}]},
{"id":"ERR_9","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","difficulty":"medium","description":"AssetsMultiplexor::subscribe_changed registers the same `changed` callback against every underlying asset source via subscribe_changed. If multiple sources watch overlapping or related directories, a single file change can invoke the reload callback multiple times. There is also no deduplication or debouncing, so a noisy filesystem source can trigger repeated full reloads.","fix":"Document the multi-fire behavior, or debounce/coalesce callback invocations (e.g. wrap `changed` so rapid successive calls collapse into one reload).","locations":[{"ref":"i18n-embed/src/assets.rs:336-346","code":" 336 | fn subscribe_changed(\n 337 | &self,\n 338 | changed: std::sync::Arc<dyn Fn() + Send + Sync + 'static>,\n 339 | ) -> Result<Box<dyn Watcher + Send + Sync + 'static>, I18nEmbedError> {\n 340 | let watchers: Vec<_> = self\n 341 | .assets\n 342 | .iter()\n 343 | .map(|assets| assets.subscribe_changed(changed.clone()))\n 344 | .collect::<Result<_, _>>()?;\n 345 | Ok(Box::new(Watchers(watchers)))\n 346 | }","lines":11,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/assets.rs#L336-L346"}]},
{"id":"ERR_10","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","difficulty":"medium","description":"The fluent loader logs formatting errors but discards them and returns the partially-formatted string, while completely missing messages produce a placeholder string ('No localization for id: ...') rather than an error or empty value. Callers cannot distinguish a real translation from the placeholder, so missing-translation bugs silently ship to end users with the placeholder text rendered in the UI. This is a deliberate design choice but worth flagging as it provides no programmatic signal.","fix":"Consider offering a fallible variant (returning Option/Result) so callers can detect missing localizations, while keeping the current infallible convenience method.","locations":[{"ref":"i18n-embed/src/fluent.rs:179-187","code":" 179 | .unwrap_or_else(|| {\n 180 | log::error!(\n 181 | target:\"i18n_embed::fluent\",\n 182 | \"Unable to find localization for language \\\"{}\\\" and id \\\"{}\\\".\",\n 183 | inner.current_languages.languages.first().unwrap_or(&self.fallback_language),\n 184 | message_id\n 185 | );\n 186 | format!(\"No localization for id: \\\"{}\\\"\", message_id)\n 187 | })","lines":9,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/fluent.rs#L179-L187"}]},
{"id":"API_1","type":"api_misuse","type_label":"Api Misuse","severity":"medium","difficulty":"easy","description":"`RawCrate` declares `#[serde(alias = \"workspace\")]` on the `package` field, intending to also accept a `[workspace]` table where `[package]` is expected. This does not work as intended: a workspace-root `Cargo.toml`'s `[workspace]` table has no `name`/`version` keys, so deserialization into `RawPackage` still fails. The alias gives a false impression that workspace manifests are supported, while in practice they cause a `CannotDeserializeToml` error.","fix":"Remove the misleading `#[serde(alias = \"workspace\")]`, and instead explicitly detect/handle workspace manifests (see the `WhyNotCrate::Workspace` finding) rather than relying on a field alias that cannot supply the required fields.","locations":[{"ref":"i18n-config/src/lib.rs:59-69","code":" 59 | #[derive(Deserialize)]\n 60 | struct RawCrate {\n 61 | #[serde(alias = \"workspace\")]\n 62 | package: RawPackage,\n 63 | }\n 64 | \n 65 | #[derive(Deserialize)]\n 66 | struct RawPackage {\n 67 | name: String,\n 68 | version: String,\n 69 | }","lines":11,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-config/src/lib.rs#L59-L69"}]},
{"id":"API_2","type":"api_misuse","type_label":"Api Misuse","severity":"low","difficulty":"medium","description":"`run_msgcat` passes the file paths to `Exec::cmd(\"msgcat\").args(...)` as raw OsStr args. If any pot file path begins with a hyphen, msgcat will interpret it as an option rather than an input file. None of the gettext command invocations use a `--` separator before file path arguments (also affects xtr, msgmerge, msgfmt), so paths starting with `-` are misparsed by the external tools.","fix":"Insert a `--` argument before positional path arguments, or otherwise guard against leading-hyphen paths.","locations":[{"ref":"i18n-build/src/gettext_impl/mod.rs:193-196","code":" 193 | let msgcat_command_name = \"msgcat\";\n 194 | let msgcat = Exec::cmd(msgcat_command_name)\n 195 | .args(msgcat_args.as_slice())\n 196 | .stdout(output_pot_file);","lines":4,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-build/src/gettext_impl/mod.rs#L193-L196"},{"ref":"i18n-build/src/gettext_impl/mod.rs:107-123","code":" 107 | xtr.args([\n 108 | \"--package-name\",\n 109 | crt.name.as_str(),\n 110 | \"--package-version\",\n 111 | crt.version.as_str(),\n 112 | \"--default-domain\",\n 113 | crt.module_name().as_str(),\n 114 | \"--add-location\",\n 115 | gettext_config.add_location.to_str(),\n 116 | \"-o\",\n 117 | pot_file_path.to_str().ok_or_else(|| {\n 118 | PathError::not_valid_utf8(pot_file_path.clone(), \"pot\", PathType::File)\n 119 | })?,\n 120 | rs_file_path.to_str().ok_or_else(|| {\n 121 | PathError::not_valid_utf8(rs_file_path.clone(), \"rs\", PathType::File)\n 122 | })?,\n 123 | ]);","lines":17,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-build/src/gettext_impl/mod.rs#L107-L123"}]},
{"id":"API_3","type":"api_misuse","type_label":"Api Misuse","severity":"low","difficulty":"medium","description":"`with_fluent_message(...).unwrap()` assumes the message exists. It is only reached in the `else if` branch where the message was confirmed present, so it is currently safe, but the safety is implicit and coupled to the preceding `!checked_loader_has_message && !loader.has(...)` condition. A future refactor of that condition could turn this into a compile-time-only panic with a useless message.","fix":"Use `unwrap_or_default()` or handle `None` explicitly (empty suggestions) instead of `.unwrap()`, so the suggestion lookup degrades gracefully rather than panicking.","locations":[{"ref":"i18n-embed-fl/src/lib.rs:645-650","code":" 645 | let suggestions = &domain_data\n 646 | .loader\n 647 | .with_fluent_message(message_id_str, |message| {\n 648 | fuzzy_attribute_suggestions(&message, attr_id_str, 5).join(\"\\n\")\n 649 | })\n 650 | .unwrap();","lines":6,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed-fl/src/lib.rs#L645-L650"}]},
{"id":"API_4","type":"api_misuse","type_label":"Api Misuse","severity":"low","difficulty":"easy","description":"The generated code `#fallback_language.parse().unwrap()` parses the fallback language string into a LanguageIdentifier at the call site at runtime and unwraps it. The string is already validated at macro-expansion time (it came from a parsed `LanguageIdentifier` via `config.fallback_language.to_string()`), so this runtime parse+unwrap is redundant work in user code and an unwrap that, while practically safe, leaks into downstream binaries.","fix":"Construct the LanguageIdentifier directly in generated tokens (e.g. emit its components) or document why a runtime re-parse is required; at minimum avoid `.unwrap()` in emitted user code.","locations":[{"ref":"i18n-embed/i18n-embed-impl/src/lib.rs:60-69","code":" 60 | let fallback_language = syn::LitStr::new(\n 61 | &config.fallback_language.to_string(),\n 62 | proc_macro2::Span::call_site(),\n 63 | );\n 64 | \n 65 | let gen = quote::quote! {\n 66 | #i18n_embed_crate_ident::gettext::GettextLanguageLoader::new(\n 67 | module_path!(),\n 68 | #fallback_language.parse().unwrap(),\n 69 | )","lines":10,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/i18n-embed-impl/src/lib.rs#L60-L69"},{"ref":"i18n-embed/i18n-embed-impl/src/lib.rs:133-148","code":" 133 | let fallback_language = syn::LitStr::new(\n 134 | &config.fallback_language.to_string(),\n 135 | proc_macro2::Span::call_site(),\n 136 | );\n 137 | \n 138 | let domain_str = config\n 139 | .fluent\n 140 | .and_then(|f| f.domain)\n 141 | .unwrap_or(current_crate_package_name);\n 142 | let domain = syn::LitStr::new(&domain_str, proc_macro2::Span::call_site());\n 143 | \n 144 | let gen = quote::quote! {\n 145 | #i18n_embed_crate_ident::fluent::FluentLanguageLoader::new(\n 146 | #domain,\n 147 | #fallback_language.parse().unwrap(),\n 148 | )","lines":16,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/i18n-embed-impl/src/lib.rs#L133-L148"}]},
{"id":"API_5","type":"api_misuse","type_label":"Api Misuse","severity":"negligible","difficulty":"medium","description":"WebLanguageRequester::requested_languages uses .expect(\"language value should be a string.\") on each JsValue returned by navigator.languages(). If the browser ever returns a non-string entry (or the array contains a hole), this panics inside a library API rather than skipping the bad entry. Same pattern with window().expect(\"no global window exists\") which panics in non-window WASM contexts (e.g. workers).","fix":"Use filter_map with as_string() returning None for non-string entries, and return an empty Vec (or Result) when no window is available instead of panicking.","locations":[{"ref":"i18n-embed/src/requester.rs:326-335","code":" 326 | let window = web_sys::window().expect(\"no global `window` exists\");\n 327 | let navigator = window.navigator();\n 328 | let languages = navigator.languages();\n 329 | \n 330 | let requested_languages =\n 331 | convert_vec_str_to_langids_lossy(languages.iter().map(|js_value| {\n 332 | js_value\n 333 | .as_string()\n 334 | .expect(\"language value should be a string.\")\n 335 | }));","lines":10,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/requester.rs#L326-L335"}]},
{"id":"PERF_1","type":"performance","type_label":"Performance","severity":"high","difficulty":"medium","description":"The domain cache is keyed inconsistently. The lookup at line 409 uses `domain` derived from the crate package name, but when `[fluent].domain` is overridden in i18n.toml, the insert at line 483 uses the shadowed `domain` from line 440 (the override). Whenever a domain override is configured and differs from the crate name, the cache lookup always misses, so every single `fl!()` invocation re-reads the config file, re-instantiates FileSystemAssets, and reloads the fallback language from disk during compilation - defeating the entire purpose of the static DOMAINS cache.","fix":"Key the cache by the final (possibly overridden) domain consistently, or look up/insert using the same key. E.g. resolve the override before the `domains().get()` call, or store under the original crate-name key as well.","locations":[{"ref":"i18n-embed-fl/src/lib.rs:402-440","code":" 402 | let domain = {\n 403 | let manifest = find_crate::Manifest::new().expect(\"Error reading Cargo.toml\");\n 404 | manifest.crate_package().map(|pkg| pkg.name).unwrap_or(\n 405 | std::env::var(\"CARGO_PKG_NAME\").expect(\"Error fetching `CARGO_PKG_NAME` env\"),\n 406 | )\n 407 | };\n 408 | \n 409 | let domain_data = if let Some(domain_data) = domains().get(&domain) {\n 410 | domain_data\n 411 | } else {\n 412 | let crate_paths = i18n_config::locate_crate_paths()\n 413 | .unwrap_or_else(|error| panic!(\"fl!() is unable to locate crate paths: {}\", error));\n 414 | \n 415 | let config_file_path = &crate_paths.i18n_config_file;\n 416 | \n 417 | let config = i18n_config::I18nConfig::from_file(config_file_path).unwrap_or_else(|err| {\n 418 | abort! {\n 419 | proc_macro2::Span::call_site(),\n 420 | format!(\n 421 | \"fl!() had a problem reading i18n config file {config_file_path:?}: {err}\"\n 422 | );\n 423 | help = \"Try creating the `i18n.toml` configuration file.\";\n 424 | }\n 425 | });\n 426 | \n 427 | let fluent_config = config.fluent.unwrap_or_else(|| {\n 428 | abort! {\n 429 | proc_macro2::Span::call_site(),\n 430 | format!(\n 431 | \"fl!() had a problem parsing i18n config file {config_file_path:?}: \\\n 432 | there is no `[fluent]` subsection.\"\n 433 | );\n 434 | help = \"Add the `[fluent]` subsection to `i18n.toml`, \\\n 435 | along with its required `assets_dir`.\";\n 436 | }\n 437 | });\n 438 | \n 439 | // Use the domain override in the configuration.\n 440 | let domain = fluent_config.domain.unwrap_or(domain);","lines":39,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed-fl/src/lib.rs#L402-L440"},{"ref":"i18n-embed-fl/src/lib.rs:483","code":" 483 | domains().entry_or_insert(&domain, data)","lines":1,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed-fl/src/lib.rs#L483"}]},
{"id":"PERF_2","type":"performance","type_label":"Performance","severity":"low","difficulty":"medium","description":"`find_parent` canonicalizes `self.path` once per subcrate iteration inside the `any` closure (lib.rs:290-296), even though it is constant across iterations. For a parent with many subcrates this performs redundant filesystem syscalls; it also canonicalizes the subcrate path per entry which is necessary, but the self path should be hoisted out of the loop.","fix":"Canonicalize `self.path` once before the `.any(...)` call, bail/return early on its error, and compare each canonicalized subcrate path against the precomputed value.","locations":[{"ref":"i18n-config/src/lib.rs:278-299","code":" 278 | let this_is_subcrate = config\n 279 | .subcrates\n 280 | .iter()\n 281 | .any(|subcrate_path| {\n 282 | let subcrate_path_canon = match crt.path.join(subcrate_path).canonicalize() {\n 283 | Ok(canon) => canon,\n 284 | Err(err) => {\n 285 | error!(\"Error: unable to canonicalize the subcrate path: {0:?} because {1}\", subcrate_path, err);\n 286 | return false;\n 287 | }\n 288 | };\n 289 | \n 290 | let self_path_canon = match self.path.canonicalize() {\n 291 | Ok(canon) => canon,\n 292 | Err(err) => {\n 293 | error!(\"Error: unable to canonicalize the crate path: {0:?} because {1}\", self.path, err);\n 294 | return false;\n 295 | }\n 296 | };\n 297 | \n 298 | subcrate_path_canon == self_path_canon\n 299 | });","lines":22,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-config/src/lib.rs#L278-L299"}]},
{"id":"PERF_3","type":"performance","type_label":"Performance","severity":"negligible","difficulty":"easy","description":"`run_xtr` collects every `.rs` file then spawns a separate `xtr` process per file, each writing its own pot file, before concatenating them all with msgcat. For crates with many source files this spawns a large number of subprocesses serially, which is slow. xtr can accept multiple input files, or the work could be batched.","fix":"Batch source files into fewer xtr invocations where feasible, or run extractions in parallel.","locations":[{"ref":"i18n-build/src/gettext_impl/mod.rs:70-128","code":" 70 | for rs_file_path in rs_files {\n 71 | let parent_dir = rs_file_path.parent().context(format!(\n 72 | \"the rs file {0} is not inside a directory\",\n 73 | rs_file_path.to_string_lossy()\n 74 | ))?;\n 75 | let src_dir_relative = parent_dir.strip_prefix(src_dir).map_err(|_| {\n 76 | PathError::not_inside_dir(parent_dir, format!(\"crate {0}/src\", crt.name), src_dir)\n 77 | })?;\n 78 | let file_stem = rs_file_path.file_stem().context(format!(\n 79 | \"expected rs file path {0} would have a filename\",\n 80 | rs_file_path.to_string_lossy()\n 81 | ))?;\n 82 | \n 83 | let pot_file_path = pot_src_dir\n 84 | .join(src_dir_relative)\n 85 | .join(file_stem)\n 86 | .with_extension(\"pot\");\n 87 | \n 88 | util::create_dir_all_if_not_exists(pot_file_path.parent().with_context(|| {\n 89 | format!(\n 90 | \"Expected that pot file path \\\"{0}\\\" would be inside a directory (have a parent)\",\n 91 | &pot_file_path.to_string_lossy()\n 92 | )\n 93 | })?)?;\n 94 | \n 95 | // ======= Run the `xtr` command to extract translatable strings =======\n 96 | let xtr_command_name = \"xtr\";\n 97 | let mut xtr = Command::new(xtr_command_name);\n 98 | \n 99 | if let Some(copyright_holder) = &gettext_config.copyright_holder {\n 100 | xtr.args([\"--copyright-holder\", copyright_holder.as_str()]);\n 101 | }\n 102 | \n 103 | if let Some(msgid_bugs_address) = &gettext_config.msgid_bugs_address {\n 104 | xtr.args([\"--msgid-bugs-address\", msgid_bugs_address.as_str()]);\n 105 | }\n 106 | \n 107 | xtr.args([\n 108 | \"--package-name\",\n 109 | crt.name.as_str(),\n 110 | \"--package-version\",\n 111 | crt.version.as_str(),\n 112 | \"--default-domain\",\n 113 | crt.module_name().as_str(),\n 114 | \"--add-location\",\n 115 | gettext_config.add_location.to_str(),\n 116 | \"-o\",\n 117 | pot_file_path.to_str().ok_or_else(|| {\n 118 | PathError::not_valid_utf8(pot_file_path.clone(), \"pot\", PathType::File)\n 119 | })?,\n 120 | rs_file_path.to_str().ok_or_else(|| {\n 121 | PathError::not_valid_utf8(rs_file_path.clone(), \"rs\", PathType::File)\n 122 | })?,\n 123 | ]);\n 124 | \n 125 | util::run_command_and_check_success(xtr_command_name, xtr)?;\n 126 | \n 127 | pot_paths.push(pot_file_path.to_owned());\n 128 | }","lines":59,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-build/src/gettext_impl/mod.rs#L70-L128"}]},
{"id":"PERF_4","type":"performance","type_label":"Performance","severity":"negligible","difficulty":"easy","description":"In `check_message_args`/`check_attribute_args`, the `available_args` string (joining all available argument names) is rebuilt from scratch inside the per-argument closure on every missing argument. If multiple specified args are invalid, the same available-args list is recomputed repeatedly. Minor compile-time-only cost.","fix":"Compute the `available_args` joined string once before the iteration and reuse it.","locations":[{"ref":"i18n-embed-fl/src/lib.rs:748-753","code":" 748 | if !args_set.contains(arg.as_str()) {\n 749 | let available_args: String = args_set\n 750 | .iter()\n 751 | .map(|arg| format!(\"`{arg}`\"))\n 752 | .collect::<Vec<String>>()\n 753 | .join(\", \");","lines":6,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed-fl/src/lib.rs#L748-L753"},{"ref":"i18n-embed-fl/src/lib.rs:818-823","code":" 818 | if !args_set.contains(arg.as_str()) {\n 819 | let available_args: String = args_set\n 820 | .iter()\n 821 | .map(|arg| format!(\"`{arg}`\"))\n 822 | .collect::<Vec<String>>()\n 823 | .join(\", \");","lines":6,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed-fl/src/lib.rs#L818-L823"}]},
{"id":"PERF_5","type":"performance","type_label":"Performance","severity":"negligible","difficulty":"easy","description":"In gettext load_languages, `language_ids.iter().next()` is an over-verbose way to obtain the first element; `language_ids.first()` is clearer and idiomatic. Purely a readability/idiom nit, no behavioral impact.","fix":"Replace `.iter().next()` with `.first()`.","locations":[{"ref":"i18n-embed/src/gettext.rs:90-93","code":" 90 | let language_id = language_ids\n 91 | .iter()\n 92 | .next()\n 93 | .ok_or(I18nEmbedError::RequestedLanguagesEmpty)?;","lines":4,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/gettext.rs#L90-L93"}]},
{"id":"PERF_6","type":"performance","type_label":"Performance","severity":"negligible","difficulty":"easy","description":"available_languages collects path components into a Vec<Component> only to read components.first() and components.get(1). Collecting the whole component list allocates a Vec per filename even though only the first two are needed.","fix":"Use the components iterator directly: `let mut it = path.components(); let locale = it.next(); let file = it.next();` avoiding the Vec allocation.","locations":[{"ref":"i18n-embed/src/lib.rs:679-694","code":" 679 | let components: Vec<Component<'_>> = path.components().collect();\n 680 | \n 681 | let locale: Option<String> = match components.first() {\n 682 | Some(Component::Normal(s)) => {\n 683 | Some(s.to_str().expect(\"path should be valid utf-8\").to_string())\n 684 | }\n 685 | _ => None,\n 686 | };\n 687 | \n 688 | let language_file_name: Option<String> =\n 689 | components.get(1).and_then(|component| match component {\n 690 | Component::Normal(s) => {\n 691 | Some(s.to_str().expect(\"path should be valid utf-8\").to_string())\n 692 | }\n 693 | _ => None,\n 694 | });","lines":16,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/lib.rs#L679-L694"}]},
{"id":"DEAD_1","type":"dead_code","type_label":"Dead Code","severity":"low","difficulty":"easy","description":"In `run_xtr`, a `msgcat_args: Vec<Box<OsStr>>` is built from `pot_paths` and pushed into, but it is never read afterwards: `run_msgcat(&pot_paths, ...)` is called with `pot_paths` directly. This is leftover/duplicated logic (the same Vec construction is repeated inside `run_msgcat` itself) that allocates needlessly and confuses readers.","fix":"Delete the unused `msgcat_args` block (lines 130-134).","locations":[{"ref":"i18n-build/src/gettext_impl/mod.rs:130-138","code":" 130 | let mut msgcat_args: Vec<Box<OsStr>> = Vec::new();\n 131 | \n 132 | for path in &pot_paths {\n 133 | msgcat_args.push(Box::from(path.as_os_str()));\n 134 | }\n 135 | \n 136 | let combined_pot_file_path = crate_module_pot_file_path(crt, pot_dir);\n 137 | \n 138 | run_msgcat(&pot_paths, &combined_pot_file_path)","lines":9,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-build/src/gettext_impl/mod.rs#L130-L138"}]},
{"id":"DEAD_2","type":"dead_code","type_label":"Dead Code","severity":"low","difficulty":"easy","description":"`pot_dir()` contains a large commented-out match block, including a `panic!(\"panic\")` placeholder, left over from development. It is noise that obscures the actual one-line implementation below it.","fix":"Delete the commented-out block (lines 69-74).","locations":[{"ref":"i18n-config/src/gettext.rs:68-78","code":" 68 | pub fn pot_dir(&self) -> PathBuf {\n 69 | // match self.pot_dir {\n 70 | // Some(pot_dir) => pot_dir,\n 71 | // None => {\n 72 | // panic!(\"panic\");\n 73 | // },\n 74 | // }\n 75 | self.pot_dir\n 76 | .clone()\n 77 | .unwrap_or_else(|| self.output_dir.join(\"pot\"))\n 78 | }","lines":11,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-config/src/gettext.rs#L68-L78"}]},
{"id":"DEAD_3","type":"dead_code","type_label":"Dead Code","severity":"low","difficulty":"easy","description":"The `CannotParseCargoToml(PathBuf, String)` and `CannotPaseI18nToml(PathBuf, String)` error variants are defined but never constructed anywhere in the crate (cargo-toml parse errors go through `CannotDeserializeToml`; i18n parse errors likewise). They are unused public API surface.","fix":"Remove the two unused variants, or actually use them where toml parsing currently funnels into `CannotDeserializeToml`.","locations":[{"ref":"i18n-config/src/lib.rs:39-44","code":" 39 | #[error(\"Cannot parse Cargo configuration file {0:?} because {1}.\")]\n 40 | CannotParseCargoToml(PathBuf, String),\n 41 | #[error(\"Cannot deserialize toml file {0:?} because {1}.\")]\n 42 | CannotDeserializeToml(PathBuf, basic_toml::Error),\n 43 | #[error(\"Cannot parse i18n configuration file {0:?} because {1}.\")]\n 44 | CannotPaseI18nToml(PathBuf, String),","lines":6,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-config/src/lib.rs#L39-L44"}]},
{"id":"DEAD_4","type":"dead_code","type_label":"Dead Code","severity":"low","difficulty":"medium","description":"DefaultLocalizer stores `watchers: Vec<Box<dyn Watcher>>` but the field is never read anywhere - watchers are only kept alive so their Drop unsubscribes. There is no accessor and no use beyond storage. This is fine as an RAII guard, but it is only ever populated by with_autoreload which is restricted to DefaultLocalizer<'static>; for the common non-static localizer the field is permanently empty dead storage.","fix":"Acceptable as an RAII guard; consider a brief comment that the field exists solely to keep Watchers alive, mirroring the comment on assets.rs Watchers struct.","locations":[{"ref":"i18n-embed/src/lib.rs:535","code":" 535 | watchers: Vec<Box<dyn Watcher + Send + Sync + 'static>>,","lines":1,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/lib.rs#L535"},{"ref":"i18n-embed/src/lib.rs:567","code":" 567 | watchers: Vec::new(),","lines":1,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/lib.rs#L567"},{"ref":"i18n-embed/src/lib.rs:572-587","code":" 572 | impl DefaultLocalizer<'static> {\n 573 | /// Create a new [DefaultLocalizer](DefaultLocalizer).\n 574 | pub fn with_autoreload(mut self) -> Result<Self, I18nEmbedError> {\n 575 | let assets = self.i18n_assets;\n 576 | let loader = self.language_loader;\n 577 | let watcher = self\n 578 | .i18n_assets\n 579 | .subscribe_changed(std::sync::Arc::new(move || {\n 580 | if let Err(error) = loader.reload(assets) {\n 581 | log::error!(\"Error autoreloading assets: {error:?}\")\n 582 | }\n 583 | }))?;\n 584 | self.watchers.push(watcher);\n 585 | Ok(self)\n 586 | }\n 587 | }","lines":16,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/lib.rs#L572-L587"}]},
{"id":"CMT_1","type":"invalid_comment","type_label":"Invalid Comment","severity":"low","difficulty":"easy","description":"The error walking message is hardcoded to \"error walking directory gui/: {}\" inside the generic `cargo_rerun_if_dir_changed`, which walks an arbitrary `path` argument, not a `gui/` directory. The message is copied from a specific caller and is misleading for any other directory.","fix":"Interpolate the actual `path` into the message, e.g. `anyhow!(\"error walking directory {}: {}\", path.display(), err)`.","locations":[{"ref":"i18n-build/src/watch.rs:31-37","code":" 31 | for result in WalkDir::new(path) {\n 32 | match result {\n 33 | Ok(entry) => {\n 34 | cargo_rerun_if_changed(entry.path())?;\n 35 | }\n 36 | Err(err) => return Err(anyhow!(\"error walking directory gui/: {}\", err)),\n 37 | }","lines":7,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-build/src/watch.rs#L31-L37"}]},
{"id":"CMT_2","type":"invalid_comment","type_label":"Invalid Comment","severity":"low","difficulty":"medium","description":"The doc comment for `I18nConfigError::CannotReadFile` says the file is read \"in the current working directory\", but the variant is also used to report failures reading `Cargo.toml` (lib.rs:110-112) and the i18n toml at an absolute/relative path (lib.rs:357-363) that are not necessarily in the CWD. The second field `io::Result<PathBuf>` stores the CWD only for context, so the message is misleading about where the unreadable file actually lives.","fix":"Reword the message to clarify the first path is the file that could not be read and the second is merely the current working directory for context, e.g. \"Cannot read file {0:?} (current working directory: {1:?}) because {2}.\"","locations":[{"ref":"i18n-config/src/lib.rs:37-38","code":" 37 | #[error(\"Cannot read file {0:?} in the current working directory {1:?} because {2}.\")]\n 38 | CannotReadFile(PathBuf, io::Result<PathBuf>, #[source] io::Error),","lines":2,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-config/src/lib.rs#L37-L38"}]},
{"id":"CMT_3","type":"invalid_comment","type_label":"Invalid Comment","severity":"low","difficulty":"easy","description":"The doc comment for select() (the `fn select` method on the Localizer trait) reads \"Automatically the language currently requested by the system by the the [LanguageRequester]\" - it is missing a verb (e.g. \"selects\") and contains a doubled \"the the\" and an unbalanced parenthesis, making it ungrammatical and unclear.","fix":"Rewrite, e.g.: \"Automatically selects the language currently requested by the system (via the LanguageRequester), loading it using the provided LanguageLoader.\"","locations":[{"ref":"i18n-embed/src/lib.rs:514-516","code":" 514 | /// Automatically the language currently requested by the system\n 515 | /// by the the [LanguageRequester](LanguageRequester)), and load\n 516 | /// it using the provided [LanguageLoader](LanguageLoader).","lines":3,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/lib.rs#L514-L516"}]},
{"id":"CMT_4","type":"invalid_comment","type_label":"Invalid Comment","severity":"negligible","difficulty":"easy","description":"The NotifyError doc comment claims the type \"requires the following crate features to be activated: filesystem-assets\", but the type is gated on `#[cfg(feature = \"autoreload\")]`, not filesystem-assets. The comment text and the cfg gate disagree.","fix":"Change the doc to reference the `autoreload` feature to match the cfg gate.","locations":[{"ref":"i18n-embed/src/assets.rs:150-155","code":" 150 | /// An error that occurs during notification of changes when the `autoreload feature is enabled.`\n 151 | ///\n 152 | /// ⚠️ *This type requires the following crate features to be activated: `filesystem-assets`.*\n 153 | #[cfg(feature = \"autoreload\")]\n 154 | #[derive(Debug)]\n 155 | pub struct NotifyError(notify::Error);","lines":6,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/assets.rs#L150-L155"}]},
{"id":"CMT_5","type":"invalid_comment","type_label":"Invalid Comment","severity":"negligible","difficulty":"easy","description":"Typo / stray backtick in NotifyError doc: \"when the `autoreload feature is enabled.`\" - the backtick spans the words and the trailing period, producing malformed inline code in rendered docs.","fix":"Wrap only the feature name: \"when the `autoreload` feature is enabled.\"","locations":[{"ref":"i18n-embed/src/assets.rs:150","code":" 150 | /// An error that occurs during notification of changes when the `autoreload feature is enabled.`","lines":1,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/assets.rs#L150"}]},
{"id":"CMT_6","type":"invalid_comment","type_label":"Invalid Comment","severity":"negligible","difficulty":"easy","description":"Typo in doc comment: 'Apply some configuration to each budle in this loader.' ('budle' should be 'bundle'). Minor but appears in public API documentation.","fix":"Fix the typo: 'each bundle'.","locations":[{"ref":"i18n-embed/src/fluent.rs:422","code":" 422 | /// Apply some configuration to each budle in this loader.","lines":1,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/fluent.rs#L422"}]},
{"id":"DUP_1","type":"duplicate_code","type_label":"Duplicate Code","severity":"medium","difficulty":"medium","description":"`check_message_args` and `check_attribute_args` are almost line-for-line identical (~60 lines each): both build `args_set` via `args_from_pattern`, iterate `specified_args` emitting the same 'argument does not exist' error, compute `key_args_set`, and emit the same 'arguments have not been specified' error. The only differences are the source of the pattern (message.value() vs attr.value()) and the words 'message' vs 'attribute' in error strings.","fix":"Extract a shared helper taking the already-resolved `Pattern`, the bundle, the specified args, and a noun string ('message'/'attribute') for the error text; call it from both functions.","locations":[{"ref":"i18n-embed-fl/src/lib.rs:730-867","code":" 730 | fn check_message_args<R>(\n 731 | message: FluentMessage<'_>,\n 732 | bundle: &FluentBundle<R>,\n 733 | specified_args: &Vec<(syn::LitStr, Box<syn::Expr>)>,\n 734 | ) where\n 735 | R: std::borrow::Borrow<FluentResource>,\n 736 | {\n 737 | if let Some(pattern) = message.value() {\n 738 | let mut args = Vec::new();\n 739 | args_from_pattern(pattern, bundle, &mut args);\n 740 | \n 741 | let args_set: HashSet<&str> = args.into_iter().collect();\n 742 | \n 743 | let key_args: Vec<String> = specified_args\n 744 | .iter()\n 745 | .map(|(key, _value)| {\n 746 | let arg = key.value();\n 747 | \n 748 | if !args_set.contains(arg.as_str()) {\n 749 | let available_args: String = args_set\n 750 | .iter()\n 751 | .map(|arg| format!(\"`{arg}`\"))\n 752 | .collect::<Vec<String>>()\n 753 | .join(\", \");\n 754 | \n 755 | emit_error! {\n 756 | key,\n 757 | format!(\n 758 | \"fl!() argument `{0}` does not exist in the \\\n 759 | fluent message. Available arguments: {1}.\",\n 760 | &arg, available_args\n 761 | );\n 762 | help = \"Enter the correct arguments, or fix the message \\\n 763 | in the fluent localization file so that the arguments \\\n 764 | match this macro invocation.\";\n 765 | };\n 766 | }\n 767 | \n 768 | arg\n 769 | })\n 770 | .collect();\n 771 | \n 772 | let key_args_set: HashSet<&str> = key_args.iter().map(|v| v.as_str()).collect();\n 773 | \n 774 | let unspecified_args: Vec<String> = args_set\n 775 | .iter()\n 776 | .filter_map(|arg| {\n 777 | if !key_args_set.contains(arg) {\n 778 | Some(format!(\"`{arg}`\"))\n 779 | } else {\n 780 | None\n 781 | }\n 782 | })\n 783 | .collect();\n 784 | \n 785 | if !unspecified_args.is_empty() {\n 786 | emit_error! {\n 787 | proc_macro2::Span::call_site(),\n 788 | format!(\n 789 | \"fl!() the following arguments have not been specified: {}\",\n 790 | unspecified_args.join(\", \")\n 791 | );\n 792 | help = \"Enter the correct arguments, or fix the message \\\n 793 | in the fluent localization file so that the arguments \\\n 794 | match this macro invocation.\";\n 795 | };\n 796 | }\n 797 | }\n 798 | }\n 799 | \n 800 | fn check_attribute_args<R>(\n 801 | attr: FluentAttribute<'_>,\n 802 | bundle: &FluentBundle<R>,\n 803 | specified_args: &Vec<(syn::LitStr, Box<syn::Expr>)>,\n 804 | ) where\n 805 | R: std::borrow::Borrow<FluentResource>,\n 806 | {\n 807 | let pattern = attr.value();\n 808 | let mut args = Vec::new();\n 809 | args_from_pattern(pattern, bundle, &mut args);\n 810 | \n 811 | let args_set: HashSet<&str> = args.into_iter().collect();\n 812 | \n 813 | let key_args: Vec<String> = specified_args\n 814 | .iter()\n 815 | .map(|(key, _value)| {\n 816 | let arg = key.value();\n 817 | \n 818 | if !args_set.contains(arg.as_str()) {\n 819 | let available_args: String = args_set\n 820 | .iter()\n 821 | .map(|arg| format!(\"`{arg}`\"))\n 822 | .collect::<Vec<String>>()\n 823 | .join(\", \");\n 824 | \n 825 | emit_error! {\n 826 | key,\n 827 | format!(\n 828 | \"fl!() argument `{0}` does not exist in the \\\n 829 | fluent attribute. Available arguments: {1}.\",\n 830 | &arg, available_args\n 831 | );\n 832 | help = \"Enter the correct arguments, or fix the attribute \\\n 833 | in the fluent localization file so that the arguments \\\n 834 | match this macro invocation.\";\n 835 | };\n 836 | }\n 837 | \n 838 | arg\n 839 | })\n 840 | .collect();\n 841 | \n 842 | let key_args_set: HashSet<&str> = key_args.iter().map(|v| v.as_str()).collect();\n 843 | \n 844 | let unspecified_args: Vec<String> = args_set\n 845 | .iter()\n 846 | .filter_map(|arg| {\n 847 | if !key_args_set.contains(arg) {\n 848 | Some(format!(\"`{arg}`\"))\n 849 | } else {\n 850 | None\n 851 | }\n 852 | })\n 853 | .collect();\n 854 | \n 855 | if !unspecified_args.is_empty() {\n 856 | emit_error! {\n 857 | proc_macro2::Span::call_site(),\n 858 | format!(\n 859 | \"fl!() the following arguments have not been specified: {}\",\n 860 | unspecified_args.join(\", \")\n 861 | );\n 862 | help = \"Enter the correct arguments, or fix the attribute \\\n 863 | in the fluent localization file so that the arguments \\\n 864 | match this macro invocation.\";\n 865 | };\n 866 | }\n 867 | }","lines":138,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed-fl/src/lib.rs#L730-L867"}]},
{"id":"DUP_2","type":"duplicate_code","type_label":"Duplicate Code","severity":"medium","difficulty":"medium","description":"`gettext_language_loader` and `fluent_language_loader` share a large near-identical prologue (~30 lines): reading the manifest, computing `current_crate_package_name`, the i18n_embed crate-name special case, building the crate ident, locating crate paths, reading the config, and constructing the fallback_language LitStr. Only the section-name checks ('[gettext]' vs '[fluent]') and the final quote! differ.","fix":"Extract a shared helper returning (i18n_embed_crate_ident, config, fallback_language_litstr, current_crate_package_name) and call it from both macros.","locations":[{"ref":"i18n-embed/i18n-embed-impl/src/lib.rs:16-63","code":" 16 | let manifest = find_crate::Manifest::new().expect(\"Error reading Cargo.toml\");\n 17 | let current_crate_package_name = {\n 18 | manifest.crate_package().map(|pkg| pkg.name).unwrap_or(\n 19 | std::env::var(\"CARGO_PKG_NAME\").expect(\"Error fetching `CARGO_PKG_NAME` env\"),\n 20 | )\n 21 | };\n 22 | \n 23 | // Special case for when this macro is invoked in i18n-embed tests/docs\n 24 | let i18n_embed_crate_name = if current_crate_package_name == \"i18n_embed\" {\n 25 | \"i18n_embed\".to_string()\n 26 | } else {\n 27 | manifest\n 28 | .find(|s| s == \"i18n-embed\")\n 29 | .expect(\"i18n-embed should be an active dependency in your Cargo.toml\")\n 30 | .name\n 31 | };\n 32 | \n 33 | let i18n_embed_crate_ident =\n 34 | syn::Ident::new(&i18n_embed_crate_name, proc_macro2::Span::call_site());\n 35 | \n 36 | let config_file_path = i18n_config::locate_crate_paths()\n 37 | .unwrap_or_else(|error| {\n 38 | panic!(\n 39 | \"gettext_language_loader!() is unable to locate i18n config file: {}\",\n 40 | error\n 41 | )\n 42 | })\n 43 | .i18n_config_file;\n 44 | \n 45 | let config = i18n_config::I18nConfig::from_file(&config_file_path).unwrap_or_else(|err| {\n 46 | panic!(\n 47 | \"gettext_language_loader!() had a problem reading i18n config file {0:?}: {1}\",\n 48 | std::fs::canonicalize(&config_file_path).unwrap_or_else(|_| config_file_path.clone()),\n 49 | err\n 50 | )\n 51 | });\n 52 | \n 53 | if config.gettext.is_none() {\n 54 | panic!(\n 55 | \"gettext_language_loader!() had a problem parsing i18n config file {0:?}: there is no `[gettext]` section\",\n 56 | std::fs::canonicalize(&config_file_path).unwrap_or(config_file_path)\n 57 | )\n 58 | }\n 59 | \n 60 | let fallback_language = syn::LitStr::new(\n 61 | &config.fallback_language.to_string(),\n 62 | proc_macro2::Span::call_site(),\n 63 | );","lines":48,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/i18n-embed-impl/src/lib.rs#L16-L63"},{"ref":"i18n-embed/i18n-embed-impl/src/lib.rs:90-136","code":" 90 | let manifest = find_crate::Manifest::new().expect(\"Error reading Cargo.toml\");\n 91 | let current_crate_package_name = manifest\n 92 | .crate_package()\n 93 | .map(|pkg| pkg.name)\n 94 | .unwrap_or(std::env::var(\"CARGO_PKG_NAME\").expect(\"Error fetching `CARGO_PKG_NAME` env\"));\n 95 | \n 96 | // Special case for when this macro is invoked in i18n-embed tests/docs\n 97 | let i18n_embed_crate_name = if current_crate_package_name == \"i18n_embed\" {\n 98 | \"i18n_embed\".to_string()\n 99 | } else {\n 100 | manifest\n 101 | .find(|s| s == \"i18n-embed\")\n 102 | .expect(\"i18n-embed should be an active dependency in your Cargo.toml\")\n 103 | .name\n 104 | };\n 105 | \n 106 | let i18n_embed_crate_ident =\n 107 | syn::Ident::new(&i18n_embed_crate_name, proc_macro2::Span::call_site());\n 108 | \n 109 | let config_file_path = i18n_config::locate_crate_paths()\n 110 | .unwrap_or_else(|error| {\n 111 | panic!(\n 112 | \"fluent_language_loader!() is unable to locate i18n config file: {}\",\n 113 | error\n 114 | )\n 115 | })\n 116 | .i18n_config_file;\n 117 | \n 118 | let config = i18n_config::I18nConfig::from_file(&config_file_path).unwrap_or_else(|err| {\n 119 | panic!(\n 120 | \"fluent_language_loader!() had a problem reading i18n config file {0:?}: {1}\",\n 121 | std::fs::canonicalize(&config_file_path).unwrap_or_else(|_| config_file_path.clone()),\n 122 | err\n 123 | )\n 124 | });\n 125 | \n 126 | if config.fluent.is_none() {\n 127 | panic!(\n 128 | \"fluent_language_loader!() had a problem parsing i18n config file {0:?}: there is no `[fluent]` section\",\n 129 | std::fs::canonicalize(&config_file_path).unwrap_or(config_file_path)\n 130 | )\n 131 | }\n 132 | \n 133 | let fallback_language = syn::LitStr::new(\n 134 | &config.fallback_language.to_string(),\n 135 | proc_macro2::Span::call_site(),\n 136 | );","lines":47,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/i18n-embed-impl/src/lib.rs#L90-L136"}]},
{"id":"DUP_3","type":"duplicate_code","type_label":"Duplicate Code","severity":"low","difficulty":"medium","description":"The blanket RustEmbed I18nAssets impl and the RustEmbedNotifyAssets I18nAssets impl have near-identical get_files and filenames_iter bodies (the only difference being Self::get vs T::get / Self::iter vs T::iter). The duplication invites the two from drifting apart.","fix":"Factor the shared logic into a free helper generic over a RustEmbed type, e.g. `fn rust_embed_get_files<E: RustEmbed>(path: &str) -> Vec<Cow<[u8]>>` and `fn rust_embed_filenames<E: RustEmbed>() -> ...`, and call it from both impls.","locations":[{"ref":"i18n-embed/src/assets.rs:36-54","code":" 36 | fn get_files(&self, file_path: &str) -> Vec<Cow<'_, [u8]>> {\n 37 | Self::get(file_path)\n 38 | .map(|file| file.data)\n 39 | .into_iter()\n 40 | .collect()\n 41 | }\n 42 | \n 43 | fn filenames_iter(&self) -> Box<dyn Iterator<Item = String>> {\n 44 | Box::new(Self::iter().map(|filename| filename.to_string()))\n 45 | }\n 46 | \n 47 | #[allow(unused_variables)]\n 48 | fn subscribe_changed(\n 49 | &self,\n 50 | changed: std::sync::Arc<dyn Fn() + Send + Sync + 'static>,\n 51 | ) -> Result<Box<dyn Watcher + Send + Sync + 'static>, I18nEmbedError> {\n 52 | Ok(Box::new(()))\n 53 | }\n 54 | }","lines":19,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/assets.rs#L36-L54"},{"ref":"i18n-embed/src/assets.rs:84-93","code":" 84 | fn get_files(&self, file_path: &str) -> Vec<Cow<'_, [u8]>> {\n 85 | T::get(file_path)\n 86 | .map(|file| file.data)\n 87 | .into_iter()\n 88 | .collect()\n 89 | }\n 90 | \n 91 | fn filenames_iter(&self) -> Box<dyn Iterator<Item = String>> {\n 92 | Box::new(T::iter().map(|filename| filename.to_string()))\n 93 | }","lines":10,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/assets.rs#L84-L93"}]},
{"id":"DUP_4","type":"duplicate_code","type_label":"Duplicate Code","severity":"low","difficulty":"medium","description":"get_args_fluent and get_attr_args_fluent share a near-identical body: locate a message in the bundles, call `format_pattern`, log formatting errors with the same message template, convert to String, and produce the same 'No localization for ...' fallback string and error log. The only real difference is fetching a message value vs. an attribute value and which lookup set is iterated. This ~25 line block is duplicated and must be kept in sync (e.g. the error-log wording and selection-respecting iteration drift between the two).","fix":"Extract a shared helper that takes a closure resolving a `&Pattern` from a bundle/message plus the not-found context, and have both get_args_fluent and get_attr_args_fluent delegate to it.","locations":[{"ref":"i18n-embed/src/fluent.rs:156-188","code":" 156 | inner\n 157 | .current_languages\n 158 | .indices\n 159 | .iter()\n 160 | .map(|&idx| &language_config.language_bundles[idx])\n 161 | .flat_map(|language_bundles| language_bundles.iter())\n 162 | .find_map(|language_bundle| language_bundle\n 163 | .bundle\n 164 | .get_message(message_id)\n 165 | .and_then(|m: FluentMessage<'_>| m.value())\n 166 | .map(|pattern: &Pattern<&str>| {\n 167 | let mut errors = Vec::new();\n 168 | let value = language_bundle.bundle.format_pattern(pattern, args, &mut errors);\n 169 | if !errors.is_empty() {\n 170 | log::error!(\n 171 | target:\"i18n_embed::fluent\",\n 172 | \"Failed to format a message for language \\\"{}\\\" and id \\\"{}\\\".\\nErrors\\n{:?}.\",\n 173 | inner.current_languages.languages.first().unwrap_or(&self.fallback_language), message_id, errors\n 174 | )\n 175 | }\n 176 | value.into()\n 177 | })\n 178 | )\n 179 | .unwrap_or_else(|| {\n 180 | log::error!(\n 181 | target:\"i18n_embed::fluent\",\n 182 | \"Unable to find localization for language \\\"{}\\\" and id \\\"{}\\\".\",\n 183 | inner.current_languages.languages.first().unwrap_or(&self.fallback_language),\n 184 | message_id\n 185 | );\n 186 | format!(\"No localization for id: \\\"{}\\\"\", message_id)\n 187 | })\n 188 | }","lines":33,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/fluent.rs#L156-L188"},{"ref":"i18n-embed/src/fluent.rs:231-265","code":" 231 | language_config.language_bundles.iter()\n 232 | .flat_map(|language_bundles| language_bundles.iter())\n 233 | .find_map(|language_bundle| {\n 234 | language_bundle\n 235 | .bundle\n 236 | .get_message(message_id)\n 237 | .and_then(|m: FluentMessage<'_>| {\n 238 | m.get_attribute(attribute_id)\n 239 | .map(|a: FluentAttribute<'_>| {\n 240 | a.value()\n 241 | })\n 242 | })\n 243 | .map(|pattern: &Pattern<&str>| {\n 244 | let mut errors = Vec::new();\n 245 | let value = language_bundle.bundle.format_pattern(pattern, args, &mut errors);\n 246 | if !errors.is_empty() {\n 247 | log::error!(\n 248 | target:\"i18n_embed::fluent\",\n 249 | \"Failed to format a message for language \\\"{}\\\" and id \\\"{}\\\".\\nErrors\\n{:?}.\",\n 250 | current_language, message_id, errors\n 251 | )\n 252 | }\n 253 | value.into()\n 254 | })\n 255 | })\n 256 | .unwrap_or_else(|| {\n 257 | log::error!(\n 258 | target:\"i18n_embed::fluent\",\n 259 | \"Unable to find localization for language \\\"{}\\\", message id \\\"{}\\\" and attribute id \\\"{}\\\".\",\n 260 | current_language,\n 261 | message_id,\n 262 | attribute_id\n 263 | );\n 264 | format!(\"No localization for message id: \\\"{message_id}\\\" and attribute id: \\\"{attribute_id}\\\"\")\n 265 | })","lines":35,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/fluent.rs#L231-L265"}]},
{"id":"DUP_5","type":"duplicate_code","type_label":"Duplicate Code","severity":"low","difficulty":"medium","description":"Lines 167-168 explicitly check that `path` and `path.join(config_file_path)` exist, but `Crate::from` (i18n-config) already validates the crate path (it returns `NotACrate`/`CannotReadFile` if `Cargo.toml` is missing) and treats the i18n config file as optional (it is only loaded `if full_config_file_path.exists()`). The line-168 check therefore makes the config file mandatory in main.rs while `Crate::from` deliberately allows it to be absent, creating an inconsistency: a crate with a Cargo.toml but no i18n config is rejected here even though the underlying API supports it, and the path-exists check duplicates validation done downstream.","fix":"Drop the redundant existence checks and rely on `Crate::from`'s validation (and the build step) for clear errors, or, if a missing i18n config really should be a hard error, document that intent rather than duplicating/contradicting the library's optional-config behaviour.","locations":[{"ref":"src/main.rs:167-170","code":" 167 | i18n_build::util::check_path_exists(&path)?;\n 168 | i18n_build::util::check_path_exists(path.join(&config_file_path))?;\n 169 | \n 170 | let crt: Crate = Crate::from(path, None, config_file_path)?;","lines":4,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/src/main.rs#L167-L170"}]},
{"id":"OTHER_1","type":"other","type_label":"Other","severity":"low","difficulty":"easy","description":"Typo in the error variant name `CannotPaseI18nToml` (should be `CannotParseI18nToml`). As a public enum variant this is part of the crate's API and the misspelling is visible to downstream users.","fix":"Rename to `CannotParseI18nToml`.","locations":[{"ref":"i18n-config/src/lib.rs:43-44","code":" 43 | #[error(\"Cannot parse i18n configuration file {0:?} because {1}.\")]\n 44 | CannotPaseI18nToml(PathBuf, String),","lines":2,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-config/src/lib.rs#L43-L44"}]},
{"id":"OTHER_2","type":"other","type_label":"Other","severity":"low","difficulty":"easy","description":"Typo in the user-facing `--help` long description: \"You can the \\\"i18n-embed\\\" library to conveniently embed\" is missing a verb (should read \"You can use the ...\"). This text is shown to every user running `cargo i18n --help`.","fix":"Change the string to \"You can use the \\\"i18n-embed\\\" library to conveniently embed the localizations inside your application.\"","locations":[{"ref":"src/main.rs:61-62","code":" 61 | You can the \\\"i18n-embed\\\" library to conveniently embed the \\\n 62 | localizations inside your application.","lines":2,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/src/main.rs#L61-L62"}]},
{"id":"OTHER_3","type":"other","type_label":"Other","severity":"negligible","difficulty":"easy","description":"Doc comment for `has` is truncated/missing its first sentence: it begins mid-sentence with 'available in any of the languages currently loaded ...', having lost the leading 'Determines if a message ... is' clause, making the rendered rustdoc ungrammatical.","fix":"Restore the full sentence, e.g. 'Determines if a message with the given `message_id` is available in any of the languages currently loaded (including the fallback language).'","locations":[{"ref":"i18n-embed/src/fluent.rs:287-289","code":" 287 | /// available in any of the languages currently loaded (including\n 288 | /// the fallback language).\n 289 | pub fn has(&self, message_id: &str) -> bool {","lines":3,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/fluent.rs#L287-L289"}]},
{"id":"OTHER_4","type":"other","type_label":"Other","severity":"negligible","difficulty":"easy","description":"Doc comment duplicates the word 'whatever': 'Returns `Some` of whatever whatever the closure returns'. Cosmetic public-doc defect.","fix":"Remove the duplicated 'whatever'.","locations":[{"ref":"i18n-embed/src/fluent.rs:328-329","code":" 328 | /// `Some` of whatever whatever the closure returns, or `None` if\n 329 | /// no messages were found matching the `message_id`.","lines":2,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/i18n-embed/src/fluent.rs#L328-L329"}]},
{"id":"OTHER_5","type":"other","type_label":"Other","severity":"negligible","difficulty":"easy","description":"Trailing whitespace inside the help string after \"described at \" on line 65, and the URL is wrapped with surrounding spaces \"( https://... )\". The trailing space produces a stray space in the rendered help output before the line continuation.","fix":"Remove the trailing whitespace after \"at\" and tidy the URL parenthesization so the rendered help text reads cleanly.","locations":[{"ref":"src/main.rs:64-67","code":" 64 | The display language used for this command is selected automatically \\\n 65 | using your system settings (as described at \n 66 | https://github.com/1Password/sys-locale?tab=readme-ov-file#sys-locale ) \\\n 67 | however you can override it using the -l, --language option.","lines":4,"link":"https://github.com/kellpossible/cargo-i18n/blob/9dcd77dbc62717f232815377838a2dc37f8f7f6a/src/main.rs#L64-L67"}]}
]</script>
<script type="application/json" id="PI">{"name":"cargo-i18n","git_url":"https://github.com/kellpossible/cargo-i18n.git","repo_url":"https://github.com/kellpossible/cargo-i18n","commit":"9dcd77dbc62717f232815377838a2dc37f8f7f6a","commit_short":"9dcd77dbc6","commit_url":"https://github.com/kellpossible/cargo-i18n/commit/9dcd77dbc62717f232815377838a2dc37f8f7f6a","branch":"master","generated":"2026-09-08","description":"A Rust Cargo sub-command and libraries to extract and build localization resources to embed in your application/library"}</script>
<div class="app">
<div class="top">
<div>
<h1>Code Audit Report — cargo-i18n</h1>
<div class="meta">Generated 2026-09-08 — <span id="stats"></span></div>
</div>
<div class="top-right">
<div class="search-wrap">
<input type="search" id="search" placeholder="Search findings…" autocomplete="off">
<button id="search-scope" class="action-btn" type="button" title="Toggle: search everywhere vs. only in file/folder paths">📁 Paths only</button>
</div>
<label class="group-label">Group by
<select id="groupby">
<option value="type">Type</option>
<option value="severity">Severity</option>
<option value="file">File</option>
<option value="none">None (flat)</option>
</select>
</label>
</div>
</div>
<div class="project-info"><div class="pi-name">cargo-i18n</div><div class="pi-desc">A Rust Cargo sub-command and libraries to extract and build localization resources to embed in your application/library</div><div class="pi-row"><a class="pi-repo-btn" href="https://github.com/kellpossible/cargo-i18n" target="_blank" rel="noopener">🔗 https://github.com/kellpossible/cargo-i18n</a><span><span class="pi-k">branch</span> <code>master</code></span><span><span class="pi-k">commit</span> <a href="https://github.com/kellpossible/cargo-i18n/commit/9dcd77dbc62717f232815377838a2dc37f8f7f6a" target="_blank" rel="noopener"><code>9dcd77dbc6</code></a></span></div></div>
<div class="filterbar" id="filterbar"></div>
<div id="progress-wrap">
<div id="progress-bar"><div id="progress-fill"></div></div>
<span id="progress-text"></span>
<button class="action-btn" data-action="collapse-all">⊟ Collapse all</button>
<button class="action-btn" data-action="expand-all">⊞ Expand all</button>
<button class="action-btn" data-action="download-md">⬇ MD</button>
<button class="action-btn" data-action="download-md-short">⬇ MD Short</button>
<button class="action-btn" data-action="download-toml">⬇ TOML</button>
</div>
<div id="hidden-bar"></div>
<div id="content"></div>
</div>
<script>
(function(){
'use strict';
// Data
const FINDINGS = JSON.parse(document.getElementById('D').textContent);
const PROJECT_INFO = JSON.parse(document.getElementById('PI').textContent);
const SEVS = ['critical','high','medium','low','negligible'];
const STATUSES = ['open','fixed','wont-fix','false-positive'];
const ALL_TYPES = [...new Set(FINDINGS.map(f => f.type))].sort();
// Build lookup map for O(1) access
const FINDING_BY_ID = new Map(FINDINGS.map(f => [f.id, f]));
// Only severities actually present in the report — the filter bar hides the rest.
const PRESENT_SEVS = SEVS.filter(s => FINDINGS.some(f => (f.severity || 'medium') === s));
// Whether any finding is flagged easy — the "Quick wins" filter row is hidden otherwise.
const DIFFICULTY_ORDER = ['very_easy','easy','medium','hard'];
const DIFFICULTY_LBL = {very_easy:'⚡ Very Easy', easy:'Easy', medium:'Medium', hard:'Hard'};
const DIFFICULTY_CLS = {very_easy:'diff-very-easy', easy:'diff-easy', medium:'diff-medium', hard:'diff-hard'};
const PRESENT_DIFFS = DIFFICULTY_ORDER.filter(d => FINDINGS.some(f => f.difficulty === d));
const HAS_DIFFICULTY = PRESENT_DIFFS.length > 0;
const STORE_KEY = 'audit-status:' + location.href;
const STATUS_CLS = {open:'st-open', fixed:'st-fixed', 'wont-fix':'st-wont', 'false-positive':'st-fp'};
const STATUS_LBL = {open:'● open', fixed:'✓ fixed', 'wont-fix':"— won't fix", 'false-positive':'✗ false pos.'};
const STATUS_NEXT = {open:'fixed', fixed:'wont-fix', 'wont-fix':'false-positive', 'false-positive':'open'};
// State
const S = {
search: '', searchScope: 'all', groupBy: 'type',
hiddenSev: new Set(), hiddenType: new Set(),
hiddenStatus: new Set(), hiddenDiff: new Set(), hiddenGroup: new Set(),
collapsed: new Set(),
};
const statuses = {};
// Persistence
function loadStatuses() {
try { Object.assign(statuses, JSON.parse(localStorage.getItem(STORE_KEY) || '{}')); } catch {}
}
function saveStatuses() {
try { localStorage.setItem(STORE_KEY, JSON.stringify(statuses)); } catch {}
}
function statusOf(id) { return statuses[id] || 'open'; }
// URL hash — debounced so rapid filter changes don't thrash history
let _hashTimer = 0;
function syncHash() {
clearTimeout(_hashTimer);
_hashTimer = setTimeout(() => {
const p = new URLSearchParams();
if (S.groupBy !== 'type') p.set('g', S.groupBy);
if (S.hiddenSev.size) p.set('hs', [...S.hiddenSev].join(','));
if (S.hiddenType.size) p.set('ht', [...S.hiddenType].join(','));
if (S.hiddenStatus.size) p.set('hst', [...S.hiddenStatus].join(','));
if (S.hiddenDiff.size) p.set('hd', [...S.hiddenDiff].join(','));
if (S.search) p.set('q', S.search);
if (S.searchScope !== 'all') p.set('qs', S.searchScope);
const str = p.toString();
history.replaceState(null, '', str ? '#' + str : location.pathname + location.search);
}, 300);
}
function loadHash() {
const p = new URLSearchParams(location.hash.slice(1));
if (p.has('g')) { S.groupBy = p.get('g'); document.getElementById('groupby').value = S.groupBy; }
if (p.has('q')) { S.search = p.get('q'); document.getElementById('search').value = S.search; }
if (p.has('qs')) { S.searchScope = p.get('qs'); }
document.getElementById('search-scope').classList.toggle('active', S.searchScope === 'locations');
if (p.has('hs')) p.get('hs') .split(',').filter(Boolean).forEach(v => S.hiddenSev.add(v));
if (p.has('ht')) p.get('ht') .split(',').filter(Boolean).forEach(v => S.hiddenType.add(v));
if (p.has('hst')) p.get('hst').split(',').filter(Boolean).forEach(v => S.hiddenStatus.add(v));
if (p.has('hd')) p.get('hd') .split(',').filter(Boolean).forEach(v => S.hiddenDiff.add(v));
}
// Helpers
function esc(s) {
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
function highlight(text, q) {
if (!q) return esc(text);
const re = new RegExp('(' + q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&') + ')', 'gi');
return String(text).split(re).map((p, i) => i % 2 ? '<mark>' + esc(p) + '</mark>' : esc(p)).join('');
}
// Render text with ```fenced``` code blocks and `inline` backticks,
// also paragraph-splitting on blank lines. Output is HTML-safe.
function renderMd(text, q) {
const src = String(text == null ? '' : text);
const FENCE = /```[ \t]*([A-Za-z0-9_+-]*)[ \t]*\r?\n([\s\S]*?)```/g;
const parts = [];
let last = 0, m;
while ((m = FENCE.exec(src)) !== null) {
parts.push({kind: 'text', value: src.slice(last, m.index)});
parts.push({kind: 'code', value: m[2].replace(/\r?\n$/, '')});
last = m.index + m[0].length;
}
parts.push({kind: 'text', value: src.slice(last)});
function renderInline(s) {
const re = /`([^`\n]+)`/g;
let out = '', i = 0, mm;
while ((mm = re.exec(s)) !== null) {
out += highlight(s.slice(i, mm.index), q);
out += '<code class="md-inline">' + highlight(mm[1], q) + '</code>';
i = mm.index + mm[0].length;
}
out += highlight(s.slice(i), q);
return out;
}
function renderTextChunk(s) {
const paragraphs = s.split(/\r?\n[ \t]*\r?\n/);
const out = [];
for (const p of paragraphs) {
const trimmed = p.replace(/^\s+|\s+$/g, '');
if (!trimmed) continue;
out.push('<p>' + renderInline(trimmed).replace(/\r?\n/g, '<br>') + '</p>');
}
return out.join('');
}
let html = '';
for (const p of parts) {
if (p.kind === 'text') html += renderTextChunk(p.value);
else html += '<pre class="md-code">' + highlight(p.value, q) + '</pre>';
}
return html;
}
function sevCls(s) { return SEVS.includes(s) ? 'sev-'+s : 'sev-negligible'; }
function sevLbl(s) { return s ? s[0].toUpperCase()+s.slice(1) : 'Medium'; }
function typeLabel(t) { return t.replace(/_/g,' ').replace(/\b\w/g, c => c.toUpperCase()); }
// First sentence of text (up to and including the first period).
function firstSentence(text) {
const m = String(text).match(/\.(\s|$)/);
return m ? text.slice(0, text.indexOf(m[0]) + 1) : text;
}
// Render a "**Label:** value" line. GitHub/CommonMark only recognizes a
// ``` fence when it starts at the beginning of a line, so if the value
// itself opens with a fence, put it on its own line instead of right
// after the inline label - otherwise the fence marker gets glued onto
// "**Label:** ```" and never renders as a code block.
function mdField(label, value) {
value = value || '';
return /^```/.test(value.trim())
? '**' + label + ':**\n\n' + value
: '**' + label + ':** ' + value;
}
// Render the "**Locations:**" block. When a location has a commit-pinned
// permalink (GitHub/GitLab), just paste the bare URL on its own line -
// GitHub/GitLab auto-expand a line-range blob link into an inline code
// preview, so re-pasting the snippet as a fenced block would duplicate it.
// Forges without a detected permalink shape (or missing commit/repo info)
// fall back to the fenced ref+snippet so the location is still readable.
function mdLocations(locations) {
if (!locations.length) return '';
let out = '**Locations:**\n\n';
for (const l of locations) {
if (l.link) out += l.link + '\n\n';
else if (l.code != null) out += '```\n' + l.ref + '\n' + l.code + '\n```\n\n';
else out += '- `' + l.ref + '` _(file not found)_\n\n';
}
return out;
}
// Build full MD report for in-browser download.
function buildMdFull() {
const SORD = ['critical','high','medium','low','negligible'];
const today = new Date().toISOString().slice(0, 10);
let out = '# Code Audit Report\n\n_Generated ' + today + '_\n\n';
const typeMap = new Map();
for (const f of FINDINGS) {
if (!typeMap.has(f.type)) typeMap.set(f.type, []);
typeMap.get(f.type).push(f);
}
const sortedTypes = [...typeMap.keys()].sort();
out += '## Summary\n\n| Type | Severity | Count |\n|------|----------|-------|\n';
for (const t of sortedTypes) {
const items = typeMap.get(t);
const topSev = items.map(f => f.severity || 'medium').reduce((best, s) => {
const bi = SORD.indexOf(best), si = SORD.indexOf(s);
return (si >= 0 && (bi < 0 || si < bi)) ? s : best;
}, 'negligible');
out += '| ' + typeLabel(t) + ' | ' + topSev + ' | ' + items.length + ' |\n';
}
for (const t of sortedTypes) {
out += '\n## ' + typeLabel(t) + '\n\n';
for (const f of typeMap.get(t)) {
const sevStr = f.severity ? ' `' + f.severity.toUpperCase() + '`' : '';
out += '### ' + f.id + sevStr + '\n\n';
out += mdField('Description', f.description) + '\n\n';
out += mdLocations(f.locations);
out += mdField('Fix', f.fix) + '\n\n---\n\n';
}
}
return out;
}
// Build MD short checklist for in-browser download.
function buildMdShort() {
const lines = [];
const ids = [];
const sorted = [...FINDINGS].sort((a, b) => a.type.localeCompare(b.type));
for (const f of sorted) {
ids.push(f.id);
const desc = firstSentence(f.description || '');
const locs = f.locations.map(l => l.ref);
const locStr = locs.length ? ' - [' + locs.join(', ') + ']' : '';
const sevStr = f.severity ? ' [' + f.severity.toUpperCase() + ']' : '';
lines.push('- [ ] - ' + f.id + sevStr + ' - ' + desc + locStr);
}
lines.push('', '## IDs only', '');
ids.forEach(id => lines.push('- [ ] - ' + id));
return lines.join('\n') + '\n';
}
function downloadText(text, filename, mime) {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([text], {type: mime || 'text/plain'}));
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
function tomlStr(s) {
return '"' + String(s == null ? '' : s)
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t') + '"';
}
// Build a [project] header block from embedded PROJECT_INFO, so the exported
// findings.toml is self-contained (doesn't depend on a sibling project-info.toml
// that a second, separately-triggered download could silently fail to save).
// '' when there is no project info at all.
function buildProjectToml() {
const ORDER = ['name','description','git_url','repo_url','commit','commit_short','commit_url','branch','generated'];
const pi = PROJECT_INFO || {};
if (!Object.keys(pi).length) return '';
const lines = ['[project]'];
for (const k of ORDER) {
if (k in pi) lines.push(k + ' = ' + tomlStr(pi[k]));
}
return lines.join('\n') + '\n\n';
}
// Build TOML matching the original findings.toml format (re-parseable by toml_to_report.py).
function buildFindingsToml() {
function pad(key) { return (key + ' ').slice(0, 12); }
const chunks = [];
for (const f of FINDINGS) {
const lns = ['[[finding]]'];
lns.push(pad('type') + '= ' + tomlStr(f.type));
lns.push(pad('severity') + '= ' + tomlStr(f.severity || 'medium'));
if (f.difficulty) lns.push(pad('difficulty') + '= ' + tomlStr(f.difficulty));
lns.push(pad('description') + '= ' + tomlStr(f.description || ''));
const refs = f.locations.map(l => tomlStr(l.ref));
lns.push(pad('locations') + '= [' + refs.join(', ') + ']');
lns.push(pad('fix') + '= ' + tomlStr(f.fix || ''));
chunks.push(lns.join('\n'));
}
return buildProjectToml() + chunks.join('\n\n') + '\n';
}
// Build the GitHub-ready Markdown for one finding (mirrors findings.md).
function findingToMd(f) {
let out = '### ' + f.id + (f.severity ? ' `' + f.severity.toUpperCase() + '`' : '') + '\n\n';
out += mdField('Description', f.description) + '\n\n';
out += mdLocations(f.locations);
out += mdField('Fix', f.fix) + '\n';
return out;
}
function flashBtn(btn, msg) {
btn.textContent = msg;
setTimeout(() => { btn.textContent = '⧉ Copy'; }, 1200);
}
function copyText(text, btn) {
const ok = () => flashBtn(btn, '✓ Copied');
const no = () => flashBtn(btn, '✗ Failed');
const fallback = () => {
try {
const ta = document.createElement('textarea');
ta.value = text; ta.style.position = 'fixed'; ta.style.top = '-9999px';
document.body.appendChild(ta); ta.select();
const r = document.execCommand('copy');
document.body.removeChild(ta);
r ? ok() : no();
} catch { no(); }
};
if (navigator.clipboard && navigator.clipboard.writeText)
navigator.clipboard.writeText(text).then(ok, fallback);
else fallback();
}
// Filter logic
function getKey(f) {
if (S.groupBy === 'severity') return f.severity || 'medium';
if (S.groupBy === 'file') return f.locations.length ? f.locations[0].ref.replace(/:.*/,'') : '(no location)';
if (S.groupBy === 'none') return '';
return f.type_label;
}
// Pre-compute search haystacks per finding (cleared on full re-render).
// Two variants: everything (default), and file/folder paths only - for when a
// search term (e.g. a directory name like 'dlls/abc') also happens to appear
// inside description/fix prose and you only want path matches.
const _hay = new Map();
const _hayLoc = new Map();
function hayOf(f) {
if (!_hay.has(f.id)) {
_hay.set(f.id, [f.id, f.type_label, f.severity, f.description, f.fix,
f.difficulty ? (DIFFICULTY_LBL[f.difficulty] || f.difficulty) : '',
...f.locations.map(l => l.ref)].join(' ').toLowerCase());
}
return _hay.get(f.id);
}
function hayLocOf(f) {
if (!_hayLoc.has(f.id)) {
_hayLoc.set(f.id, f.locations.map(l => l.ref).join(' ').toLowerCase());
}
return _hayLoc.get(f.id);
}
function matches(f) {
if (S.hiddenDiff.size > 0 && f.difficulty && S.hiddenDiff.has(f.difficulty)) return false;
if (S.hiddenSev.has(f.severity || 'medium')) return false;
if (S.hiddenType.has(f.type)) return false;
const st = statusOf(f.id);
if (S.hiddenStatus.has(st)) return false;
if (S.search) {
const q = S.search.toLowerCase();
if (S.searchScope === 'locations') {
if (!hayLocOf(f).includes(q)) return false;
} else if (!hayOf(f).includes(q) && !st.includes(q)) {
return false;
}
}
return true;
}
function sortKeys(keys) {
if (S.groupBy === 'severity') {
return [...keys].sort((a, b) => {
const ai = SEVS.indexOf(a), bi = SEVS.indexOf(b);
return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi);
});
}
return [...keys].sort();
}
// Build body HTML for an expanded card (called lazily on first expand).
function renderCardBody(f, q) {
let locs = '';
for (const l of f.locations) {
const lineInfo = l.lines != null
? ' <span class="loc-lines">(' + l.lines + ' line' + (l.lines === 1 ? '' : 's') + ')</span>'
: '';
const refHtml = l.link
? '<a class="loc-link" href="' + esc(l.link) + '" target="_blank" rel="noopener" title="Open at this commit">' + highlight(l.ref, q) + ' ↗</a>'
: highlight(l.ref, q);
locs += '<div class="loc-block"><div class="loc-ref">' + refHtml + lineInfo + '</div>';
locs += l.code != null ? '<pre>' + esc(l.code) + '</pre>' : '<div class="not-found">(file not found)</div>';
locs += '</div>';
}
return (
'<div class="field-lbl">Description</div>' +
'<div class="desc">' + renderMd(f.description, q) + '</div>' +
(locs ? '<div class="field-lbl">Locations</div>' + locs : '') +
'<div class="field-lbl">Fix</div>' +
'<div class="fix-txt">' + renderMd(f.fix, q) + '</div>'
);
}
// Render card shell only — header + empty or populated body.
// When collapsed the body is empty (display:none) — avoids renderMd for 1800+ cards.
function renderCard(f, q) {
const sc = sevCls(f.severity || 'medium');
const sl = sevLbl(f.severity || 'medium');
const st = statusOf(f.id);
const sc2 = STATUS_CLS[st] || 'st-open';
const sl2 = STATUS_LBL[st] || '● open';
const isCollapsed = S.collapsed.has(f.id);
const isResolved = st !== 'open';
const locSummary = f.locations.length
? f.locations[0].ref + (f.locations.length > 1 ? ' \u2026' : '')
: '';
const bodyInner = isCollapsed ? '' : renderCardBody(f, q);
return (
'<div class="card' + (isResolved ? ' resolved' : '') + '" data-card-id="' + esc(f.id) + '">' +
'<div class="card-header" data-action="toggle-card" data-id="' + esc(f.id) + '">' +
'<span class="card-arrow">' + (isCollapsed ? '\u25b6' : '\u25bc') + '</span>' +
'<span class="card-id">' + esc(f.id) + '</span>' +
'<span class="badge ' + sc + '" style="cursor:default">' + esc(sl) + '</span>' +
'<span class="badge type-badge" style="cursor:default">' + esc(f.type_label) + '</span>' +
(f.difficulty ? '<span class="badge ' + (DIFFICULTY_CLS[f.difficulty]||'') + '" style="cursor:default" title="Difficulty: ' + esc(DIFFICULTY_LBL[f.difficulty]||f.difficulty) + '">' + esc(DIFFICULTY_LBL[f.difficulty]||f.difficulty) + '</span>' : '') +
(locSummary ? '<span class="card-loc" title="' + esc(locSummary) + '">' + esc(locSummary) + '</span>' : '') +
'<button class="status-btn ' + sc2 + '" data-action="cycle-status" data-id="' + esc(f.id) + '">' + sl2 + '</button>' +
'<button class="copy-btn" data-action="copy-md" data-id="' + esc(f.id) + '" title="Copy as Markdown for GitHub">\u29c9 Copy</button>' +
'</div>' +
'<div class="card-body" data-body-id="' + esc(f.id) + '"' + (isCollapsed ? ' style="display:none"' : '') + '>' +
bodyInner +
'</div></div>'
);
}
function initFilterBar() {
const fb = document.getElementById('filterbar');
let h = '<div class="filter-row"><span class="filter-lbl">Severity:</span>';
for (const s of PRESENT_SEVS)
h += '<button class="badge ' + sevCls(s) + '" data-sev="' + s + '">' + sevLbl(s) + '</button>';
h += '<button class="filter-toggle" data-toggle-all="sev"></button></div>';
h += '<div class="filter-row"><span class="filter-lbl">Type:</span>';
for (const t of ALL_TYPES)
h += '<button class="badge type-badge" data-type="' + esc(t) + '">' + esc(typeLabel(t)) + '</button>';
h += '<button class="filter-toggle" data-toggle-all="type"></button></div>';
h += '<div class="filter-row"><span class="filter-lbl">Status:</span>';
for (const st of STATUSES)
h += '<button class="badge ' + (STATUS_CLS[st]||'st-open') + '" data-status="' + st + '">' + (STATUS_LBL[st]||st) + '</button>';
h += '<button class="filter-toggle" data-toggle-all="status"></button></div>';
if (HAS_DIFFICULTY) {
h += '<div class="filter-row"><span class="filter-lbl">Difficulty:</span>';
for (const d of PRESENT_DIFFS)
h += '<button class="badge ' + (DIFFICULTY_CLS[d]||'') + '" data-diff="' + d + '">' + esc(DIFFICULTY_LBL[d]||d) + '</button>';
h += '<button class="filter-toggle" data-toggle-all="diff"></button></div>';
}
fb.innerHTML = h;
}
function updateToggles() {
for (const b of document.querySelectorAll('[data-sev]')) b.classList.toggle('off', S.hiddenSev.has(b.dataset.sev));
for (const b of document.querySelectorAll('[data-type]')) b.classList.toggle('off', S.hiddenType.has(b.dataset.type));
for (const b of document.querySelectorAll('[data-status]')) b.classList.toggle('off', S.hiddenStatus.has(b.dataset.status));
for (const b of document.querySelectorAll('[data-diff]')) b.classList.toggle('off', S.hiddenDiff.has(b.dataset.diff));
for (const b of document.querySelectorAll('[data-toggle-all]')) {
const k = b.dataset.toggleAll;
const set = k === 'sev' ? S.hiddenSev : k === 'type' ? S.hiddenType : k === 'status' ? S.hiddenStatus : k === 'diff' ? S.hiddenDiff : null;
if (set) b.textContent = set.size === 0 ? 'Hide all' : 'Show all';
}
}
function updateProgress() {
const total = FINDINGS.length;
const done = FINDINGS.filter(f => statusOf(f.id) !== 'open').length;
const pct = total ? Math.round(done / total * 100) : 0;
document.getElementById('progress-fill').style.width = pct + '%';
document.getElementById('progress-text').textContent = done + ' / ' + total + ' resolved (' + pct + '%)';
}
// Surgical toggle: flip one card without touching the rest of the DOM.
function toggleCard(id) {
const wasCollapsed = S.collapsed.has(id);
if (wasCollapsed) S.collapsed.delete(id); else S.collapsed.add(id);
const body = document.querySelector('[data-body-id="' + id + '"]');
const arrow = document.querySelector('[data-card-id="' + id + '"] .card-arrow');
if (!body) { render(); return; }
if (wasCollapsed) {
if (!body.firstChild) {
const f = FINDING_BY_ID.get(id);
if (f) body.innerHTML = renderCardBody(f, S.search);
}
body.style.display = '';
if (arrow) arrow.textContent = '\u25bc';
} else {
body.style.display = 'none';
if (arrow) arrow.textContent = '\u25b6';
}
}
// Surgical status update: update one card's button + resolved class.
function cycleStatus(id) {
statuses[id] = STATUS_NEXT[statusOf(id)] || 'open';
saveStatuses();
const st = statusOf(id);
const btn = document.querySelector('.status-btn[data-id="' + id + '"]');
if (!btn) { render(); return; }
btn.className = 'status-btn ' + (STATUS_CLS[st] || 'st-open');
btn.textContent = STATUS_LBL[st] || '\u25cf open';
const card = document.querySelector('[data-card-id="' + id + '"]');
if (card) card.classList.toggle('resolved', st !== 'open');
updateProgress();
}
// Surgical collapse/expand for a group: toggle all bodies in the group without full re-render.
function collapseGroup(key, collapse) {
FINDINGS.forEach(f => {
if (getKey(f) !== key) return;
if (collapse) S.collapsed.add(f.id); else S.collapsed.delete(f.id);
const body = document.querySelector('[data-body-id="' + f.id + '"]');
const arrow = document.querySelector('[data-card-id="' + f.id + '"] .card-arrow');
if (!body) return;
if (collapse) {
body.style.display = 'none';
if (arrow) arrow.textContent = '\u25b6';
} else {
if (!body.firstChild) {
const ff = FINDING_BY_ID.get(f.id);
if (ff) body.innerHTML = renderCardBody(ff, S.search);
}
body.style.display = '';
if (arrow) arrow.textContent = '\u25bc';
}
});
}
// Full re-render: used for filter/group-by/search changes.
// Batched via requestAnimationFrame.
let _rafPending = false;
function scheduleRender() {
if (_rafPending) return;
_rafPending = true;
requestAnimationFrame(() => { _rafPending = false; render(); });
}
function render() {
_hay.clear();
_hayLoc.clear();
updateToggles();
updateProgress();
const q = S.search;
const visible = FINDINGS.filter(matches);
document.getElementById('stats').textContent = visible.length + ' / ' + FINDINGS.length + ' findings';
// Build groups
const map = new Map();
for (const f of visible) {
const k = getKey(f);
if (!map.has(k)) map.set(k, []);
map.get(k).push(f);
}
// Hidden-groups bar
const hbar = document.getElementById('hidden-bar');
if (S.hiddenGroup.size > 0) {
let hh = '<div class="hidden-inner"><span class="hidden-lbl">Hidden:</span>';
for (const g of [...S.hiddenGroup].sort())
hh += '<button class="show-btn" data-action="show-group" data-key="' + esc(g) + '">' + esc(g || '(all findings)') + '</button>';
hh += '<button class="show-all-btn" data-action="show-all">Show all</button></div>';
hbar.innerHTML = hh;
} else {
hbar.innerHTML = '';
}
// Content
const content = document.getElementById('content');
if (!visible.length) {
content.innerHTML = '<div class="no-results">No findings match the current filters.</div>';
syncHash();
return;
}
let hh = '';
for (const k of sortKeys([...map.keys()])) {
if (S.hiddenGroup.has(k)) continue;
const items = map.get(k);
const title = k || 'All Findings';
const done = items.filter(f => statusOf(f.id) !== 'open').length;
const allDone = done === items.length;
hh += '<div class="group-section">' +
'<div class="group-header">' +
'<span class="group-title' + (allDone ? ' all-done' : '') + '">' + esc(title) + '</span>' +
'<span class="group-count">' + done + '/' + items.length + '</span>' +
'<button class="icon-btn" data-action="collapse-group" data-key="' + esc(k) + '" title="Collapse all">\u229f</button>' +
'<button class="icon-btn" data-action="expand-group" data-key="' + esc(k) + '" title="Expand all">\u229e</button>' +
'<button class="hide-btn" data-action="hide-group" data-key="' + esc(k) + '">Hide</button>' +
'</div>';
for (const f of items) hh += renderCard(f, q);
hh += '</div>';
}
content.innerHTML = hh;
syncHash();
}
// Long-press solo: hold a filter badge 450 ms to isolate that value (hide all others in the group).
let _soloTimer = 0;
let _soloFired = false;
function _applySolo(b) {
_soloFired = true;
const sev = b.dataset.sev, typ = b.dataset.type, sta = b.dataset.status, dif = b.dataset.diff;
if (sev) { S.hiddenSev.clear(); PRESENT_SEVS.filter(v => v !== sev).forEach(v => S.hiddenSev.add(v)); }
else if (typ) { S.hiddenType.clear(); ALL_TYPES.filter(v => v !== typ).forEach(v => S.hiddenType.add(v)); }
else if (sta) { S.hiddenStatus.clear(); STATUSES.filter(v => v !== sta).forEach(v => S.hiddenStatus.add(v)); }
else if (dif) { S.hiddenDiff.clear(); PRESENT_DIFFS.filter(v => v !== dif).forEach(v => S.hiddenDiff.add(v)); }
scheduleRender();
}
const _fb = document.getElementById('filterbar');
_fb.addEventListener('pointerdown', e => {
const b = e.target.closest('[data-sev],[data-type],[data-status],[data-diff]');
if (!b) return;
_soloFired = false;
clearTimeout(_soloTimer);
_soloTimer = setTimeout(() => _applySolo(b), 450);
}, {passive: true});
_fb.addEventListener('pointerup', () => clearTimeout(_soloTimer), {passive: true});
_fb.addEventListener('pointercancel', () => clearTimeout(_soloTimer), {passive: true});
_fb.addEventListener('pointermove', e => {
if (!_soloTimer) return;
if (!e.target.closest('[data-sev],[data-type],[data-status],[data-diff]')) clearTimeout(_soloTimer);
}, {passive: true});
// Event delegation
_fb.addEventListener('click', e => {
if (_soloFired) { _soloFired = false; return; }
const b = e.target.closest('[data-sev],[data-type],[data-status],[data-toggle-all],[data-diff]');
if (!b) return;
if (b.dataset.toggleAll) {
const k = b.dataset.toggleAll;
const set = k === 'sev' ? S.hiddenSev : k === 'type' ? S.hiddenType : k === 'status' ? S.hiddenStatus : k === 'diff' ? S.hiddenDiff : null;
const full = k === 'sev' ? PRESENT_SEVS : k === 'type' ? ALL_TYPES : k === 'status' ? STATUSES : k === 'diff' ? PRESENT_DIFFS : [];
if (!set) return;
if (set.size === 0) full.forEach(v => set.add(v));
else set.clear();
}
else if (b.dataset.sev) { const s = S.hiddenSev; s.has(b.dataset.sev) ? s.delete(b.dataset.sev) : s.add(b.dataset.sev); }
else if (b.dataset.type) { const s = S.hiddenType; s.has(b.dataset.type) ? s.delete(b.dataset.type) : s.add(b.dataset.type); }
else if (b.dataset.status) { const s = S.hiddenStatus; s.has(b.dataset.status) ? s.delete(b.dataset.status) : s.add(b.dataset.status); }
else if (b.dataset.diff) { const s = S.hiddenDiff; s.has(b.dataset.diff) ? s.delete(b.dataset.diff) : s.add(b.dataset.diff); }
scheduleRender();
});
document.addEventListener('click', e => {
const b = e.target.closest('[data-action]');
if (!b) return;
const { action, key, id } = b.dataset;
if (action === 'hide-group') { S.hiddenGroup.add(key); scheduleRender(); }
else if (action === 'show-group') { S.hiddenGroup.delete(key); scheduleRender(); }
else if (action === 'show-all') { S.hiddenGroup.clear(); scheduleRender(); }
else if (action === 'collapse-all') { FINDINGS.forEach(f => S.collapsed.add(f.id)); scheduleRender(); }
else if (action === 'expand-all') { S.collapsed.clear(); scheduleRender(); }
else if (action === 'copy-md') {
e.stopPropagation();
const f = FINDING_BY_ID.get(id);
if (f) copyText(findingToMd(f), b);
}
else if (action === 'download-md') { downloadText(buildMdFull(), 'findings.md'); }
else if (action === 'download-md-short') { downloadText(buildMdShort(), 'findings-short.md'); }
else if (action === 'download-toml') {
// Single download (was two) - a second auto-triggered download is commonly
// blocked by the browser unless the user explicitly allows it, which silently
// drops project-info.toml. Embedding [project] in findings.toml itself avoids
// that failure mode entirely.
downloadText(buildFindingsToml(), 'findings.toml', 'application/toml');
}
else if (action === 'cycle-status') {
e.stopPropagation();
cycleStatus(id);
}
else if (action === 'toggle-card') {
toggleCard(id);
}
else if (action === 'collapse-group') {
collapseGroup(key, true);
}
else if (action === 'expand-group') {
collapseGroup(key, false);
}
});
// Debounced search — 180 ms after last keystroke
let _searchTimer = 0;
document.getElementById('search').addEventListener('input', e => {
S.search = e.target.value;
clearTimeout(_searchTimer);
_searchTimer = setTimeout(scheduleRender, 180);
});
document.getElementById('groupby').addEventListener('change', e => {
S.groupBy = e.target.value;
S.hiddenGroup.clear();
scheduleRender();
});
document.getElementById('search-scope').addEventListener('click', e => {
S.searchScope = S.searchScope === 'locations' ? 'all' : 'locations';
e.currentTarget.classList.toggle('active', S.searchScope === 'locations');
scheduleRender();
});
// Init
loadStatuses();
initFilterBar();
loadHash();
FINDINGS.forEach(f => S.collapsed.add(f.id));
render();
})();
</script>
</body></html>