diff --git a/Cargo.lock b/Cargo.lock index 37de6d7..1cb726e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -101,6 +101,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "block-buffer" version = "0.10.4" @@ -149,6 +155,12 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + [[package]] name = "cc" version = "1.2.39" @@ -189,6 +201,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -320,6 +333,41 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.106", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.106", +] + [[package]] name = "data-encoding" version = "2.9.0" @@ -346,6 +394,16 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "deranged" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" +dependencies = [ + "powerfmt", + "serde_core", +] + [[package]] name = "digest" version = "0.10.7" @@ -356,6 +414,35 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -368,6 +455,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -397,6 +490,12 @@ dependencies = [ "crunchy", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.16.0" @@ -409,6 +508,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hex_fmt" version = "0.3.0" @@ -439,6 +544,130 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.11.4" @@ -446,7 +675,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.0", + "serde", + "serde_core", ] [[package]] @@ -479,8 +710,10 @@ dependencies = [ name = "jacquard-common" version = "0.1.0" dependencies = [ + "bytes", "chrono", "cid", + "enum_dispatch", "langtag", "miette", "multibase", @@ -489,10 +722,16 @@ dependencies = [ "serde", "serde_html_form", "serde_json", + "serde_with", "smol_str", "thiserror 2.0.16", + "url", ] +[[package]] +name = "jacquard-lexicon" +version = "0.1.0" + [[package]] name = "js-sys" version = "0.3.81" @@ -520,6 +759,12 @@ version = "0.2.176" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + [[package]] name = "log" version = "0.4.28" @@ -592,6 +837,12 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-traits" version = "0.2.19" @@ -619,6 +870,21 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "potential_utf" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -667,6 +933,26 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d20581732dd76fa913c7dff1a2412b714afe3573e94d41c34719de73337cc8ab" +[[package]] +name = "ref-cast" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "regex" version = "1.11.3" @@ -708,6 +994,30 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "serde" version = "1.0.227" @@ -754,7 +1064,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f" dependencies = [ "form_urlencoded", - "indexmap", + "indexmap 2.11.4", "itoa", "ryu", "serde_core", @@ -773,6 +1083,38 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_with" +version = "3.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c522100790450cf78eeac1507263d0a350d4d5b30df0c8e1fe051a10c22b376e" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.11.4", + "schemars 0.9.0", + "schemars 1.0.4", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327ada00f7d64abaac1e55a6911e90cf665aa051b9a561c7006c157f4633135e" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "sha2" version = "0.10.9" @@ -812,6 +1154,12 @@ dependencies = [ "serde", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + [[package]] name = "static-regular-grammar" version = "2.0.2" @@ -859,6 +1207,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -899,6 +1258,47 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "typenum" version = "1.18.0" @@ -923,6 +1323,24 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1126,3 +1544,87 @@ name = "windows_x86_64_msvc" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] diff --git a/crates/jacquard-common/Cargo.toml b/crates/jacquard-common/Cargo.toml index df2f3ac..f794d00 100644 --- a/crates/jacquard-common/Cargo.toml +++ b/crates/jacquard-common/Cargo.toml @@ -3,11 +3,19 @@ name = "jacquard-common" edition.workspace = true version.workspace = true authors.workspace = true +repository.workspace = true +keywords.workspace = true +categories.workspace = true +readme.workspace = true +documentation.workspace = true +exclude.workspace = true description.workspace = true [dependencies] +bytes = "1.10.1" chrono = "0.4.42" cid = { version = "0.11.1", features = ["serde", "std"] } +enum_dispatch = "0.3.13" langtag = { version = "0.4.0", features = ["serde"] } miette = "7.6.0" multibase = "0.9.1" @@ -16,5 +24,7 @@ regex = "1.11.3" serde = { version = "1.0.227", features = ["derive"] } serde_html_form = "0.2.8" serde_json = "1.0.145" +serde_with = "3.14.1" smol_str = { version = "0.3.2", features = ["serde"] } thiserror = "2.0.16" +url = "2.5.7" diff --git a/crates/jacquard-common/src/lib.rs b/crates/jacquard-common/src/lib.rs index 8af902f..9d57cb2 100644 --- a/crates/jacquard-common/src/lib.rs +++ b/crates/jacquard-common/src/lib.rs @@ -7,3 +7,6 @@ pub mod types; pub use cowstr::CowStr; pub use into_static::IntoStatic; + +pub use smol_str; +pub use url; diff --git a/crates/jacquard-common/src/types.rs b/crates/jacquard-common/src/types.rs index 57da675..74380e4 100644 --- a/crates/jacquard-common/src/types.rs +++ b/crates/jacquard-common/src/types.rs @@ -1,3 +1,5 @@ +use serde::{Deserialize, Serialize}; + pub mod aturi; pub mod blob; pub mod cid; @@ -11,10 +13,98 @@ pub mod language; pub mod link; pub mod nsid; pub mod recordkey; +pub mod string; pub mod tid; +pub mod uri; +pub mod value; /// Trait for a constant string literal type pub trait Literal: Clone + Copy + PartialEq + Eq + Send + Sync + 'static { /// The string literal const LITERAL: &'static str; } + +pub const DISALLOWED_TLDS: &[&str] = &[ + ".local", + ".arpa", + ".invalid", + ".localhost", + ".internal", + ".example", + ".alt", + // policy could concievably change on ".onion" some day + ".onion", + // NOTE: .test is allowed in testing and devopment. In practical terms + // "should" "never" actually resolve and get registered in production +]; + +pub fn ends_with(string: impl AsRef, list: &[&str]) -> bool { + let string = string.as_ref(); + for item in list { + if string.ends_with(item) { + return true; + } + } + false +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] +#[serde(rename_all = "kebab-case")] +pub enum DataModelType { + Null, + Boolean, + Integer, + Bytes, + CidLink, + Blob, + Array, + Object, + #[serde(untagged)] + String(LexiconStringType), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] +#[serde(rename_all = "kebab-case")] +pub enum LexiconType { + Params, + Token, + Ref, + Union, + Unknown, + Record, + Query, + Procedure, + Subscription, + #[serde(untagged)] + DataModel(DataModelType), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] +#[serde(rename_all = "kebab-case")] +pub enum LexiconStringType { + Datetime, + AtUri, + Did, + Handle, + AtIdentifier, + Nsid, + Cid, + Language, + Tid, + RecordKey, + Uri(UriType), + #[serde(untagged)] + String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum UriType { + Did, + At, + Https, + Wss, + Cid, + Dns, + Any, +} diff --git a/crates/jacquard-common/src/types/aturi.rs b/crates/jacquard-common/src/types/aturi.rs index 5fd401c..2aa7114 100644 --- a/crates/jacquard-common/src/types/aturi.rs +++ b/crates/jacquard-common/src/types/aturi.rs @@ -2,10 +2,11 @@ use crate::CowStr; use crate::types::ident::AtIdentifier; use crate::types::nsid::Nsid; use crate::types::recordkey::{RecordKey, Rkey}; +use crate::types::string::AtStrError; use regex::Regex; use serde::Serializer; use serde::{Deserialize, Deserializer, Serialize, de::Error}; -use smol_str::ToSmolStr; +use smol_str::{SmolStr, ToSmolStr}; use std::fmt; use std::sync::LazyLock; use std::{ops::Deref, str::FromStr}; @@ -41,14 +42,19 @@ pub static ATURI_REGEX: LazyLock = LazyLock::new(|| { impl<'u> AtUri<'u> { /// Fallible constructor, validates, borrows from input - pub fn new(uri: &'u str) -> Result { + pub fn new(uri: &'u str) -> Result { if let Some(parts) = ATURI_REGEX.captures(uri) { if let Some(authority) = parts.name("authority") { - let authority = AtIdentifier::new(authority.as_str())?; + let authority = AtIdentifier::new(authority.as_str()) + .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; let path = if let Some(collection) = parts.name("collection") { - let collection = Nsid::new(collection.as_str())?; + let collection = Nsid::new(collection.as_str()) + .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; let rkey = if let Some(rkey) = parts.name("rkey") { - let rkey = RecordKey::from(Rkey::new(rkey.as_str())?); + let rkey = + RecordKey::from(Rkey::new(rkey.as_str()).map_err(|e| { + AtStrError::wrap("at-uri-scheme", uri.to_string(), e) + })?); Some(rkey) } else { None @@ -68,22 +74,65 @@ impl<'u> AtUri<'u> { fragment, }) } else { - Err("at:// URI missing authority") + Err(AtStrError::missing("at-uri-scheme", uri, "authority")) } } else { - Err("Invalid at:// URI via regex") + Err(AtStrError::regex( + "at-uri-scheme", + uri, + SmolStr::new_static("doesn't match schema"), + )) } } - pub fn new_owned(uri: impl AsRef) -> Result { + pub fn raw(uri: &'u str) -> Self { + if let Some(parts) = ATURI_REGEX.captures(uri) { + if let Some(authority) = parts.name("authority") { + let authority = AtIdentifier::raw(authority.as_str()); + let path = if let Some(collection) = parts.name("collection") { + let collection = Nsid::raw(collection.as_str()); + let rkey = if let Some(rkey) = parts.name("rkey") { + let rkey = RecordKey::from(Rkey::raw(rkey.as_str())); + Some(rkey) + } else { + None + }; + Some(UriPath { collection, rkey }) + } else { + None + }; + let fragment = parts.name("fragment").map(|fragment| { + let fragment = CowStr::Borrowed(fragment.as_str()); + fragment + }); + AtUri { + uri: CowStr::Borrowed(uri), + authority, + path, + fragment, + } + } else { + panic!("at:// URI missing authority") + } + } else { + panic!("Invalid at:// URI via regex") + } + } + + pub fn new_owned(uri: impl AsRef) -> Result { let uri = uri.as_ref(); if let Some(parts) = ATURI_REGEX.captures(uri) { if let Some(authority) = parts.name("authority") { - let authority = AtIdentifier::new_owned(authority.as_str())?; + let authority = AtIdentifier::new_owned(authority.as_str()) + .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; let path = if let Some(collection) = parts.name("collection") { - let collection = Nsid::new_owned(collection.as_str())?; + let collection = Nsid::new_owned(collection.as_str()) + .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; let rkey = if let Some(rkey) = parts.name("rkey") { - let rkey = RecordKey::from(Rkey::new_owned(rkey.as_str())?); + let rkey = + RecordKey::from(Rkey::new_owned(rkey.as_str()).map_err(|e| { + AtStrError::wrap("at-uri-scheme", uri.to_string(), e) + })?); Some(rkey) } else { None @@ -103,22 +152,31 @@ impl<'u> AtUri<'u> { fragment, }) } else { - Err("at:// URI missing authority") + Err(AtStrError::missing("at-uri-scheme", uri, "authority")) } } else { - Err("Invalid at:// URI via regex") + Err(AtStrError::regex( + "at-uri-scheme", + uri, + SmolStr::new_static("doesn't match schema"), + )) } } - pub fn new_static(uri: &'static str) -> Result, &'static str> { + pub fn new_static(uri: &'static str) -> Result, AtStrError> { let uri = uri.as_ref(); if let Some(parts) = ATURI_REGEX.captures(uri) { if let Some(authority) = parts.name("authority") { - let authority = AtIdentifier::new_static(authority.as_str())?; + let authority = AtIdentifier::new_static(authority.as_str()) + .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; let path = if let Some(collection) = parts.name("collection") { - let collection = Nsid::new_static(collection.as_str())?; + let collection = Nsid::new_static(collection.as_str()) + .map_err(|e| AtStrError::wrap("at-uri-scheme", uri.to_string(), e))?; let rkey = if let Some(rkey) = parts.name("rkey") { - let rkey = RecordKey::from(Rkey::new_static(rkey.as_str())?); + let rkey = + RecordKey::from(Rkey::new_static(rkey.as_str()).map_err(|e| { + AtStrError::wrap("at-uri-scheme", uri.to_string(), e) + })?); Some(rkey) } else { None @@ -138,10 +196,14 @@ impl<'u> AtUri<'u> { fragment, }) } else { - Err("at:// URI missing authority") + Err(AtStrError::missing("at-uri-scheme", uri, "authority")) } } else { - Err("Invalid at:// URI via regex") + Err(AtStrError::regex( + "at-uri-scheme", + uri, + SmolStr::new_static("doesn't match schema"), + )) } } @@ -198,7 +260,7 @@ impl<'u> AtUri<'u> { } impl FromStr for AtUri<'_> { - type Err = &'static str; + type Err = AtStrError; /// Has to take ownership due to the lifetime constraints of the FromStr trait. /// Prefer `AtUri::new()` or `AtUri::raw()` if you want to borrow. @@ -245,7 +307,7 @@ impl<'d> From> for CowStr<'d> { } impl TryFrom for AtUri<'static> { - type Error = &'static str; + type Error = AtStrError; fn try_from(value: String) -> Result { Self::new_owned(&value) @@ -253,7 +315,7 @@ impl TryFrom for AtUri<'static> { } impl<'d> TryFrom> for AtUri<'d> { - type Error = &'static str; + type Error = AtStrError; /// TODO: rewrite to avoid taking ownership/cloning fn try_from(value: CowStr<'d>) -> Result { Self::new_owned(value) diff --git a/crates/jacquard-common/src/types/blob.rs b/crates/jacquard-common/src/types/blob.rs index 1a50a88..3d28b7c 100644 --- a/crates/jacquard-common/src/types/blob.rs +++ b/crates/jacquard-common/src/types/blob.rs @@ -12,7 +12,7 @@ use std::{ str::FromStr, }; -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] #[serde(rename_all = "camelCase")] pub struct Blob<'b> { pub r#ref: Cid<'b>, diff --git a/crates/jacquard-common/src/types/cid.rs b/crates/jacquard-common/src/types/cid.rs index f76241c..eae2eed 100644 --- a/crates/jacquard-common/src/types/cid.rs +++ b/crates/jacquard-common/src/types/cid.rs @@ -40,13 +40,23 @@ impl<'c> Cid<'c> { Ok(Self::Str(cid_str)) } } - pub fn ipld(cid: IpldCid) -> Self { + + pub fn new_owned(cid: &[u8]) -> Result, Error> { + if let Ok(cid) = IpldCid::try_from(cid.as_ref()) { + Ok(Self::ipld(cid)) + } else { + let cid_str = CowStr::from_utf8(cid)?; + Ok(Cid::Str(cid_str.into_static())) + } + } + + pub fn ipld(cid: IpldCid) -> Cid<'static> { let s = CowStr::Owned( cid.to_string_of_base(ATP_CID_BASE) .unwrap_or_default() .to_smolstr(), ); - Self::Ipld { cid, s } + Cid::Ipld { cid, s } } pub fn str(cid: &'c str) -> Self { diff --git a/crates/jacquard-common/src/types/datetime.rs b/crates/jacquard-common/src/types/datetime.rs index 00ad45f..fcb1962 100644 --- a/crates/jacquard-common/src/types/datetime.rs +++ b/crates/jacquard-common/src/types/datetime.rs @@ -13,7 +13,7 @@ pub static ISO8601_REGEX: LazyLock = LazyLock::new(|| { }); /// A Lexicon timestamp. -#[derive(Clone, Debug, Eq)] +#[derive(Clone, Debug, Eq, Hash)] pub struct Datetime { /// Serialized form. Preserved during parsing to ensure round-trip re-serialization. serialized: CowStr<'static>, @@ -98,7 +98,7 @@ impl FromStr for Datetime { if ISO8601_REGEX.is_match(s) { let dt = chrono::DateTime::parse_from_rfc3339(s)?; Ok(Self { - serialized: CowStr::Borrowed(s).into_static(), + serialized: CowStr::Owned(s.to_smolstr()), dt, }) } else { diff --git a/crates/jacquard-common/src/types/did.rs b/crates/jacquard-common/src/types/did.rs index daa61a4..7411e4d 100644 --- a/crates/jacquard-common/src/types/did.rs +++ b/crates/jacquard-common/src/types/did.rs @@ -1,7 +1,8 @@ +use crate::types::string::AtStrError; use crate::{CowStr, IntoStatic}; use regex::Regex; use serde::{Deserialize, Deserializer, Serialize, de::Error}; -use smol_str::ToSmolStr; +use smol_str::{SmolStr, ToSmolStr}; use std::fmt; use std::sync::LazyLock; use std::{ops::Deref, str::FromStr}; @@ -16,37 +17,49 @@ pub static DID_REGEX: LazyLock = impl<'d> Did<'d> { /// Fallible constructor, validates, borrows from input - pub fn new(did: &'d str) -> Result { + pub fn new(did: &'d str) -> Result { let did = did.strip_prefix("at://").unwrap_or(did); if did.len() > 2048 { - Err("DID too long") + Err(AtStrError::too_long("did", did, 2048, did.len())) } else if !DID_REGEX.is_match(did) { - Err("Invalid DID") + Err(AtStrError::regex( + "did", + did, + SmolStr::new_static("invalid"), + )) } else { Ok(Self(CowStr::Borrowed(did))) } } /// Fallible constructor, validates, takes ownership - pub fn new_owned(did: impl AsRef) -> Result { + pub fn new_owned(did: impl AsRef) -> Result { let did = did.as_ref(); let did = did.strip_prefix("at://").unwrap_or(did); if did.len() > 2048 { - Err("DID too long") + Err(AtStrError::too_long("did", did, 2048, did.len())) } else if !DID_REGEX.is_match(did) { - Err("Invalid DID") + Err(AtStrError::regex( + "did", + did, + SmolStr::new_static("invalid"), + )) } else { Ok(Self(CowStr::Owned(did.to_smolstr()))) } } /// Fallible constructor, validates, doesn't allocate - pub fn new_static(did: &'static str) -> Result { + pub fn new_static(did: &'static str) -> Result { let did = did.strip_prefix("at://").unwrap_or(did); if did.len() > 2048 { - Err("DID too long") + Err(AtStrError::too_long("did", did, 2048, did.len())) } else if !DID_REGEX.is_match(did) { - Err("Invalid DID") + Err(AtStrError::regex( + "did", + did, + SmolStr::new_static("invalid"), + )) } else { Ok(Self(CowStr::new_static(did))) } @@ -82,7 +95,7 @@ impl<'d> Did<'d> { } impl FromStr for Did<'_> { - type Err = &'static str; + type Err = AtStrError; /// Has to take ownership due to the lifetime constraints of the FromStr trait. /// Prefer `Did::new()` or `Did::raw` if you want to borrow. diff --git a/crates/jacquard-common/src/types/handle.rs b/crates/jacquard-common/src/types/handle.rs index ffc1b4e..773c8c8 100644 --- a/crates/jacquard-common/src/types/handle.rs +++ b/crates/jacquard-common/src/types/handle.rs @@ -1,7 +1,9 @@ +use crate::types::string::AtStrError; +use crate::types::{DISALLOWED_TLDS, ends_with}; use crate::{CowStr, IntoStatic}; use regex::Regex; use serde::{Deserialize, Deserializer, Serialize, de::Error}; -use smol_str::ToSmolStr; +use smol_str::{SmolStr, ToSmolStr}; use std::fmt; use std::sync::LazyLock; use std::{ops::Deref, str::FromStr}; @@ -20,23 +22,29 @@ impl<'h> Handle<'h> { /// Fallible constructor, validates, borrows from input /// /// Accepts (and strips) preceding '@' if present - pub fn new(handle: &'h str) -> Result { + pub fn new(handle: &'h str) -> Result { let handle = handle .strip_prefix("at://") .unwrap_or(handle) .strip_prefix('@') .unwrap_or(handle); if handle.len() > 253 { - Err("handle too long") + Err(AtStrError::too_long("handle", handle, 253, handle.len())) } else if !HANDLE_REGEX.is_match(handle) { - Err("Invalid handle") + Err(AtStrError::regex( + "handle", + handle, + SmolStr::new_static("invalid"), + )) + } else if ends_with(handle, DISALLOWED_TLDS) { + Err(AtStrError::disallowed("handle", handle, DISALLOWED_TLDS)) } else { Ok(Self(CowStr::Borrowed(handle))) } } /// Fallible constructor, validates, takes ownership - pub fn new_owned(handle: impl AsRef) -> Result { + pub fn new_owned(handle: impl AsRef) -> Result { let handle = handle.as_ref(); let handle = handle .strip_prefix("at://") @@ -44,25 +52,37 @@ impl<'h> Handle<'h> { .strip_prefix('@') .unwrap_or(handle); if handle.len() > 253 { - Err("handle too long") + Err(AtStrError::too_long("handle", handle, 253, handle.len())) } else if !HANDLE_REGEX.is_match(handle) { - Err("Invalid handle") + Err(AtStrError::regex( + "handle", + handle, + SmolStr::new_static("invalid"), + )) + } else if ends_with(handle, DISALLOWED_TLDS) { + Err(AtStrError::disallowed("handle", handle, DISALLOWED_TLDS)) } else { Ok(Self(CowStr::Owned(handle.to_smolstr()))) } } /// Fallible constructor, validates, doesn't allocate - pub fn new_static(handle: &'static str) -> Result { + pub fn new_static(handle: &'static str) -> Result { let handle = handle .strip_prefix("at://") .unwrap_or(handle) .strip_prefix('@') .unwrap_or(handle); if handle.len() > 253 { - Err("handle too long") + Err(AtStrError::too_long("handle", handle, 253, handle.len())) } else if !HANDLE_REGEX.is_match(handle) { - Err("Invalid handle") + Err(AtStrError::regex( + "handle", + handle, + SmolStr::new_static("invalid"), + )) + } else if ends_with(handle, DISALLOWED_TLDS) { + Err(AtStrError::disallowed("handle", handle, DISALLOWED_TLDS)) } else { Ok(Self(CowStr::new_static(handle))) } @@ -83,6 +103,8 @@ impl<'h> Handle<'h> { panic!("handle too long") } else if !HANDLE_REGEX.is_match(handle) { panic!("Invalid handle") + } else if ends_with(handle, DISALLOWED_TLDS) { + panic!("top-level domain not allowed in handles") } else { Self(CowStr::Borrowed(handle)) } @@ -110,7 +132,7 @@ impl<'h> Handle<'h> { } impl FromStr for Handle<'_> { - type Err = &'static str; + type Err = AtStrError; /// Has to take ownership due to the lifetime constraints of the FromStr trait. /// Prefer `Handle::new()` or `Handle::raw` if you want to borrow. @@ -163,43 +185,13 @@ impl<'h> From> for CowStr<'h> { impl From for Handle<'static> { fn from(value: String) -> Self { - let value = if let Some(handle) = value - .strip_prefix("at://") - .unwrap_or(&value) - .strip_prefix('@') - { - CowStr::Borrowed(handle) - } else { - value.into() - }; - if value.len() > 253 { - panic!("handle too long") - } else if !HANDLE_REGEX.is_match(&value) { - panic!("Invalid handle") - } else { - Self(value.into_static()) - } + Self::new_owned(value).unwrap() } } impl<'h> From> for Handle<'h> { fn from(value: CowStr<'h>) -> Self { - let value = if let Some(handle) = value - .strip_prefix("at://") - .unwrap_or(&value) - .strip_prefix('@') - { - CowStr::Borrowed(handle) - } else { - value - }; - if value.len() > 253 { - panic!("handle too long") - } else if !HANDLE_REGEX.is_match(&value) { - panic!("Invalid handle") - } else { - Self(value.into_static()) - } + Self::new_owned(value).unwrap() } } diff --git a/crates/jacquard-common/src/types/ident.rs b/crates/jacquard-common/src/types/ident.rs index f790eb5..820cf71 100644 --- a/crates/jacquard-common/src/types/ident.rs +++ b/crates/jacquard-common/src/types/ident.rs @@ -1,4 +1,5 @@ use crate::types::handle::Handle; +use crate::types::string::AtStrError; use crate::{IntoStatic, types::did::Did}; use std::fmt; use std::str::FromStr; @@ -18,7 +19,7 @@ pub enum AtIdentifier<'i> { impl<'i> AtIdentifier<'i> { /// Fallible constructor, validates, borrows from input - pub fn new(ident: &'i str) -> Result { + pub fn new(ident: &'i str) -> Result { if let Ok(did) = ident.parse() { Ok(AtIdentifier::Did(did)) } else { @@ -27,7 +28,7 @@ impl<'i> AtIdentifier<'i> { } /// Fallible constructor, validates, takes ownership - pub fn new_owned(ident: impl AsRef) -> Result { + pub fn new_owned(ident: impl AsRef) -> Result { let ident = ident.as_ref(); if let Ok(did) = Did::new_owned(ident) { Ok(AtIdentifier::Did(did)) @@ -37,7 +38,7 @@ impl<'i> AtIdentifier<'i> { } /// Fallible constructor, validates, doesn't allocate - pub fn new_static(ident: &'static str) -> Result, &'static str> { + pub fn new_static(ident: &'static str) -> Result, AtStrError> { if let Ok(did) = Did::new_static(ident) { Ok(AtIdentifier::Did(did)) } else { @@ -93,7 +94,7 @@ impl<'i> From> for AtIdentifier<'i> { } impl FromStr for AtIdentifier<'_> { - type Err = &'static str; + type Err = AtStrError; fn from_str(s: &str) -> Result { if let Ok(did) = s.parse() { diff --git a/crates/jacquard-common/src/types/language.rs b/crates/jacquard-common/src/types/language.rs index b447787..42ac045 100644 --- a/crates/jacquard-common/src/types/language.rs +++ b/crates/jacquard-common/src/types/language.rs @@ -5,22 +5,24 @@ use std::{ops::Deref, str::FromStr}; use crate::CowStr; -/// A [Timestamp Identifier]. +/// An IETF language tag. /// -/// [Timestamp Identifier]: https://atproto.com/specs/lang +/// Uses langtag crate for validation, but is stored as a SmolStr for size/avoiding allocations +/// +/// TODO: Implement langtag-style semantic matching for this type, delegating to langtag #[derive(Clone, Debug, PartialEq, Eq, Serialize, Hash)] #[serde(transparent)] #[repr(transparent)] -pub struct Lang(SmolStr); +pub struct Language(SmolStr); -impl Lang { +impl Language { /// Parses an IETF language tag from the given string. pub fn new(lang: &T) -> Result> where T: AsRef + ?Sized, { let tag = langtag::LangTag::new(lang)?; - Ok(Lang(SmolStr::new_inline(tag.as_str()))) + Ok(Language(SmolStr::new_inline(tag.as_str()))) } /// Infallible constructor for when you *know* the string is a valid IETF language tag. @@ -30,7 +32,7 @@ impl Lang { pub fn raw(lang: impl AsRef) -> Self { let lang = lang.as_ref(); let tag = langtag::LangTag::new(lang).expect("valid IETF language tag"); - Lang(SmolStr::new_inline(tag.as_str())) + Language(SmolStr::new_inline(tag.as_str())) } /// Infallible constructor for when you *know* the string is a valid IETF language tag. @@ -49,7 +51,7 @@ impl Lang { } } -impl FromStr for Lang { +impl FromStr for Language { type Err = SmolStr; fn from_str(s: &str) -> Result { @@ -57,7 +59,7 @@ impl FromStr for Lang { } } -impl<'de> Deserialize<'de> for Lang { +impl<'de> Deserialize<'de> for Language { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, @@ -67,43 +69,43 @@ impl<'de> Deserialize<'de> for Lang { } } -impl fmt::Display for Lang { +impl fmt::Display for Language { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) } } -impl From for String { - fn from(value: Lang) -> Self { +impl From for String { + fn from(value: Language) -> Self { value.0.to_string() } } -impl From for SmolStr { - fn from(value: Lang) -> Self { +impl From for SmolStr { + fn from(value: Language) -> Self { value.0 } } -impl From for Lang { +impl From for Language { fn from(value: String) -> Self { Self::raw(&value) } } -impl<'t> From> for Lang { +impl<'t> From> for Language { fn from(value: CowStr<'t>) -> Self { Self::raw(&value) } } -impl AsRef for Lang { +impl AsRef for Language { fn as_ref(&self) -> &str { self.as_str() } } -impl Deref for Lang { +impl Deref for Language { type Target = str; fn deref(&self) -> &Self::Target { diff --git a/crates/jacquard-common/src/types/nsid.rs b/crates/jacquard-common/src/types/nsid.rs index c2f91cc..1855bb3 100644 --- a/crates/jacquard-common/src/types/nsid.rs +++ b/crates/jacquard-common/src/types/nsid.rs @@ -1,4 +1,5 @@ use crate::types::recordkey::RecordKeyType; +use crate::types::string::AtStrError; use crate::{CowStr, IntoStatic}; use regex::Regex; use serde::{Deserialize, Deserializer, Serialize, de::Error}; @@ -22,34 +23,46 @@ pub static NSID_REGEX: LazyLock = LazyLock::new(|| { impl<'n> Nsid<'n> { /// Fallible constructor, validates, borrows from input - pub fn new(nsid: &'n str) -> Result { + pub fn new(nsid: &'n str) -> Result { if nsid.len() > 317 { - Err("NSID too long") + Err(AtStrError::too_long("nsid", nsid, 317, nsid.len())) } else if !NSID_REGEX.is_match(nsid) { - Err("Invalid NSID") + Err(AtStrError::regex( + "nsid", + nsid, + SmolStr::new_static("invalid"), + )) } else { Ok(Self(CowStr::Borrowed(nsid))) } } /// Fallible constructor, validates, borrows from input - pub fn new_owned(nsid: impl AsRef) -> Result { + pub fn new_owned(nsid: impl AsRef) -> Result { let nsid = nsid.as_ref(); if nsid.len() > 317 { - Err("NSID too long") + Err(AtStrError::too_long("nsid", nsid, 317, nsid.len())) } else if !NSID_REGEX.is_match(nsid) { - Err("Invalid NSID") + Err(AtStrError::regex( + "nsid", + nsid, + SmolStr::new_static("invalid"), + )) } else { Ok(Self(CowStr::Owned(nsid.to_smolstr()))) } } /// Fallible constructor, validates, doesn't allocate - pub fn new_static(nsid: &'static str) -> Result { + pub fn new_static(nsid: &'static str) -> Result { if nsid.len() > 317 { - Err("NSID too long") + Err(AtStrError::too_long("nsid", nsid, 317, nsid.len())) } else if !NSID_REGEX.is_match(nsid) { - Err("Invalid NSID") + Err(AtStrError::regex( + "nsid", + nsid, + SmolStr::new_static("invalid"), + )) } else { Ok(Self(CowStr::new_static(nsid))) } @@ -96,7 +109,7 @@ impl<'n> Nsid<'n> { } impl<'n> FromStr for Nsid<'n> { - type Err = &'static str; + type Err = AtStrError; /// Has to take ownership due to the lifetime constraints of the FromStr trait. /// Prefer `Nsid::new()` or `Nsid::raw` if you want to borrow. diff --git a/crates/jacquard-common/src/types/recordkey.rs b/crates/jacquard-common/src/types/recordkey.rs index 06204a6..44d5d4f 100644 --- a/crates/jacquard-common/src/types/recordkey.rs +++ b/crates/jacquard-common/src/types/recordkey.rs @@ -1,4 +1,5 @@ use crate::types::Literal; +use crate::types::string::AtStrError; use crate::{CowStr, IntoStatic}; use regex::Regex; use serde::{Deserialize, Deserializer, Serialize, de::Error}; @@ -74,34 +75,46 @@ pub static RKEY_REGEX: LazyLock = /// AT Protocol rkey impl<'r> Rkey<'r> { /// Fallible constructor, validates, borrows from input - pub fn new(rkey: &'r str) -> Result { + pub fn new(rkey: &'r str) -> Result { if [".", ".."].contains(&rkey) { - Err("Disallowed rkey") + Err(AtStrError::disallowed("record-key", rkey, &[".", ".."])) } else if !RKEY_REGEX.is_match(rkey) { - Err("Invalid rkey") + Err(AtStrError::regex( + "record-key", + rkey, + SmolStr::new_static("doesn't match 'any' schema"), + )) } else { Ok(Self(CowStr::Borrowed(rkey))) } } /// Fallible constructor, validates, borrows from input - pub fn new_owned(rkey: impl AsRef) -> Result { + pub fn new_owned(rkey: impl AsRef) -> Result { let rkey = rkey.as_ref(); if [".", ".."].contains(&rkey) { - Err("Disallowed rkey") + Err(AtStrError::disallowed("record-key", rkey, &[".", ".."])) } else if !RKEY_REGEX.is_match(rkey) { - Err("Invalid rkey") + Err(AtStrError::regex( + "record-key", + rkey, + SmolStr::new_static("doesn't match 'any' schema"), + )) } else { Ok(Self(CowStr::Owned(rkey.to_smolstr()))) } } /// Fallible constructor, validates, doesn't allocate - pub fn new_static(rkey: &'static str) -> Result { + pub fn new_static(rkey: &'static str) -> Result { if [".", ".."].contains(&rkey) { - Err("Disallowed rkey") + Err(AtStrError::disallowed("record-key", rkey, &[".", ".."])) } else if !RKEY_REGEX.is_match(rkey) { - Err("Invalid rkey") + Err(AtStrError::regex( + "record-key", + rkey, + SmolStr::new_static("doesn't match 'any' schema"), + )) } else { Ok(Self(CowStr::new_static(rkey))) } @@ -136,13 +149,17 @@ impl<'r> Rkey<'r> { } impl<'r> FromStr for Rkey<'r> { - type Err = &'static str; + type Err = AtStrError; fn from_str(s: &str) -> Result { if [".", ".."].contains(&s) { - Err("Disallowed rkey") + Err(AtStrError::disallowed("record-key", s, &[".", ".."])) } else if !RKEY_REGEX.is_match(s) { - Err("Invalid rkey") + Err(AtStrError::regex( + "record-key", + s, + SmolStr::new_static("doesn't match 'any' schema"), + )) } else { Ok(Self(CowStr::Owned(s.to_smolstr()))) } diff --git a/crates/jacquard-common/src/types/string.rs b/crates/jacquard-common/src/types/string.rs new file mode 100644 index 0000000..5eb8a93 --- /dev/null +++ b/crates/jacquard-common/src/types/string.rs @@ -0,0 +1,394 @@ +use bytes::Bytes; +use miette::SourceSpan; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use smol_str::{SmolStr, ToSmolStr}; +use std::{collections::BTreeMap, str::FromStr, sync::Arc}; + +use crate::IntoStatic; +pub use crate::{ + CowStr, + types::{ + aturi::AtUri, + cid::Cid, + datetime::Datetime, + did::Did, + handle::Handle, + ident::AtIdentifier, + language::Language, + nsid::Nsid, + recordkey::{RecordKey, Rkey}, + tid::Tid, + uri::Uri, + }, +}; + +/// ATProto string value +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum AtprotoStr<'s> { + Datetime(Datetime), + Language(Language), + Tid(Tid), + Nsid(Nsid<'s>), + Did(Did<'s>), + Handle(Handle<'s>), + AtIdentifier(AtIdentifier<'s>), + AtUri(AtUri<'s>), + Uri(Uri<'s>), + Cid(Cid<'s>), + RecordKey(RecordKey>), + String(CowStr<'s>), +} + +impl<'s> AtprotoStr<'s> { + /// Borrowing constructor for bare atproto string values + /// This is fairly exhaustive and potentially **slow**, prefer using anything + /// that narrows down the search field quicker. + /// + /// Note: We don't construct record keys from bare strings in this because + /// the type is too permissive and too many things would be classified as rkeys. + /// + /// Value object deserialization checks against the field names for common + /// names (uri, cid, did, handle, createdAt, indexedAt, etc.) to improve + /// performance of the happy path. + pub fn new(string: &'s str) -> Self { + // TODO: do some quick prefix checks like in Uri to drop through faster + if let Ok(datetime) = Datetime::from_str(string) { + Self::Datetime(datetime) + } else if let Ok(lang) = Language::new(string) { + Self::Language(lang) + } else if let Ok(tid) = Tid::from_str(string) { + Self::Tid(tid) + } else if let Ok(did) = Did::new(string) { + Self::Did(did) + } else if let Ok(handle) = Handle::new(string) { + Self::Handle(handle) + } else if let Ok(atid) = AtIdentifier::new(string) { + Self::AtIdentifier(atid) + } else if let Ok(nsid) = Nsid::new(string) { + Self::Nsid(nsid) + } else if let Ok(aturi) = AtUri::new(string) { + Self::AtUri(aturi) + } else if let Ok(uri) = Uri::new(string) { + Self::Uri(uri) + } else if let Ok(cid) = Cid::new(string.as_bytes()) { + Self::Cid(cid) + } else { + // We don't construct record keys from bare strings because the type is too permissive + Self::String(CowStr::Borrowed(string)) + } + } + + pub fn as_str(&self) -> &str { + match self { + Self::Datetime(datetime) => datetime.as_str(), + Self::Language(lang) => lang.as_str(), + Self::Handle(handle) => handle.as_str(), + Self::AtIdentifier(atid) => atid.as_str(), + Self::Nsid(nsid) => nsid.as_str(), + Self::AtUri(aturi) => aturi.as_str(), + Self::Uri(uri) => uri.as_str(), + Self::Cid(cid) => cid.as_str(), + Self::Tid(tid) => tid.as_str(), + Self::Did(did) => did.as_str(), + Self::RecordKey(rkey) => rkey.as_ref(), + Self::String(string) => string.as_ref(), + } + } +} + +impl AtprotoStr<'static> { + /// Owned constructor for bare atproto string values + /// This is fairly exhaustive and potentially **slow**, prefer using anything + /// that narrows down the search field quicker. + /// + /// Note: We don't construct record keys from bare strings in this because + /// the type is too permissive and too many things would be classified as rkeys. + /// + /// Value object deserialization checks against the field names for common + /// names (uri, cid, did, handle, createdAt, indexedAt, etc.) to improve + /// performance of the happy path. + pub fn new_owned(string: impl AsRef) -> AtprotoStr<'static> { + let string = string.as_ref(); + // TODO: do some quick prefix checks like in Uri to drop through faster + if let Ok(datetime) = Datetime::from_str(string) { + Self::Datetime(datetime) + } else if let Ok(lang) = Language::new(string) { + Self::Language(lang) + } else if let Ok(tid) = Tid::from_str(string) { + Self::Tid(tid) + } else if let Ok(did) = Did::new_owned(string) { + Self::Did(did) + } else if let Ok(handle) = Handle::new_owned(string) { + Self::Handle(handle) + } else if let Ok(atid) = AtIdentifier::new_owned(string) { + Self::AtIdentifier(atid) + } else if let Ok(nsid) = Nsid::new_owned(string) { + Self::Nsid(nsid) + } else if let Ok(aturi) = AtUri::new_owned(string) { + Self::AtUri(aturi) + } else if let Ok(uri) = Uri::new_owned(string) { + Self::Uri(uri) + } else if let Ok(cid) = Cid::new_owned(string.as_bytes()) { + Self::Cid(cid) + } else { + // We don't construct record keys from bare strings because the type is too permissive + Self::String(CowStr::Owned(string.to_smolstr())) + } + } +} + +impl<'s> AsRef for AtprotoStr<'s> { + fn as_ref(&self) -> &str { + match self { + Self::Datetime(datetime) => datetime.as_str(), + Self::Language(lang) => lang.as_ref(), + Self::Tid(tid) => tid.as_ref(), + Self::Did(did) => did.as_ref(), + Self::Handle(handle) => handle.as_ref(), + Self::AtIdentifier(atid) => atid.as_ref(), + Self::Nsid(nsid) => nsid.as_ref(), + Self::AtUri(aturi) => aturi.as_ref(), + Self::Uri(uri) => uri.as_str(), + Self::Cid(cid) => cid.as_ref(), + Self::RecordKey(rkey) => rkey.as_ref(), + Self::String(string) => string.as_ref(), + } + } +} + +impl Serialize for AtprotoStr<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_ref()) + } +} + +impl<'de> Deserialize<'de> for AtprotoStr<'de> { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = Deserialize::deserialize(deserializer)?; + Ok(Self::new(value)) + } +} + +impl IntoStatic for AtprotoStr<'_> { + type Output = AtprotoStr<'static>; + + fn into_static(self) -> Self::Output { + match self { + AtprotoStr::Datetime(datetime) => AtprotoStr::Datetime(datetime), + AtprotoStr::Language(language) => AtprotoStr::Language(language), + AtprotoStr::Tid(tid) => AtprotoStr::Tid(tid), + AtprotoStr::Nsid(nsid) => AtprotoStr::Nsid(nsid.into_static()), + AtprotoStr::Did(did) => AtprotoStr::Did(did.into_static()), + AtprotoStr::Handle(handle) => AtprotoStr::Handle(handle.into_static()), + AtprotoStr::AtIdentifier(ident) => AtprotoStr::AtIdentifier(ident.into_static()), + AtprotoStr::AtUri(at_uri) => { + AtprotoStr::AtUri(AtUri::new_owned(at_uri.as_str()).unwrap()) + } + AtprotoStr::Uri(uri) => AtprotoStr::Uri(uri.into_static()), + AtprotoStr::Cid(cid) => AtprotoStr::Cid(cid.into_static()), + AtprotoStr::RecordKey(record_key) => AtprotoStr::RecordKey(record_key.into_static()), + AtprotoStr::String(cow_str) => AtprotoStr::String(cow_str.into_static()), + } + } +} + +/// Parsing Error for atproto string types which don't have third-party specs +/// (e.g. datetime, CIDs, language tags). +/// +/// `spec` refers to the final url path segment on atproto.com/specs, +/// detailing the specification for the type +/// `source` is the source string, or part of it +/// `kind` is the type of parsing error: `[StrParseKind]` +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("error in `{source}`: {kind}")] +#[diagnostic( + url("https://atproto.com/specs/{spec}"), + help("if something doesn't match the spec, contact the crate author") +)] +pub struct AtStrError { + pub spec: SmolStr, + #[source_code] + source: String, + #[source] + #[diagnostic_source] + pub kind: StrParseKind, +} + +impl AtStrError { + pub fn new(spec: &'static str, source: String, kind: StrParseKind) -> Self { + Self { + spec: SmolStr::new_static(spec), + source, + kind, + } + } + + pub fn wrap(spec: &'static str, source: String, error: AtStrError) -> Self { + if let Some(span) = match &error.kind { + StrParseKind::Disallowed { problem, .. } => problem, + StrParseKind::MissingComponent { span, .. } => span, + _ => &None, + } { + Self { + spec: SmolStr::new_static(spec), + source, + kind: StrParseKind::Wrap { + span: Some(*span), + err: Arc::new(error), + }, + } + } else { + let span = source + .find(&error.source) + .map(|start| (start, error.source.len()).into()); + Self { + spec: SmolStr::new_static(spec), + source, + kind: StrParseKind::Wrap { + span, + err: Arc::new(error), + }, + } + } + } + + /// something on the provided disallowed list was found in the source string + /// does a substring search for any of the offending strings to get the span + pub fn disallowed(spec: &'static str, source: &str, disallowed: &[&str]) -> Self { + for item in disallowed { + if let Some(loc) = source.find(item) { + return Self { + spec: SmolStr::new_static(spec), + source: source.to_string(), + kind: StrParseKind::Disallowed { + problem: Some(SourceSpan::new(loc.into(), item.len())), + message: smol_str::format_smolstr!("`{item}`"), + }, + }; + } + } + Self { + spec: SmolStr::new_static(spec), + source: source.to_string(), + kind: StrParseKind::Disallowed { + problem: None, + message: SmolStr::new_static(""), + }, + } + } + + pub fn too_long(spec: &'static str, source: &str, max: usize, actual: usize) -> Self { + Self { + spec: SmolStr::new_static(spec), + source: source.to_string(), + kind: StrParseKind::TooLong { max, actual }, + } + } + + pub fn too_short(spec: &'static str, source: &str, min: usize, actual: usize) -> Self { + Self { + spec: SmolStr::new_static(spec), + source: source.to_string(), + kind: StrParseKind::TooShort { min, actual }, + } + } + + /// missing component, with what was expected to be found + pub fn missing(spec: &'static str, source: &str, expected: &str) -> Self { + if let Some(loc) = source.find(expected) { + return Self { + spec: SmolStr::new_static(spec), + source: source.to_string(), + kind: StrParseKind::MissingComponent { + span: Some(SourceSpan::new(loc.into(), expected.len())), + message: smol_str::format_smolstr!("`{expected}` found incorrectly here"), + }, + }; + } + Self { + spec: SmolStr::new_static(spec), + source: source.to_string(), + kind: StrParseKind::MissingComponent { + span: None, + message: SmolStr::new(expected), + }, + } + } + + /// missing component, with the span where it was expected to be founf + pub fn missing_from( + spec: &'static str, + source: &str, + expected: &str, + span: (usize, usize), + ) -> Self { + Self { + spec: SmolStr::new_static(spec), + source: source.to_string(), + kind: StrParseKind::MissingComponent { + span: Some(span.into()), + message: SmolStr::new(expected), + }, + } + } + + pub fn regex(spec: &'static str, source: &str, message: SmolStr) -> Self { + Self { + spec: SmolStr::new_static(spec), + source: source.to_string(), + kind: StrParseKind::RegexFail { + span: None, + message, + }, + } + } +} + +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +pub enum StrParseKind { + #[error("regex failure - {message}")] + #[diagnostic(code(jacquard::types::string::regex_fail))] + RegexFail { + #[label] + span: Option, + #[help] + message: SmolStr, + }, + #[error("string too long (allowed: {max}, actual: {actual})")] + #[diagnostic(code(jacquard::types::string::wrong_length))] + TooLong { max: usize, actual: usize }, + + #[error("string too short (allowed: {min}, actual: {actual})")] + #[diagnostic(code(jacquard::types::string::wrong_length))] + TooShort { min: usize, actual: usize }, + #[error("disallowed - {message}")] + #[diagnostic(code(jacquard::types::string::disallowed))] + Disallowed { + #[label] + problem: Option, + #[help] + message: SmolStr, + }, + #[error("missing - {message}")] + #[diagnostic(code(jacquard::atstr::missing_component))] + MissingComponent { + #[label] + span: Option, + #[help] + message: SmolStr, + }, + #[error("{err:?}")] + #[diagnostic(code(jacquard::atstr::inner))] + Wrap { + #[label] + span: Option, + #[source] + err: Arc, + }, +} diff --git a/crates/jacquard-common/src/types/tid.rs b/crates/jacquard-common/src/types/tid.rs index c55f3ed..0fd4939 100644 --- a/crates/jacquard-common/src/types/tid.rs +++ b/crates/jacquard-common/src/types/tid.rs @@ -6,6 +6,7 @@ use std::{ops::Deref, str::FromStr}; use crate::CowStr; use crate::types::integer::LimitedU32; +use crate::types::string::{AtStrError, StrParseKind}; use regex::Regex; fn s32_encode(mut i: u64) -> SmolStr { @@ -40,12 +41,27 @@ pub struct Tid(SmolStr); impl Tid { /// Parses a `TID` from the given string. - pub fn new(tid: impl AsRef) -> Result { + pub fn new(tid: impl AsRef) -> Result { let tid = tid.as_ref(); if tid.len() != 13 { - Err("TID must be 13 characters") + let kind = if tid.len() > 13 { + StrParseKind::TooLong { + max: 13, + actual: tid.len(), + } + } else { + StrParseKind::TooShort { + min: 13, + actual: tid.len(), + } + }; + Err(AtStrError::new("tid", tid.to_string(), kind)) } else if !TID_REGEX.is_match(&tid.as_ref()) { - Err("Invalid TID") + let kind = StrParseKind::RegexFail { + span: None, + message: SmolStr::new_static("didn't match schema"), + }; + Err(AtStrError::new("tid", tid.to_string(), kind)) } else { Ok(Self(SmolStr::new_inline(&tid))) } @@ -117,7 +133,7 @@ impl Tid { } impl FromStr for Tid { - type Err = &'static str; + type Err = AtStrError; /// Has to take ownership due to the lifetime constraints of the FromStr trait. /// Prefer `Did::new()` or `Did::raw` if you want to borrow. diff --git a/crates/jacquard-common/src/types/uri.rs b/crates/jacquard-common/src/types/uri.rs new file mode 100644 index 0000000..04f93c8 --- /dev/null +++ b/crates/jacquard-common/src/types/uri.rs @@ -0,0 +1,124 @@ +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use smol_str::ToSmolStr; +use url::Url; + +use crate::{ + CowStr, IntoStatic, + types::{aturi::AtUri, cid::Cid, did::Did, string::AtStrError}, +}; + +/// URI with best-available contextual type +/// TODO: figure out wtf a DNS uri should look like +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Uri<'u> { + Did(Did<'u>), + At(AtUri<'u>), + Https(Url), + Wss(Url), + Cid(Cid<'u>), + Any(CowStr<'u>), +} + +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +pub enum UriParseError { + #[error("Invalid atproto string: {0}")] + At(#[from] AtStrError), + #[error(transparent)] + Url(#[from] url::ParseError), + #[error(transparent)] + Cid(#[from] crate::types::cid::Error), +} + +impl<'u> Uri<'u> { + pub fn new(uri: &'u str) -> Result { + if uri.starts_with("did:") { + Ok(Uri::Did(Did::new(uri)?)) + } else if uri.starts_with("at://") { + Ok(Uri::At(AtUri::new(uri)?)) + } else if uri.starts_with("https://") { + Ok(Uri::Https(Url::parse(uri)?)) + } else if uri.starts_with("wss://") { + Ok(Uri::Https(Url::parse(uri)?)) + } else if uri.starts_with("ipld://") { + Ok(Uri::Cid(Cid::new(uri.as_bytes())?)) + } else { + Ok(Uri::Any(CowStr::Borrowed(uri))) + } + } + + pub fn new_owned(uri: impl AsRef) -> Result, UriParseError> { + let uri = uri.as_ref(); + if uri.starts_with("did:") { + Ok(Uri::Did(Did::new_owned(uri)?)) + } else if uri.starts_with("at://") { + Ok(Uri::At(AtUri::new_owned(uri)?)) + } else if uri.starts_with("https://") { + Ok(Uri::Https(Url::parse(uri)?)) + } else if uri.starts_with("wss://") { + Ok(Uri::Https(Url::parse(uri)?)) + } else if uri.starts_with("ipld://") { + Ok(Uri::Cid(Cid::new_owned(uri.as_bytes())?)) + } else { + Ok(Uri::Any(CowStr::Owned(uri.to_smolstr()))) + } + } + + pub fn as_str(&self) -> &str { + match self { + Uri::Did(did) => did.as_str(), + Uri::At(at_uri) => at_uri.as_str(), + Uri::Https(url) => url.as_str(), + Uri::Wss(url) => url.as_str(), + Uri::Cid(cid) => cid.as_str(), + Uri::Any(s) => s.as_ref(), + } + } +} + +impl Serialize for Uri<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for Uri<'de> { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::Error; + let value = Deserialize::deserialize(deserializer)?; + Self::new(value).map_err(D::Error::custom) + } +} + +impl<'s> AsRef for Uri<'s> { + fn as_ref(&self) -> &str { + match self { + Uri::Did(did) => did.as_str(), + Uri::At(at_uri) => at_uri.as_str(), + Uri::Https(url) => url.as_str(), + Uri::Wss(url) => url.as_str(), + Uri::Cid(cid) => cid.as_str(), + Uri::Any(s) => s.as_ref(), + } + } +} + +impl IntoStatic for Uri<'_> { + type Output = Uri<'static>; + + fn into_static(self) -> Self::Output { + match self { + Uri::Did(did) => Uri::Did(did.into_static()), + Uri::At(at_uri) => Uri::At(AtUri::new_owned(at_uri.as_str()).unwrap()), + Uri::Https(url) => Uri::Https(url), + Uri::Wss(url) => Uri::Wss(url), + Uri::Cid(cid) => Uri::Cid(cid.into_static()), + Uri::Any(s) => Uri::Any(s.into_static()), + } + } +} diff --git a/crates/jacquard-common/src/types/value.rs b/crates/jacquard-common/src/types/value.rs new file mode 100644 index 0000000..758e65a --- /dev/null +++ b/crates/jacquard-common/src/types/value.rs @@ -0,0 +1,25 @@ +use bytes::Bytes; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use smol_str::SmolStr; +use std::collections::BTreeMap; + +use crate::types::{blob::Blob, string::*}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Data<'s> { + Null, + Boolean(bool), + Integer(i64), + String(AtprotoStr<'s>), + Bytes(Bytes), // maybe need custom type for serialization + CidLink(Cid<'s>), // maybe need custom type for serialization + Array(Array<'s>), + Object(Object<'s>), + Blob(Blob<'s>), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Array<'s>(pub Vec>); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Object<'s>(pub BTreeMap>); diff --git a/crates/jacquard-lexicon/Cargo.toml b/crates/jacquard-lexicon/Cargo.toml new file mode 100644 index 0000000..5da4704 --- /dev/null +++ b/crates/jacquard-lexicon/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "jacquard-lexicon" +edition.workspace = true +version.workspace = true +authors.workspace = true +repository.workspace = true +keywords.workspace = true +categories.workspace = true +readme.workspace = true +documentation.workspace = true +exclude.workspace = true +description.workspace = true + +[dependencies] diff --git a/crates/jacquard-lexicon/src/lib.rs b/crates/jacquard-lexicon/src/lib.rs new file mode 100644 index 0000000..b93cf3f --- /dev/null +++ b/crates/jacquard-lexicon/src/lib.rs @@ -0,0 +1,14 @@ +pub fn add(left: u64, right: u64) -> u64 { + left + right +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_works() { + let result = add(2, 2); + assert_eq!(result, 4); + } +}