From 8d7498a5f671b2d9a4432ef515741cc2b2aba7d7 Mon Sep 17 00:00:00 2001 From: Adam Tagscherer Date: Tue, 27 Oct 2026 15:41:22 +0100 Subject: [PATCH 1/3] feat(catalog): discover the catalog when adding a domain or site URL Signed-off-by: Adam Tagscherer --- Cargo.toml | 1 + README.md | 14 +- src/cli.rs | 4 +- src/commands/catalog_add.rs | 261 +++++++++++++++++++++++++++++++----- src/fetch.rs | 209 ++++++++++++++++++++++++++++- src/resolver.rs | 9 +- 6 files changed, 459 insertions(+), 39 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8fa3e37..55fb4ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ path = "src/main.rs" [dependencies] ai-catalog = "0.3.0" +ai-catalog-discover = "0.3.0" ai-catalog-oci = "0.3.0" ai-catalog-trust = "0.3.0" ai-catalog-validate = "0.3.0" diff --git a/README.md b/README.md index bc7bb00..279d6be 100644 --- a/README.md +++ b/README.md @@ -162,17 +162,27 @@ cat catalog.json | ai-catalog format - ### `catalog add` -Fetches a catalog from a URL (or `file://` path), stores all catalog blobs in -the local content-addressed cache, and registers the catalog in the local +Fetches a catalog from a URL, a domain or a local file, stores all catalog blobs +in the local content-addressed cache, and registers the catalog in the local registry. Nested catalogs are fetched recursively (up to depth 4). ```sh ai-catalog catalog add my-registry https://example.com/ai-catalog.json +ai-catalog catalog add example https://example.com # or just: example.com ai-catalog catalog add local-demo file:///path/to/catalog.json ``` Makes network requests. All other consumer commands operate on the local cache. +A domain or an `https://` URL does not have to be the catalog itself. If it +isn't one, the CLI follows the specification's +[discovery procedure](https://ai-catalog.io/spec/#discovery): a `Link` header +with `rel="ai-catalog"`, then an HTML `` element, then +`/.well-known/ai-catalog.json`. The catalog that is found is the one that gets +registered, under its own URL, which is what `catalog update` fetches again. If +nothing is found, the error lists every URL that was tried. Files and `http://` +URLs are used as they are, without discovery. + References inside a catalog must be `https` (or relative to the catalog). A local `file://` path is followed only from a local catalog, so a remote catalog can't read your disk. Cycles, `http://` references and nesting beyond depth 4 are diff --git a/src/cli.rs b/src/cli.rs index 57e1d0e..7d32a6d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -60,11 +60,11 @@ pub enum Command { #[derive(Debug, Subcommand)] pub enum CatalogCommand { - /// Register a catalog from a URL or a file + /// Register a catalog from a URL, a domain or a file Add { /// Name to register the catalog under name: String, - /// URL or path of the catalog + /// URL or path of the catalog, or a website or domain to discover it from url: String, }, /// List the registered catalogs diff --git a/src/commands/catalog_add.rs b/src/commands/catalog_add.rs index 873b800..909d417 100644 --- a/src/commands/catalog_add.rs +++ b/src/commands/catalog_add.rs @@ -5,51 +5,43 @@ use colored::Colorize; use ai_catalog::CatalogEntry; +use ai_catalog_discover::Fetcher; use crate::cache::CacheManager; use crate::error::{Error, Result}; -use crate::fetch::local_path_to_file_url; +use crate::fetch::{Located, build_fetcher, locate}; use crate::resolver::{Resolver, cli_extension, make_entry_extensions, registry_identifier}; -pub fn execute(name: &str, url: &str) -> Result<()> { - let url = local_path_to_file_url(url)?; - let url = url.as_str(); +pub fn execute(name: &str, input: &str) -> Result<()> { let cache = CacheManager::new()?; + add(&cache, &build_fetcher()?, name, input) +} + +fn add(cache: &CacheManager, fetcher: &dyn Fetcher, name: &str, input: &str) -> Result<()> { cache.ensure_dirs()?; - { - let registry = cache.read_registry()?; - for entry in ®istry.entries { - if entry - .display_name - .as_deref() - .map(|n| n.eq_ignore_ascii_case(name)) - .unwrap_or(false) - { - return Err(Error::Other(format!( - "a catalog named \"{name}\" is already registered. Use `ai-catalog catalog update {name}` to refresh it." - ))); - } - if let Some(source_url) = cli_extension(entry) - .and_then(|m| m.get("sourceUrl")) - .and_then(|v| v.as_str()) - && source_url.eq_ignore_ascii_case(url) - { - return Err(Error::Other(format!( - "URL is already registered as \"{}\". Use `ai-catalog catalog update {}` to refresh it.", - entry.display_name.as_deref().unwrap_or("?"), - entry.display_name.as_deref().unwrap_or("?") - ))); + ensure_name_is_free(cache, name)?; + println!("Fetching catalog from {}...", input.trim().cyan()); + let mut resolver = Resolver::online(cache)?; + let (url, root_hash) = match locate(input, fetcher)? { + Located::Url(url) => { + ensure_url_is_free(cache, &url)?; + let hash = resolver.fetch(&url)?; + (url, hash) + } + Located::Discovered { url, bytes, method } => { + if method != ai_catalog_discover::Method::Direct { + println!("Found the catalog at {} ({method})", url.cyan()); } + ensure_url_is_free(cache, &url)?; + let hash = resolver.fetch_bytes(&url, &bytes)?; + (url, hash) } - } - println!("Fetching catalog from {}...", url.cyan()); - let mut resolver = Resolver::online(&cache)?; - let root_hash = resolver.fetch(url)?; + }; let entry_count = resolver.leaves().count(); resolver.into_found(); let file_url = cache.object_file_url(&root_hash); let identifier = registry_identifier("local", name); - let extensions = make_entry_extensions(url, &root_hash, entry_count); + let extensions = make_entry_extensions(&url, &root_hash, entry_count); let new_entry = CatalogEntry { identifier, entry_type: "application/ai-catalog+json".to_string(), @@ -76,3 +68,208 @@ pub fn execute(name: &str, url: &str) -> Result<()> { ); Ok(()) } + +fn ensure_name_is_free(cache: &CacheManager, name: &str) -> Result<()> { + let registry = cache.read_registry()?; + let taken = registry.entries.iter().any(|entry| { + entry + .display_name + .as_deref() + .is_some_and(|n| n.eq_ignore_ascii_case(name)) + }); + if taken { + return Err(Error::Other(format!( + "a catalog named \"{name}\" is already registered. Use `ai-catalog catalog update {name}` to refresh it." + ))); + } + Ok(()) +} + +fn ensure_url_is_free(cache: &CacheManager, url: &str) -> Result<()> { + let registry = cache.read_registry()?; + for entry in ®istry.entries { + if let Some(source_url) = cli_extension(entry) + .and_then(|m| m.get("sourceUrl")) + .and_then(|v| v.as_str()) + && source_url.eq_ignore_ascii_case(url) + { + let name = entry.display_name.as_deref().unwrap_or("?"); + return Err(Error::Other(format!( + "URL is already registered as \"{name}\". Use `ai-catalog catalog update {name}` to refresh it." + ))); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use ai_catalog_discover::Response; + use url::Url; + + use super::*; + + const CATALOG: &str = r#"{ + "specVersion": "1.0", + "entries": [{"identifier": "a", "type": "application/json", "url": "https://example.com/a.json"}] + }"#; + + /// A site with a home page that advertises its catalog in a `Link` header. + struct Site { + requests: Cell, + } + + impl Fetcher for Site { + fn get(&self, url: &Url) -> std::result::Result { + self.requests.set(self.requests.get() + 1); + let (status, content_type, links, body) = match url.as_str() { + "https://example.com/" => ( + 200, + "text/html", + vec![r#"; rel="ai-catalog""#.to_string()], + "", + ), + "https://example.com/catalog/ai.json" => { + (200, "application/ai-catalog+json", vec![], CATALOG) + } + _ => (404, "text/plain", vec![], ""), + }; + Ok(Response { + url: url.clone(), + status, + content_type: Some(content_type.to_string()), + link_headers: links, + body: body.as_bytes().to_vec(), + }) + } + } + + fn cache(dir: &tempfile::TempDir) -> CacheManager { + CacheManager::with_base_dir(dir.path().join("cache")) + } + + fn registered(cache: &CacheManager) -> Vec<(String, String)> { + cache + .read_registry() + .unwrap() + .entries + .iter() + .map(|e| { + let source = cli_extension(e).unwrap()["sourceUrl"].as_str().unwrap(); + (e.display_name.clone().unwrap(), source.to_string()) + }) + .collect() + } + + #[test] + fn a_bare_domain_registers_the_catalog_it_advertises() { + let dir = tempfile::TempDir::new().unwrap(); + let cache = cache(&dir); + let site = Site { + requests: Cell::new(0), + }; + + add(&cache, &site, "example", "example.com").unwrap(); + + assert_eq!( + registered(&cache), + [( + "example".to_string(), + "https://example.com/catalog/ai.json".to_string() + )] + ); + } + + #[test] + fn the_catalog_url_is_what_gets_updated_later() { + let dir = tempfile::TempDir::new().unwrap(); + let cache = cache(&dir); + let site = Site { + requests: Cell::new(0), + }; + + add(&cache, &site, "example", "https://example.com").unwrap(); + + let entry = &cache.read_registry().unwrap().entries[0]; + let ext = cli_extension(entry).unwrap(); + assert_eq!(ext["sourceUrl"], "https://example.com/catalog/ai.json"); + assert_eq!(ext["entryCount"], 1); + } + + #[test] + fn a_catalog_url_is_fetched_once() { + let dir = tempfile::TempDir::new().unwrap(); + let cache = cache(&dir); + let site = Site { + requests: Cell::new(0), + }; + + add( + &cache, + &site, + "example", + "https://example.com/catalog/ai.json", + ) + .unwrap(); + + assert_eq!(site.requests.get(), 1); + } + + #[test] + fn the_same_catalog_cannot_be_registered_under_two_names() { + let dir = tempfile::TempDir::new().unwrap(); + let cache = cache(&dir); + let site = Site { + requests: Cell::new(0), + }; + add(&cache, &site, "first", "example.com").unwrap(); + + let error = add( + &cache, + &site, + "second", + "https://example.com/catalog/ai.json", + ) + .unwrap_err() + .to_string(); + + assert!(error.contains("already registered as \"first\""), "{error}"); + assert_eq!(registered(&cache).len(), 1); + } + + #[test] + fn a_name_cannot_be_used_twice() { + let dir = tempfile::TempDir::new().unwrap(); + let cache = cache(&dir); + let site = Site { + requests: Cell::new(0), + }; + add(&cache, &site, "example", "example.com").unwrap(); + let before = site.requests.get(); + + let error = add(&cache, &site, "EXAMPLE", "example.com") + .unwrap_err() + .to_string(); + + assert!(error.contains("already registered"), "{error}"); + assert_eq!(site.requests.get(), before, "nothing should be fetched"); + } + + #[test] + fn a_site_without_a_catalog_is_not_registered() { + let dir = tempfile::TempDir::new().unwrap(); + let cache = cache(&dir); + let site = Site { + requests: Cell::new(0), + }; + + let error = add(&cache, &site, "nothing", "https://example.com/missing") + .unwrap_err() + .to_string(); + + assert!(error.contains("no AI Catalog found"), "{error}"); + assert!(registered(&cache).is_empty()); + } +} diff --git a/src/fetch.rs b/src/fetch.rs index d073c51..801a368 100644 --- a/src/fetch.rs +++ b/src/fetch.rs @@ -3,9 +3,10 @@ // SPDX-License-Identifier: Apache-2.0 use std::io::Read; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::Duration; +use ai_catalog_discover::{Fetcher, HttpFetcher, Method, discover_with}; use reqwest::blocking::Client; use reqwest::redirect::Policy; use url::Url; @@ -70,6 +71,61 @@ pub fn join(base: &str, reference: &str) -> Result { }) } +/// Where the catalog that a user pointed the CLI at actually is. +#[derive(Debug)] +pub enum Located { + /// A catalog URL, to be fetched. + Url(String), + /// A catalog that discovery has fetched already. + Discovered { + url: String, + bytes: Vec, + method: Method, + }, +} + +/// Turn what the user typed (a local path, a `file://` or `http://` URL, an `https://` +/// URL or a bare domain) into a catalog. +/// +/// Local files and explicit `http://` URLs are used as they are. An `https://` URL or a +/// bare domain goes through discovery (), which +/// accepts a URL that is a catalog itself and otherwise looks for the catalog the site +/// advertises through a `Link` header, an HTML `` element or the well-known URI. +pub fn locate(input: &str, fetcher: &dyn Fetcher) -> Result { + let input = input.trim(); + if input.starts_with("file://") || input.starts_with("http://") || Path::new(input).exists() { + return Ok(Located::Url(local_path_to_file_url(input)?)); + } + let bare = !input.contains("://"); + if bare && !looks_like_host(input) { + return Ok(Located::Url(local_path_to_file_url(input)?)); + } + match discover_with(fetcher, input) { + Ok(found) => Ok(Located::Discovered { + url: found.url, + bytes: found.bytes, + method: found.method, + }), + Err(e) if bare => Err(Error::Other(format!( + "{e}\n (there is no local file \"{input}\" either)" + ))), + Err(e) => Err(Error::Other(e.to_string())), + } +} + +/// A bare argument is a domain, not a mistyped path, when it starts like a host name +/// and its first segment has a dot or a port. +fn looks_like_host(input: &str) -> bool { + let host = input.split('/').next().unwrap_or(""); + host.starts_with(|c: char| c.is_ascii_alphanumeric()) + && (host.contains('.') || host.contains(':') || host == "localhost") +} + +/// The fetcher discovery uses, with the CLI's own client. +pub fn build_fetcher() -> Result { + Ok(HttpFetcher::with_client(build_client()?)) +} + /// Fetch a catalog document, refusing anything over the size limit. pub fn fetch_catalog(url: &str, client: &Client) -> Result> { let mut bytes = Vec::new(); @@ -119,6 +175,157 @@ mod tests { ); } + use std::cell::RefCell; + + use ai_catalog_discover::Response; + + const CATALOG: &str = r#"{"specVersion": "1.0", "entries": []}"#; + + /// Serves canned responses, and records the requests. + #[derive(Default)] + struct Mock { + pages: Vec<(&'static str, &'static str, Vec<&'static str>, &'static str)>, + requests: RefCell>, + } + + impl Mock { + /// A page at `url`, with a content type, `Link` headers and a body. + fn page( + mut self, + url: &'static str, + content_type: &'static str, + links: &[&'static str], + body: &'static str, + ) -> Self { + self.pages.push((url, content_type, links.to_vec(), body)); + self + } + } + + impl Fetcher for Mock { + fn get(&self, url: &Url) -> std::result::Result { + self.requests.borrow_mut().push(url.to_string()); + let page = self.pages.iter().find(|p| p.0 == url.as_str()); + Ok(Response { + url: url.clone(), + status: if page.is_some() { 200 } else { 404 }, + content_type: page.map(|p| p.1.to_string()), + link_headers: page + .map(|p| p.2.iter().map(|l| l.to_string()).collect()) + .unwrap_or_default(), + body: page.map(|p| p.3.as_bytes().to_vec()).unwrap_or_default(), + }) + } + } + + #[test] + fn locate_leaves_files_and_http_urls_alone() { + let mock = Mock::default(); + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("catalog.json"); + std::fs::write(&path, CATALOG).unwrap(); + + for input in [ + path.to_str().unwrap(), + "file:///tmp/catalog.json", + "http://localhost:8080/catalog.json", + ] { + let Located::Url(url) = locate(input, &mock).unwrap() else { + panic!("{input} should not be discovered"); + }; + assert!(url.contains("://"), "{url}"); + } + assert!(mock.requests.borrow().is_empty()); + } + + #[test] + fn locate_discovers_the_catalog_of_a_bare_domain() { + let mock = Mock::default() + .page( + "https://example.com/", + "text/html", + &[], + r#""#, + ) + .page( + "https://example.com/ai.json", + "application/ai-catalog+json", + &[], + CATALOG, + ); + + let Located::Discovered { url, bytes, method } = locate("example.com", &mock).unwrap() + else { + panic!("expected discovery"); + }; + + assert_eq!(url, "https://example.com/ai.json"); + assert_eq!(bytes, CATALOG.as_bytes()); + assert_eq!(method, Method::HtmlLink); + } + + #[test] + fn locate_does_not_fetch_a_catalog_url_twice() { + let mock = Mock::default().page( + "https://example.com/ai.json", + "application/json", + &[], + CATALOG, + ); + + let Located::Discovered { method, .. } = + locate("https://example.com/ai.json", &mock).unwrap() + else { + panic!("expected discovery"); + }; + + assert_eq!(method, Method::Direct); + assert_eq!(mock.requests.borrow().len(), 1); + } + + #[test] + fn locate_explains_a_failed_discovery() { + let mock = Mock::default().page("https://example.com/", "text/html", &[], ""); + + let error = locate("example.com", &mock).unwrap_err().to_string(); + + assert!(error.contains("no AI Catalog found"), "{error}"); + assert!(error.contains("/.well-known/ai-catalog.json"), "{error}"); + assert!(error.contains("no local file"), "{error}"); + let error = locate("https://example.com", &mock) + .unwrap_err() + .to_string(); + assert!(!error.contains("no local file"), "{error}"); + } + + #[test] + fn locate_keeps_the_error_for_a_mistyped_path() { + let mock = Mock::default(); + + for input in ["catalogs/missing.json", "./missing.json", "~/missing.json"] { + let error = locate(input, &mock).unwrap_err().to_string(); + assert!(error.contains("cannot resolve path"), "{error}"); + } + assert!(mock.requests.borrow().is_empty()); + } + + #[test] + fn only_names_that_look_like_hosts_are_discovered() { + for host in ["example.com", "example.com/docs", "localhost:8443", "a.b"] { + assert!(looks_like_host(host), "{host}"); + } + for path in [ + "catalog", + "catalogs/a.json", + "./a.json", + "../a.json", + "~/a.json", + "/a.json", + ] { + assert!(!looks_like_host(path), "{path}"); + } + } + #[test] fn fetch_catalog_rejects_oversized_files() { let dir = tempfile::TempDir::new().unwrap(); diff --git a/src/resolver.rs b/src/resolver.rs index fb1063d..8321801 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -69,8 +69,13 @@ impl<'a> Resolver<'a> { .as_ref() .expect("fetch needs an online resolver"); let bytes = fetch_catalog(url, client)?; - let catalog = serde_json::from_slice(&bytes)?; - let hash = self.store(url, &bytes)?; + self.fetch_bytes(url, &bytes) + } + + /// Like [`Resolver::fetch`], for a root document the caller has fetched from `url`. + pub fn fetch_bytes(&mut self, url: &str, bytes: &[u8]) -> Result { + let catalog = serde_json::from_slice(bytes)?; + let hash = self.store(url, bytes)?; self.path.push(url.to_string()); self.walk(&catalog, url, 0); self.path.pop(); From a40e617a2bede8c39bc90eef7aa5510a9216282b Mon Sep 17 00:00:00 2001 From: Adam Tagscherer Date: Wed, 28 Oct 2026 10:11:52 +0100 Subject: [PATCH 2/3] feat(catalog): trim tests and comments Signed-off-by: Adam Tagscherer --- src/commands/catalog_add.rs | 35 -------------------- src/fetch.rs | 65 +++---------------------------------- 2 files changed, 4 insertions(+), 96 deletions(-) diff --git a/src/commands/catalog_add.rs b/src/commands/catalog_add.rs index 909d417..d99b747 100644 --- a/src/commands/catalog_add.rs +++ b/src/commands/catalog_add.rs @@ -182,41 +182,6 @@ mod tests { ); } - #[test] - fn the_catalog_url_is_what_gets_updated_later() { - let dir = tempfile::TempDir::new().unwrap(); - let cache = cache(&dir); - let site = Site { - requests: Cell::new(0), - }; - - add(&cache, &site, "example", "https://example.com").unwrap(); - - let entry = &cache.read_registry().unwrap().entries[0]; - let ext = cli_extension(entry).unwrap(); - assert_eq!(ext["sourceUrl"], "https://example.com/catalog/ai.json"); - assert_eq!(ext["entryCount"], 1); - } - - #[test] - fn a_catalog_url_is_fetched_once() { - let dir = tempfile::TempDir::new().unwrap(); - let cache = cache(&dir); - let site = Site { - requests: Cell::new(0), - }; - - add( - &cache, - &site, - "example", - "https://example.com/catalog/ai.json", - ) - .unwrap(); - - assert_eq!(site.requests.get(), 1); - } - #[test] fn the_same_catalog_cannot_be_registered_under_two_names() { let dir = tempfile::TempDir::new().unwrap(); diff --git a/src/fetch.rs b/src/fetch.rs index 801a368..e5cf5c5 100644 --- a/src/fetch.rs +++ b/src/fetch.rs @@ -84,13 +84,9 @@ pub enum Located { }, } -/// Turn what the user typed (a local path, a `file://` or `http://` URL, an `https://` -/// URL or a bare domain) into a catalog. -/// -/// Local files and explicit `http://` URLs are used as they are. An `https://` URL or a -/// bare domain goes through discovery (), which -/// accepts a URL that is a catalog itself and otherwise looks for the catalog the site -/// advertises through a `Link` header, an HTML `` element or the well-known URI. +/// Turn what the user typed into a catalog. Local files and `http://` URLs are used as +/// they are; an `https://` URL or a bare domain goes through discovery +/// (). pub fn locate(input: &str, fetcher: &dyn Fetcher) -> Result { let input = input.trim(); if input.starts_with("file://") || input.starts_with("http://") || Path::new(input).exists() { @@ -113,8 +109,7 @@ pub fn locate(input: &str, fetcher: &dyn Fetcher) -> Result { } } -/// A bare argument is a domain, not a mistyped path, when it starts like a host name -/// and its first segment has a dot or a port. +/// A bare argument is a domain, not a path, when its first segment has a dot or a port. fn looks_like_host(input: &str) -> bool { let host = input.split('/').next().unwrap_or(""); host.starts_with(|c: char| c.is_ascii_alphanumeric()) @@ -189,7 +184,6 @@ mod tests { } impl Mock { - /// A page at `url`, with a content type, `Link` headers and a body. fn page( mut self, url: &'static str, @@ -264,40 +258,6 @@ mod tests { assert_eq!(method, Method::HtmlLink); } - #[test] - fn locate_does_not_fetch_a_catalog_url_twice() { - let mock = Mock::default().page( - "https://example.com/ai.json", - "application/json", - &[], - CATALOG, - ); - - let Located::Discovered { method, .. } = - locate("https://example.com/ai.json", &mock).unwrap() - else { - panic!("expected discovery"); - }; - - assert_eq!(method, Method::Direct); - assert_eq!(mock.requests.borrow().len(), 1); - } - - #[test] - fn locate_explains_a_failed_discovery() { - let mock = Mock::default().page("https://example.com/", "text/html", &[], ""); - - let error = locate("example.com", &mock).unwrap_err().to_string(); - - assert!(error.contains("no AI Catalog found"), "{error}"); - assert!(error.contains("/.well-known/ai-catalog.json"), "{error}"); - assert!(error.contains("no local file"), "{error}"); - let error = locate("https://example.com", &mock) - .unwrap_err() - .to_string(); - assert!(!error.contains("no local file"), "{error}"); - } - #[test] fn locate_keeps_the_error_for_a_mistyped_path() { let mock = Mock::default(); @@ -309,23 +269,6 @@ mod tests { assert!(mock.requests.borrow().is_empty()); } - #[test] - fn only_names_that_look_like_hosts_are_discovered() { - for host in ["example.com", "example.com/docs", "localhost:8443", "a.b"] { - assert!(looks_like_host(host), "{host}"); - } - for path in [ - "catalog", - "catalogs/a.json", - "./a.json", - "../a.json", - "~/a.json", - "/a.json", - ] { - assert!(!looks_like_host(path), "{path}"); - } - } - #[test] fn fetch_catalog_rejects_oversized_files() { let dir = tempfile::TempDir::new().unwrap(); From f016cfd910b2f3f960c3b13a433118815d0c57c3 Mon Sep 17 00:00:00 2001 From: Adam Tagscherer Date: Wed, 28 Oct 2026 18:10:08 +0100 Subject: [PATCH 3/3] chore: regenerate Cargo.lock Signed-off-by: Adam Tagscherer --- Cargo.lock | 303 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 303 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 1cc97df..9269672 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,6 +28,7 @@ name = "ai-catalog-cli" version = "0.3.0" dependencies = [ "ai-catalog", + "ai-catalog-discover", "ai-catalog-oci", "ai-catalog-trust", "ai-catalog-validate", @@ -46,6 +47,19 @@ dependencies = [ "url", ] +[[package]] +name = "ai-catalog-discover" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5c98bbff69f42d4dee321372ebf2bfed177a706e1f9d7506f4697f0413b3ef6" +dependencies = [ + "ai-catalog", + "reqwest", + "scraper", + "thiserror", + "url", +] + [[package]] name = "ai-catalog-oci" version = "0.3.0" @@ -377,12 +391,56 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "cssparser" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11119743ad110e8c1bdccd930d7f5c30c99e5fc76a7b63ec9807e84eef0c5f59" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" +dependencies = [ + "quote", + "syn 3.0.9", +] + [[package]] name = "deranged" version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + [[package]] name = "digest" version = "0.11.3" @@ -426,12 +484,33 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + [[package]] name = "dunce" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ego-tree" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bd50d24b1b75aa817bb0339cb9f2e0e664bdfab1e160a69a0fda9145b77fab2" + [[package]] name = "errno" version = "0.3.14" @@ -557,6 +636,17 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "html5ever" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456a1a377e608e555d22ddab27ac0114bc7a7b4199078108e34c2aeae6c9b130" +dependencies = [ + "log", + "markup5ever", + "memchr", +] + [[package]] name = "http" version = "1.5.0" @@ -906,6 +996,15 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.34" @@ -918,6 +1017,17 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" +[[package]] +name = "markup5ever" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab3dc68ac4a0f5719e560136778c1ee716e296030d75dbd4484e37e39e3a842" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + [[package]] name = "memchr" version = "2.8.3" @@ -935,6 +1045,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "num-conv" version = "0.2.2" @@ -974,12 +1090,88 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b585a510fb76fdebead6897982ef2a03a21d8e6cbcca904999742a4afc6ffe" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1007,6 +1199,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1114,6 +1312,15 @@ dependencies = [ "rand_core", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_users" version = "0.5.3" @@ -1337,6 +1544,25 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scraper" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6844b5f49a5579f12ba4f89d9f7eec6d71889e4aa0bc7181821040ce66f08fff" +dependencies = [ + "cssparser", + "ego-tree", + "html5ever", + "precomputed-hash", + "selectors", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -1360,6 +1586,23 @@ dependencies = [ "libc", ] +[[package]] +name = "selectors" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8322aebea94a6cc288ff884e8a49bdbceef60206c400c6c46d22e62fc4fd91a6" +dependencies = [ + "bitflags", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + [[package]] name = "semver" version = "1.0.28" @@ -1420,6 +1663,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "servo_arc" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4eceb519ec34d8290aaf6e46f435102f2bfa63f22b05a144fb16ab266d3b198" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "sha2" version = "0.11.1" @@ -1453,6 +1705,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + [[package]] name = "slab" version = "0.4.12" @@ -1481,6 +1739,30 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "string_cache" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffa8a5dbe8b3f0bbe29d4c3225daafaeead63afdc1b65fc4c01a1384166038e6" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928dcdf75e47626b3617a976ec205d9f057584c371c1f23b782129268d0e6edc" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + [[package]] name = "strsim" version = "0.11.1" @@ -1548,6 +1830,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + [[package]] name = "thiserror" version = "2.0.21" @@ -1851,6 +2142,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7572660c8890448ba236b7376f27e389c6a7e1c70195622faced601f855c0ada" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + [[package]] name = "webpki-root-certs" version = "1.0.11"