mnml-rs 0.2.13

A NvChad-style terminal IDE in Rust — vim or standard editing, LSP, git, and an embedded HTTP client.
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
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
//! Marketplace — federated discovery of mnml apps and launchers.
//!
//! Queries two kinds of external sources to build the "installable
//! things" list shown in mnml's Integrations panel Marketplace tab:
//!
//! 1. **Apps** — compiled integrations on crates.io tagged with the
//!    `mnml-integration` keyword. Anyone can publish. Discovery is
//!    fully public — mnml doesn't gate what appears.
//! 2. **Launchers** — TOML descriptors under a configured GitHub
//!    repo's folder (default: `chris-mclennan/mnml-integrations/launchers`).
//!    Third parties run their own launcher catalogs by adding their
//!    repo to `[[marketplace.source]]` in user config.
//!
//! ## Design
//!
//! - **Blocking fetch on a background thread.** Matches the integration
//!   loader pattern (`mpsc` channel, main loop polls). Kept out of
//!   this module — this file only exposes synchronous fetch helpers
//!   that a caller wraps in `thread::spawn`.
//! - **Local cache** at `~/.cache/mnml/marketplace.json`. Read on
//!   demand; write after every successful fetch. TTL configurable
//!   (`cache_ttl_secs`, default 3600). Stale-while-revalidate is
//!   the caller's decision.
//! - **Optional gh-auth-token acceleration.** Detected at runtime
//!   via `gh auth token`. Present → 5000 req/hr on GitHub; absent
//!   → 60 req/hr unauth. Neither path fails; the cache absorbs the
//!   rate-limit difference.
//! - **No hardcoded sources.** The default source list ships in
//!   `default_sources()`, but every mnml install can override /
//!   extend via `[[marketplace.source]]` in config. No repo name
//!   is baked into the marketplace query path.
//!
//! ## Not in this module
//!
//! - Config parsing of `[[marketplace.source]]` — P4b.
//! - UI wiring to the Integrations panel Marketplace tab — P4b.
//! - Async plumbing (spawn thread, mpsc) — P4b.
//! - Install actions (cargo install, download TOML) — P4b.
//!
//! P4a's scope is just: fetch → parse → cache round-trip, plus
//! type shapes the UI can render against.

use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

/// One entry in the marketplace — an app or a launcher the user
/// could install. Rendered as a row in the Marketplace tab.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MarketplaceEntry {
    /// Which source produced this entry. Rendered as a small tag
    /// so the user can tell "reference collection" from "third-party".
    pub source_id: String,
    /// App or launcher. Drives install method + rendering.
    pub kind: MarketplaceKind,
    /// Stable id — either the crate name (apps) or the launcher
    /// TOML's `id` field (launchers).
    pub id: String,
    /// Short display name — from Cargo.toml `description` (first
    /// sentence) or the launcher TOML's `label` field.
    pub label: String,
    /// Longer form — from Cargo.toml `description` (full) or the
    /// launcher TOML's `description` field.
    pub description: Option<String>,
    /// `install_command` for apps ("cargo install foo"), download
    /// URL for launchers.
    pub install: InstallSpec,
    /// Optional metadata — downloads (crates.io), stars (GitHub),
    /// last-updated timestamp. Populated when the source provides
    /// it; renderers use it as a sort key.
    #[serde(default)]
    pub stats: EntryStats,
    /// #849 — tagged at fetch time by matching `source_id` against
    /// [`default_sources()`]. Official entries render with a green
    /// badge and sort first; Community entries with a grey badge.
    /// `#[serde(default)]` = older caches deserialize as
    /// `Community` (safe under-count).
    #[serde(default)]
    pub provenance: Provenance,
    /// 2026-08-05 — Nerd Font glyph rendered in the marketplace row,
    /// so an unlisted entry looks like its installed counterpart at
    /// a glance. Sources:
    ///   1. Launcher TOMLs — pulled from `chip.glyph` at parse time.
    ///   2. `builtin_catalog()` — well-known crates.io app IDs
    ///      hardcoded in mnml so users see icons on first fetch
    ///      before any hosted catalog is deployed.
    ///   3. (Future) hosted catalog JSON on mnml's site, fetched as
    ///      a `Catalog` source variant that enriches entries by id.
    /// `None` triggers the kind-based fallback in the renderer.
    #[serde(default)]
    pub glyph: Option<String>,
    /// Companion to `glyph` — theme color name (`"blue"`, `"cyan"`,
    /// `"yellow"`, …), applied to the glyph span. `None` → `t.fg`.
    #[serde(default)]
    pub color: Option<String>,
    /// 2026-08-08 (renamed 2026-08-19, task #1055) — separate from
    /// `provenance` ("who wrote it"), this flags "the author has
    /// declared this ready to show". Interim source is a curated
    /// allow-list in mnml core (see [`ready_ids`]); the eventual
    /// design is `ready = true` in each integration's own manifest.
    /// The Marketplace tab HIDES entries with `ready = false` by
    /// default so half-baked experiments don't drown out real
    /// integrations. Rendered as a `✓ Ready` chip on the row
    /// alongside (not instead of) the Official/Community chip.
    #[serde(default, alias = "verified")]
    pub ready: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MarketplaceKind {
    /// Compiled integration with its own binary. Install via `cargo install`.
    App,
    /// TOML descriptor. Install by downloading the file to
    /// `~/.config/mnml/integrations/<id>.toml`.
    Launcher,
}

/// #849 — provenance of a marketplace entry. Tagged at fetch time
/// by matching the entry's source-id against the shipped default
/// list — NOT a manifest field (any author could set that). The
/// gatekeeper is who has write access to the source repo /
/// crates-io-keyword-cache, and that's exactly what
/// `default_sources()` catalogs.
///
/// Rendering:
/// - Official entries get a green `✓ Official` chip in the
///   marketplace tab row.
/// - Community entries get a grey `~ Community` chip.
/// - Default sort puts Official first, then Community, alphabetical
///   within each group.
///
/// Users overriding a default via a custom-id source (adding
/// `chris-mclennan/mnml-integrations` under a different id) still
/// get the Official tag because the repo URL / crates-keyword
/// matches — see `provenance_for()`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Provenance {
    /// First-party — an entry from a source in
    /// [`default_sources()`], matched by source id.
    Official,
    /// Third-party — a user-added source, or any source whose id
    /// isn't in the built-in defaults. Default when deserializing
    /// older caches that predate the field.
    #[serde(other)]
    #[default]
    Community,
}

