From 38525bfa69b30404ebaa3f97ef4629a64267573d Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Sat, 13 Jul 2024 10:41:35 -0400 Subject: [PATCH 1/2] Document `src/zarrman/` and its API requirements --- doc/zarrman.md | 115 ++++++++++++++++++++ src/zarrman/manifest.rs | 19 +++- src/zarrman/mod.rs | 227 +++++++++++++++++++++++++++------------ src/zarrman/path.rs | 15 +++ src/zarrman/resources.rs | 47 +++++++- 5 files changed, 345 insertions(+), 78 deletions(-) create mode 100644 doc/zarrman.md diff --git a/doc/zarrman.md b/doc/zarrman.md new file mode 100644 index 0000000..a2b016a --- /dev/null +++ b/doc/zarrman.md @@ -0,0 +1,115 @@ +Requirements for the Zarr Manifest API +====================================== + +*This document is up-to-date as of 2024 July 13.* + +The implementation of the `/zarrs/` hierarchy served by `dandidav` works by +fetching documents called *Zarr manifests* from a URL hierarchy, hereafter +referred to as the *manifest tree*, the base URL of which is hereafter referred +to as the *manifest root*. Currently, the manifest root is hardcoded to +, +a subdirectory of a mirror of (but +see [issue #161](https://github.com/dandi/dandidav/issues/161)). The manifest +tree and the Zarr manifests are expected to meet the following requirements: + +- An HTTP `GET` request to any extant directory in the manifest tree (including + the manifest root) must return a JSON object containing the following keys: + + - `"files"` — an array of strings listing the names (i.e., final path + components) of the files (if any) available immediately within the + directory + + - `"directories"` — an array of strings listing the names (i.e., final path + components) of the subdirectories (if any) available immediately within + the directory + +- Each Zarr manifest describes a single Zarr in a DANDI Archive instance at a + certain point in time. The URL for each Zarr manifest must be of the form + `{manifest_root}/{prefix1}/{prefix2}/{zarr_id}/{checksum}.json`, where: + + - `{prefix1}` is the first three characters of `{zarr_id}` + - `{prefix2}` is the next three characters of `{zarr_id}` + - `{zarr_id}` is the Zarr ID of the Zarr on the Archive instance + - `{checksum}` is the [Zarr checksum][] of the Zarr's contents at point in + time that the manifest represents + +- The manifest tree should not contain any files that are not Zarr manifests + nor any directories that are not a parent directory of a Zarr manifest. + `dandidav`'s behavior should it encounter any such "extra" resources is + currently an implementation detail and may change in the future. + +- A Zarr manifest is a JSON object containing an `"entries"` key whose value is + a tree of objects mirroring the directory & entry structure of the Zarr. + + - Each entry in the Zarr is represented as an array of the following four + elements, in order, describing the entry as of the point in time + represented by the Zarr manifest: + + - The S3 version ID (as a string) of the then-current version of the S3 + object in which the entry is stored in the Archive instance's S3 + bucket + + - The `LastModified` timestamp of the entry's S3 object as a string of + the form `"YYYY-MM-DDTHH:MM:SS±HH:MM"` + + - The size in bytes of the entry as an integer + + - The `ETag` of the entry's S3 object as a string with leading & + trailing double quotation marks (U+0022) removed (not counting the + double quotation marks used by the JSON serialization) + + - Each directory in the Zarr is represented as an object in which each key + is the name of an entry or subdirectory inside the directory and the + corresponding value is either an entry array or a directory object. + + - The `entries` object itself represents the top level directory of the + Zarr. + + For example, a Zarr with the following structure: + + ```text + . + ├── .zgroup + ├── foo/ + │   ├── .zgroup + │   ├── bar/ + │   │   ├── .zarray + │   │   └── 0 + │   └── baz/ + │   ├── .zarray + │   └── 0 + └── quux/ + ├── .zarray + └── 0 + ``` + + would have an `entries` field as follows (with elements of the entry arrays + omitted): + + ```json + { + ".zgroup": [ ... ], + "foo": { + ".zgroup": [ ... ], + "bar": { + ".zarray": [ ... ], + "0": [ ... ] + }, + "baz": { + ".zarray": [ ... ], + "0": [ ... ] + } + }, + "quux": { + ".zarray": [ ... ], + "0": [ ... ] + } + } + ``` + +- For a Zarr with Zarr ID `zarr_id` and an entry therein at path `entry_path`, + the download URL for the entry is expected to be + `{base_url}/{zarr_id}/{entry_path}`, where `base_url` is currently hardcoded + for all entries to . + +[Zarr checksum]: https://github.com/dandi/dandi-archive/blob/master/doc/design/zarr-support-3.md#zarr-entry-checksum-format diff --git a/src/zarrman/manifest.rs b/src/zarrman/manifest.rs index 64fcfcd..65fdcf3 100644 --- a/src/zarrman/manifest.rs +++ b/src/zarrman/manifest.rs @@ -4,12 +4,16 @@ use serde::Deserialize; use std::collections::BTreeMap; use time::OffsetDateTime; +/// A parsed Zarr manifest #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] pub(super) struct Manifest { + /// A tree of the Zarr's entries pub(super) entries: ManifestFolder, } impl Manifest { + /// Retrieve a reference to the folder or entry in the manifest at `path`, + /// if any pub(super) fn get(&self, path: &PurePath) -> Option> { let mut folder = &self.entries; for (pos, p) in path.components().with_position() { @@ -31,6 +35,8 @@ pub(super) enum EntryRef<'a> { Entry(&'a ManifestEntry), } +/// A representation of a folder within a Zarr manifest: a mapping from entry & +/// subdirectory names to the entries & subdirectories pub(super) type ManifestFolder = BTreeMap; #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] @@ -40,14 +46,23 @@ pub(super) enum FolderEntry { Entry(ManifestEntry), } +/// Information on a Zarr entry in a manifest as of the point in time +/// represented by the manifest #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] pub(super) struct ManifestEntry { - // Keep these fields in this order so that deserialization will work - // properly! + // IMPORTANT: Keep these fields in this order so that deserialization will + // work properly! + /// The S3 version ID of the entry's S3 object pub(super) version_id: String, + + /// The entry's S3 object's modification time #[serde(with = "time::serde::rfc3339")] pub(super) modified: OffsetDateTime, + + /// The size of the entry in bytes pub(super) size: i64, + + /// The ETag of the entry's S3 object pub(super) etag: String, } diff --git a/src/zarrman/mod.rs b/src/zarrman/mod.rs index e26c7f4..67fe56a 100644 --- a/src/zarrman/mod.rs +++ b/src/zarrman/mod.rs @@ -1,3 +1,17 @@ +//! The implementation of the data source for the `/zarrs/` hierarchy +//! +//! Information about Zarrs and their entries is parsed from documents called +//! *Zarr manifests*, which are retrieved from a URL hierarchy (the *manifest +//! tree*), the base URL of which is the *manifest root*. See `doc/zarrman.md` +//! in the source repository for information on the manifest tree API and Zarr +//! manifest format. +//! +//! The hierarchy that `dandidav` serves under `/zarrs/` mirrors the layout of +//! the manifest tree, except that Zarr manifests are replaced by collections +//! (with the same name as the corresponding Zarr manifests, but with the +//! `.json` extension changed to `.zarr`) containing the respective Zarrs' +//! entry hierarchies. + mod manifest; mod path; mod resources; @@ -11,23 +25,54 @@ use std::sync::Arc; use thiserror::Error; use url::Url; +/// The manifest root URL. +/// +/// This is the base URL of the manifest tree (a URL hierarchy containing Zarr +/// manifests). +/// +/// The current value is a subdirectory of a mirror of +/// . static MANIFEST_ROOT_URL: &str = "https://datasets.datalad.org/dandi/zarr-manifests/zarr-manifests-v2-sorted/"; -static S3_DOWNLOAD_PREFIX: &str = "https://dandiarchive.s3.amazonaws.com/zarr/"; +/// The URL beneath which Zarr entries listed in the Zarr manifests should be +/// available for download. +/// +/// Given a Zarr with Zarr ID `zarr_id` and an entry therein at path +/// `entry_path`, the download URL for the entry is expected to be +/// `{ENTRY_DOWNLOAD_PREFIX}/{zarr_id}/{entry_path}`. +static ENTRY_DOWNLOAD_PREFIX: &str = "https://dandiarchive.s3.amazonaws.com/zarr/"; +/// The maximum number of manifests cached at once const MANIFEST_CACHE_SIZE: u64 = 16; +/// A client for fetching data about Zarrs via Zarr manifest files #[derive(Clone, Debug)] pub(crate) struct ZarrManClient { + /// The HTTP client used for making requests to the manifest tree inner: Client, + + /// A cache of parsed manifest files, keyed by their path under + /// `MANIFEST_ROOT_URL` manifests: Cache>, + + /// [`MANIFEST_ROOT_URL`], parsed into a [`url::Url`] manifest_root_url: Url, - s3_download_prefix: Url, + + /// [`ENTRY_DOWNLOAD_PREFIX`], parsed into a [`url::Url`] + entry_download_prefix: Url, + + /// The directory path `"zarrs/"`, used at various points in the code, + /// pre-parsed for convenience web_path_prefix: PureDirPath, } impl ZarrManClient { + /// Construct a new client instance + /// + /// # Errors + /// + /// Returns an error if construction of the inner `reqwest::Client` fails pub(crate) fn new() -> Result { let inner = Client::new()?; let manifests = CacheBuilder::new(MANIFEST_CACHE_SIZE) @@ -35,8 +80,8 @@ impl ZarrManClient { .build(); let manifest_root_url = Url::parse(MANIFEST_ROOT_URL).expect("MANIFEST_ROOT_URL should be a valid URL"); - let s3_download_prefix = - Url::parse(S3_DOWNLOAD_PREFIX).expect("S3_DOWNLOAD_PREFIX should be a valid URL"); + let entry_download_prefix = + Url::parse(ENTRY_DOWNLOAD_PREFIX).expect("ENTRY_DOWNLOAD_PREFIX should be a valid URL"); let web_path_prefix = "zarrs/" .parse::() .expect(r#""zarrs/" should be a valid URL"#); @@ -44,75 +89,22 @@ impl ZarrManClient { inner, manifests, manifest_root_url, - s3_download_prefix, + entry_download_prefix, web_path_prefix, }) } + /// Retrieve the resources at the top level of `/zarrs/`, i.e., those + /// matching the resources at the top level of the manifest tree pub(crate) async fn get_top_level_dirs(&self) -> Result, ZarrManError> { self.get_index_entries(None).await } - async fn get_index_entries( - &self, - path: Option<&PureDirPath>, - ) -> Result, ZarrManError> { - let url = match path { - Some(p) => urljoin_slashed(&self.manifest_root_url, p.component_strs()), - None => self.manifest_root_url.clone(), - }; - let index = self.inner.get_json::(url).await?; - let mut entries = - Vec::with_capacity(index.files.len().saturating_add(index.directories.len())); - if let Some(path) = path { - if let Some(prefix) = path.parent() { - for f in index.files { - // This calls Component::strip_suffix(), so `checksum` is - // guaranteed to be non-empty. - let Some(checksum) = f.strip_suffix(".json") else { - // Ignore - continue; - }; - if !checksum.contains('.') { - entries.push(ZarrManResource::Manifest(Manifest { - path: ManifestPath { - prefix: prefix.clone(), - zarr_id: path.name(), - checksum, - }, - })); - } - // else: Ignore - } - } - } - // else: Ignore - let web_path_prefix = match path { - Some(p) => self.web_path_prefix.join_dir(p), - None => self.web_path_prefix.clone(), - }; - for d in index.directories { - let web_path = web_path_prefix.join_one_dir(&d); - entries.push(ZarrManResource::WebFolder(WebFolder { web_path })); - } - Ok(entries) - } - - async fn get_zarr_manifest( - &self, - path: &ManifestPath, - ) -> Result, ZarrManError> { - self.manifests - .try_get_with_by_ref(path, async move { - self.inner - .get_json::(path.urljoin(&self.manifest_root_url)) - .await - .map(Arc::new) - }) - .await - .map_err(Into::into) - } - + /// Get details on the resource at the given `path` (sans leading `zarrs/`) + /// in the `/zarrs/` hierarchy + /// + /// Although `path` is a `PurePath`, the resulting resource may be a + /// collection. pub(crate) async fn get_resource( &self, path: &PurePath, @@ -157,6 +149,12 @@ impl ZarrManClient { } } + /// Get details on the resource at the given `path` (sans leading `zarrs/`) + /// in the `/zarrs/` hierarchy along with its immediate child resources (if + /// any) + /// + /// Although `path` is a `PurePath`, the resulting resource may be a + /// collection. pub(crate) async fn get_resource_with_children( &self, path: &PurePath, @@ -210,6 +208,80 @@ impl ZarrManClient { } } + /// Retrieve the resources in the given directory of the manifest tree. + /// + /// `path` must be relative to the manifest root. Unlike the + /// `get_resource*()` methods, Zarr manifests are not transparently + /// converted to collections. + async fn get_index_entries( + &self, + path: Option<&PureDirPath>, + ) -> Result, ZarrManError> { + let url = match path { + Some(p) => urljoin_slashed(&self.manifest_root_url, p.component_strs()), + None => self.manifest_root_url.clone(), + }; + let index = self.inner.get_json::(url).await?; + let mut entries = + Vec::with_capacity(index.files.len().saturating_add(index.directories.len())); + if let Some(path) = path { + if let Some(prefix) = path.parent() { + for f in index.files { + // This calls Component::strip_suffix(), so `checksum` is + // guaranteed to be non-empty. + let Some(checksum) = f.strip_suffix(".json") else { + // Ignore + continue; + }; + if !checksum.contains('.') { + entries.push(ZarrManResource::Manifest(Manifest { + path: ManifestPath { + prefix: prefix.clone(), + zarr_id: path.name(), + checksum, + }, + })); + } + // else: Ignore + } + } + } + // else: Ignore + let web_path_prefix = match path { + Some(p) => self.web_path_prefix.join_dir(p), + None => self.web_path_prefix.clone(), + }; + for d in index.directories { + let web_path = web_path_prefix.join_one_dir(&d); + entries.push(ZarrManResource::WebFolder(WebFolder { web_path })); + } + Ok(entries) + } + + /// Retrieve the Zarr manifest at the given [`ManifestPath`] in the + /// manifest tree, either via an HTTP request or from a cache + async fn get_zarr_manifest( + &self, + path: &ManifestPath, + ) -> Result, ZarrManError> { + self.manifests + .try_get_with_by_ref(path, async move { + self.inner + .get_json::( + path.under_manifest_root(&self.manifest_root_url), + ) + .await + .map(Arc::new) + }) + .await + .map_err(Into::into) + } + + /// Convert the [`manifest::ManifestEntry`] `entry` with path `entry_path` + /// in the manifest at `manifest_path` to a [`ManifestEntry`]. + /// + /// This largely consists of calculating the `web_path` and `url` fields of + /// the entry. fn convert_manifest_entry( &self, manifest_path: &ManifestPath, @@ -218,7 +290,7 @@ impl ZarrManClient { ) -> ManifestEntry { let web_path = manifest_path.to_web_path().join(entry_path); let mut url = urljoin( - &self.s3_download_prefix, + &self.entry_download_prefix, std::iter::once(manifest_path.zarr_id()).chain(entry_path.component_strs()), ); url.query_pairs_mut() @@ -232,14 +304,16 @@ impl ZarrManClient { } } + /// Convert the entries in `folder` (a folder at path `folder_path` in the + /// manifest at `manifest_path`) to [`ZarrManResource`]s fn convert_manifest_folder_children( &self, manifest_path: &ManifestPath, - entry_path: Option<&PurePath>, + folder_path: Option<&PurePath>, folder: &manifest::ManifestFolder, ) -> Vec { let mut children = Vec::with_capacity(folder.len()); - let web_path_prefix = match entry_path { + let web_path_prefix = match folder_path { Some(p) => manifest_path.to_web_path().join_dir(&p.to_dir_path()), None => manifest_path.to_web_path(), }; @@ -251,13 +325,13 @@ impl ZarrManClient { })); } manifest::FolderEntry::Entry(entry) => { - let thispath = match entry_path { + let entry_path = match folder_path { Some(p) => p.join_one(name), None => PurePath::from(name.clone()), }; children.push(ZarrManResource::ManEntry(self.convert_manifest_entry( manifest_path, - &thispath, + &entry_path, entry, ))); } @@ -269,11 +343,16 @@ impl ZarrManClient { #[derive(Debug, Error)] pub(crate) enum ZarrManError { + /// An HTTP error occurred while interacting with the manifest tree #[error(transparent)] Http(#[from] Arc), + + /// The request path was invalid for the `/zarrs/` hierarchy #[error("invalid path requested: {path:?}")] InvalidPath { path: PurePath }, - #[error("path {entry_path:?} inside manifest at {manifest_path} does not exist")] + + /// An request was made for a nonexistent path inside an extant Zarr + #[error("path {entry_path:?} inside manifest at {manifest_path:?} does not exist")] ManifestPathNotFound { manifest_path: ManifestPath, entry_path: PurePath, @@ -281,6 +360,7 @@ pub(crate) enum ZarrManError { } impl ZarrManError { + /// Was the error ultimately caused by something not being found? pub(crate) fn is_404(&self) -> bool { matches!( self, @@ -298,9 +378,14 @@ impl From for ZarrManError { } } +/// A directory listing parsed from the response to a `GET` request to a +/// directory in the manifest tree #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] struct Index { + // Returned by the manifest tree API but not used by dandidav: //path: String, + /// The names of the files in the directory files: Vec, + /// The names of the subdirectories of the directory directories: Vec, } diff --git a/src/zarrman/path.rs b/src/zarrman/path.rs index d420eff..9f9d862 100644 --- a/src/zarrman/path.rs +++ b/src/zarrman/path.rs @@ -1,17 +1,32 @@ use super::resources::ManifestPath; use crate::paths::{PureDirPath, PurePath}; +/// A parsed representation of a path under the `/zarrs/` hierarchy #[derive(Clone, Debug, Eq, PartialEq)] pub(super) enum ReqPath { + /// A directory path between the manifest root and the Zarr manifests. The + /// path will have one of the following formats: + /// + /// - `{prefix1}/` + /// - `{prefix1}/{prefix2}/` + /// - `{prefix1}/{prefix2}/{zarr_id}/` Dir(PureDirPath), + + /// A path to a manifest file Manifest(ManifestPath), + + /// A path beneath a manifest file, i.e., inside a Zarr InManifest { + /// The path to the manifest file manifest_path: ManifestPath, + /// The portion of the path within the Zarr entry_path: PurePath, }, } impl ReqPath { + /// Parse a path (sans leading `zarrs/`) to a resource in the `/zarrs/` + /// hierarchy into a `ReqPath`. Returns `None` if the path is invalid. pub(super) fn parse_path(path: &PurePath) -> Option { let mut components = path.components(); let Some(c1) = components.next() else { diff --git a/src/zarrman/resources.rs b/src/zarrman/resources.rs index ae05318..5a18f2a 100644 --- a/src/zarrman/resources.rs +++ b/src/zarrman/resources.rs @@ -5,6 +5,8 @@ use std::fmt; use time::OffsetDateTime; use url::Url; +/// A resource served under `dandidav`'s `/zarrs/` hierarchy, not including +/// information on child resources #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) enum ZarrManResource { WebFolder(WebFolder), @@ -13,28 +15,48 @@ pub(crate) enum ZarrManResource { ManEntry(ManifestEntry), } +/// A collection between the root of the `/zarrs/` hierarchy and the Zarr +/// manifests #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct WebFolder { + /// The path to the entry as served by `dandidav`, including the leading + /// `zarrs/`. The path will have one of the following formats: + /// + /// - `zarrs/{prefix1}/` + /// - `zarrs/{prefix1}/{prefix2}/` + /// - `zarrs/{prefix1}/{prefix2}/{zarr_id}/` pub(crate) web_path: PureDirPath, } +/// A Zarr manifest, served as a virtual collection of the Zarr's entries #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct Manifest { pub(crate) path: ManifestPath, } -#[derive(Clone, Debug, Eq, Hash, PartialEq)] +/// A path to a Zarr manifest in the manifest tree or a Zarr collection in the +/// `/zarrs/` hierarchy +#[derive(Clone, Eq, Hash, PartialEq)] pub(crate) struct ManifestPath { + /// The portion of the path between the manifest root and the Zarr ID, of + /// the form `{prefix1}/{prefix2}/` pub(super) prefix: PureDirPath, + + /// The Zarr ID pub(super) zarr_id: Component, + + /// The Zarr's checksum pub(super) checksum: Component, } impl ManifestPath { + /// Returns the Zarr ID pub(super) fn zarr_id(&self) -> &str { self.zarr_id.as_ref() } + /// Returns the path to the Zarr as served by `dandidav`, in the form + /// `zarrs/{prefix1}/{prefix2}/{zarr_id}/{checksum}.zarr/`. pub(crate) fn to_web_path(&self) -> PureDirPath { PureDirPath::try_from(format!( "zarrs/{}{}/{}.zarr/", @@ -43,9 +65,10 @@ impl ManifestPath { .expect("ManifestPath should have valid web_path") } - pub(crate) fn urljoin(&self, url: &Url) -> Url { + /// Returns the URL of the Zarr manifest underneath the given manifest root + pub(crate) fn under_manifest_root(&self, manifest_root_url: &Url) -> Url { urljoin( - url, + manifest_root_url, self.prefix .component_strs() .map(Cow::from) @@ -58,7 +81,7 @@ impl ManifestPath { } } -impl fmt::Display for ManifestPath { +impl fmt::Debug for ManifestPath { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, @@ -70,6 +93,8 @@ impl fmt::Display for ManifestPath { } } +/// A resource served under `dandidav`'s `/zarrs/` hierarchy, including +/// information on child resources #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) enum ZarrManResourceWithChildren { WebFolder { @@ -87,17 +112,29 @@ pub(crate) enum ZarrManResourceWithChildren { ManEntry(ManifestEntry), } +/// A folder within a Zarr #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct ManifestFolder { pub(crate) web_path: PureDirPath, } +/// An entry within a Zarr #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct ManifestEntry { + /// The path to the entry as served by `dandidav`, i.e., a path of the form + /// `zarrs/{p1}/{p2}/{zarr_id}/{checksum}.zarr/{entry_path}` pub(crate) web_path: PurePath, + + /// The size of the entry in bytes pub(crate) size: i64, + + /// The entry's S3 object's modification time pub(crate) modified: OffsetDateTime, + + /// The ETag of the entry's S3 object pub(crate) etag: String, + + /// The download URL for the entry pub(crate) url: Url, } @@ -115,6 +152,6 @@ mod tests { .unwrap(), }; assert_eq!(mp.to_web_path(), "zarrs/128/4a1/1284a14f-fe4f-4dc3-b10d-48e5db8bf18d/6ddc4625befef8d6f9796835648162be-509--710206390.zarr/"); - assert_eq!(mp.urljoin(&"https://datasets.datalad.org/dandi/zarr-manifests/zarr-manifests-v2-sorted/".parse().unwrap()).as_str(), "https://datasets.datalad.org/dandi/zarr-manifests/zarr-manifests-v2-sorted/128/4a1/1284a14f-fe4f-4dc3-b10d-48e5db8bf18d/6ddc4625befef8d6f9796835648162be-509--710206390.json"); + assert_eq!(mp.under_manifest_root(&"https://datasets.datalad.org/dandi/zarr-manifests/zarr-manifests-v2-sorted/".parse().unwrap()).as_str(), "https://datasets.datalad.org/dandi/zarr-manifests/zarr-manifests-v2-sorted/128/4a1/1284a14f-fe4f-4dc3-b10d-48e5db8bf18d/6ddc4625befef8d6f9796835648162be-509--710206390.json"); } } From e332546cfcf84177e3e9a8f68a4ae22ad53c90ff Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Sat, 13 Jul 2024 10:50:52 -0400 Subject: [PATCH 2/2] Add linting of docs --- .github/workflows/test.yml | 20 ++++++++++++++++++++ Cargo.toml | 10 ++++++++++ 2 files changed, 30 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d082641..a9eaa67 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -118,4 +118,24 @@ jobs: - name: Check formatting run: cargo fmt --check + docs: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install nightly Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: nightly + + - name: Activate cache + if: "!startsWith(github.head_ref, 'renovate/')" + uses: Swatinem/rust-cache@v2 + + - name: Check docs + run: cargo doc --no-deps --all-features --document-private-items + env: + RUSTDOCFLAGS: -Dwarnings + # vim:set et sts=2: diff --git a/Cargo.toml b/Cargo.toml index 5e8fee5..4dad865 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -138,6 +138,16 @@ unused_comparisons = "deny" useless_ptr_null_checks = "deny" while_true = "deny" +[lints.rustdoc] +bare_urls = "allow" +broken_intra_doc_links = "deny" +invalid_codeblock_attributes = "deny" +invalid_html_tags = "deny" +invalid_rust_codeblocks = "deny" +private_intra_doc_links = "deny" +redundant_explicit_links = "deny" +unescaped_backticks = "deny" + [lints.clippy] # Deny all warn-by-default lints: all = { level = "deny", priority = -1 }