jarvy 0.5.0

Jarvy is a fast, cross-platform CLI that installs and manages developer tools across macOS and Linux.
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
//! Shared library-registry pattern (PRD-054).
//!
//! Establishes the manifest + HTTPS fetch + cache mechanism reused by
//! three consumers: `[ai_hooks] library_sources`, `[mcp_register]
//! library_sources`, `[skills] library_sources`. One format, one fetch
//! pipeline, one cache layout — adding a fourth library kind in the
//! future is a new variant on `LibraryItem`, not a new module.
//!
//! # Trust model
//!
//! - **HTTPS-only**. Non-HTTPS URLs refused at the fetch boundary.
//! - **Bounded reads** (`MAX_MANIFEST_BYTES`, `MAX_ITEM_BYTES`) to
//!   protect against accidental DoS from a misbehaving publisher.
//! - **Remote-config refusal**: `library_sources` from a remote-fetched
//!   `jarvy.toml` (`jarvy setup --from <url>`) are refused — emits
//!   `library.remote_refused` event. Mirrors `[packages] allow_remote`
//!   semantics.
//! - **Signature verification**: scaffolded but not enforced in v1.
//!   `require_signature = true` (default) is honored once cosign wiring
//!   lands. Until then, libraries are fetched on trust of the URL plus
//!   sha256 verification of off-manifest artifacts. Documented as
//!   "signing recommended, will be enforced in a future Jarvy release."
//!
//! # Cache
//!
//! In-process cache per `LibrarySource` URL, populated lazily on first
//! `sync()` call. Persisted to disk at
//! `~/.jarvy/library.d/<sha256-of-url>/manifest.json` so a network
//! outage doesn't break `jarvy setup`. TTL is per-source via
//! `refresh_interval_secs` (default 86400 = 24h).
//!
//! # Public API
//!
//! ```ignore
//! // Fetch + cache a manifest:
//! let report = library_registry::sync(&source)?;
//!
//! // Resolve a hook / mcp server / skill by name across every cached
//! // library, in declaration order. First match wins; built-in
//! // libraries (`crate::ai_hooks::LIBRARY`) are checked BEFORE
//! // library_sources by the consumer, so name collisions favor the
//! // canonical Jarvy-shipped entry.
//! let item: Option<LibraryHookItem> = library_registry::resolve_hook("no-prod-deploys");
//! ```

pub mod cache;
pub mod config;
pub mod fetch;
pub mod git_fetch;
pub mod manifest;
pub mod signature;
pub mod url_parser;

pub use config::LibrarySource;
pub use manifest::{
    LibraryHookItem, LibraryItem, LibraryMcpItem, LibrarySkillItem, MANIFEST_SCHEMA_VERSION,
    Manifest,
};

use sha2::{Digest, Sha256};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use thiserror::Error;

fn sha256_hex(bytes: &[u8]) -> String {
    let mut hasher = Sha256::new();
    hasher.update(bytes);
    hex::encode(hasher.finalize())
}

#[derive(Debug, Error)]
pub enum LibraryError {
    #[error("fetch failed: {0}")]
    Fetch(#[from] fetch::FetchError),

    #[error("manifest parse error for {url}: {source}")]
    Parse {
        url: String,
        #[source]
        source: serde_json::Error,
    },

    #[error("unsupported manifest schema_version {actual} (this binary understands {expected})")]
    UnsupportedSchema { expected: u32, actual: u32 },

    #[error("io error: {0}")]
    Io(#[from] std::io::Error),

    #[error(
        "remote-fetched config attempted to declare library_sources for {consumer}; refusing (PRD-054 trust gate)"
    )]
    RemoteRefused { consumer: &'static str },

    #[error(
        "manifest sha256 mismatch for {url}: pinned `{expected}`, fetched body computes `{actual}` — \
         either the publisher re-published (run `jarvy library freeze` to update the pin) \
         or someone is tampering with the manifest in transit"
    )]
    ManifestShaMismatch {
        url: String,
        expected: String,
        actual: String,
    },

    #[error(
        "cosign signature verification failed for {url}: {reason} — \
         either the manifest was tampered with, the publisher rotated their signing identity, \
         or `identity_regexp` / `oidc_issuer` in the library_sources entry no longer matches \
         what the publisher emits"
    )]
    SignatureRejected { url: String, reason: String },

    #[error(
        "require_signature = true for {url} but signature companions are missing: {reason}. \
         Either ship `manifest.json.sig` + `manifest.json.pem` next to the manifest, \
         or set `require_signature = false` in the library_sources entry (recommended only for development)"
    )]
    SignatureCompanionsMissing { url: String, reason: String },

    #[error(
        "require_signature = true for {url} but cosign is not installed on PATH. \
         Install cosign or set `require_signature = false`"
    )]
    CosignMissing { url: String },
}