/// Determine the provenance for a source id by matching against
/// the shipped defaults. Any source that matches a default-source
/// id is Official; everything else is Community.
///
/// This is a fetch-time function that runs when an entry is being
/// constructed. Serialized entries carry their provenance in the
/// cache directly (see `MarketplaceEntry::provenance`), so
/// consumers reading the cache don't need to re-derive it.
pub fn provenance_for(source_id: &str) -> Provenance {
    if default_sources().iter().any(|s| s.id() == source_id) {
        Provenance::Official
    } else {
        Provenance::Community
    }
}

/// 2026-08-08 — the curated list of marketplace ids the maintainer has
/// declared ready to surface. Rendered as a green `✓ Ready` chip on the
/// marketplace row, and (as of #1055) the Marketplace tab HIDES entries
/// whose id is missing here — so half-baked experiments in the
/// monorepo don't drown out real integrations. Add an id after using
/// the crate end-to-end without hitting blockers; remove one if a real
/// user reports it's broken.
///
/// Distinct from [`Provenance`], which is about AUTHORSHIP (who wrote
/// it). A community-authored integration can be marked ready once we've
/// used it; an official integration stays unready until we've tried it.
///
/// This is the interim home for the flag; the eventual design is
/// `ready = true` in each integration's own `mnml-bridge` manifest so
/// community authors can hide their own in-progress crates without
/// waiting on a mnml release (see task #1055).
pub fn ready_ids() -> &'static [&'static str] {
    &[
        "mnml-forge-bitbucket",
        "mnml-tracker-jira",
        "mnml-aws-amplify",
        "mnml-aws-codebuild",
        "mnml-db",
        // 2026-08-19 (#1062) — `mnml-msg-slack` removed from the
        // ready set: still WIP (threads unfinished, canvases tab
        // was mislabeled "Slack Boards" until 0.1.4). Reinstate
        // when the integration ships end-to-end + user confirms.
        // 2026-08-19 (#1090) — `mnml-forge-github` removed too.
        // User: "not ready, only in development tab should show
        // this one". Also surfaced an install "Error:" message on
        // config-template bootstrap (#1091). Reinstate when both
        // are addressed.
    ]
}

