kick-rs-assets 0.1.1

Typed asset manifest + compile-time embedding for kick-rs
Documentation
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
#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
#![warn(missing_docs, rust_2018_idioms)]

use kick_rs_core::{KickError, KickResult};
use std::collections::BTreeMap;
use std::path::Path;

/// Map of logical asset keys (`"app.js"`) to their hashed filenames
/// (`"app.a1b2c3.js"`), plus an optional URL prefix prepended at
/// resolve time.
///
/// The JSON shape we accept is the lowest common denominator —
/// a flat object of `key: string` entries:
///
/// ```json
/// {
///   "app.js":  "app.a1b2c3.js",
///   "app.css": "app.d4e5f6.css"
/// }
/// ```
///
/// Vite's full manifest (with nested `imports` / `css` arrays) is
/// also accepted — call [`Self::from_vite_json`] to parse it
/// directly; we reduce each entry to its `file` field.
#[derive(Debug, Default, Clone)]
pub struct AssetManifest {
    entries: BTreeMap<String, String>,
    url_prefix: String,
}

impl AssetManifest {
    /// Read + parse a manifest from disk. Errors fall into two codes:
    /// `RK_C_IO` for read failure, `RK_C_PARSE` for malformed JSON.
    pub fn load<P: AsRef<Path>>(path: P) -> KickResult<Self> {
        let path = path.as_ref();
        let raw = std::fs::read_to_string(path).map_err(|e| {
            KickError::new(
                "RK_C_IO",
                format!("could not read asset manifest `{}`: {e}", path.display()),
            )
        })?;
        Self::from_json(&raw).map_err(|e| {
            // Re-wrap to mention the file path, since the from_json
            // error doesn't know where the string came from.
            KickError::new(e.code, format!("{} (file: {})", e.message, path.display()))
        })
    }

    /// Parse a manifest from a JSON string. Useful for tests + when
    /// the manifest is bundled into the binary via `embed_assets!`.
    pub fn from_json(json: &str) -> KickResult<Self> {
        let entries: BTreeMap<String, String> = serde_json::from_str(json)
            .map_err(|e| KickError::new("RK_C_PARSE", format!("invalid asset manifest: {e}")))?;
        Ok(Self {
            entries,
            url_prefix: String::new(),
        })
    }

    /// Parse vite's full `manifest.json` shape — the one vite emits
    /// when `build.manifest = true`. Each top-level key maps to a
    /// record whose `file` field is the hashed output filename:
    ///
    /// ```json
    /// {
    ///   "src/main.js": {
    ///     "file": "assets/main.4889e940.js",
    ///     "src": "src/main.js",
    ///     "isEntry": true,
    ///     "imports": ["_shared.83069a53.js"],
    ///     "css":     ["assets/main.b82dbe22.css"]
    ///   }
    /// }
    /// ```
    ///
    /// We reduce that to the flat `entry_key → file` map [`Self::resolve`]
    /// uses. The CSS / imports / asset arrays nested under each entry
    /// are *not* surfaced separately yet — `resolve("src/main.js")`
    /// returns the JS file URL; an API for retrieving the matching
    /// CSS files for an entry is planned for a follow-up release (the
    /// underlying data isn't kept right now).
    ///
    /// Rejects shape errors with `RK_C_PARSE`.
    pub fn from_vite_json(json: &str) -> KickResult<Self> {
        // Local, intentionally lenient deser type — vite emits a few
        // optional fields we don't use; serde gracefully ignores any
        // unknown keys by default.
        #[derive(serde::Deserialize)]
        struct ViteEntry {
            file: String,
        }

        let raw: BTreeMap<String, ViteEntry> = serde_json::from_str(json)
            .map_err(|e| KickError::new("RK_C_PARSE", format!("invalid vite manifest: {e}")))?;

        let entries: BTreeMap<String, String> = raw.into_iter().map(|(k, v)| (k, v.file)).collect();

        Ok(Self {
            entries,
            url_prefix: String::new(),
        })
    }

    /// Set the URL prefix prepended to every resolved value. Trailing
    /// slashes are normalized away so adopters get the expected
    /// joined form regardless of input.
    pub fn with_url_prefix(mut self, prefix: impl Into<String>) -> Self {
        let mut p = prefix.into();
        while p.ends_with('/') {
            p.pop();
        }
        self.url_prefix = p;
        self
    }

    /// The current URL prefix (without trailing slash).
    pub fn url_prefix(&self) -> &str {
        &self.url_prefix
    }