impl LibraryError {
    /// Stable telemetry discriminant.
    #[allow(dead_code)] // Public lib API for downstream consumers
    pub fn kind(&self) -> &'static str {
        match self {
            LibraryError::Fetch(_) => "fetch",
            LibraryError::Parse { .. } => "parse",
            LibraryError::UnsupportedSchema { .. } => "unsupported_schema",
            LibraryError::Io(_) => "io",
            LibraryError::RemoteRefused { .. } => "remote_refused",
            LibraryError::ManifestShaMismatch { .. } => "manifest_sha_mismatch",
            LibraryError::SignatureRejected { .. } => "signature_rejected",
            LibraryError::SignatureCompanionsMissing { .. } => "signature_companions_missing",
            LibraryError::CosignMissing { .. } => "cosign_missing",
        }
    }
}

/// Result of a successful `sync` call.
#[derive(Debug, Clone)]
#[allow(dead_code)] // Fields surface via Debug + structured logging; consumers may read individually
pub struct SyncReport {
    pub url: String,
    pub items_total: usize,
    pub ai_hook_count: usize,
    pub mcp_server_count: usize,
    pub skill_count: usize,
    pub from_cache: bool,
    pub signature_verified: bool,
}

/// Entries stored in the process cache: (manifest URL, shared manifest).
type CacheEntries = Vec<(String, Arc<Manifest>)>;

/// Process-wide cache of fetched manifests, keyed by URL. Populated
/// lazily on first `sync()` for each URL. Survives the process lifetime
/// — no TTL — because the disk-cache layer handles staleness.
///
/// Each manifest is held behind `Arc<Manifest>` so the resolvers can
/// snapshot the cache (cheap `Arc::clone` per entry), release the
/// mutex, then walk items without contending the lock for the
/// duration of the scan (perf P1, review item 20).
static MANIFEST_CACHE: Mutex<Option<CacheEntries>> = Mutex::new(None);