/// Convenience — does the given entry id appear in [`ready_ids()`]?
pub fn is_ready(id: &str) -> bool {
    ready_ids().contains(&id)
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum InstallSpec {
    /// `cargo install <name>` — for crates.io apps.
    Cargo { name: String },
    /// HTTP URL to the raw launcher TOML. Downloading it + writing
    /// to `~/.config/mnml/integrations/<id>.toml` completes install.
    LauncherToml { url: String },
    /// `cargo install --git https://github.com/<repo>.git --path <path>`
    /// — for private-repo apps not on crates.io. `repo` is the
    /// `owner/name` slug (turned into an HTTPS URL at install time so
    /// gh CLI auth applies). `path` is the sub-directory of the crate
    /// inside a monorepo (e.g. `apps/mnml-tattle-coverage`); use `.`
    /// for a single-crate repo. 2026-08-15.
    CargoGit { repo: String, path: String },
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EntryStats {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub downloads: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub stars: Option<u64>,
    /// Unix timestamp in seconds — from source's "updated_at".
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub updated_at: Option<u64>,
}

/// One configured marketplace source. Users add these via
/// `[[marketplace.source]]` in config; defaults live in
/// [`default_sources()`].
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Source {
    /// crates.io keyword search — every public crate tagged with
    /// `keyword` shows up. The default `mnml-integration` finds
    /// every published mnml app regardless of author.
    CratesKeyword { id: String, keyword: String },
    /// GitHub repository folder — every `.toml` file directly under
    /// `path` is treated as a launcher descriptor. The user can
    /// point at their own repo to run a private launcher catalog.
    GithubLauncherFolder {
        id: String,
        repo: String,
        path: String,
    },
    /// GitHub monorepo apps — every sub-directory of `<repo>/<apps_dir>`
    /// is treated as an installable Rust integration crate. Installed
    /// via `cargo install --git https://github.com/<repo>.git --path
    /// <apps_dir>/<name>`. gh auth applies to both the enumeration
    /// (via `Authorization: Bearer`) AND the cargo install (via
    /// `credential.helper` — cargo shells to git which uses the
    /// user's git config). Private repos work iff the user has org
    /// access. Rows render with a yellow `Private` chip (styled to
    /// match the existing `✓ Verified` / `~ Community` chips on the
    /// same row). 2026-08-15.
    GithubMonorepoApps {
        id: String,
        repo: String,
        apps_dir: String,
    },
}

impl Source {
    pub fn id(&self) -> &str {
        match self {
            Source::CratesKeyword { id, .. } => id,
            Source::GithubLauncherFolder { id, .. } => id,
            Source::GithubMonorepoApps { id, .. } => id,
        }
    }
}

/// The default source list mnml ships with. Users' config
/// `[[marketplace.source]]` entries append to this (or replace if
/// they set `[marketplace] use_defaults = false`).
pub fn default_sources() -> Vec<Source> {
    vec![
        Source::CratesKeyword {
            id: "crates.io".to_string(),
            keyword: "mnml-integration".to_string(),
        },
        Source::GithubLauncherFolder {
            id: "chris-mclennan/mnml-integrations".to_string(),
            repo: "chris-mclennan/mnml-integrations".to_string(),
            path: "launchers".to_string(),
        },
        // 2026-08-19 (#1055) — the crates.io keyword source only
        // finds crates whose Cargo.toml sets `keywords =
        // ["mnml-integration"]`. Most integrations never opted into that,
        // so mnml-tracker-jira and friends were invisible in the
        // marketplace even after being installed. The monorepo apps
        // folder catches everything else — but the render layer
        // filters to `ready = true` (populated from `ready_ids()`),
        // so half-baked experiments in the folder don't drown out
        // the ~7 real integrations.
        Source::GithubMonorepoApps {
            id: "chris-mclennan/mnml-integrations-apps".to_string(),
            repo: "chris-mclennan/mnml-integrations".to_string(),
            apps_dir: "apps".to_string(),
        },
    ]
}

// ── crates.io API response shapes ────────────────────────────────
//
// crates.io returns JSON with a `crates: [...]` array. Only the
// fields we render / sort by land in this Deserialize.

#[derive(Debug, Deserialize)]
struct CratesResponse {
    #[serde(default)]
    crates: Vec<CratesCrate>,
}

#[derive(Debug, Deserialize)]
struct CratesCrate {
    #[serde(rename = "name")]
    name: String,
    #[serde(default)]
    description: Option<String>,
    #[serde(default)]
    downloads: Option<u64>,
    /// ISO 8601 timestamp like "2026-08-01T18:00:00.000000+00:00".
    #[serde(default)]
    updated_at: Option<String>,
}

/// Parse a crates.io keyword-search JSON response into a list of
/// marketplace entries. Pure — takes the response body, no HTTP.
pub fn parse_crates_response(source_id: &str, body: &str) -> Result<Vec<MarketplaceEntry>, String> {
    let resp: CratesResponse =
        serde_json::from_str(body).map_err(|e| format!("crates.io json: {e}"))?;
    let provenance = provenance_for(source_id);
    let out = resp
        .crates
        .into_iter()
        .map(|c| {
            let label = c.name.clone();
            let (glyph, color) = catalog_lookup(&c.name);
            let name_for_verified = c.name.clone();
            MarketplaceEntry {
                source_id: source_id.to_string(),
                kind: MarketplaceKind::App,
                id: c.name.clone(),
                label,
                description: c.description,
                install: InstallSpec::Cargo { name: c.name },
                stats: EntryStats {
                    downloads: c.downloads,
                    stars: None,
                    updated_at: c.updated_at.and_then(|s| parse_iso8601_secs(&s)),
                },
                provenance,
                glyph,
                color,
                ready: is_ready(&name_for_verified),
            }
        })
        .collect();
    Ok(out)
}

// ── GitHub Contents API response shapes ──────────────────────────
//
// The `/contents/<path>` endpoint returns an array of file objects.
// For each `.toml` file we also fetch its raw content via
// `download_url` and parse it as an IntegrationManifest.

#[derive(Debug, Deserialize)]
struct GhFileEntry {
    name: String,
    #[serde(rename = "type")]
    entry_type: String,
    download_url: Option<String>,
}

/// Filter a GitHub contents-API response body to just the `.toml`
/// file entries directly under the folder. Returns
/// `(filename, download_url)` pairs the caller feeds to
/// [`parse_launcher_toml`] after fetching each file's contents.
pub fn parse_github_folder_response(body: &str) -> Result<Vec<(String, String)>, String> {
    let entries: Vec<GhFileEntry> =
        serde_json::from_str(body).map_err(|e| format!("github contents json: {e}"))?;
    let out = entries
        .into_iter()
        .filter(|e| e.entry_type == "file" && e.name.ends_with(".toml"))
        .filter_map(|e| e.download_url.map(|url| (e.name, url)))
        .collect();
    Ok(out)
}

/// Filter a GitHub contents-API response body to just the sub-directory
/// names. Used by [`Source::GithubMonorepoApps`] where each sub-directory
/// under `<repo>/<apps_dir>` is an installable Rust crate.
///
/// Names starting with `.` or `_` are skipped as convention (build
/// outputs, hidden dirs). Names that contain anything outside the
/// crate-safe charset `[A-Za-z0-9._-]` are ALSO skipped — this is a
/// security boundary, not a stylistic filter. The name flows into a
/// shell command in `install_marketplace_entry` (twice: once as the
/// cargo `--path` component and once as the binary invocation).
/// Unlike crates.io (which enforces the same charset upstream)
/// GitHub directory names are unconstrained, so a hostile / typo'd
/// directory called `` `evil` `` or `foo;rm -rf ~` would otherwise
/// achieve arbitrary shell execution on install-click. 2026-08-15.
pub fn parse_github_dir_children(body: &str) -> Result<Vec<String>, String> {
    let entries: Vec<GhFileEntry> =
        serde_json::from_str(body).map_err(|e| format!("github contents json: {e}"))?;
    Ok(entries
        .into_iter()
        .filter(|e| e.entry_type == "dir")
        .map(|e| e.name)
        .filter(|n| !n.starts_with('.') && !n.starts_with('_'))
        .filter(|n| is_safe_crate_component(n))
        .collect())
}

/// Charset guard for path components that flow into a shell command.
/// Matches crates.io's own crate-name policy (`[A-Za-z0-9_-]`) plus
/// `.` (some sub-directories contain a dot). Rejects empty strings
/// and the two traversal sentinels `.` / `..` so any future call
/// site can rely on this function *alone* — `is_safe_repo_subpath`
/// duplicates the `..` rejection because a bare `..` would also
/// fail this check, but a nested `foo/../bar` still needs its own
/// segment-level guard. 2026-08-15.
pub fn is_safe_crate_component(s: &str) -> bool {
    if s.is_empty() || s == "." || s == ".." {
        return false;
    }
    s.chars()
        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
}

/// Charset guard for a GitHub `owner/name` repo slug. Same allow-list
/// as [`is_safe_crate_component`] plus exactly one `/`. Rejects empty
/// / leading-slash / trailing-slash / multi-slash. Used at Source
/// construction time (see `RawMarketplaceSource::into_source` in
/// `config.rs`) so an invalid repo slug never reaches the shell.
pub fn is_safe_repo_slug(s: &str) -> bool {
    let mut parts = s.split('/');
    match (parts.next(), parts.next(), parts.next()) {
        (Some(owner), Some(name), None) => {
            is_safe_crate_component(owner) && is_safe_crate_component(name)
        }
        _ => false,
    }
}

/// Charset guard for a repo sub-directory path (`apps_dir` in
/// `Source::GithubMonorepoApps`). Same allow-list as
/// [`is_safe_crate_component`] plus `/` for nesting. Rejects empty /
/// leading-slash / `..` traversal segments.
pub fn is_safe_repo_subpath(s: &str) -> bool {
    if s.is_empty() || s.starts_with('/') || s.ends_with('/') {
        return false;
    }
    s.split('/')
        .all(|seg| !seg.is_empty() && seg != ".." && is_safe_crate_component(seg))
}

/// Extract `(description, label)` from a Cargo.toml raw body.
/// Used by [`Source::GithubMonorepoApps`] to enrich each app's row
/// with the crate's own description and (if present) a friendlier
/// display name. Missing / empty fields return `None` for that
/// component so the caller falls back to the crate name. 2026-08-15.
pub fn parse_cargo_toml_metadata(body: &str) -> Result<(Option<String>, Option<String>), String> {
    #[derive(Debug, Deserialize)]
    struct CargoManifest {
        package: Option<Package>,
    }
    #[derive(Debug, Deserialize)]
    struct Package {
        description: Option<String>,
        // Cargo's `name` field. The caller uses it as the row label,
        // falling back to the raw directory name only if this + the
        // whole [package] table are absent. `package.metadata.mnml.
        // label` remains reserved for a friendlier display name once
        // an integration wants to override.
        name: Option<String>,
    }
    let m: CargoManifest = toml::from_str(body).map_err(|e| format!("cargo.toml: {e}"))?;
    let pkg = m.package.unwrap_or(Package {
        description: None,
        name: None,
    });
    let desc = pkg
        .description
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty());
    let label = pkg
        .name
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty());
    Ok((desc, label))
}