    /// Look up the versioned URL for `key`. Returns
    /// `<url_prefix>/<hashed_filename>`. Errors with
    /// `RK_C_UNKNOWN_ASSET` if the key isn't in the manifest, with a
    /// list of known keys in the hint.
    pub fn resolve(&self, key: &str) -> KickResult<String> {
        let hashed = self.entries.get(key).ok_or_else(|| {
            let known: Vec<&str> = self.entries.keys().map(String::as_str).collect();
            KickError::new(
                "RK_C_UNKNOWN_ASSET",
                format!("no asset entry for key `{key}`"),
            )
            .with_hint(format!(
                "known keys: {}",
                if known.is_empty() {
                    "<none — manifest is empty>".into()
                } else {
                    known.join(", ")
                }
            ))
        })?;
        if self.url_prefix.is_empty() {
            Ok(format!("/{hashed}"))
        } else {
            Ok(format!("{}/{}", self.url_prefix, hashed))
        }
    }

    /// Iterate `(key, hashed_filename)` pairs in key order.
    pub fn entries(&self) -> impl Iterator<Item = (&str, &str)> {
        self.entries.iter().map(|(k, v)| (k.as_str(), v.as_str()))
    }

    /// Number of entries in the manifest.
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    /// Whether the manifest has any entries.
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }
}

// ─────────────────────────── Embedded assets ─────────────────────────────
#[cfg(feature = "embed")]
pub use embed::*;

#[cfg(feature = "embed")]
mod embed {
    //! Compile-time bundling via the `kick-rs-assets-macros` proc-macro.
    //!
    //! The tree is a plain `&'static` cascade — no `Box`, `Vec`, or
    //! lazy allocation. Every file's contents come from `include_bytes!`
    //! emitted by the proc-macro.

    use kick_rs_core::{KickError, KickResult};

    /// Embed a directory tree into the binary at compile time.
    ///
    /// ```ignore
    /// use kick_rs_assets::{embed_assets, EmbeddedAssets};
    ///
    /// static ASSETS: EmbeddedAssets = embed_assets!("$CARGO_MANIFEST_DIR/dist");
    ///
    /// fn handler() {
    ///     if let Some(file) = ASSETS.get_file("app.a1b2c3.js") {
    ///         // serve file.contents()
    ///     }
    /// }
    /// ```
    ///
    /// Accepted path forms: absolute, `$CARGO_MANIFEST_DIR/...`,
    /// `$OUT_DIR/...`, or relative (resolved against
    /// `$CARGO_MANIFEST_DIR`).
    pub use kick_rs_assets_macros::embed_assets;

    /// One bundled directory. Static — every field lives in `'static`
    /// memory; iteration is cheap and allocation-free.
    #[derive(Debug, Clone, Copy)]
    pub struct EmbeddedAssets {
        path: &'static str,
        entries: &'static [EmbeddedEntry],
    }

    /// One bundled file.
    #[derive(Debug, Clone, Copy)]
    pub struct EmbeddedFile {
        path: &'static str,
        contents: &'static [u8],
    }

    /// Entry in an embedded tree — either a file or a sub-directory.
    #[derive(Debug, Clone, Copy)]
    pub enum EmbeddedEntry {
        /// A file with its bytes loaded via `include_bytes!`.
        File(EmbeddedFile),
        /// A nested directory.
        Dir(EmbeddedAssets),
    }

    impl EmbeddedAssets {
        // Constructor exposed for use by the proc-macro's expansion.
        // `pub` + `#[doc(hidden)]` is the established Rust pattern for
        // "callable from generated code, not from humans".
        #[doc(hidden)]
        pub const fn __new(path: &'static str, entries: &'static [EmbeddedEntry]) -> Self {
            Self { path, entries }
        }

        /// Path the tree was rooted at, relative to its parent. Empty
        /// string for the top-level tree.
        pub fn path(&self) -> &'static str {
            self.path
        }