/// Fetch + cache the manifest at `source.url`. Returns a `SyncReport`
/// describing what was synced. On network failure, falls back to the
/// disk cache if present; if neither network nor disk has a copy,
/// returns the original `LibraryError::Fetch`.
pub fn sync(source: &LibrarySource) -> Result<SyncReport, LibraryError> {
    let telemetry_on = crate::observability::telemetry_gate::is_enabled();
    // Per-sync span so every child event (fetch start/complete, sha
    // check, signature warnings, sync.failed / completed) nests under
    // one trace ID per library URL (obs P1, review item 22). Held for
    // the lifetime of this call via the `_sync_span.entered()` guard.
    let _sync_span = tracing::info_span!(
        "library.sync",
        url = %crate::network::redact_credentials(&source.url),
        require_signature = source.require_signature,
    )
    .entered();
    if telemetry_on {
        tracing::info!(
            event = "library.sync.started",
            url = %crate::network::redact_credentials(&source.url),
            require_signature = source.require_signature,
        );
    }

    // Dispatch on URL scheme (PRD-055): manifest URLs go through the
    // HTTPS fetcher; git+https:// / github: URLs go through the git
    // fetcher which clones + walks for SKILL.md and synthesizes a
    // manifest in-memory.
    let scheme = url_parser::parse_source(&source.url)?;
    let cache_path = cache::manifest_cache_path(&source.url)?;
    let (manifest, from_cache) = match scheme {
        url_parser::SourceScheme::Manifest { .. } => match fetch_and_parse(source) {
            Ok(m) => {
                // Write-through cache. Best-effort — if disk is full, we
                // still return the freshly-fetched manifest.
                if let Err(e) = cache::write_manifest(&cache_path, &m) {
                    tracing::warn!(
                        event = "library.cache.write_failed",
                        url = %crate::network::redact_credentials(&source.url),
                        error = %e,
                    );
                }
                (m, false)
            }
            Err(fetch_err) => match cache::read_manifest(&cache_path) {
                Ok(Some(m)) => {
                    if telemetry_on {
                        tracing::info!(
                            event = "library.fetch.cached_hit",
                            url = %crate::network::redact_credentials(&source.url),
                            reason = "fetch_failed",
                        );
                    }
                    (m, true)
                }
                Ok(None) | Err(_) => {
                    // Review item 8 (P0). Previously every sync failure
                    // returned silently to the caller; on-call could not
                    // compute "what fraction of library syncs failed in
                    // the last hour." Emit a structured event so
                    // dashboards can graph the failure rate.
                    if telemetry_on {
                        tracing::error!(
                            event = "library.sync.failed",
                            url = %crate::network::redact_credentials(&source.url),
                            scheme = "manifest",
                            error_kind = fetch_err.kind(),
                            error = %fetch_err,
                        );
                    }
                    return Err(fetch_err);
                }
            },
        },
        url_parser::SourceScheme::Git {
            repo,
            git_ref,
            subpath,
        } => {
            // Git fetch always populates from origin (or the disk cache
            // of the prior clone if `git fetch` succeeds offline-stale).
            // The synthesized manifest is written next to the clone.
            let clone_root = cache_path
                .parent()
                .ok_or_else(|| LibraryError::Io(std::io::Error::other("cache path has no parent")))?
                .to_path_buf();
            match git_fetch::sync_git(&repo, &git_ref, subpath.as_deref(), &clone_root) {
                Ok((m, _)) => {
                    if let Err(e) = cache::write_manifest(&cache_path, &m) {
                        tracing::warn!(
                            event = "library.cache.write_failed",
                            url = %crate::network::redact_credentials(&source.url),
                            error = %e,
                        );
                    }
                    (m, false)
                }
                Err(git_err) => match cache::read_manifest(&cache_path) {
                    Ok(Some(m)) => {
                        if telemetry_on {
                            tracing::info!(
                                event = "library.git.cache_hit",
                                url = %crate::network::redact_credentials(&source.url),
                                reason = "git_failed",
                            );
                        }
                        (m, true)
                    }
                    Ok(None) | Err(_) => {
                        // Review item 8 (P0) — see manifest-branch
                        // counterpart above.
                        if telemetry_on {
                            tracing::error!(
                                event = "library.sync.failed",
                                url = %crate::network::redact_credentials(&source.url),
                                scheme = "git",
                                error_kind = git_err.kind(),
                                error = %git_err,
                            );
                        }
                        return Err(git_err);
                    }
                },
            }
        }
    };

    // Populate the process cache. Wrap in Arc so resolvers can snapshot
    // the cache cheaply (per perf P1, review item 20).
    let manifest_arc = Arc::new(manifest);
    {
        let mut cache_guard = MANIFEST_CACHE.lock().unwrap_or_else(|p| p.into_inner());
        let entries = cache_guard.get_or_insert_with(Vec::new);
        if let Some(slot) = entries.iter_mut().find(|(u, _)| *u == source.url) {
            slot.1 = Arc::clone(&manifest_arc);
        } else {
            entries.push((source.url.clone(), Arc::clone(&manifest_arc)));
        }
    }
    let manifest = manifest_arc;

    let mut ai_hook_count = 0;
    let mut mcp_server_count = 0;
    let mut skill_count = 0;
    for item in &manifest.items {
        match item {
            LibraryItem::AiHook(_) => ai_hook_count += 1,
            LibraryItem::McpServer(_) => mcp_server_count += 1,
            LibraryItem::Skill(_) => skill_count += 1,
        }
    }

    let report = SyncReport {
        url: source.url.clone(),
        items_total: manifest.items.len(),
        ai_hook_count,
        mcp_server_count,
        skill_count,
        from_cache,
        signature_verified: false, // v1 — see module-level doc
    };

    if telemetry_on {
        tracing::info!(
            event = "library.sync.completed",
            url = %crate::network::redact_credentials(&source.url),
            items_synced = report.items_total,
            ai_hook_count,
            mcp_server_count,
            skill_count,
            from_cache,
            signature_verified = report.signature_verified,
        );
    }

    if !source.require_signature && telemetry_on {
        tracing::warn!(
            event = "library.signature_disabled",
            url = %crate::network::redact_credentials(&source.url),
        );
        eprintln!(
            "  Warning: library {} fetched without signature verification \
             (`require_signature = false`); recommended only for development.",
            crate::network::redact_credentials(&source.url),
        );
    }

    // PRD-054 phase 5 — `require_signature = true` now actually
    // verifies via cosign in `fetch_and_parse` above. By the time we
    // reach this branch, either signature verification succeeded OR
    // we served the cached copy (in which case we re-verified at the
    // original sync time — disk cache only ever holds verified
    // manifests). Emit a success signal so operators can graph
    // "what fraction of library fetches verified cleanly."
    if source.require_signature && !from_cache && telemetry_on {
        tracing::info!(
            event = "library.signature.verified",
            url = %crate::network::redact_credentials(&source.url),
        );
    }

    Ok(report)
}