/// Parse a launcher TOML file's raw contents into a marketplace
/// entry. Uses the existing `IntegrationManifest` type so the
/// schema stays in sync with what mnml expects on install.
pub fn parse_launcher_toml(
    source_id: &str,
    download_url: &str,
    body: &str,
) -> Result<MarketplaceEntry, String> {
    let m: crate::integration_manifest::IntegrationManifest =
        toml::from_str(body).map_err(|e| format!("launcher toml: {e}"))?;
    // Launcher glyph comes from the manifest's chip spec. Fall back
    // to the builtin catalog by id (e.g. a manifest that omits chip
    // still shows a recognizable icon if we know the id).
    let (chip_glyph, chip_color) = match &m.chip {
        Some(c) => (Some(c.glyph.clone()), Some(c.color.clone())),
        None => (None, None),
    };
    let (fallback_glyph, fallback_color) = catalog_lookup(&m.id);
    let ready = is_ready(&m.id);
    Ok(MarketplaceEntry {
        source_id: source_id.to_string(),
        kind: MarketplaceKind::Launcher,
        id: m.id,
        label: m.label,
        description: m.description,
        install: InstallSpec::LauncherToml {
            url: download_url.to_string(),
        },
        stats: EntryStats::default(),
        provenance: provenance_for(source_id),
        glyph: chip_glyph.or(fallback_glyph),
        color: chip_color.or(fallback_color),
        ready,
    })
}

/// Marketplace row glyph lookup — well-known IDs get their real
/// Nerd Font codepoint + color; anything else returns `(None, None)`
/// so the renderer falls back to a neutral kind icon (package for
/// App, plug for Launcher). No monograms — a two-letter placeholder
/// was worse than an honest generic icon.
///
/// Launcher entries also get their glyph from their TOML manifest
/// (populated in `parse_launcher_toml`), which takes precedence
/// over this table. This table is here for **crates.io apps** —
/// mnml has no manifest to consult until after install.
///
/// Future: a `Catalog` source variant will fetch a JSON on the
/// mnml site that OVERRIDES this map so community entries pick up
/// icons without a mnml release.
pub fn catalog_lookup(id: &str) -> (Option<String>, Option<String>) {
    // All codepoints below are inside the standard Nerd Fonts
    // ranges (Devicons E700-E8EF, Codicons EA60-EC1E, MDI
    // F0001-F1AFF) OR the mnml-baked PUA range (F1B00-F20FF).
    // Ghostty's `font-codepoint-map` must route these to a real
    // Nerd Font (e.g. Symbols Nerd Font Mono) for the last three
    // ranges, and to MnmlSymbols for the PUA one.
    let (glyph, color): (&str, &str) = match id {
        // System monitors — real MDI codepoints (verified in font).
        // Prior F0AEF/F0AF5/F0AF6 were md-alpha_b/h/i (letter icons),
        // corrected 2026-08-05.
        "btop" => ("\u{F0AEF}", "red"), // nf-md-alpha_b — full-cell "B" glyph, red
        "htop" => ("\u{F0379}", "green"), // md-monitor
        "iftop" => ("\u{F06F3}", "cyan"), // md-network

        // Editors / IDEs — nf-dev-vscode (E8DA). Chose over cod-
        // vscode (EC29) because ghostty 1.3.1 doesn't route EC29
        // even with the codicon range extended — devicon E8DA
        // renders reliably. Both are official VS Code glyphs.
        "vscode" | "VSCode" => ("\u{E8DA}", "blue"),

        // AI (first-party, baked into MnmlSymbols)
        "claude_code" | "claude-code" => ("\u{F1E00}", "orange"),
        "codex" => ("\u{F1E01}", "cyan"),

        // Browser — nf-cod-browser
        "browser" => ("\u{EB01}", "blue"),

        // Database family — dev-database (E64D is in Devicons)
        "mnml-db" => ("\u{E64D}", "blue"),

        // Database drivers — real engine icon + brand color.
        // The [driver] tag is purple (family), but the glyph itself
        // uses each engine's brand color so the icons read like the
        // real logos, not a uniform purple wash.
        "mnml-db-driver-postgres" => ("\u{E76E}", "blue"), // dev-postgresql — postgres elephant blue
        "mnml-db-driver-mariadb" => ("\u{F1C12}", "teal"), // MariaDB SVG bake (mono seal) — dark teal to match brand
        "mnml-db-driver-mysql" => ("\u{E704}", "orange"),  // dev-mysql — orange dolphin
        "mnml-db-driver-redis" => ("\u{F1C13}", "red"), // Redis SVG bake (mono cube) in Redis red
        "mnml-db-driver-sqlite" => ("\u{E7C4}", "blue"), // dev-sqlite
        "mnml-db-driver-docdb" => ("\u{F1C11}", "blue"), // AWS DocumentDB SVG bake — matches redshift/dynamodb AWS blue
        "mnml-db-driver-clickhouse" => ("\u{F1C0F}", "yellow"), // ClickHouse SVG bake in MnmlSymbols
        "mnml-db-driver-redshift" => ("\u{F1C10}", "purple"), // AWS Redshift SVG bake (re-baked from aws-amazon-redshift.svg, inverted from purple bg)
        "mnml-db-driver-dynamodb" => ("\u{F1C06}", "blue"),   // AWS SVG bake — DynamoDB blue

        // SCM hosts — Devicons range (E5FA-E8EF).
        "mnml-scm-bitbucket" | "mnml-forge-bitbucket" | "bitbucket" => ("\u{F00A8}", "blue"),
        "mnml-scm-github" | "mnml-forge-github" | "github" => ("\u{E709}", "fg"),

        // Messaging — mdi-slack (F04B1, the nf-md-slack codepoint on
        // nerdfonts.com). Matches icon_catalog.rs + the installed
        // slack_channels/slack_canvases manifests. F198 is the older
        // nf-fa-slack (pre-2019 wave logo); F03EF is not a slack glyph
        // at all — both were prior bakes that left the marketplace
        // card visually inconsistent (or plain wrong).
        "mnml-msg-slack" | "slack" => ("\u{F04B1}", "white"),

        // Jira tracker app (3 chips on install) — same F0303 as
        // the launcher so preview matches the installed chip.
        "mnml-tracker-jira" => ("\u{F0303}", "blue"),

        // Tattle coverage (private) — U+F437 per user request 2026-08-16, a
        // line-chart glyph in the MDI range that mnml's default
        // font-codepoint-map routes. First attempt used EC2E
        // (nf-cod-graph), which is above ghostty's default Codicons
        // ceiling (EA60-EC1E) and rendered as tofu. 2026-08-15.
        "mnml-tattle-coverage" => ("\u{F437}", "cyan"),

        // AWS integrations — SVGs baked from
        // ~/Downloads/mnml-aws-icon-preview-inverted at F1C03-F1C0E.
        // Colors sourced directly from each SVG's fill= attribute so
        // the marketplace matches AWS's official brand palette:
        //   #ED7100 orange  = Compute (ECR, ECS, Lambda)
        //   #C925D1 purple  = Database + DevTools (DynamoDB, RDS, CodeBuild)
        //   #E7157B magenta = App Integration + Observability
        //                     (EventBridge, SNS, SQS, CloudWatch)
        //   #DD344C red     = Security + Front-End (Cognito, Amplify)
        "mnml-aws-amplify" => ("\u{F1C0E}", "red"), // #DD344C
        "mnml-aws-cloudwatch" => ("\u{F1C03}", "pink"), // #E7157B (Analytics — pink)
        "mnml-aws-codebuild" => ("\u{F1C04}", "pink"), // #C925D1 (DevTools — pink per user)
        "mnml-aws-cognito" => ("\u{F1C05}", "red"), // #DD344C
        "mnml-aws-dynamodb" => ("\u{F1C06}", "purple"), // #C925D1
        "mnml-aws-ecr" => ("\u{F1C07}", "orange"),  // #ED7100
        "mnml-aws-ecs" => ("\u{F1C08}", "orange"),  // #ED7100
        "mnml-aws-eventbridge" => ("\u{F1C09}", "magenta"), // #E7157B
        "mnml-aws-lambda" => ("\u{F1C0A}", "orange"), // #ED7100
        "mnml-aws-rds" => ("\u{F1C0B}", "purple"),  // #C925D1
        "mnml-aws-sns" => ("\u{F1C0C}", "magenta"), // #E7157B
        "mnml-aws-sqs" => ("\u{F1C0D}", "magenta"), // #E7157B

        // Anything else — no match; renderer skips the leading
        // glyph column and just prints the [kind] tag.
        _ => return (None, None),
    };
    (Some(glyph.to_string()), Some(color.to_string()))
}