        /// Direct child entries (one level — does not recurse).
        pub fn entries(&self) -> &'static [EmbeddedEntry] {
            self.entries
        }

        /// Find a file by its forward-slash-separated path relative
        /// to *this* directory. Walks sub-directories as needed.
        pub fn get_file(&self, rel: &str) -> Option<&'static EmbeddedFile> {
            // Strip a leading slash so `/foo.js` and `foo.js` both work.
            let rel = rel.strip_prefix('/').unwrap_or(rel);
            for entry in self.entries {
                match entry {
                    EmbeddedEntry::File(f) => {
                        if path_matches(f.path, self.path, rel) {
                            return Some(f);
                        }
                    }
                    EmbeddedEntry::Dir(d) => {
                        if let Some(f) = d.get_file(rel) {
                            return Some(f);
                        }
                    }
                }
            }
            None
        }
    }

    impl EmbeddedFile {
        // Same convention as EmbeddedAssets::__new.
        #[doc(hidden)]
        pub const fn __new(path: &'static str, contents: &'static [u8]) -> Self {
            Self { path, contents }
        }

        /// The file's path relative to the embedded tree's root.
        pub fn path(&self) -> &'static str {
            self.path
        }

        /// The file's bytes.
        pub fn contents(&self) -> &'static [u8] {
            self.contents
        }
    }

    /// Path-comparison helper. `file_path` is the file's path relative
    /// to the *root* of the embedded tree. `dir_prefix` is the path of
    /// the directory we're searching from. `target` is the path the
    /// caller is looking up, relative to `dir_prefix`. Returns true
    /// when `file_path == join(dir_prefix, target)`.
    fn path_matches(file_path: &str, dir_prefix: &str, target: &str) -> bool {
        if dir_prefix.is_empty() {
            return file_path == target;
        }
        // file_path should be `<dir_prefix>/<target>`. Avoid an alloc
        // by comparing in pieces.
        file_path
            .strip_prefix(dir_prefix)
            .and_then(|rest| rest.strip_prefix('/'))
            == Some(target)
    }

    /// Best-effort content-type guess from a file extension. Returns
    /// `application/octet-stream` for unknown extensions so the
    /// caller always has *something* safe to send.
    pub fn content_type_for(name: &str) -> &'static str {
        let lower = name.to_ascii_lowercase();
        let Some(dot) = lower.rfind('.') else {
            return "application/octet-stream";
        };
        match &lower[dot + 1..] {
            "html" | "htm" => "text/html; charset=utf-8",
            "css" => "text/css; charset=utf-8",
            "js" | "mjs" => "application/javascript; charset=utf-8",
            "json" => "application/json",
            "wasm" => "application/wasm",
            "svg" => "image/svg+xml",
            "png" => "image/png",
            "jpg" | "jpeg" => "image/jpeg",
            "gif" => "image/gif",
            "webp" => "image/webp",
            "ico" => "image/x-icon",
            "woff" => "font/woff",
            "woff2" => "font/woff2",
            "ttf" => "font/ttf",
            "otf" => "font/otf",
            "txt" | "text" => "text/plain; charset=utf-8",
            "map" => "application/json",
            _ => "application/octet-stream",
        }
    }

    /// Read a file from the embedded tree as a `&[u8]`. Errors with
    /// `RK_C_UNKNOWN_ASSET` if the path isn't bundled.
    pub fn read_embedded(dir: &EmbeddedAssets, rel: &str) -> KickResult<&'static [u8]> {
        dir.get_file(rel)
            .map(EmbeddedFile::contents)
            .ok_or_else(|| {
                KickError::new(
                    "RK_C_UNKNOWN_ASSET",
                    format!("no embedded asset at `{rel}`"),
                )
            })
    }
}

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

    #[test]
    fn from_vite_json_reduces_to_flat() {
        // Realistic vite manifest fixture: two entries, both with the
        // optional `imports` / `css` arrays vite emits. We only need
        // `file` — the other fields exist to verify we ignore them
        // without erroring.
        let m = AssetManifest::from_vite_json(
            r#"{
              "src/main.js": {
                "file": "assets/main.4889e940.js",
                "src": "src/main.js",
                "isEntry": true,
                "imports": ["_shared.83069a53.js"],
                "css": ["assets/main.b82dbe22.css"]
              },
              "_shared.83069a53.js": {
                "file": "assets/shared.83069a53.js"
              }
            }"#,
        )
        .unwrap()
        .with_url_prefix("/static");

        // Adopter looks up by the same key vite uses internally.
        assert_eq!(
            m.resolve("src/main.js").unwrap(),
            "/static/assets/main.4889e940.js"
        );
        assert_eq!(
            m.resolve("_shared.83069a53.js").unwrap(),
            "/static/assets/shared.83069a53.js"
        );
        // CSS / imports aren't surfaced separately yet — documented
        // limitation. Only the entry's `file` field maps over.
        assert_eq!(m.len(), 2);
    }

    #[test]
    fn from_vite_json_rejects_malformed() {
        // Missing the `file` field on the entry — vite would never
        // emit this, but we should fail loudly if a hand-rolled or
        // truncated manifest sneaks through.
        let err = AssetManifest::from_vite_json(r#"{"x": {"src": "x.js"}}"#).unwrap_err();
        assert_eq!(err.code, "RK_C_PARSE");
        let err2 = AssetManifest::from_vite_json("not even json").unwrap_err();
        assert_eq!(err2.code, "RK_C_PARSE");
    }

    #[test]
    fn from_vite_json_ignores_unknown_top_level_keys() {
        // serde deserializes the same way regardless of what's nested
        // inside the entry — we explicitly use a struct with only
        // `file`. Sanity: unknown fields in the entry don't trip the
        // parser.
        let m = AssetManifest::from_vite_json(
            r#"{"x.js": {"file": "x.HASH.js", "isDynamicEntry": true, "extra": 42}}"#,
        )
        .unwrap();
        assert_eq!(m.resolve("x.js").unwrap(), "/x.HASH.js");
    }

    #[test]
    fn from_json_parses_flat_object() {
        let m = AssetManifest::from_json(
            r#"{ "app.js": "app.a1b2c3.js", "app.css": "app.d4e5f6.css" }"#,
        )
        .unwrap();
        assert_eq!(m.len(), 2);
        assert_eq!(m.entries().count(), 2);
        // BTreeMap order — keys are sorted, .css comes before .js.
        let pairs: Vec<_> = m.entries().collect();
        assert_eq!(pairs[0].0, "app.css");
        assert_eq!(pairs[1].0, "app.js");
    }

    #[test]
    fn from_json_rejects_malformed_input() {
        let err = AssetManifest::from_json("not json").unwrap_err();
        assert_eq!(err.code, "RK_C_PARSE");
    }

    #[test]
    fn resolve_prepends_prefix_with_normalized_slash() {
        let m = AssetManifest::from_json(r#"{ "app.js": "app.a1b2c3.js" }"#)
            .unwrap()
            .with_url_prefix("/static///");
        assert_eq!(m.url_prefix(), "/static");
        assert_eq!(m.resolve("app.js").unwrap(), "/static/app.a1b2c3.js");
    }

    #[test]
    fn resolve_without_prefix_starts_with_slash() {
        let m = AssetManifest::from_json(r#"{ "app.js": "app.a1b2c3.js" }"#).unwrap();
        assert_eq!(m.resolve("app.js").unwrap(), "/app.a1b2c3.js");
    }

    #[test]
    fn resolve_unknown_key_errors_with_catalog_in_hint() {
        let m = AssetManifest::from_json(r#"{ "a.js": "a.x.js", "b.js": "b.y.js" }"#).unwrap();
        let err = m.resolve("c.js").unwrap_err();
        assert_eq!(err.code, "RK_C_UNKNOWN_ASSET");
        let hint = err.fix_hint.as_deref().unwrap_or("");
        assert!(hint.contains("a.js"), "hint: {hint}");
        assert!(hint.contains("b.js"), "hint: {hint}");
    }

    #[test]
    fn load_reads_from_tempfile() {
        let tmp = tempfile::NamedTempFile::new().unwrap();
        std::fs::write(tmp.path(), r#"{ "app.js": "app.fff.js" }"#).unwrap();
        let m = AssetManifest::load(tmp.path()).unwrap();
        assert_eq!(m.resolve("app.js").unwrap(), "/app.fff.js");
    }

    #[test]
    fn load_missing_file_errors() {
        let err = AssetManifest::load("does-not-exist.json").unwrap_err();
        assert_eq!(err.code, "RK_C_IO");
    }

    #[cfg(feature = "embed")]
    #[test]
    fn content_type_for_common_extensions() {
        assert_eq!(
            content_type_for("app.js"),
            "application/javascript; charset=utf-8"
        );
        assert_eq!(content_type_for("app.css"), "text/css; charset=utf-8");
        // Case-insensitive — `HTML` works the same as `html`.
        assert_eq!(content_type_for("index.HTML"), "text/html; charset=utf-8");
        assert_eq!(content_type_for("logo.svg"), "image/svg+xml");
        assert_eq!(content_type_for("font.woff2"), "font/woff2");
        assert_eq!(content_type_for("noext"), "application/octet-stream");
        assert_eq!(content_type_for("weird.exotic"), "application/octet-stream");
    }
}