fn fetch_and_parse(source: &LibrarySource) -> Result<Manifest, LibraryError> {
    let url = canonicalize_manifest_url(&source.url);
    let body = fetch::fetch_bounded(&url, fetch::MAX_MANIFEST_BYTES)?;
    // Review item 13 (P1). Manifest sha-pin verification — runs
    // BEFORE deserialization so a body that doesn't match the pin
    // is refused even if its JSON is otherwise valid. Computes sha
    // over the raw bytes the publisher served (not the canonicalized
    // / normalized JSON), matching what `jarvy library freeze` would
    // produce.
    if let Some(ref expected) = source.manifest_sha256 {
        let actual = sha256_hex(&body);
        if !actual.eq_ignore_ascii_case(expected) {
            return Err(LibraryError::ManifestShaMismatch {
                url: crate::network::redact_credentials(&url).into_owned(),
                expected: expected.clone(),
                actual,
            });
        }
    }
    // PRD-054 phase 5 — cosign signature enforcement. When
    // `require_signature = true` we fetch `manifest.json.sig` +
    // `manifest.json.pem`, run cosign verify-blob, and refuse on
    // failure. `require_signature = false` short-circuits — the
    // `library.signature_disabled` warning lives in `sync()`.
    signature::verify_manifest_signature(
        &url,
        &body,
        source.require_signature,
        source.identity_regexp.as_deref(),
        source.oidc_issuer.as_deref(),
    )?;
    let manifest: Manifest = serde_json::from_slice(&body).map_err(|e| LibraryError::Parse {
        url: crate::network::redact_credentials(&url).into_owned(),
        source: e,
    })?;
    if manifest.schema_version != MANIFEST_SCHEMA_VERSION {
        return Err(LibraryError::UnsupportedSchema {
            expected: MANIFEST_SCHEMA_VERSION,
            actual: manifest.schema_version,
        });
    }
    Ok(manifest)
}

/// If the URL ends with `/`, append `manifest.json` so users can point
/// at the parent directory instead of typing the filename. Idempotent.
fn canonicalize_manifest_url(url: &str) -> String {
    if url.ends_with('/') {
        format!("{url}manifest.json")
    } else {
        url.to_string()
    }
}

/// Look up an AI hook by name across every cached library, in
/// insertion order. Returns the first match. Consumers SHOULD check
/// the built-in `crate::ai_hooks::LIBRARY` first so name collisions
/// favor the canonical Jarvy-shipped entry.
pub fn resolve_hook(name: &str) -> Option<LibraryHookItem> {
    let snapshot = snapshot_manifests();
    for manifest in &snapshot {
        for item in &manifest.items {
            if let LibraryItem::AiHook(h) = item {
                if h.name == name {
                    return Some(h.clone());
                }
            }
        }
    }
    None
}

/// Look up an MCP server by name across every cached library.
pub fn resolve_mcp_server(name: &str) -> Option<LibraryMcpItem> {
    let snapshot = snapshot_manifests();
    for manifest in &snapshot {
        for item in &manifest.items {
            if let LibraryItem::McpServer(s) = item {
                if s.name == name {
                    return Some(s.clone());
                }
            }
        }
    }
    None
}

/// Look up a skill by name across every cached library.
pub fn resolve_skill(name: &str) -> Option<LibrarySkillItem> {
    let snapshot = snapshot_manifests();
    for manifest in &snapshot {
        for item in &manifest.items {
            if let LibraryItem::Skill(s) = item {
                if s.name == name {
                    return Some(s.clone());
                }
            }
        }
    }
    None
}