// ── Cache ─────────────────────────────────────────────────────────

/// Serialized cache file at `~/.cache/mnml/marketplace.json`. Keeps
/// each source's last-successful entries + a Unix-seconds timestamp
/// so we can honor TTL on next load.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MarketplaceCache {
    /// Unix seconds when the cache was last successfully written.
    pub fetched_at: u64,
    /// TTL applied on this write — carried in the file so a shorter
    /// runtime TTL doesn't retroactively invalidate a stale cache.
    /// (Runtime always respects the LATEST of file TTL and config
    /// TTL — err on the side of showing something rather than
    /// nothing.)
    pub ttl_secs: u64,
    pub entries: Vec<MarketplaceEntry>,
}

impl MarketplaceCache {
    /// Standard file location. In HOME mode this is the XDG cache
    /// dir (`~/.cache/mnml/marketplace.json`, kept separate from
    /// `~/.config/mnml/` to follow XDG conventions). In portable
    /// mode the cache lives under `<mnml-data>/cache/` so a
    /// portable install stays self-contained.
    pub fn path() -> Option<PathBuf> {
        if crate::data_root::data_root_kind() == crate::data_root::DataRootKind::Portable {
            return Some(
                crate::data_root::data_root()
                    .join("cache")
                    .join("marketplace.json"),
            );
        }
        let home = std::env::var_os("HOME").map(PathBuf::from)?;
        Some(home.join(".cache").join("mnml").join("marketplace.json"))
    }

    /// Load from disk. Returns `None` on any error (missing file,
    /// parse fail, wrong shape) — the cache is best-effort.
    pub fn load_from(path: &Path) -> Option<Self> {
        let text = std::fs::read_to_string(path).ok()?;
        let mut cache: Self = serde_json::from_str(&text).ok()?;
        // 2026-08-05 — enrich pre-glyph caches on load so users see
        // icons without waiting for the next refresh. New fetches
        // already populate `glyph`/`color`; this is only for entries
        // serialized before the field existed.
        for e in &mut cache.entries {
            if e.glyph.is_none() {
                let (g, c) = catalog_lookup(&e.id);
                e.glyph = g;
                e.color = c;
            }
        }
        Some(cache)
    }

    /// Write to disk. Creates the parent dir if missing. Returns
    /// error string on failure so the caller can toast it, but the
    /// caller never NEEDS to succeed — the cache write is a nice-
    /// to-have on top of the successful fetch.
    pub fn save_to(&self, path: &Path) -> Result<(), String> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).map_err(|e| format!("mkdir cache: {e}"))?;
        }
        let json = serde_json::to_string_pretty(self)
            .map_err(|e| format!("serialize marketplace cache: {e}"))?;
        std::fs::write(path, json).map_err(|e| format!("write cache: {e}"))
    }

    /// Has the cache exceeded its TTL? `Some(true)` means expired
    /// (data still safe to render, but a refresh is due). `Some(false)`
    /// means still fresh. `None` when the fetched_at timestamp is
    /// wrong (system clock in a bad state) — treat as expired.
    pub fn is_expired(&self) -> bool {
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or(Duration::ZERO)
            .as_secs();
        now.saturating_sub(self.fetched_at) > self.ttl_secs
    }
}

// ── Helpers ──────────────────────────────────────────────────────

/// Parse an ISO 8601 timestamp like `"2026-08-01T18:00:00Z"` or
/// `"2026-08-01T18:00:00.000000+00:00"` to Unix seconds. Returns
/// None on any parse failure — the timestamp is metadata for sort
/// order only, never load-bearing.
///
/// Hand-rolled to avoid pulling in chrono (not currently a mnml
/// dep). Supports the two forms crates.io / GitHub emit; anything
/// else returns None (the entry just loses its updated_at, doesn't
/// break rendering).
fn parse_iso8601_secs(s: &str) -> Option<u64> {
    // Expected shape: `YYYY-MM-DDTHH:MM:SS[.fraction][Z|+HH:MM|-HH:MM]`.
    // We split on 'T', parse each half, apply timezone offset.
    let (date_str, rest) = s.split_once('T')?;
    let date_parts: Vec<&str> = date_str.split('-').collect();
    if date_parts.len() != 3 {
        return None;
    }
    let year: i64 = date_parts[0].parse().ok()?;
    let month: u32 = date_parts[1].parse().ok()?;
    let day: u32 = date_parts[2].parse().ok()?;
    // Split time from timezone marker.
    let (time_str, tz_offset_secs) = if let Some(idx) = rest.find(['Z', '+', '-']) {
        let (t, tz) = rest.split_at(idx);
        let offset = match tz.chars().next()? {
            'Z' => 0i64,
            sign => {
                let after = &tz[1..];
                let (hh, mm) = after.split_once(':')?;
                let hh: i64 = hh.parse().ok()?;
                let mm: i64 = mm.parse().ok()?;
                let mag = hh * 3600 + mm * 60;
                if sign == '-' { -mag } else { mag }
            }
        };
        (t, offset)
    } else {
        // No timezone marker — assume UTC.
        (rest, 0i64)
    };
    // Strip fractional seconds if present.
    let time_str = time_str.split('.').next()?;
    let time_parts: Vec<&str> = time_str.split(':').collect();
    if time_parts.len() != 3 {
        return None;
    }
    let hh: u32 = time_parts[0].parse().ok()?;
    let mm: u32 = time_parts[1].parse().ok()?;
    let ss: u32 = time_parts[2].parse().ok()?;
    // Convert to Unix seconds via the days-since-epoch algorithm
    // used by every date library. mnml doesn't need microsecond
    // precision here (marketplace sort key), so u64 is fine.
    let epoch_days = days_since_epoch(year, month, day)?;
    let secs =
        epoch_days * 86_400 + (hh as i64) * 3600 + (mm as i64) * 60 + (ss as i64) - tz_offset_secs;
    u64::try_from(secs).ok()
}

/// Days between 1970-01-01 and the given Gregorian date. Returns
/// None for pre-epoch dates (the marketplace never sees those).
///
/// Uses the algorithm from Howard Hinnant's date-time paper —
/// straight arithmetic, no lookup tables.
fn days_since_epoch(year: i64, month: u32, day: u32) -> Option<i64> {
    if year < 1970 || month == 0 || month > 12 || day == 0 || day > 31 {
        return None;
    }
    let y = if month <= 2 { year - 1 } else { year };
    let m = if month <= 2 { month + 9 } else { month - 3 };
    let era = if y >= 0 { y } else { y - 399 } / 400;
    let yoe = (y - era * 400) as u64;
    let doy = ((153 * m as u64 + 2) / 5) + day as u64 - 1;
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
    let days = era * 146_097 + doe as i64 - 719_468;
    Some(days)
}

// ── HTTP fetch — blocking, one function per source type ──────────
//
// Callers wrap these in `thread::spawn` if they need async delivery
// (see the amplify integration for the pattern). The functions are
// blocking on purpose: unit-testable, cacheable, no runtime dep.

/// Standard mnml User-Agent for outbound HTTP. GitHub rejects
/// requests without one; crates.io accepts anything but likes
/// unique agents for analytics.
fn user_agent() -> String {
    format!("mnml-marketplace/{}", env!("CARGO_PKG_VERSION"))
}

/// Blocking HTTP fetch for a source. Attaches gh auth token to
/// GitHub requests when available. 10s timeout — matches the
/// max the user should ever wait on a marketplace refresh.
///
/// Returns entries on success; the caller decides whether to
/// merge with cache or overwrite.
pub fn fetch_source(source: &Source) -> Result<Vec<MarketplaceEntry>, String> {
    let client = reqwest::blocking::Client::builder()
        .user_agent(user_agent())
        .timeout(std::time::Duration::from_secs(10))
        .build()
        .map_err(|e| format!("build http client: {e}"))?;
    match source {
        Source::CratesKeyword { id, keyword } => {
            let url = format!(
                "https://crates.io/api/v1/crates?keyword={}&per_page=100",
                keyword
            );
            let body = client
                .get(&url)
                .send()
                .and_then(|r| r.error_for_status())
                .and_then(|r| r.text())
                .map_err(|e| format!("crates.io fetch: {e}"))?;
            parse_crates_response(id, &body)
        }
        Source::GithubLauncherFolder { id, repo, path } => {
            let list_url = format!("https://api.github.com/repos/{}/contents/{}", repo, path);
            let mut req = client.get(&list_url);
            if let Some(tok) = detect_gh_auth_token() {
                req = req.bearer_auth(tok);
            }
            let body = req
                .send()
                .and_then(|r| r.error_for_status())
                .and_then(|r| r.text())
                .map_err(|e| format!("github contents: {e}"))?;
            let files = parse_github_folder_response(&body)?;
            let mut entries = Vec::with_capacity(files.len());
            for (name, download_url) in files {
                // Best-effort: skip individual files that fail to
                // fetch or parse rather than failing the whole
                // source. Errors go to stderr for diagnostics.
                match client.get(&download_url).send().and_then(|r| r.text()) {
                    Ok(toml_body) => match parse_launcher_toml(id, &download_url, &toml_body) {
                        Ok(entry) => entries.push(entry),
                        Err(e) => eprintln!("marketplace: skip {name}: {e}"),
                    },
                    Err(e) => eprintln!("marketplace: fetch {name}: {e}"),
                }
            }
            Ok(entries)
        }
        Source::GithubMonorepoApps { id, repo, apps_dir } => {
            let list_url = format!(
                "https://api.github.com/repos/{}/contents/{}",
                repo, apps_dir
            );
            let mut req = client.get(&list_url);
            if let Some(tok) = detect_gh_auth_token() {
                req = req.bearer_auth(tok);
            }
            let body = req
                .send()
                .and_then(|r| r.error_for_status())
                .and_then(|r| r.text())
                .map_err(|e| format!("github contents (monorepo apps): {e}"))?;
            let dirs = parse_github_dir_children(&body)?;
            let gh_tok = detect_gh_auth_token();
            Ok(dirs
                .into_iter()
                .map(|name| {
                    let (glyph, color) = catalog_lookup(&name);
                    let install_path = format!("{}/{}", apps_dir, name);
                    // Best-effort: fetch the app's Cargo.toml so the
                    // marketplace row shows a real description +
                    // label instead of a bare crate name. Silent on
                    // failure — a missing Cargo.toml doesn't block
                    // the row from appearing. 2026-08-15.
                    let cargo_toml_url = format!(
                        "https://api.github.com/repos/{}/contents/{}/Cargo.toml",
                        repo, install_path
                    );
                    let mut cargo_req = client.get(&cargo_toml_url);
                    if let Some(tok) = &gh_tok {
                        cargo_req = cargo_req.bearer_auth(tok);
                    }
                    let cargo_meta = cargo_req
                        .header("Accept", "application/vnd.github.raw")
                        .send()
                        .ok()
                        .and_then(|r| r.error_for_status().ok())
                        .and_then(|r| r.text().ok())
                        .and_then(|body| parse_cargo_toml_metadata(&body).ok());
                    let (description, label) = match cargo_meta {
                        Some((desc, lbl)) => (desc, lbl.unwrap_or_else(|| name.clone())),
                        None => (None, name.clone()),
                    };
                    MarketplaceEntry {
                        source_id: id.clone(),
                        kind: MarketplaceKind::App,
                        id: name.clone(),
                        label,
                        description,
                        install: InstallSpec::CargoGit {
                            repo: repo.clone(),
                            path: install_path,
                        },
                        stats: EntryStats::default(),
                        provenance: provenance_for(id),
                        glyph,
                        color,
                        ready: is_ready(&name),
                    }
                })
                .collect())
        }
    }
}