/// Summary view of every cached library — used by `jarvy library list`.
#[derive(Debug, Clone, serde::Serialize)]
pub struct CachedLibrary {
    pub url: String,
    pub publisher: String,
    pub description: String,
    pub ai_hook_count: usize,
    pub mcp_server_count: usize,
    pub skill_count: usize,
}

/// Snapshot every manifest currently in the process cache plus a
/// summary of its item counts. Consumed by `jarvy library list`.
pub fn list_cached() -> Vec<CachedLibrary> {
    let snapshot = snapshot_entries();
    snapshot
        .into_iter()
        .map(|(url, m)| {
            let mut ai = 0;
            let mut mcp = 0;
            let mut sk = 0;
            for item in &m.items {
                match item {
                    LibraryItem::AiHook(_) => ai += 1,
                    LibraryItem::McpServer(_) => mcp += 1,
                    LibraryItem::Skill(_) => sk += 1,
                }
            }
            CachedLibrary {
                url,
                publisher: m.publisher.clone(),
                description: m.description.clone(),
                ai_hook_count: ai,
                mcp_server_count: mcp,
                skill_count: sk,
            }
        })
        .collect()
}

/// Resolve one cached library by URL. `url` is the source URL declared
/// in `[<subsystem>.library_sources]`. Consumed by `jarvy library show`.
pub fn get_cached(url: &str) -> Option<(String, Arc<Manifest>)> {
    snapshot_entries().into_iter().find(|(u, _)| u == url)
}

/// Wipe the process cache. Consumed by `jarvy library clean` (clears
/// in-memory cache only — the disk cache lives under
/// `~/.jarvy/library.d/` and is wiped by [`clear_disk_cache`]). Also
/// used by every cache-mutating test.
pub fn clear_cache() {
    let mut guard = MANIFEST_CACHE.lock().unwrap_or_else(|p| p.into_inner());
    *guard = None;
}

/// Wipe the on-disk library cache (`~/.jarvy/library.d/`).
///
/// Returns `(removed_count, removed_bytes)` so the caller can report
/// what was reclaimed. Process cache is wiped too (so the next sync
/// re-fetches into a clean slot).
pub fn clear_disk_cache() -> std::io::Result<(usize, u64)> {
    let root = cache::cache_root()?;
    let mut removed = 0usize;
    let mut bytes = 0u64;
    if !root.exists() {
        clear_cache();
        return Ok((0, 0));
    }
    for entry in std::fs::read_dir(&root)? {
        let entry = entry?;
        let path = entry.path();
        bytes += dir_size(&path).unwrap_or(0);
        if path.is_dir() {
            std::fs::remove_dir_all(&path)?;
        } else {
            std::fs::remove_file(&path)?;
        }
        removed += 1;
    }
    clear_cache();
    Ok((removed, bytes))
}

fn dir_size(path: &std::path::Path) -> std::io::Result<u64> {
    if path.is_file() {
        return Ok(std::fs::metadata(path)?.len());
    }
    let mut total = 0;
    for entry in std::fs::read_dir(path)? {
        let entry = entry?;
        let p = entry.path();
        if p.is_dir() {
            total += dir_size(&p)?;
        } else {
            total += std::fs::metadata(&p).map(|m| m.len()).unwrap_or(0);
        }
    }
    Ok(total)
}

/// Snapshot just the `Arc<Manifest>` handles, drop the lock, then let
/// callers walk items without contending the mutex (perf P1, review
/// item 20). Each entry is a cheap atomic refcount bump.
fn snapshot_manifests() -> Vec<Arc<Manifest>> {
    let guard = MANIFEST_CACHE.lock().unwrap_or_else(|p| p.into_inner());
    match guard.as_deref() {
        Some(entries) => entries.iter().map(|(_, m)| Arc::clone(m)).collect(),
        None => Vec::new(),
    }
}

/// Snapshot including URLs — needed by `list_cached`. Pays one URL
/// clone per entry plus the Arc bumps, then releases the lock.
fn snapshot_entries() -> Vec<(String, Arc<Manifest>)> {
    let guard = MANIFEST_CACHE.lock().unwrap_or_else(|p| p.into_inner());
    match guard.as_deref() {
        Some(entries) => entries
            .iter()
            .map(|(u, m)| (u.clone(), Arc::clone(m)))
            .collect(),
        None => Vec::new(),
    }
}