/// Best-effort GitHub auth token discovery — invokes `gh auth token`
/// if the `gh` CLI is on PATH and authenticated. Returns None on
/// any failure. Used by the fetcher to attach `Authorization: Bearer …`
/// on GitHub requests, unlocking 5000 req/hr instead of 60.
///
/// Not called during tests — pure runtime helper.
pub fn detect_gh_auth_token() -> Option<String> {
    let out = std::process::Command::new("gh")
        .args(["auth", "token"])
        .output()
        .ok()?;
    if !out.status.success() {
        return None;
    }
    let token = String::from_utf8(out.stdout).ok()?.trim().to_string();
    if token.is_empty() { None } else { Some(token) }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_sources_include_crates_and_github() {
        let s = default_sources();
        assert_eq!(s.len(), 3);
        assert!(matches!(s[0], Source::CratesKeyword { .. }));
        assert!(matches!(s[1], Source::GithubLauncherFolder { .. }));
        // #1055 — third source: the monorepo apps folder. Necessary
        // because most integrations' Cargo.toml never opted into the
        // `mnml-integration` keyword the crates.io source searches
        // for. Render-time Ready gate keeps this from surfacing the
        // half-baked experiments in that folder.
        assert!(matches!(s[2], Source::GithubMonorepoApps { .. }));
    }

    #[test]
    fn parses_crates_response_with_all_fields() {
        let body = r#"{
            "crates": [
                {
                    "name": "mnml-aws-amplify",
                    "description": "AWS Amplify viewer for mnml",
                    "downloads": 42,
                    "updated_at": "2026-08-01T18:00:00.000000+00:00"
                },
                {
                    "name": "mnml-msg-slack",
                    "description": null,
                    "downloads": null,
                    "updated_at": null
                }
            ]
        }"#;
        let entries = parse_crates_response("crates.io", body).unwrap();
        assert_eq!(entries.len(), 2);
        assert_eq!(entries[0].id, "mnml-aws-amplify");
        assert_eq!(entries[0].label, "mnml-aws-amplify");
        assert_eq!(
            entries[0].description.as_deref(),
            Some("AWS Amplify viewer for mnml")
        );
        assert_eq!(entries[0].stats.downloads, Some(42));
        assert!(entries[0].stats.updated_at.is_some());
        assert!(matches!(entries[0].kind, MarketplaceKind::App));
        assert!(matches!(entries[0].install, InstallSpec::Cargo { .. }));
        assert_eq!(entries[1].description, None);
        assert_eq!(entries[1].stats.downloads, None);
    }

    #[test]
    fn parses_crates_response_empty() {
        let entries = parse_crates_response("s", r#"{"crates":[]}"#).unwrap();
        assert!(entries.is_empty());
    }

    #[test]
    fn parses_crates_response_malformed_returns_error() {
        assert!(parse_crates_response("s", "not json").is_err());
    }

    #[test]
    fn parses_github_folder_response_filters_to_toml_files() {
        let body = r#"[
            {
                "name": "htop.toml",
                "type": "file",
                "download_url": "https://raw.githubusercontent.com/x/y/main/launchers/htop.toml"
            },
            {
                "name": "README.md",
                "type": "file",
                "download_url": "https://raw.githubusercontent.com/x/y/main/launchers/README.md"
            },
            {
                "name": "subfolder",
                "type": "dir",
                "download_url": null
            },
            {
                "name": "iftop.toml",
                "type": "file",
                "download_url": "https://raw.githubusercontent.com/x/y/main/launchers/iftop.toml"
            }
        ]"#;
        let entries = parse_github_folder_response(body).unwrap();
        assert_eq!(entries.len(), 2);
        assert_eq!(entries[0].0, "htop.toml");
        assert_eq!(entries[1].0, "iftop.toml");
    }

    #[test]
    fn parses_github_dir_children_filters_to_visible_dirs() {
        let body = r#"[
            {"name": "mnml-tattle-coverage", "type": "dir", "download_url": null},
            {"name": "README.md", "type": "file", "download_url": "http://x"},
            {"name": ".github", "type": "dir", "download_url": null},
            {"name": "_scratch", "type": "dir", "download_url": null},
            {"name": "mnml-tattle-launchpad", "type": "dir", "download_url": null}
        ]"#;
        let dirs = parse_github_dir_children(body).unwrap();
        assert_eq!(dirs, vec!["mnml-tattle-coverage", "mnml-tattle-launchpad"]);
    }

    #[test]
    fn parses_cargo_toml_metadata_extracts_description_and_name() {
        let body = r#"
[package]
name = "mnml-tattle-coverage"
version = "0.1.0"
description = "Feature + Istanbul coverage rollups from tattle S3 (employees only)"
edition = "2024"

[dependencies]
serde = "1"
"#;
        let (desc, label) = parse_cargo_toml_metadata(body).unwrap();
        assert_eq!(
            desc.as_deref(),
            Some("Feature + Istanbul coverage rollups from tattle S3 (employees only)")
        );
        assert_eq!(label.as_deref(), Some("mnml-tattle-coverage"));
    }

    #[test]
    fn parses_cargo_toml_metadata_handles_missing_fields_gracefully() {
        // No [package] table at all — legitimate for a workspace-only
        // Cargo.toml. Both fields come back None.
        assert_eq!(
            parse_cargo_toml_metadata("[workspace]\nmembers = []").unwrap(),
            (None, None)
        );
        // Empty description string collapses to None so the caller
        // shows "(no description)" instead of a blank line.
        let (desc, _) =
            parse_cargo_toml_metadata("[package]\nname = \"foo\"\ndescription = \"   \"").unwrap();
        assert_eq!(desc, None);
    }

    #[test]
    fn parses_github_dir_children_drops_shell_injection_names() {
        // Every one of these names has a valid `dir` type but a
        // shell-metachar payload. `parse_github_dir_children` must
        // silently drop them — they'd otherwise be interpolated
        // unescaped into `cargo install --path <name>` at install
        // time. 2026-08-15 review-fix.
        let body = r#"[
            {"name": "good-crate", "type": "dir", "download_url": null},
            {"name": "foo;rm -rf ~", "type": "dir", "download_url": null},
            {"name": "back`tick`", "type": "dir", "download_url": null},
            {"name": "$evil", "type": "dir", "download_url": null},
            {"name": "sp ace", "type": "dir", "download_url": null},
            {"name": "path/traversal", "type": "dir", "download_url": null},
            {"name": "another-good", "type": "dir", "download_url": null}
        ]"#;
        let dirs = parse_github_dir_children(body).unwrap();
        assert_eq!(dirs, vec!["good-crate", "another-good"]);
    }

    #[test]
    fn safe_charset_guards_accept_expected_shapes() {
        assert!(is_safe_crate_component("mnml-tattle-coverage"));
        assert!(is_safe_crate_component("foo_bar.baz"));
        assert!(!is_safe_crate_component(""));
        assert!(!is_safe_crate_component("."));
        assert!(!is_safe_crate_component(".."));
        assert!(!is_safe_crate_component("foo bar"));
        assert!(!is_safe_crate_component("foo;bar"));
        assert!(!is_safe_crate_component("foo/bar"));

        assert!(is_safe_repo_slug("chris-mclennan/mnml-tattle-integrations"));
        assert!(!is_safe_repo_slug("chris-mclennan"));
        assert!(!is_safe_repo_slug("chris/mnml/extra"));
        assert!(!is_safe_repo_slug("/foo/bar"));
        assert!(!is_safe_repo_slug("chris; rm/-rf"));

        assert!(is_safe_repo_subpath("apps"));
        assert!(is_safe_repo_subpath("apps/foo"));
        assert!(is_safe_repo_subpath("crates/mnml-bridge"));
        assert!(!is_safe_repo_subpath(""));
        assert!(!is_safe_repo_subpath("/apps"));
        assert!(!is_safe_repo_subpath("apps/"));
        assert!(!is_safe_repo_subpath("apps/../etc"));
        assert!(!is_safe_repo_subpath("apps; rm"));
    }

    #[test]
    fn parses_launcher_toml_end_to_end() {
        let body = r#"
id = "htop"
label = "htop"
description = "Interactive process viewer"

[chip]
glyph = "5"
fallback = "H"
color = "green"
enabled = false

[[commands]]
id = "htop.open"
title = "htop: open"
group = "system"
run = ":term htop"
"#;
        let entry = parse_launcher_toml("src", "https://example/htop.toml", body).unwrap();
        assert_eq!(entry.id, "htop");
        assert_eq!(entry.label, "htop");
        assert_eq!(
            entry.description.as_deref(),
            Some("Interactive process viewer")
        );
        assert!(matches!(entry.kind, MarketplaceKind::Launcher));
        match &entry.install {
            InstallSpec::LauncherToml { url } => assert_eq!(url, "https://example/htop.toml"),
            _ => panic!("wrong install spec"),
        }
    }

    #[test]
    fn cache_roundtrips_via_json() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("marketplace.json");
        let original = MarketplaceCache {
            fetched_at: 1_754_000_000,
            ttl_secs: 3600,
            entries: vec![MarketplaceEntry {
                source_id: "crates.io".to_string(),
                kind: MarketplaceKind::App,
                id: "mnml-x".to_string(),
                label: "mnml-x".to_string(),
                description: Some("An x".to_string()),
                install: InstallSpec::Cargo {
                    name: "mnml-x".to_string(),
                },
                stats: EntryStats {
                    downloads: Some(100),
                    stars: None,
                    updated_at: Some(1_753_000_000),
                },
                provenance: Provenance::Official,
                glyph: None,
                color: None,
                ready: false,
            }],
        };
        original.save_to(&path).unwrap();
        let loaded = MarketplaceCache::load_from(&path).unwrap();
        assert_eq!(loaded.entries.len(), 1);
        assert_eq!(loaded.entries[0].id, "mnml-x");
        assert_eq!(loaded.fetched_at, 1_754_000_000);
    }

    /// #849 — the two default-source ids get `Official`; anything
    /// else is `Community`. Round-trips through the `default_sources()`
    /// list so any future addition of a default source is
    /// automatically covered.
    #[test]
    fn provenance_for_default_source_ids_is_official() {
        for source in default_sources() {
            assert_eq!(
                provenance_for(source.id()),
                Provenance::Official,
                "default source {:?} should be Official",
                source.id()
            );
        }
    }

    #[test]
    fn provenance_for_user_added_source_is_community() {
        for id in ["my-catalog", "some-other-source", ""] {
            assert_eq!(
                provenance_for(id),
                Provenance::Community,
                "unknown source id {id:?} should be Community"
            );
        }
    }

    /// Unrecognized provenance string (say a future variant an
    /// older mnml doesn't know about) deserializes as Community
    /// via `#[serde(other)]`. Distinct from the "field missing"
    /// case above — both mechanisms are required, both land on
    /// the same safe under-count.
    #[test]
    fn unknown_provenance_string_deserializes_as_community() {
        let unknown_variant = r#"{
            "fetched_at": 1754000000,
            "ttl_secs": 3600,
            "entries": [{
                "source_id": "crates.io",
                "kind": "app",
                "id": "foo",
                "label": "Foo",
                "description": null,
                "install": {"kind": "cargo", "name": "foo"},
                "stats": {},
                "provenance": "premium"
            }]
        }"#;
        let cache: MarketplaceCache = serde_json::from_str(unknown_variant).unwrap();
        assert_eq!(cache.entries[0].provenance, Provenance::Community);
    }

    /// Old cache entries lacking the field deserialize as
    /// `Community` (the `#[serde(default)]`). Users on stale
    /// caches never see false-Official labels for arbitrary crates.
    #[test]
    fn old_cache_entries_default_to_community_provenance() {
        let old_shape = r#"{
            "fetched_at": 1754000000,
            "ttl_secs": 3600,
            "entries": [{
                "source_id": "crates.io",
                "kind": "app",
                "id": "foo",
                "label": "Foo",
                "description": null,
                "install": {"kind": "cargo", "name": "foo"},
                "stats": {}
            }]
        }"#;
        let cache: MarketplaceCache = serde_json::from_str(old_shape).unwrap();
        assert_eq!(cache.entries.len(), 1);
        assert_eq!(cache.entries[0].provenance, Provenance::Community);
    }

    #[test]
    fn cache_missing_file_returns_none() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("missing.json");
        assert!(MarketplaceCache::load_from(&path).is_none());
    }

    #[test]
    fn cache_expiry_math() {
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs();
        let fresh = MarketplaceCache {
            fetched_at: now,
            ttl_secs: 3600,
            entries: vec![],
        };
        assert!(!fresh.is_expired());
        let stale = MarketplaceCache {
            fetched_at: now - 4000,
            ttl_secs: 3600,
            entries: vec![],
        };
        assert!(stale.is_expired());
    }

    #[test]
    fn parses_iso_8601_variations() {
        assert!(parse_iso8601_secs("2026-08-01T18:00:00Z").is_some());
        assert!(parse_iso8601_secs("2026-08-01T18:00:00.000000+00:00").is_some());
        assert!(parse_iso8601_secs("not a timestamp").is_none());
    }
}