/// Drive `check_origin` + per-source `sync` for every entry in a
/// consumer's `library_sources`. Centralizes the three identical copies
/// previously living in `ai_hooks::runner`, `mcp_register::runner`, and
/// `commands::skills_cmd` (maint P1, review item 16). Per-source
/// failures are logged but never fatal — downstream `resolve_*` calls
/// will surface "unknown library item" for anything that depended on a
/// failed sync.
///
/// `consumer` is the subsystem label (`"ai_hooks"`, `"mcp_register"`,
/// `"skills"`). `fallback_hint` is the trailing clause appended to the
/// per-source warning so each subsystem can describe its own fallback
/// posture (e.g. "Falling back to cached + built-in hooks.").
pub fn sync_all(
    consumer: &'static str,
    fallback_hint: &str,
    sources: &[LibrarySource],
    origin: crate::ai_hooks::ConfigOrigin,
) {
    if sources.is_empty() {
        return;
    }
    if let Err(e) = check_origin(origin, consumer) {
        eprintln!(
            "  Warning: {consumer} library_sources refused: {e}. \
             Move the URL into your local jarvy.toml or ~/.jarvy/config.toml."
        );
        return;
    }
    for source in sources {
        if let Err(e) = sync(source) {
            let url = crate::network::redact_credentials(&source.url);
            if fallback_hint.is_empty() {
                eprintln!("  Warning: {consumer} library_sources sync failed for {url}: {e}.");
            } else {
                eprintln!(
                    "  Warning: {consumer} library_sources sync failed for {url}: {e}. \
                     {fallback_hint}"
                );
            }
        }
    }
}

/// Refuse a `library_sources` declaration that came from a remote
/// `jarvy.toml`. Used by every consumer at apply time.
pub fn check_origin(
    origin: crate::ai_hooks::ConfigOrigin,
    consumer: &'static str,
) -> Result<(), LibraryError> {
    if origin == crate::ai_hooks::ConfigOrigin::Remote {
        let telemetry_on = crate::observability::telemetry_gate::is_enabled();
        if telemetry_on {
            tracing::warn!(
                event = "library.remote_refused",
                consumer,
                reason = "remote_config_cannot_declare_library_sources",
            );
        }
        return Err(LibraryError::RemoteRefused { consumer });
    }
    Ok(())
}

/// Touch the disk cache mtime for a fetched manifest so `staleness`
/// checks based on `SystemTime::now() - mtime` are accurate even when
/// the cache was hit (not refetched).
#[allow(dead_code)] // Reserved for the TTL-aware cache refresh path
pub fn cache_age(source: &LibrarySource) -> Option<std::time::Duration> {
    let path = cache::manifest_cache_path(&source.url).ok()?;
    let mtime = std::fs::metadata(&path).ok()?.modified().ok()?;
    SystemTime::now().duration_since(mtime).ok()
}

#[allow(dead_code)]
fn _ensure_cache_dir() -> std::io::Result<PathBuf> {
    cache::cache_root()
}

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

    #[test]
    fn canonicalize_appends_manifest_json_to_trailing_slash() {
        assert_eq!(
            canonicalize_manifest_url("https://cdn.example.com/jarvy/"),
            "https://cdn.example.com/jarvy/manifest.json"
        );
    }

    #[test]
    fn canonicalize_passes_explicit_filename_through() {
        assert_eq!(
            canonicalize_manifest_url("https://cdn.example.com/jarvy/manifest.json"),
            "https://cdn.example.com/jarvy/manifest.json"
        );
        assert_eq!(
            canonicalize_manifest_url("https://cdn.example.com/library-v2.json"),
            "https://cdn.example.com/library-v2.json"
        );
    }

    #[test]
    fn check_origin_refuses_remote() {
        let err = check_origin(crate::ai_hooks::ConfigOrigin::Remote, "ai_hooks")
            .expect_err("remote must refuse");
        match err {
            LibraryError::RemoteRefused { consumer } => assert_eq!(consumer, "ai_hooks"),
            other => panic!("expected RemoteRefused, got {other:?}"),
        }
    }

    #[test]
    fn check_origin_allows_local() {
        check_origin(crate::ai_hooks::ConfigOrigin::Local, "ai_hooks").expect("local must pass");
    }

    #[test]
    fn resolve_returns_none_when_cache_empty() {
        clear_cache();
        assert!(resolve_hook("anything").is_none());
        assert!(resolve_mcp_server("anything").is_none());
        assert!(resolve_skill("anything").is_none());
    }
}