diff --git a/.claude/rules/code-coverage.md b/.claude/rules/code-coverage.md index 26739d6..d2737ac 100644 --- a/.claude/rules/code-coverage.md +++ b/.claude/rules/code-coverage.md @@ -1,3 +1,4 @@ # Code Coverage Measurement - Use `make coverage` to measure code coverage. This is the authoritative source. + diff --git a/.claude/rules/github-actions.md b/.claude/rules/github-actions.md index 942a3e3..1457661 100644 --- a/.claude/rules/github-actions.md +++ b/.claude/rules/github-actions.md @@ -7,5 +7,3 @@ paths: - Actions must represent a single purpose and a single concern. - Compose actions if they need to provide a more complex functionality. -- GitHub actions must be named after their purpose and intent. Avoid names like "setup", "ci", and such. They need to describe the intention, and purpose behind them. - diff --git a/.claude/rules/github-workflows.md b/.claude/rules/github-workflows.md index 2c6f81c..7ed01ae 100644 --- a/.claude/rules/github-workflows.md +++ b/.claude/rules/github-workflows.md @@ -7,10 +7,8 @@ paths: - Always use Makefile targets in the workflow to avoid code duplication (if they need to run something that is already present in a Makefile). - Never add the tests that use LLMs to GitHub workflows, because the default GitHub worker does not have the capacity to run them. -- Only add unit tests or linters to GitHub workflows. +- Only add unit tests to GitHub workflows. - Keep GitHub workflows responsible for only a single concern. For example, run linter, and tests in parallel. - Treat GitHub workflows as a coding project. Use composable actions, factor similar concerns into actions. - Encapsulate functionalities in composable actions. - Keep the workflows clean and purposeful. -- GitHub workflows must be named after their purpose and intent. Avoid names like "setup", "ci", and such. They need to describe the intention, and purpose behind them. - diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000..a3b2353 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,2 @@ +[profile.default] +slow-timeout = { period = "100ms", terminate-after = 3 } diff --git a/Makefile b/Makefile index d74a1cd..d7ddaa1 100644 --- a/Makefile +++ b/Makefile @@ -48,13 +48,22 @@ clippy: .PHONY: coverage coverage: node_modules cargo llvm-cov clean --workspace - cargo llvm-cov --workspace --no-report + cargo llvm-cov nextest --workspace --no-report cargo llvm-cov report --json --output-path target/llvm-cov.json cargo llvm-cov report npx rust-coverage-check target/llvm-cov.json \ --workspace-root $(CURDIR) \ - --gated rhai_components \ - --required-percent 100 + --gated poet=80 \ + --gated rhai_components=100 + +.PHONY: coverage-clean +coverage-clean: + cargo llvm-cov clean --workspace + rm -f target/llvm-cov.json + +.PHONY: coverage-report +coverage-report: + cargo llvm-cov nextest --workspace --html .PHONY: fmt fmt: node_modules diff --git a/package-lock.json b/package-lock.json index cf8a85e..6705cff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "license": "Apache-2.0", "devDependencies": { - "@intentee/rust-coverage-check": "0.1.0", + "@intentee/rust-coverage-check": "0.3.1", "@types/hotwired__turbo": "^8.0.4", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", @@ -676,9 +676,9 @@ } }, "node_modules/@intentee/rust-coverage-check": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@intentee/rust-coverage-check/-/rust-coverage-check-0.1.0.tgz", - "integrity": "sha512-0PpHUxGda5FjSOh7ebMrR99xbFRC93PnWY1mSTeuetX2yMgqVhY8Bjrg5GqedsxXZt6z7OrQ3SOwBA2gTgDUBg==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@intentee/rust-coverage-check/-/rust-coverage-check-0.3.1.tgz", + "integrity": "sha512-Ep4kv95XYjNESk1LYmhs+KPuzaQQ1fGq3ZSwB/Knwu5aZj23ICY7hLQDdU//BEQMZU0hwrhmv2gtQ8Z1bPcNrQ==", "dev": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index a096b5c..f34d321 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "type": "module", "version": "0.1.0", "devDependencies": { - "@intentee/rust-coverage-check": "0.1.0", + "@intentee/rust-coverage-check": "0.3.1", "@types/hotwired__turbo": "^8.0.4", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", diff --git a/poet/src/anyhow_error_aggregate.rs b/poet/src/anyhow_error_aggregate.rs index 7cf675b..2b9c28b 100644 --- a/poet/src/anyhow_error_aggregate.rs +++ b/poet/src/anyhow_error_aggregate.rs @@ -26,3 +26,37 @@ impl fmt::Display for AnyhowErrorAggregate { Ok(()) } } + +#[cfg(test)] +mod tests { + use anyhow::anyhow; + + use super::*; + + #[test] + fn display_lists_every_aggregated_error() { + let aggregate = AnyhowErrorAggregate::default(); + + aggregate + .errors + .insert("first".to_string(), anyhow!("boom one")); + aggregate + .errors + .insert("second".to_string(), anyhow!("boom two")); + + let rendered = aggregate.to_string(); + + assert!(rendered.contains("(2 total)")); + assert!(rendered.contains("[first]")); + assert!(rendered.contains("[second]")); + } + + #[test] + fn display_reports_zero_when_empty() { + assert!( + AnyhowErrorAggregate::default() + .to_string() + .contains("(0 total)") + ); + } +} diff --git a/poet/src/app_dir_desktop_entry.rs b/poet/src/app_dir_desktop_entry.rs index 73d97b7..952450c 100644 --- a/poet/src/app_dir_desktop_entry.rs +++ b/poet/src/app_dir_desktop_entry.rs @@ -77,3 +77,83 @@ impl Display for AppDirDesktopEntry { ) } } + +#[cfg(test)] +mod tests { + use indoc::indoc; + + use super::*; + + #[test] + fn parses_all_fields_from_desktop_entry() -> Result<()> { + let entry = AppDirDesktopEntry::parse(indoc! {r#" + [Desktop Entry] + Name=mysite + X-PoetVersion=0.6.2 + X-SiteVersion=1.2.3 + X-ImplementationTitle=My Site + "#})?; + + assert_eq!(entry.name, "mysite"); + assert_eq!(entry.poet_version, "0.6.2"); + assert_eq!(entry.site_version, "1.2.3"); + assert_eq!(entry.title, "My Site"); + + Ok(()) + } + + #[test] + fn errors_when_required_field_is_missing() { + assert!( + AppDirDesktopEntry::parse(indoc! {r#" + [Desktop Entry] + Name=mysite + X-SiteVersion=1.2.3 + X-ImplementationTitle=My Site + "#}) + .is_err() + ); + } + + #[test] + fn errors_when_field_is_defined_more_than_once() { + assert!( + AppDirDesktopEntry::parse(indoc! {r#" + [Desktop Entry] + Name=mysite + Name=othersite + X-PoetVersion=0.6.2 + X-SiteVersion=1.2.3 + X-ImplementationTitle=My Site + "#}) + .is_err() + ); + } + + #[test] + fn errors_when_primary_section_is_missing() { + assert!( + AppDirDesktopEntry::parse(indoc! {r#" + [Other Section] + Name=mysite + "#}) + .is_err() + ); + } + + #[test] + fn renders_desktop_entry_with_all_fields() { + let rendered = AppDirDesktopEntry { + name: "mysite".to_string(), + poet_version: "0.6.2".to_string(), + site_version: "1.2.3".to_string(), + title: "My Site".to_string(), + } + .to_string(); + + assert!(rendered.contains("Name=mysite")); + assert!(rendered.contains("X-PoetVersion=0.6.2")); + assert!(rendered.contains("X-SiteVersion=1.2.3")); + assert!(rendered.contains("X-ImplementationTitle=My Site")); + } +} diff --git a/poet/src/assert_valid_desktop_entry_string.rs b/poet/src/assert_valid_desktop_entry_string.rs index 7a2ae69..39cbbc4 100644 --- a/poet/src/assert_valid_desktop_entry_string.rs +++ b/poet/src/assert_valid_desktop_entry_string.rs @@ -12,3 +12,23 @@ pub fn assert_valid_desktop_entry_string(input: &str) -> Result { )) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn returns_string_when_valid() -> Result<()> { + assert_eq!( + assert_valid_desktop_entry_string("My Site")?, + "My Site".to_string() + ); + + Ok(()) + } + + #[test] + fn errors_when_string_contains_control_character() { + assert!(assert_valid_desktop_entry_string("line\nbreak").is_err()); + } +} diff --git a/poet/src/asset_manager.rs b/poet/src/asset_manager.rs index f8f0c28..0f9cad0 100644 --- a/poet/src/asset_manager.rs +++ b/poet/src/asset_manager.rs @@ -11,6 +11,7 @@ use rhai::TypeBuilder; use crate::asset_path_renderer::AssetPathRenderer; use crate::external_asset::ExternalAsset; +use crate::is_image_path::is_image_path; #[derive(Clone)] pub struct AssetManager { @@ -47,6 +48,24 @@ impl AssetManager { Err(format!("Asset not found: '{asset}'")) } + pub fn image(&self, asset: &str) -> Result { + if let Some(static_paths) = self.esbuild_metafile.find_static_paths_for_input(asset) { + let mut image_paths = static_paths.iter().filter(|path| is_image_path(path)); + + if let Some(image_path) = image_paths.next() { + if image_paths.next().is_some() { + return Err(format!( + "Multiple image assets resolved to the same input: '{asset}'" + )); + } + + return Ok(self.path_renderer.render_path(image_path)); + } + } + + Err(format!("Image asset not found: '{asset}'")) + } + fn rhai_add(&mut self, asset: String) -> Result<(), Box> { if self.http_preloader.register_input(&asset).is_none() { return Err(format!("Asset not found: {asset}").into()); @@ -59,6 +78,10 @@ impl AssetManager { Ok(self.file(&asset)?) } + fn rhai_image(&mut self, asset: String) -> Result> { + Ok(self.image(&asset)?) + } + fn rhai_preload(&mut self, asset: String) { self.http_preloader.register_preload(&asset); } @@ -117,9 +140,270 @@ impl CustomType for AssetManager { .with_name("AssetManager") .with_fn("add", Self::rhai_add) .with_fn("file", Self::rhai_file) + .with_fn("image", Self::rhai_image) .with_fn("preload", Self::rhai_preload) .with_fn("render", Self::rhai_render) .with_fn("script", Self::rhai_script) .with_fn("stylesheet", Self::rhai_stylesheet); } } + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use indoc::indoc; + + use super::*; + use crate::asset_path_renderer::AssetPathRenderer; + + fn asset_manager(metafile_json: &str) -> Result { + Ok(AssetManager::from_esbuild_metafile( + Arc::new(EsbuildMetaFile::from_str(metafile_json)?), + AssetPathRenderer { + base_path: "/".to_string(), + }, + )) + } + + #[test] + fn file_resolves_single_static_path() -> Result<(), anyhow::Error> { + let metafile = indoc! {r#" + { + "outputs": { + "static/logo_ABCDEF12.png": { + "imports": [], + "inputs": { "logo.png": {} } + }, + "static/entry_ABCDEF12.js": { + "imports": [{ "path": "static/logo_ABCDEF12.png" }], + "entryPoint": "logo.png", + "inputs": {} + } + } + } + "#}; + + assert_eq!( + asset_manager(metafile)?.file("logo.png"), + Ok("/static/logo_ABCDEF12.png".to_string()) + ); + + Ok(()) + } + + #[test] + fn file_fails_for_unknown_input() -> Result<(), anyhow::Error> { + assert_eq!( + asset_manager(r#"{ "outputs": {} }"#)?.file("missing.png"), + Err("Asset not found: 'missing.png'".to_string()) + ); + + Ok(()) + } + + #[test] + fn file_fails_when_input_resolves_to_multiple_paths() -> Result<(), anyhow::Error> { + let metafile = indoc! {r#" + { + "outputs": { + "static/a_AAAAAAAA.png": { + "imports": [], + "inputs": { "img.png": {} } + }, + "static/b_BBBBBBBB.png": { + "imports": [], + "inputs": { "img.png": {} } + }, + "static/entry_CCCCCCCC.js": { + "imports": [ + { "path": "static/a_AAAAAAAA.png" }, + { "path": "static/b_BBBBBBBB.png" } + ], + "entryPoint": "img.png", + "inputs": {} + } + } + } + "#}; + + assert!(asset_manager(metafile)?.file("img.png").is_err()); + + Ok(()) + } + + const AMBIGUOUS_FAVICON_METAFILE: &str = indoc! {r#" + { + "outputs": { + "static/favicon_ABCDEF12.svg": { + "imports": [], + "inputs": { "favicon.svg": {} } + }, + "static/chunk_ABCDEF12.js": { + "imports": [], + "inputs": { "favicon.svg": {} } + } + } + } + "#}; + + #[test] + fn image_picks_single_image_among_ambiguous_paths() -> Result<(), anyhow::Error> { + assert_eq!( + asset_manager(AMBIGUOUS_FAVICON_METAFILE)?.image("favicon.svg"), + Ok("/static/favicon_ABCDEF12.svg".to_string()) + ); + + Ok(()) + } + + #[test] + fn image_fails_when_input_resolves_to_multiple_images() -> Result<(), anyhow::Error> { + let metafile = indoc! {r#" + { + "outputs": { + "static/a_AAAAAAAA.png": { + "imports": [], + "inputs": { "img.png": {} } + }, + "static/b_BBBBBBBB.png": { + "imports": [], + "inputs": { "img.png": {} } + } + } + } + "#}; + + assert_eq!( + asset_manager(metafile)?.image("img.png"), + Err("Multiple image assets resolved to the same input: 'img.png'".to_string()) + ); + + Ok(()) + } + + #[test] + fn image_fails_when_no_image_resolves_to_input() -> Result<(), anyhow::Error> { + let metafile = indoc! {r#" + { + "outputs": { + "static/data_AAAAAAAA.js": { + "imports": [], + "inputs": { "data.js": {} } + } + } + } + "#}; + + assert_eq!( + asset_manager(metafile)?.image("data.js"), + Err("Image asset not found: 'data.js'".to_string()) + ); + + Ok(()) + } + + #[test] + fn image_fails_for_unknown_input() -> Result<(), anyhow::Error> { + assert_eq!( + asset_manager(r#"{ "outputs": {} }"#)?.image("missing.png"), + Err("Image asset not found: 'missing.png'".to_string()) + ); + + Ok(()) + } + + #[test] + fn rhai_image_resolves_image_among_ambiguous_paths() -> Result<(), anyhow::Error> { + let mut manager = asset_manager(AMBIGUOUS_FAVICON_METAFILE)?; + + assert!(manager.rhai_image("favicon.svg".to_string()).is_ok()); + + Ok(()) + } + + #[test] + fn rhai_image_fails_for_unknown_input() -> Result<(), anyhow::Error> { + let mut manager = asset_manager(r#"{ "outputs": {} }"#)?; + + assert!(manager.rhai_image("missing.png".to_string()).is_err()); + + Ok(()) + } + + const ENTRY_METAFILE: &str = indoc! {r#" + { + "outputs": { + "static/logo_ABCDEF12.png": { + "imports": [], + "inputs": { "logo.png": {} } + }, + "static/entry_ABCDEF12.js": { + "imports": [{ "path": "static/logo_ABCDEF12.png" }], + "entryPoint": "logo.png", + "inputs": {} + } + } + } + "#}; + + #[test] + fn render_emits_tag_for_external_script() -> Result<(), anyhow::Error> { + let mut manager = asset_manager(r#"{ "outputs": {} }"#)?; + + manager.rhai_script("https://example.com/app.js".to_string()); + + assert!( + manager + .rhai_render() + .contains("") + ); + + Ok(()) + } + + #[test] + fn render_emits_tag_for_external_stylesheet() -> Result<(), anyhow::Error> { + let mut manager = asset_manager(r#"{ "outputs": {} }"#)?; + + manager.rhai_stylesheet("https://example.com/app.css".to_string()); + + assert!( + manager + .rhai_render() + .contains("") + ); + + Ok(()) + } + + #[test] + fn add_registers_known_input_for_rendering() -> Result<(), anyhow::Error> { + let mut manager = asset_manager(ENTRY_METAFILE)?; + + assert!(manager.rhai_add("logo.png".to_string()).is_ok()); + assert!(manager.rhai_render().contains("/static/entry_ABCDEF12.js")); + + Ok(()) + } + + #[test] + fn add_fails_for_unknown_input() -> Result<(), anyhow::Error> { + let mut manager = asset_manager(r#"{ "outputs": {} }"#)?; + + assert!(manager.rhai_add("missing.png".to_string()).is_err()); + + Ok(()) + } + + #[test] + fn preload_registers_known_input_for_rendering() -> Result<(), anyhow::Error> { + let mut manager = asset_manager(ENTRY_METAFILE)?; + + manager.rhai_preload("logo.png".to_string()); + + assert!(manager.rhai_render().contains("/static/")); + + Ok(()) + } +} diff --git a/poet/src/author_collection.rs b/poet/src/author_collection.rs index f9732c5..4a92a91 100644 --- a/poet/src/author_collection.rs +++ b/poet/src/author_collection.rs @@ -46,3 +46,42 @@ impl CustomType for AuthorCollection { builder.with_name("AuthorCollection"); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::author_data::AuthorData; + + fn author(basename: &str) -> Author { + Author { + basename: basename.to_string().into(), + data: AuthorData::mock(basename), + } + } + + fn collection() -> AuthorCollection { + let mut collection = AuthorCollection::default(); + + collection.insert("alice".to_string().into(), author("alice")); + collection.insert("bob".to_string().into(), author("bob")); + + collection + } + + #[test] + fn resolve_separates_found_and_missing_authors() { + let result = collection().resolve(&["alice".to_string(), "carol".to_string()]); + + assert_eq!(result.found_authors.len(), 1); + assert_eq!(result.found_authors[0].basename, "alice".to_string().into()); + assert_eq!(result.missing_authors, vec!["carol".to_string()]); + } + + #[test] + fn resolve_returns_empty_results_for_empty_input() { + let result = collection().resolve(&[]); + + assert!(result.found_authors.is_empty()); + assert!(result.missing_authors.is_empty()); + } +} diff --git a/poet/src/build_authors.rs b/poet/src/build_authors.rs index ba9ee2d..3b51286 100644 --- a/poet/src/build_authors.rs +++ b/poet/src/build_authors.rs @@ -42,3 +42,47 @@ pub async fn build_authors(source_filesystem: Arc) -> Result Result<()> { + let directory = tempdir()?; + let storage = Storage { + base_directory: directory.path().to_path_buf(), + }; + + storage + .set_file_contents(&PathBuf::from("authors/alice.toml"), "name = \"Alice\"") + .await?; + + let resolved = build_authors(Arc::new(storage)) + .await? + .resolve(&["alice".to_string()]); + + assert_eq!(resolved.found_authors.len(), 1); + assert_eq!(resolved.found_authors[0].data.name, "Alice"); + + Ok(()) + } + + #[tokio::test] + async fn errors_when_an_author_file_cannot_be_parsed() -> Result<()> { + let directory = tempdir()?; + let storage = Storage { + base_directory: directory.path().to_path_buf(), + }; + + storage + .set_file_contents(&PathBuf::from("authors/broken.toml"), "unexpected = true") + .await?; + + assert!(build_authors(Arc::new(storage)).await.is_err()); + + Ok(()) + } +} diff --git a/poet/src/build_project/build_project_result_stub.rs b/poet/src/build_project/build_project_result_stub.rs index 2d6752c..c79c87e 100644 --- a/poet/src/build_project/build_project_result_stub.rs +++ b/poet/src/build_project/build_project_result_stub.rs @@ -48,3 +48,81 @@ impl BuildProjectResultStub { } } } + +#[cfg(test)] +mod tests { + use std::path::Path; + + use anyhow::Result; + use tempfile::tempdir; + + use super::*; + use crate::asset_path_renderer::AssetPathRenderer; + use crate::build_authors::build_authors; + use crate::build_project::build_project; + use crate::build_project::build_project_params::BuildProjectParams; + use crate::compile_shortcodes::compile_shortcodes; + use crate::filesystem::Filesystem as _; + use crate::filesystem::storage::Storage; + + async fn build_stub(body: &str) -> Result { + let directory = tempdir()?; + let source_filesystem = Arc::new(Storage { + base_directory: directory.path().to_path_buf(), + }); + + source_filesystem + .set_file_contents( + Path::new("shortcodes/Layout.rhai"), + "fn template(context, props, content) { component { {content} } }", + ) + .await?; + source_filesystem + .set_file_contents( + Path::new("content/guide.md"), + &format!( + "+++\ndescription = \"Guide\"\nlayout = \"Layout\"\ntitle = \"Guide\"\n+++\n\n{body}\n" + ), + ) + .await?; + + let rhai_template_renderer = compile_shortcodes(source_filesystem.clone()).await?; + let authors = build_authors(source_filesystem.clone()).await?; + + build_project(BuildProjectParams { + asset_path_renderer: AssetPathRenderer { + base_path: "/".to_string(), + }, + authors, + esbuild_metafile: Default::default(), + generated_page_base_path: "/".to_string(), + generate_sitemap: false, + is_watching: false, + rhai_template_renderer, + source_filesystem, + }) + .await + } + + #[tokio::test] + async fn reports_document_with_changed_content_as_changed() -> Result<()> { + let previous: BuildProjectResult = build_stub("original body").await?.into(); + let changed = build_stub("modified body") + .await? + .changed_compared_to(previous); + + assert_eq!(changed.changed_since_last_build.len(), 1); + + Ok(()) + } + + #[tokio::test] + async fn reports_no_change_for_identical_content() -> Result<()> { + let previous: BuildProjectResult = build_stub("same body").await?.into(); + let changed = build_stub("same body").await?.changed_compared_to(previous); + + assert!(changed.changed_since_last_build.is_empty()); + + Ok(()) + } +} diff --git a/poet/src/build_project/mod.rs b/poet/src/build_project/mod.rs index e914a20..9eb6cd8 100644 --- a/poet/src/build_project/mod.rs +++ b/poet/src/build_project/mod.rs @@ -385,3 +385,331 @@ pub async fn build_project( Err(anyhow!("{error_collection}")) } } + +#[cfg(test)] +mod tests { + use std::path::Path; + use std::sync::Arc; + + use anyhow::Result; + use anyhow::anyhow; + use tempfile::tempdir; + + use super::build_project; + use crate::asset_path_renderer::AssetPathRenderer; + use crate::build_authors::build_authors; + use crate::build_project::build_project_params::BuildProjectParams; + use crate::build_project::build_project_result_stub::BuildProjectResultStub; + use crate::compile_shortcodes::compile_shortcodes; + use crate::filesystem::Filesystem as _; + use crate::filesystem::read_file_contents_result::ReadFileContentsResult; + use crate::filesystem::storage::Storage; + + const LAYOUT_MINIMAL: &str = r#" +fn template(context, props, content) { + component { + + + + Test + + + nav + {content} +
    + { + let ret = []; + + for author in context.authors { + ret.push(component { +
  • {author.data.name}
  • + }); + } + + ret + } +
+ + + } +} +"#; + + const PRIMARY_NAVIGATION: &str = r#" +fn template(context, props, content) { + component { + + } +} +"#; + + async fn build( + files: &[(&str, &str)], + generate_sitemap: bool, + ) -> Result { + let directory = tempdir()?; + let source_filesystem = Arc::new(Storage { + base_directory: directory.path().to_path_buf(), + }); + + for (relative_path, contents) in files { + source_filesystem + .set_file_contents(Path::new(relative_path), contents) + .await?; + } + + let rhai_template_renderer = compile_shortcodes(source_filesystem.clone()).await?; + let authors = build_authors(source_filesystem.clone()).await?; + + build_project(BuildProjectParams { + asset_path_renderer: AssetPathRenderer { + base_path: "/".to_string(), + }, + authors, + esbuild_metafile: Default::default(), + generated_page_base_path: "/".to_string(), + generate_sitemap, + is_watching: false, + rhai_template_renderer, + source_filesystem, + }) + .await + } + + async fn read(result: &BuildProjectResultStub, relative_path: &str) -> Result { + match result + .memory_filesystem + .read_file_contents(Path::new(relative_path)) + .await? + { + ReadFileContentsResult::Found { contents } => Ok(contents), + ReadFileContentsResult::Directory => Err(anyhow!("{relative_path} is a directory")), + ReadFileContentsResult::NotFound => Err(anyhow!("{relative_path} was not generated")), + } + } + + #[tokio::test] + async fn renders_content_documents_to_their_target_paths() -> Result<()> { + let result = build( + &[ + ("shortcodes/LayoutMinimal.rhai", LAYOUT_MINIMAL), + ("shortcodes/PrimaryNavigation.rhai", PRIMARY_NAVIGATION), + ("authors/alice.toml", "name = \"Alice\""), + ( + "content/index.md", + "+++\ndescription = \"Home\"\nlayout = \"LayoutMinimal\"\ntitle = \"Home\"\nauthors = [\"alice\"]\n+++\n\n# Hello\n\nBody text.\n", + ), + ( + "content/docs/index.md", + "+++\ndescription = \"Docs\"\nlayout = \"LayoutMinimal\"\ntitle = \"Docs\"\n\n[[collection]]\nname = \"docs\"\n+++\n\nDocs index.\n", + ), + ( + "content/docs/page.md", + "+++\ndescription = \"Page\"\nlayout = \"LayoutMinimal\"\ntitle = \"Page\"\n\n[[collection]]\nname = \"docs\"\nafter = \"docs/index\"\n+++\n\nPage body.\n", + ), + ], + false, + ) + .await?; + + let home = read(&result, "index.html").await?; + + assert!(home.contains("")); + assert!(home.contains("")); + assert!(home.contains("
  • Alice
  • ")); + + read(&result, "docs/index.html").await?; + read(&result, "docs/page/index.html").await?; + + assert_eq!(result.content_document_sources.len(), 3); + + Ok(()) + } + + #[tokio::test] + async fn generates_sitemap_with_canonical_links_when_requested() -> Result<()> { + let result = build( + &[ + ("shortcodes/LayoutMinimal.rhai", LAYOUT_MINIMAL), + ("shortcodes/PrimaryNavigation.rhai", PRIMARY_NAVIGATION), + ( + "content/index.md", + "+++\ndescription = \"Home\"\nlayout = \"LayoutMinimal\"\ntitle = \"Home\"\n+++\n\nHome.\n", + ), + ], + true, + ) + .await?; + + let sitemap = read(&result, "sitemap.xml").await?; + + assert!(sitemap.contains("/")); + + Ok(()) + } + + const LAYOUT_RICH: &str = r#" +fn template(context, props, content) { + component { + + {context.front_matter.title} + +

    {context.front_matter.description}

    + {context.reference.canonical_link} + {context.reference.basename} + reftitle:{context.reference.front_matter.title} + watching:{context.is_watching} + props:{context.front_matter.props.len()} + authors:{context.available_authors.len()} + {if context.front_matter.render { "is-rendered" } else { "" }} + { + let names = ""; + + for author in context.authors { + names += "" + author.basename + "=" + author.data.name + ""; + } + + names + } +
      + { + let toc = ""; + + for heading in context.table_of_contents.headings { + toc += "
    • " + heading.id + ":" + heading.content + ":" + heading.depth + "
    • "; + } + + toc + } +
    + { + let docs = context.collection("docs"); + let heading = "

    " + docs.name + "

    "; + + heading + render_hierarchy(docs.hierarchy, |node, level, children| { + "
  • " + node.reference.basename + " in " + node.collection_name + " kids " + node.children.len() + children + "
  • " + }) + } + {content} + + + } +} +"#; + + const LAYOUT_PLAIN: &str = r#" +fn template(context, props, content) { + component { + {content} + } +} +"#; + + #[tokio::test] + async fn rich_layout_exercises_template_accessors_and_hierarchy() -> Result<()> { + let result = build( + &[ + ("shortcodes/LayoutRich.rhai", LAYOUT_RICH), + ("shortcodes/LayoutPlain.rhai", LAYOUT_PLAIN), + ("authors/alice.toml", "name = \"Alice\""), + ( + "content/index.md", + "+++\ndescription = \"Welcome home\"\nlayout = \"LayoutRich\"\ntitle = \"Home Page\"\nauthors = [\"alice\"]\n+++\n\n# Section One\n\nHome body.\n", + ), + ( + "content/docs/index.md", + "+++\ndescription = \"Docs\"\nlayout = \"LayoutPlain\"\ntitle = \"Docs\"\n\n[[collection]]\nname = \"docs\"\n+++\n\nDocs index.\n", + ), + ( + "content/docs/page.md", + "+++\ndescription = \"Page\"\nlayout = \"LayoutPlain\"\ntitle = \"Page\"\n\n[[collection]]\nname = \"docs\"\nafter = \"docs/index\"\n+++\n\nPage body.\n", + ), + ], + false, + ) + .await?; + + let home = read(&result, "index.html").await?; + + assert!(home.contains("Home Page")); + assert!(home.contains("Welcome home")); + assert!(home.contains("is-rendered")); + assert!(home.contains("reftitle:Home Page")); + assert!(home.contains("watching:false")); + assert!(home.contains("authors:1")); + assert!(home.contains("alice=Alice")); + assert!(home.contains("section-one:Section One:1")); + assert!(home.contains("

    docs

    ")); + assert!(home.contains("in docs kids")); + + Ok(()) + } + + #[tokio::test] + async fn renders_markdown_mdx_component_and_expression() -> Result<()> { + let result = build( + &[ + ("shortcodes/LayoutMinimal.rhai", LAYOUT_MINIMAL), + ("shortcodes/PrimaryNavigation.rhai", PRIMARY_NAVIGATION), + ( + "content/index.md", + "+++\ndescription = \"Home\"\nlayout = \"LayoutMinimal\"\ntitle = \"Home\"\n+++\n\nValue {40 + 2}\n\n\ninner\n\n", + ), + ], + false, + ) + .await?; + + let home = read(&result, "index.html").await?; + + assert!(home.contains("

    Value 42

    ")); + assert!(home.contains("")); + + Ok(()) + } + + #[tokio::test] + async fn errors_on_duplicate_document_id() -> Result<()> { + let front_matter = |title: &str| { + format!( + "+++\ndescription = \"d\"\nid = \"dup\"\nlayout = \"LayoutMinimal\"\ntitle = \"{title}\"\n+++\n\nBody.\n" + ) + }; + + let outcome = build( + &[ + ("content/a.md", &front_matter("A")), + ("content/b.md", &front_matter("B")), + ], + false, + ) + .await; + + assert!( + outcome.is_err_and(|error| error.to_string().contains("Duplicate document id: #dup")) + ); + + Ok(()) + } + + #[tokio::test] + async fn errors_when_a_referenced_author_does_not_exist() -> Result<()> { + let outcome = build( + &[( + "content/a.md", + "+++\ndescription = \"d\"\nlayout = \"LayoutMinimal\"\ntitle = \"A\"\nauthors = [\"ghost\"]\n+++\n\nBody.\n", + )], + false, + ) + .await; + + assert!( + outcome.is_err_and(|error| { + error.to_string().contains("Author does not exist: 'ghost'") + }) + ); + + Ok(()) + } +} diff --git a/poet/src/build_prompt_document_controller_collection/mod.rs b/poet/src/build_prompt_document_controller_collection/mod.rs index 2d48fd5..8312bde 100644 --- a/poet/src/build_prompt_document_controller_collection/mod.rs +++ b/poet/src/build_prompt_document_controller_collection/mod.rs @@ -68,3 +68,72 @@ pub async fn build_prompt_document_controller_collection( Ok(prompt_controller_map.into()) } + +#[cfg(test)] +mod tests { + use std::path::Path; + + use tempfile::tempdir; + + use super::*; + use crate::asset_path_renderer::AssetPathRenderer; + use crate::compile_shortcodes::compile_shortcodes; + use crate::filesystem::storage::Storage; + use crate::mcp::list_resources_cursor::ListResourcesCursor; + + async fn build(prompt_files: &[(&str, &str)]) -> Result { + let directory = tempdir()?; + let source_filesystem = Arc::new(Storage { + base_directory: directory.path().to_path_buf(), + }); + + for (relative_path, contents) in prompt_files { + source_filesystem + .set_file_contents(Path::new(relative_path), contents) + .await?; + } + + let rhai_template_renderer = compile_shortcodes(source_filesystem.clone()).await?; + + build_prompt_document_controller_collection(BuildPromptControllerCollectionParams { + asset_path_renderer: AssetPathRenderer { + base_path: "/".to_string(), + }, + content_document_linker: Default::default(), + esbuild_metafile: Default::default(), + rhai_template_renderer, + source_filesystem, + }) + .await + } + + #[tokio::test] + async fn builds_a_controller_for_each_prompt_file() -> Result<()> { + let collection = build(&[( + "prompts/greet.md", + "+++\narguments = {}\ndescription = \"Greeting\"\ntitle = \"Greet\"\n+++\n\n**user**: hello\n", + )]) + .await?; + + let prompts = collection.list_mcp_prompts(ListResourcesCursor { + offset: 0, + per_page: 10, + }); + + assert_eq!(prompts.len(), 1); + assert_eq!(prompts[0].name, "greet"); + + Ok(()) + } + + #[tokio::test] + async fn aggregates_errors_from_invalid_prompt_front_matter() { + let outcome = build(&[( + "prompts/broken.md", + "+++\ntitle = \"Missing required fields\"\n+++\n\n**user**: hi\n", + )]) + .await; + + assert!(outcome.is_err()); + } +} diff --git a/poet/src/cmd/serve/mod.rs b/poet/src/cmd/serve/mod.rs index cce4188..9d699aa 100644 --- a/poet/src/cmd/serve/mod.rs +++ b/poet/src/cmd/serve/mod.rs @@ -10,9 +10,9 @@ use actix_web::App; use actix_web::HttpServer; use actix_web::web::Data; use anyhow::Result; -use indoc::formatdoc; use async_trait::async_trait; use clap::Parser; +use indoc::formatdoc; use log::info; use crate::app_dir_desktop_entry::AppDirDesktopEntry; diff --git a/poet/src/cmd/value_parser/parse_socket_addr.rs b/poet/src/cmd/value_parser/parse_socket_addr.rs index e9fac6a..7440c23 100644 --- a/poet/src/cmd/value_parser/parse_socket_addr.rs +++ b/poet/src/cmd/value_parser/parse_socket_addr.rs @@ -28,3 +28,37 @@ pub fn parse_socket_addr(arg: &str) -> Result { Err(_) => Ok(resolve_socket_addr(arg)?), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_literal_ipv4_socket_address() -> Result<()> { + assert_eq!( + parse_socket_addr("127.0.0.1:8080")?, + "127.0.0.1:8080".parse::()? + ); + + Ok(()) + } + + #[test] + fn resolve_prefers_ipv4_address() -> Result<()> { + assert!(resolve_socket_addr("127.0.0.1:8080")?.is_ipv4()); + + Ok(()) + } + + #[test] + fn resolve_returns_ipv6_when_no_ipv4_present() -> Result<()> { + assert!(resolve_socket_addr("[::1]:8080")?.is_ipv6()); + + Ok(()) + } + + #[test] + fn errors_on_malformed_socket_address() { + assert!(parse_socket_addr("definitely-not-a-socket-address").is_err()); + } +} diff --git a/poet/src/cmd/value_parser/validate_is_directory.rs b/poet/src/cmd/value_parser/validate_is_directory.rs index d38f96b..d304bb4 100644 --- a/poet/src/cmd/value_parser/validate_is_directory.rs +++ b/poet/src/cmd/value_parser/validate_is_directory.rs @@ -16,3 +16,46 @@ pub fn validate_is_directory(path_string: &str) -> Result { Ok(path) } + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use super::*; + + #[test] + fn returns_path_for_existing_directory() -> Result<()> { + let directory = tempdir()?; + + assert_eq!( + validate_is_directory(&directory.path().display().to_string())?.as_path(), + directory.path() + ); + + Ok(()) + } + + #[test] + fn errors_when_path_does_not_exist() -> Result<()> { + let directory = tempdir()?; + let missing = directory.path().join("missing"); + + assert!(validate_is_directory(&missing.display().to_string()).is_err()); + + Ok(()) + } + + #[test] + fn errors_when_path_is_a_file() -> Result<()> { + let directory = tempdir()?; + let file_path = directory.path().join("file.txt"); + + fs::write(&file_path, "contents")?; + + assert!(validate_is_directory(&file_path.display().to_string()).is_err()); + + Ok(()) + } +} diff --git a/poet/src/cmd/value_parser/validate_is_directory_or_create.rs b/poet/src/cmd/value_parser/validate_is_directory_or_create.rs index 1aa5f39..faa1287 100644 --- a/poet/src/cmd/value_parser/validate_is_directory_or_create.rs +++ b/poet/src/cmd/value_parser/validate_is_directory_or_create.rs @@ -17,3 +17,44 @@ pub fn validate_is_directory_or_create(path_string: &str) -> Result { Ok(path) } + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use super::*; + + #[test] + fn returns_existing_directory() -> Result<()> { + let directory = tempdir()?; + + assert_eq!( + validate_is_directory_or_create(&directory.path().display().to_string())?.as_path(), + directory.path() + ); + + Ok(()) + } + + #[test] + fn creates_directory_when_missing() -> Result<()> { + let directory = tempdir()?; + let created = directory.path().join("created"); + + assert!(validate_is_directory_or_create(&created.display().to_string())?.is_dir()); + + Ok(()) + } + + #[test] + fn errors_when_path_is_a_file() -> Result<()> { + let directory = tempdir()?; + let file_path = directory.path().join("file.txt"); + + fs::write(&file_path, "contents")?; + + assert!(validate_is_directory_or_create(&file_path.display().to_string()).is_err()); + + Ok(()) + } +} diff --git a/poet/src/compile_shortcodes.rs b/poet/src/compile_shortcodes.rs index edcb965..6892eee 100644 --- a/poet/src/compile_shortcodes.rs +++ b/poet/src/compile_shortcodes.rs @@ -27,3 +27,82 @@ pub async fn compile_shortcodes(source_filesystem: Arc) -> Result, + } + + async fn storage_with(files: &[(&str, &str)]) -> Result { + let directory = tempdir()?; + let filesystem = Arc::new(Storage { + base_directory: directory.path().to_path_buf(), + }); + + for (relative_path, contents) in files { + filesystem + .set_file_contents(Path::new(relative_path), contents) + .await?; + } + + Ok(TestStorage { + _directory: directory, + filesystem, + }) + } + + #[tokio::test] + async fn compiles_shortcodes_into_a_renderer_that_renders_registered_components() -> Result<()> + { + let source_storage = storage_with(&[( + "shortcodes/PrimaryNavigation.rhai", + "fn template(context, props, content) { component { } }", + )]) + .await?; + + let renderer = compile_shortcodes(source_storage.filesystem.clone()).await?; + + let rendered = renderer.render( + "PrimaryNavigation", + ContentDocumentComponentContext::mock(), + Dynamic::UNIT, + Dynamic::from("links".to_string()), + )?; + + assert!(rendered.contains("")); + + Ok(()) + } + + #[tokio::test] + async fn errors_when_a_shortcode_has_invalid_syntax() -> Result<()> { + let source_storage = storage_with(&[( + "shortcodes/Broken.rhai", + "fn template(context, props, content) { @ this is not valid rhai @ }", + )]) + .await?; + + assert!( + compile_shortcodes(source_storage.filesystem.clone()) + .await + .is_err() + ); + + Ok(()) + } +} diff --git a/poet/src/content_document_component_context.rs b/poet/src/content_document_component_context.rs index 3e225a7..88fdb0c 100644 --- a/poet/src/content_document_component_context.rs +++ b/poet/src/content_document_component_context.rs @@ -30,6 +30,31 @@ pub struct ContentDocumentComponentContext { } impl ContentDocumentComponentContext { + #[cfg(test)] + pub fn mock() -> Self { + Self { + asset_manager: AssetManager::from_esbuild_metafile( + Arc::new(esbuild_metafile::EsbuildMetaFile::default()), + crate::asset_path_renderer::AssetPathRenderer { + base_path: "/".to_string(), + }, + ), + authors: Vec::new(), + available_authors: Arc::new(AuthorCollection::default()), + available_collections: Arc::new(HashSet::new()), + content_document_collections_ranked: Arc::new(HashMap::new()), + content_document_linker: ContentDocumentLinker::default(), + front_matter: ContentDocumentFrontMatter::mock("doc"), + is_watching: false, + reference: ContentDocumentReference { + basename_path: "doc".into(), + front_matter: ContentDocumentFrontMatter::mock("doc"), + generated_page_base_path: "/".to_string(), + }, + table_of_contents: None, + } + } + pub fn with_table_of_contents(self, table_of_contents: TableOfContents) -> Self { Self { asset_manager: self.asset_manager, @@ -169,3 +194,195 @@ impl CustomType for ContentDocumentComponentContext { .with_fn("link_to", Self::rhai_link_to); } } + +#[cfg(test)] +mod tests { + use esbuild_metafile::EsbuildMetaFile; + + use super::*; + use crate::asset_path_renderer::AssetPathRenderer; + use crate::content_document_collection::ContentDocumentCollection; + use crate::content_document_front_matter::collection_placement::CollectionPlacement; + use crate::content_document_front_matter::collection_placement_list::CollectionPlacementList; + use crate::content_document_in_collection::ContentDocumentInCollection; + + fn ranked(name: &str) -> Result { + ContentDocumentCollection { + documents: vec![ContentDocumentInCollection { + collection_placement: CollectionPlacement { + after: None, + name: name.to_string(), + parent: None, + }, + reference: ContentDocumentReference { + basename_path: "entry".into(), + front_matter: ContentDocumentFrontMatter::mock("entry"), + generated_page_base_path: "/".to_string(), + }, + }], + name: name.to_string(), + } + .try_into() + } + + fn ranked_map( + names: &[&str], + ) -> Result, anyhow::Error> { + let mut map = HashMap::new(); + + for name in names { + map.insert(name.to_string(), ranked(name)?); + } + + Ok(map) + } + + fn front_matter( + placements: &[&str], + primary_collection: Option<&str>, + ) -> ContentDocumentFrontMatter { + let mut front_matter = ContentDocumentFrontMatter::mock("doc"); + + front_matter.collections = CollectionPlacementList { + placements: placements + .iter() + .map(|name| CollectionPlacement { + after: None, + name: name.to_string(), + parent: None, + }) + .collect(), + }; + front_matter.primary_collection = primary_collection.map(|name| name.to_string()); + + front_matter + } + + fn context( + front_matter: ContentDocumentFrontMatter, + ranked: HashMap, + ) -> ContentDocumentComponentContext { + let asset_manager = AssetManager::from_esbuild_metafile( + Arc::new(EsbuildMetaFile::default()), + AssetPathRenderer { + base_path: "/".to_string(), + }, + ); + + ContentDocumentComponentContext { + asset_manager, + authors: Vec::new(), + available_authors: Arc::new(AuthorCollection::default()), + available_collections: Arc::new(HashSet::new()), + content_document_collections_ranked: Arc::new(ranked), + content_document_linker: ContentDocumentLinker::default(), + front_matter, + is_watching: false, + reference: ContentDocumentReference { + basename_path: "doc".into(), + front_matter: ContentDocumentFrontMatter::mock("doc"), + generated_page_base_path: "/".to_string(), + }, + table_of_contents: None, + } + } + + #[test] + fn collection_returns_ranked_collection_when_used() -> Result<(), anyhow::Error> { + let mut context = context(front_matter(&[], None), ranked_map(&["guide"])?); + + assert_eq!(context.rhai_collection("guide")?.name, "guide"); + + Ok(()) + } + + #[test] + fn collection_fails_for_unused_collection() { + let mut context = context(front_matter(&[], None), HashMap::new()); + + assert!(context.rhai_collection("ghost").is_err()); + } + + #[test] + fn belongs_to_is_true_for_member_collection() -> Result<(), anyhow::Error> { + let mut context = context(front_matter(&["guide"], None), ranked_map(&["guide"])?); + + assert!(context.rhai_belongs_to("guide")?); + + Ok(()) + } + + #[test] + fn belongs_to_is_false_for_non_member_existing_collection() -> Result<(), anyhow::Error> { + let mut context = context(front_matter(&[], None), ranked_map(&["guide"])?); + + assert!(!context.rhai_belongs_to("guide")?); + + Ok(()) + } + + #[test] + fn primary_collection_fails_without_any_placement() { + let mut context = context(front_matter(&[], None), HashMap::new()); + + assert!(context.rhai_primary_collection().is_err()); + } + + #[test] + fn primary_collection_returns_sole_placement() -> Result<(), anyhow::Error> { + let mut context = context(front_matter(&["guide"], None), ranked_map(&["guide"])?); + + assert_eq!(context.rhai_primary_collection()?.name, "guide"); + + Ok(()) + } + + #[test] + fn primary_collection_resolves_declared_primary_among_many() -> Result<(), anyhow::Error> { + let mut context = context( + front_matter(&["guide", "reference"], Some("reference")), + ranked_map(&["guide", "reference"])?, + ); + + assert_eq!(context.rhai_primary_collection()?.name, "reference"); + + Ok(()) + } + + #[test] + fn primary_collection_fails_for_many_without_declared_primary() { + let mut context = context(front_matter(&["guide", "reference"], None), HashMap::new()); + + assert!(context.rhai_primary_collection().is_err()); + } + + #[test] + fn is_current_page_compares_resolved_basename() -> Result<(), anyhow::Error> { + let mut context = context(front_matter(&[], None), HashMap::new()); + + assert!(context.rhai_is_current_page("doc".to_string())?); + assert!(!context.rhai_is_current_page("other".to_string())?); + + Ok(()) + } + + #[test] + fn table_of_contents_fails_when_absent() { + let mut context = context(front_matter(&[], None), HashMap::new()); + + assert!(context.rhai_table_of_contents().is_err()); + } + + #[test] + fn table_of_contents_is_available_after_being_set() -> Result<(), anyhow::Error> { + let mut context = context(front_matter(&[], None), HashMap::new()).with_table_of_contents( + TableOfContents { + headings: Vec::new(), + }, + ); + + assert!(context.rhai_table_of_contents().is_ok()); + + Ok(()) + } +} diff --git a/poet/src/content_document_hierarchy.rs b/poet/src/content_document_hierarchy.rs index cef642d..6b5b001 100644 --- a/poet/src/content_document_hierarchy.rs +++ b/poet/src/content_document_hierarchy.rs @@ -89,3 +89,112 @@ impl From> for ContentDocumentHierarchy { Self { flat, roots } } } + +#[cfg(test)] +mod tests { + use anyhow::Result; + + use super::*; + use crate::content_document_front_matter::ContentDocumentFrontMatter; + + fn reference(basename: &str, render: bool) -> ContentDocumentReference { + let mut front_matter = ContentDocumentFrontMatter::mock(basename); + + front_matter.render = render; + + ContentDocumentReference { + basename_path: basename.into(), + front_matter, + generated_page_base_path: "/".to_string(), + } + } + + fn tree_node( + basename: &str, + render: bool, + children: Vec, + ) -> ContentDocumentTreeNode { + ContentDocumentTreeNode { + children: children.into_iter().collect(), + collection_name: "docs".to_string(), + reference: reference(basename, render), + } + } + + fn hierarchy() -> ContentDocumentHierarchy { + ContentDocumentHierarchy::from(vec![ + tree_node("a", true, vec![]), + tree_node("hidden", false, vec![]), + tree_node("b", true, vec![]), + ]) + } + + fn basename_of(value: Dynamic) -> Option { + value + .try_cast::() + .map(|reference| reference.basename().to_string()) + } + + #[test] + fn flattens_tree_depth_first() { + let hierarchy = ContentDocumentHierarchy::from(vec![ + tree_node("a", true, vec![tree_node("a-child", true, vec![])]), + tree_node("b", true, vec![]), + ]); + + let basenames: Vec = hierarchy + .flat + .iter() + .map(|reference| reference.basename().to_string()) + .collect(); + + assert_eq!( + basenames, + vec!["a".to_string(), "a-child".to_string(), "b".to_string()] + ); + } + + #[test] + fn after_returns_next_renderable_document_skipping_hidden() -> Result<()> { + assert_eq!( + basename_of(hierarchy().rhai_after("a".to_string())?), + Some("b".to_string()) + ); + + Ok(()) + } + + #[test] + fn after_returns_unit_for_last_document() -> Result<()> { + assert!(hierarchy().rhai_after("b".to_string())?.is_unit()); + + Ok(()) + } + + #[test] + fn after_fails_for_document_not_in_hierarchy() { + assert!(hierarchy().rhai_after("missing".to_string()).is_err()); + } + + #[test] + fn before_returns_previous_renderable_document_skipping_hidden() -> Result<()> { + assert_eq!( + basename_of(hierarchy().rhai_before("b".to_string())?), + Some("a".to_string()) + ); + + Ok(()) + } + + #[test] + fn before_returns_unit_for_first_document() -> Result<()> { + assert!(hierarchy().rhai_before("a".to_string())?.is_unit()); + + Ok(()) + } + + #[test] + fn before_fails_for_document_not_in_hierarchy() { + assert!(hierarchy().rhai_before("missing".to_string()).is_err()); + } +} diff --git a/poet/src/content_document_linker.rs b/poet/src/content_document_linker.rs index 269cf3b..b4f75a9 100644 --- a/poet/src/content_document_linker.rs +++ b/poet/src/content_document_linker.rs @@ -51,3 +51,74 @@ impl ContentDocumentLinker { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::content_document_front_matter::ContentDocumentFrontMatter; + + fn reference(basename: &str, render: bool) -> ContentDocumentReference { + let mut front_matter = ContentDocumentFrontMatter::mock(basename); + + front_matter.render = render; + + ContentDocumentReference { + basename_path: basename.into(), + front_matter, + generated_page_base_path: "/".to_string(), + } + } + + fn linker() -> ContentDocumentLinker { + let mut content_document_by_basename: HashMap< + ContentDocumentBasename, + ContentDocumentReference, + > = HashMap::new(); + + content_document_by_basename.insert("guide".to_string().into(), reference("guide", true)); + content_document_by_basename.insert("draft".to_string().into(), reference("draft", false)); + + let mut content_document_basename_by_id: HashMap = + HashMap::new(); + + content_document_basename_by_id.insert("guide-id".to_string(), "guide".to_string().into()); + + ContentDocumentLinker { + content_document_basename_by_id: Arc::new(content_document_basename_by_id), + content_document_by_basename: Arc::new(content_document_by_basename), + } + } + + #[test] + fn resolve_id_returns_basename_for_known_id() { + assert_eq!( + linker().resolve_id("#guide-id"), + Ok("guide".to_string().into()) + ); + } + + #[test] + fn resolve_id_fails_for_unknown_id() { + assert!(linker().resolve_id("#missing").is_err()); + } + + #[test] + fn resolve_id_returns_plain_path_as_basename() { + assert_eq!(linker().resolve_id("guide"), Ok("guide".to_string().into())); + } + + #[test] + fn link_to_returns_canonical_link_for_renderable_document() { + assert_eq!(linker().link_to("guide"), Ok("/guide/".to_string())); + } + + #[test] + fn link_to_fails_for_non_renderable_document() { + assert!(linker().link_to("draft").is_err()); + } + + #[test] + fn link_to_fails_for_missing_document() { + assert!(linker().link_to("ghost").is_err()); + } +} diff --git a/poet/src/content_document_reference.rs b/poet/src/content_document_reference.rs index a9f82da..5d2ee0d 100644 --- a/poet/src/content_document_reference.rs +++ b/poet/src/content_document_reference.rs @@ -142,6 +142,8 @@ impl PartialOrd for ContentDocumentReference { #[cfg(test)] mod tests { + use std::collections::hash_map::DefaultHasher; + use anyhow::Result; use super::*; @@ -249,4 +251,59 @@ mod tests { Ok(()) } + + #[test] + fn basename_last_stem_returns_final_path_segment() -> Result<()> { + let mut reference = ContentDocumentReference { + basename_path: "foo/bar".into(), + front_matter: ContentDocumentFrontMatter::mock("foo"), + generated_page_base_path: "/".to_string(), + }; + + assert_eq!(reference.rhai_basename_last_stem()?, "bar"); + + Ok(()) + } + + #[test] + fn fails_target_path_when_basename_has_no_file_stem() { + let reference = ContentDocumentReference { + basename_path: "foo/..".into(), + front_matter: ContentDocumentFrontMatter::mock("foo"), + generated_page_base_path: "/".to_string(), + }; + + assert!(reference.target_file_relative_path().is_err()); + } + + #[test] + fn fails_target_path_when_basename_has_no_parent() { + let reference = ContentDocumentReference { + basename_path: "".into(), + front_matter: ContentDocumentFrontMatter::mock("foo"), + generated_page_base_path: "/".to_string(), + }; + + assert!(reference.target_file_relative_path().is_err()); + } + + #[test] + fn identity_is_determined_by_basename_path_only() { + let make = |basename: &str, title: &str| ContentDocumentReference { + basename_path: basename.into(), + front_matter: ContentDocumentFrontMatter::mock(title), + generated_page_base_path: "/".to_string(), + }; + + assert_eq!(make("foo", "one"), make("foo", "two")); + assert!(make("a", "x") < make("b", "x")); + + let mut first_hasher = DefaultHasher::new(); + make("foo", "one").hash(&mut first_hasher); + + let mut second_hasher = DefaultHasher::new(); + make("foo", "two").hash(&mut second_hasher); + + assert_eq!(first_hasher.finish(), second_hasher.finish()); + } } diff --git a/poet/src/content_document_tree_node.rs b/poet/src/content_document_tree_node.rs index e452c42..c558945 100644 --- a/poet/src/content_document_tree_node.rs +++ b/poet/src/content_document_tree_node.rs @@ -52,3 +52,42 @@ impl CustomType for ContentDocumentTreeNode { .with_get("reference", Self::rhai_reference); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::content_document_front_matter::ContentDocumentFrontMatter; + + fn node( + basename: &str, + children: LinkedList, + ) -> ContentDocumentTreeNode { + ContentDocumentTreeNode { + children, + collection_name: "collection".to_string(), + reference: ContentDocumentReference { + basename_path: basename.into(), + front_matter: ContentDocumentFrontMatter::mock(basename), + generated_page_base_path: "/".to_string(), + }, + } + } + + #[test] + fn flatten_walks_tree_in_depth_first_preorder() { + let mut grandchildren = LinkedList::new(); + grandchildren.push_back(node("grandchild", LinkedList::new())); + + let mut children = LinkedList::new(); + children.push_back(node("child-a", grandchildren)); + children.push_back(node("child-b", LinkedList::new())); + + let basenames: Vec = node("root", children) + .flatten() + .iter() + .map(|reference| reference.basename().to_string()) + .collect(); + + assert_eq!(basenames, vec!["root", "child-a", "grandchild", "child-b"]); + } +} diff --git a/poet/src/document_error.rs b/poet/src/document_error.rs index 4782adb..6d4df5c 100644 --- a/poet/src/document_error.rs +++ b/poet/src/document_error.rs @@ -37,3 +37,51 @@ impl PartialOrd for DocumentError { Some(self.cmp(other)) } } + +#[cfg(test)] +mod tests { + use anyhow::anyhow; + + use super::*; + + #[test] + fn display_lists_basename_then_full_error_chain() { + let document_error = DocumentError { + basename: "guide".to_string(), + err: anyhow!("root cause").context("outer context"), + }; + + assert_eq!( + format!("{document_error}"), + "guide:\n- outer context\n- root cause\n" + ); + } + + #[test] + fn orders_by_basename() { + let alpha = DocumentError { + basename: "alpha".to_string(), + err: anyhow!("first"), + }; + let beta = DocumentError { + basename: "beta".to_string(), + err: anyhow!("second"), + }; + + assert!(alpha < beta); + } + + #[test] + fn equality_is_decided_by_basename() { + let one = DocumentError { + basename: "guide".to_string(), + err: anyhow!("first"), + }; + let another = DocumentError { + basename: "guide".to_string(), + err: anyhow!("second"), + }; + + assert!(one == another); + } +} diff --git a/poet/src/document_error_collection.rs b/poet/src/document_error_collection.rs index 8f614b8..f05bb4a 100644 --- a/poet/src/document_error_collection.rs +++ b/poet/src/document_error_collection.rs @@ -44,3 +44,34 @@ impl fmt::Display for DocumentErrorCollection { Ok(()) } } + +#[cfg(test)] +mod tests { + use anyhow::anyhow; + + use super::*; + + #[test] + fn is_empty_until_an_error_is_registered() { + let collection = DocumentErrorCollection::default(); + + assert!(collection.is_empty()); + + collection.register_error("guide".to_string(), anyhow!("boom")); + + assert!(!collection.is_empty()); + } + + #[test] + fn display_reports_count_and_sorts_errors_by_basename() { + let collection = DocumentErrorCollection::default(); + + collection.register_error("beta".to_string(), anyhow!("second")); + collection.register_error("alpha".to_string(), anyhow!("first")); + + assert_eq!( + format!("{collection}"), + "Multiple errors occurred (2 total):\nalpha:\n- first\n\nbeta:\n- second\n\n" + ); + } +} diff --git a/poet/src/eval_content_document_mdast.rs b/poet/src/eval_content_document_mdast.rs index 5ab3b9c..2150da6 100644 --- a/poet/src/eval_content_document_mdast.rs +++ b/poet/src/eval_content_document_mdast.rs @@ -216,7 +216,7 @@ pub fn eval_content_document_mdast( let src = if is_external_link(url) { url } else { - &match component_context.asset_manager.file(url) { + &match component_context.asset_manager.image(url) { Ok(src) => src, Err(err) => return Err(anyhow!(err)), } @@ -418,3 +418,358 @@ pub fn eval_content_document_mdast( Ok(result) } + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::collections::HashSet; + use std::str::FromStr; + use std::sync::Arc; + + use esbuild_metafile::EsbuildMetaFile; + use indoc::indoc; + use rhai::Engine; + use rhai_components::component_syntax::component_registry::ComponentRegistry; + use rhai_components::rhai_template_renderer_params::RhaiTemplateRendererParams; + use syntect::parsing::SyntaxDefinition; + use syntect::parsing::SyntaxSetBuilder; + + use super::*; + use crate::asset_manager::AssetManager; + use crate::asset_path_renderer::AssetPathRenderer; + use crate::author_collection::AuthorCollection; + use crate::content_document_front_matter::ContentDocumentFrontMatter; + use crate::content_document_linker::ContentDocumentLinker; + use crate::content_document_reference::ContentDocumentReference; + use crate::string_to_mdast::string_to_mdast; + + const ASSET_METAFILE: &str = indoc! {r#" + { + "outputs": { + "static/logo_ABCDEF12.png": { + "imports": [], + "inputs": { "logo.png": {} } + }, + "static/chunk_ABCDEF12.js": { + "imports": [], + "inputs": { "logo.png": {} } + }, + "static/entry_ABCDEF12.js": { + "imports": [{ "path": "static/logo_ABCDEF12.png" }], + "entryPoint": "logo.png", + "inputs": {} + } + } + } + "#}; + + fn asset_manager() -> Result { + Ok(AssetManager::from_esbuild_metafile( + Arc::new(EsbuildMetaFile::from_str(ASSET_METAFILE)?), + AssetPathRenderer { + base_path: "/".to_string(), + }, + )) + } + + fn linker() -> ContentDocumentLinker { + let mut content_document_by_basename = HashMap::new(); + + content_document_by_basename.insert( + "guide".to_string().into(), + ContentDocumentReference { + basename_path: "guide".into(), + front_matter: ContentDocumentFrontMatter::mock("guide"), + generated_page_base_path: "/".to_string(), + }, + ); + + ContentDocumentLinker { + content_document_basename_by_id: Arc::new(HashMap::new()), + content_document_by_basename: Arc::new(content_document_by_basename), + } + } + + fn context() -> Result { + Ok(ContentDocumentComponentContext { + asset_manager: asset_manager()?, + authors: Vec::new(), + available_authors: Arc::new(AuthorCollection::default()), + available_collections: Arc::new(HashSet::new()), + content_document_collections_ranked: Arc::new(HashMap::new()), + content_document_linker: linker(), + front_matter: ContentDocumentFrontMatter::mock("doc"), + is_watching: false, + reference: ContentDocumentReference { + basename_path: "doc".into(), + front_matter: ContentDocumentFrontMatter::mock("doc"), + generated_page_base_path: "/".to_string(), + }, + table_of_contents: None, + }) + } + + fn renderer() -> Result { + RhaiTemplateRenderer::build(RhaiTemplateRendererParams { + component_registry: Arc::new(ComponentRegistry::default()), + expression_engine: Engine::new_raw(), + }) + } + + fn single_token_syntax_set() -> Result { + let definition = SyntaxDefinition::load_from_str( + indoc! {r#" + %YAML 1.2 + --- + name: Demo + file_extensions: [demo] + scope: source.demo + contexts: + main: + - match: '\bfn\b' + scope: keyword.demo + "#}, + true, + None, + )?; + let mut builder = SyntaxSetBuilder::new(); + + builder.add(definition); + + Ok(builder.build()) + } + + fn render_with_syntax_set(markdown: &str, syntax_set: &SyntaxSet) -> Result { + eval_content_document_mdast( + &string_to_mdast(markdown)?, + &context()?, + &renderer()?, + syntax_set, + ) + } + + fn render(markdown: &str) -> Result { + render_with_syntax_set(markdown, &SyntaxSet::new()) + } + + #[test] + fn renders_heading_with_generated_id() -> Result<()> { + assert_eq!( + render("## Hello World")?, + "

    Hello World

    " + ); + + Ok(()) + } + + #[test] + fn renders_paragraph_with_emphasis_and_strong() -> Result<()> { + assert_eq!( + render("*one* **two**")?, + "

    one two

    " + ); + + Ok(()) + } + + #[test] + fn renders_inline_code() -> Result<()> { + assert_eq!(render("`let x = 1`")?, "

    let x = 1

    "); + + Ok(()) + } + + #[test] + fn renders_strikethrough() -> Result<()> { + assert_eq!(render("~~gone~~")?, "

    gone

    "); + + Ok(()) + } + + #[test] + fn renders_blockquote() -> Result<()> { + assert_eq!( + render("> quoted")?, + "

    quoted

    " + ); + + Ok(()) + } + + #[test] + fn renders_unordered_list() -> Result<()> { + let rendered = render("- first\n- second")?; + + assert!(rendered.starts_with("
      ")); + assert!(rendered.ends_with("
    ")); + assert!(rendered.contains("first")); + assert!(rendered.contains("second")); + + Ok(()) + } + + #[test] + fn renders_ordered_list() -> Result<()> { + let rendered = render("1. first\n2. second")?; + + assert!(rendered.starts_with("
      ")); + assert!(rendered.ends_with("
    ")); + + Ok(()) + } + + #[test] + fn renders_thematic_break() -> Result<()> { + assert_eq!(render("***")?, "
    "); + + Ok(()) + } + + #[test] + fn renders_table() -> Result<()> { + let rendered = render("| H1 | H2 |\n| -- | -- |\n| a | b |")?; + + assert!(rendered.starts_with("")); + assert!(rendered.ends_with("
    ")); + assert!(rendered.contains("H1H2")); + assert!(rendered.contains("ab")); + + Ok(()) + } + + #[test] + fn highlights_code_block_for_known_language() -> Result<()> { + let rendered = + render_with_syntax_set("```demo\nfn main\n```", &single_token_syntax_set()?)?; + + assert!( + rendered.starts_with("
    ")
    +        );
    +        assert!(rendered.contains("
    ")); + + Ok(()) + } + + #[test] + fn escapes_code_block_for_unknown_language() -> Result<()> { + let rendered = render("```nosuchlang\na < b\n```")?; + + assert!(rendered.contains("language-nosuchlang")); + assert!(!rendered.contains(" Result<()> { + let rendered = render("```\na < b\n```")?; + + assert!(rendered.starts_with("
    "));
    +        assert!(rendered.contains("a < b"));
    +        assert!(rendered.ends_with("
    ")); + + Ok(()) + } + + #[test] + fn renders_external_link_with_title() -> Result<()> { + assert_eq!( + render("[label](https://example.com \"Tooltip\")")?, + "

    label

    " + ); + + Ok(()) + } + + #[test] + fn resolves_internal_link_through_linker() -> Result<()> { + assert_eq!( + render("[label](guide)")?, + "

    label

    " + ); + + Ok(()) + } + + #[test] + fn fails_internal_link_to_missing_document() { + assert!(render("[label](ghost)").is_err()); + } + + #[test] + fn renders_external_image() -> Result<()> { + assert_eq!( + render("![photo](https://example.com/p.png)")?, + "

    \"photo\"

    " + ); + + Ok(()) + } + + #[test] + fn resolves_internal_image_through_asset_manager() -> Result<()> { + assert_eq!( + render("![logo](logo.png)")?, + "

    \"logo\"

    " + ); + + Ok(()) + } + + #[test] + fn fails_internal_image_for_missing_asset() { + assert!(render("![logo](missing.png)").is_err()); + } + + #[test] + fn skips_unsupported_math_node() -> Result<()> { + assert_eq!(render("$$\nx = 1\n$$")?, ""); + + Ok(()) + } + + #[test] + fn renders_hard_break() -> Result<()> { + assert!(render("first\\\nsecond")?.contains("
    ")); + + Ok(()) + } + + #[test] + fn renders_code_block_metadata_flags_and_pairs() -> Result<()> { + let rendered = render("```text highlighted label:foo\ncode\n```")?; + + assert!(rendered.contains(r#"data-meta-line="highlighted label:foo""#)); + assert!(rendered.contains(" highlighted")); + assert!(rendered.contains(r#"data-meta-label="foo""#)); + + Ok(()) + } + + #[test] + fn fails_on_invalid_code_block_metadata() { + assert!(render("```text bad:\"unterminated\ncode\n```").is_err()); + } + + #[test] + fn renders_external_image_with_title() -> Result<()> { + assert_eq!( + render("![photo](https://example.com/p.png \"Caption\")")?, + "

    \"photo\"

    " + ); + + Ok(()) + } + + #[test] + fn renders_footnote_reference() -> Result<()> { + let rendered = render("note[^a]\n\n[^a]: detail")?; + + assert!(rendered.contains(r##"href="#footnote-a""##)); + assert!(rendered.contains(r#"role="doc-noteref""#)); + + Ok(()) + } +} diff --git a/poet/src/eval_mdx_element.rs b/poet/src/eval_mdx_element.rs index 962d430..d2f331e 100644 --- a/poet/src/eval_mdx_element.rs +++ b/poet/src/eval_mdx_element.rs @@ -100,3 +100,136 @@ where Ok(result) } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use markdown::mdast::MdxJsxExpressionAttribute; + use markdown::mdast::Text; + use rhai::Engine; + use rhai::TypeBuilder; + use rhai_components::component_syntax::component_registry::ComponentRegistry; + use rhai_components::rhai_template_renderer_params::RhaiTemplateRendererParams; + + use super::*; + + #[derive(Clone)] + struct DummyContext; + + impl CustomType for DummyContext { + fn build(mut builder: TypeBuilder) { + builder.with_name("DummyContext"); + } + } + + fn renderer() -> Result { + RhaiTemplateRenderer::build(RhaiTemplateRendererParams { + component_registry: Arc::new(ComponentRegistry::default()), + expression_engine: Engine::new_raw(), + }) + } + + fn literal_attribute(name: &str, value: &str) -> AttributeContent { + AttributeContent::Property(MdxJsxAttribute { + name: name.to_string(), + value: Some(AttributeValue::Literal(value.to_string())), + }) + } + + #[test] + fn renders_non_component_element_with_literal_and_boolean_attributes() -> Result<()> { + let rendered = eval_mdx_element( + &[ + literal_attribute("class", "highlight"), + AttributeContent::Property(MdxJsxAttribute { + name: "hidden".to_string(), + value: None, + }), + ], + &[], + &DummyContext, + String::new(), + &Some("div".to_string()), + &renderer()?, + )?; + + assert!(rendered.contains("
    ")); + + Ok(()) + } + + #[test] + fn errors_when_void_element_has_children() -> Result<()> { + assert!( + eval_mdx_element( + &[], + &[Node::Text(Text { + value: "child".to_string(), + position: None, + })], + &DummyContext, + "child".to_string(), + &Some("br".to_string()), + &renderer()?, + ) + .is_err() + ); + + Ok(()) + } + + #[test] + fn errors_on_attribute_expression() -> Result<()> { + assert!( + eval_mdx_element( + &[AttributeContent::Expression(MdxJsxExpressionAttribute { + value: "spread".to_string(), + stops: Vec::new(), + })], + &[], + &DummyContext, + String::new(), + &Some("div".to_string()), + &renderer()?, + ) + .is_err() + ); + + Ok(()) + } + + #[test] + fn errors_when_element_has_no_name() -> Result<()> { + assert!( + eval_mdx_element(&[], &[], &DummyContext, String::new(), &None, &renderer()?,).is_err() + ); + + Ok(()) + } + + #[test] + fn renders_attribute_with_evaluated_expression_value() -> Result<()> { + let rendered = eval_mdx_element( + &[AttributeContent::Property(MdxJsxAttribute { + name: "data-label".to_string(), + value: Some(AttributeValue::Expression(AttributeValueExpression { + value: "\"hi\"".to_string(), + stops: Vec::new(), + })), + })], + &[], + &DummyContext, + String::new(), + &Some("div".to_string()), + &renderer()?, + )?; + + assert!(rendered.contains("data-label=\"hi\"")); + + Ok(()) + } +} diff --git a/poet/src/eval_prompt_document_mdast.rs b/poet/src/eval_prompt_document_mdast.rs index 2b042ef..e1c053d 100644 --- a/poet/src/eval_prompt_document_mdast.rs +++ b/poet/src/eval_prompt_document_mdast.rs @@ -152,7 +152,7 @@ pub fn eval_prompt_document_mdast( let src = if is_external_link(url) { url } else { - &match prompt_document_component_context.asset_manager.file(url) { + &match prompt_document_component_context.asset_manager.image(url) { Ok(src) => src, Err(err) => return Err(anyhow!(err)), } @@ -353,7 +353,133 @@ pub fn eval_prompt_document_mdast( #[cfg(test)] mod test { + use std::collections::HashMap; + use std::str::FromStr as _; + use std::sync::Arc; + use std::sync::RwLock; + + use esbuild_metafile::EsbuildMetaFile; + use rhai::Engine; + use rhai_components::component_syntax::component_registry::ComponentRegistry; + use rhai_components::rhai_template_renderer::RhaiTemplateRenderer; + use rhai_components::rhai_template_renderer_params::RhaiTemplateRendererParams; + use super::*; + use crate::asset_manager::AssetManager; + use crate::asset_path_renderer::AssetPathRenderer; + use crate::content_document_front_matter::ContentDocumentFrontMatter; + use crate::content_document_linker::ContentDocumentLinker; + use crate::content_document_reference::ContentDocumentReference; + use crate::mcp::content_block::ContentBlock; + use crate::mcp::jsonrpc::role::Role; + use crate::mcp::prompt_message::PromptMessage; + use crate::prompt_document_front_matter::PromptDocumentFrontMatter; + use crate::string_to_mdast::string_to_mdast; + + const ASSET_METAFILE: &str = r#" + { + "outputs": { + "static/logo_ABCDEF12.png": { + "imports": [], + "inputs": { "logo.png": {} } + } + } + } + "#; + + fn asset_manager() -> Result { + Ok(AssetManager::from_esbuild_metafile( + Arc::new(EsbuildMetaFile::from_str(ASSET_METAFILE)?), + AssetPathRenderer { + base_path: "/".to_string(), + }, + )) + } + + fn linker() -> ContentDocumentLinker { + let mut content_document_by_basename = HashMap::new(); + + content_document_by_basename.insert( + "guide".to_string().into(), + ContentDocumentReference { + basename_path: "guide".into(), + front_matter: ContentDocumentFrontMatter::mock("guide"), + generated_page_base_path: "/".to_string(), + }, + ); + + ContentDocumentLinker { + content_document_basename_by_id: Arc::new(HashMap::new()), + content_document_by_basename: Arc::new(content_document_by_basename), + } + } + + fn front_matter() -> PromptDocumentFrontMatter { + PromptDocumentFrontMatter { + arguments: HashMap::new(), + description: "A test prompt".to_string(), + title: "Test Prompt".to_string(), + } + } + + fn renderer() -> Result { + RhaiTemplateRenderer::build(RhaiTemplateRendererParams { + component_registry: Arc::new(ComponentRegistry::default()), + expression_engine: Engine::new_raw(), + }) + } + + fn context() -> Result { + Ok(PromptDocumentComponentContext { + arguments: HashMap::new(), + asset_manager: asset_manager()?, + content_document_linker: linker(), + current_role: None, + front_matter: front_matter(), + prompt_messages: Vec::new(), + unprocessed_message_chunk: Arc::new(RwLock::new(String::new())), + }) + } + + fn assemble_messages(markdown: &str) -> Result> { + let rhai_template_renderer = renderer()?; + let mut prompt_document_component_context = context()?; + let mdast = string_to_mdast(markdown)?; + + eval_prompt_document_mdast( + EvalPromptDocumentMdastParams { + mdast: &mdast, + is_directly_in_root: false, + is_first_child: true, + is_in_top_paragraph: false, + rhai_template_renderer: &rhai_template_renderer, + }, + &mut prompt_document_component_context, + )?; + + Ok(prompt_document_component_context.prompt_messages) + } + + fn render_block(markdown: &str) -> Result { + let rhai_template_renderer = renderer()?; + let mut prompt_document_component_context = context()?; + let mdast = string_to_mdast(markdown)?; + let block = mdast + .children() + .and_then(|children| children.first()) + .ok_or_else(|| anyhow!("Document has no block node"))?; + + eval_prompt_document_mdast( + EvalPromptDocumentMdastParams { + mdast: block, + is_directly_in_root: false, + is_first_child: false, + is_in_top_paragraph: false, + rhai_template_renderer: &rhai_template_renderer, + }, + &mut prompt_document_component_context, + ) + } #[test] fn test_blockquotes() { @@ -384,4 +510,164 @@ mod test { Ok(()) } + + #[test] + fn assembles_prompt_messages_by_role() -> Result<()> { + let messages = + assemble_messages("**user**: What is the capital of France?\n\n**assistant**: Paris.")?; + + assert_eq!(messages.len(), 2); + assert_eq!(messages[0].role, Role::User); + assert_eq!( + messages[0].content, + ContentBlock::from("What is the capital of France?") + ); + assert_eq!(messages[1].role, Role::Assistant); + assert_eq!(messages[1].content, ContentBlock::from("Paris.")); + + Ok(()) + } + + #[test] + fn errors_on_unknown_role_marker() { + assert!(assemble_messages("**moderator**: hello").is_err()); + } + + #[test] + fn renders_heading_marker() -> Result<()> { + assert_eq!(render_block("### Section Title")?, "###Section Title"); + + Ok(()) + } + + #[test] + fn renders_inline_formatting() -> Result<()> { + let rendered = render_block("*em* **bold** ~~del~~ `code`")?; + + assert!(rendered.contains("*em*")); + assert!(rendered.contains("**bold**")); + assert!(rendered.contains("~~del~~")); + assert!(rendered.contains("`code`")); + + Ok(()) + } + + #[test] + fn renders_fenced_code_block() -> Result<()> { + assert_eq!( + render_block("```rust\nlet x = 1;\n```")?, + "```rust\nlet x = 1;\n```" + ); + + Ok(()) + } + + #[test] + fn renders_list_items() -> Result<()> { + let rendered = render_block("- first\n- second")?; + + assert!(rendered.contains("- ")); + assert!(rendered.contains("first")); + assert!(rendered.contains("second")); + + Ok(()) + } + + #[test] + fn renders_blockquote() -> Result<()> { + assert!(render_block("> quoted text")?.contains("> quoted text")); + + Ok(()) + } + + #[test] + fn resolves_internal_image_and_passes_external_through() -> Result<()> { + assert!(render_block("![logo](logo.png)")?.contains("/static/logo_ABCDEF12.png")); + assert!( + render_block("![pic](https://cdn.example.com/a.png)")? + .contains("https://cdn.example.com/a.png") + ); + + Ok(()) + } + + #[test] + fn resolves_internal_link_and_passes_external_through() -> Result<()> { + assert!(render_block("[guide](guide)")?.contains("(/guide/")); + assert!(render_block("[site](https://example.com)")?.contains("https://example.com")); + + Ok(()) + } + + #[test] + fn renders_thematic_break() -> Result<()> { + assert_eq!(render_block("***")?, "---"); + + Ok(()) + } + + #[test] + fn renders_table_cells() -> Result<()> { + let rendered = render_block("| a | b |\n| - | - |\n| 1 | 2 |")?; + + assert!(rendered.contains("| a")); + assert!(rendered.contains("| 1")); + + Ok(()) + } + + #[test] + fn passes_raw_html_through_verbatim() -> Result<()> { + assert!( + render_block("
    text
    ")? + .contains("
    text
    ") + ); + + Ok(()) + } + + #[test] + fn renders_hard_break() -> Result<()> { + assert!(render_block("first\\\nsecond")?.contains(" \n")); + + Ok(()) + } + + #[test] + fn renders_image_with_title() -> Result<()> { + assert!(render_block("![logo](logo.png \"Brand\")")?.contains("\"Brand\"")); + + Ok(()) + } + + #[test] + fn fails_image_for_missing_asset() { + assert!(render_block("![x](missing.png)").is_err()); + } + + #[test] + fn renders_link_with_title() -> Result<()> { + assert!(render_block("[site](https://example.com \"Home\")")?.contains("\"Home\"")); + + Ok(()) + } + + #[test] + fn fails_link_to_missing_document() { + assert!(render_block("[x](ghost)").is_err()); + } + + #[test] + fn renders_mdx_expression() -> Result<()> { + assert_eq!(render_block(r#"{"hi"}"#)?, "hi"); + + Ok(()) + } + + #[test] + fn renders_mdx_jsx_element() -> Result<()> { + assert!(render_block("
    \ntext\n
    ")?.contains("
    Result<()> { + let source = Arc::new(memory::Memory::default()); + + source + .set_file_contents(Path::new("content/a.md"), "alpha") + .await?; + + let destination = memory::Memory::default(); + + destination + .copy_file_from(source, Path::new("content/a.md")) + .await?; + + assert_eq!( + destination + .read_file_contents_string(Path::new("content/a.md")) + .await?, + "alpha" + ); + + Ok(()) + } + + #[tokio::test] + async fn copies_all_project_files_between_filesystems() -> Result<()> { + let source = Arc::new(memory::Memory::default()); + + source + .set_file_contents(Path::new("content/a.md"), "alpha") + .await?; + source + .set_file_contents(Path::new("content/b.md"), "beta") + .await?; + + let destination = memory::Memory::default(); + + destination.copy_project_files_from(source).await?; + + assert_eq!( + destination + .read_file_contents_string(Path::new("content/a.md")) + .await?, + "alpha" + ); + assert_eq!( + destination + .read_file_contents_string(Path::new("content/b.md")) + .await?, + "beta" + ); + + Ok(()) + } + + #[tokio::test] + async fn read_file_contents_string_fails_for_missing_file() { + let filesystem = memory::Memory::default(); + + assert!( + filesystem + .read_file_contents_string(Path::new("missing.md")) + .await + .is_err() + ); + } + + #[tokio::test] + async fn read_file_contents_string_fails_for_directory() -> Result<()> { + let base_directory = tempdir()?; + let filesystem = storage::Storage { + base_directory: base_directory.path().to_path_buf(), + }; + + filesystem + .set_file_contents(Path::new("content/a.md"), "alpha") + .await?; + + assert!( + filesystem + .read_file_contents_string(Path::new("content")) + .await + .is_err() + ); + + Ok(()) + } } diff --git a/poet/src/filesystem_http_route_index.rs b/poet/src/filesystem_http_route_index.rs index ce32eb8..16a4f59 100644 --- a/poet/src/filesystem_http_route_index.rs +++ b/poet/src/filesystem_http_route_index.rs @@ -52,3 +52,75 @@ impl FilesystemHttpRouteIndex { Ok(()) } } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::filesystem::file_entry_stub::FileEntryStub; + + fn file_entry(relative_path: &str) -> Result { + FileEntryStub { + contents: String::new(), + relative_path: PathBuf::from(relative_path), + } + .try_into() + } + + #[test] + fn registers_root_index_under_empty_and_named_routes() -> Result<()> { + let index = FilesystemHttpRouteIndex::default(); + + index.register_file(file_entry("index.html")?)?; + + assert_eq!( + index + .get_file_entry_for_path("") + .map(|entry| entry.relative_path), + Some(PathBuf::from("index.html")) + ); + assert!(index.get_file_entry_for_path("index.html").is_some()); + + Ok(()) + } + + #[test] + fn registers_nested_index_under_directory_and_full_path() -> Result<()> { + let index = FilesystemHttpRouteIndex::default(); + + index.register_file(file_entry("docs/index.html")?)?; + + assert!(index.get_file_entry_for_path("docs/index.html").is_some()); + assert!(index.get_file_entry_for_path("docs/").is_some()); + + Ok(()) + } + + #[test] + fn registers_sitemap_under_its_own_path() -> Result<()> { + let index = FilesystemHttpRouteIndex::default(); + + index.register_file(file_entry("sitemap.xml")?)?; + + assert!(index.get_file_entry_for_path("sitemap.xml").is_some()); + + Ok(()) + } + + #[test] + fn rejects_unexpected_filename() -> Result<()> { + let index = FilesystemHttpRouteIndex::default(); + + assert!(index.register_file(file_entry("page.html")?).is_err()); + + Ok(()) + } + + #[test] + fn returns_none_for_unregistered_path() { + let index = FilesystemHttpRouteIndex::default(); + + assert!(index.get_file_entry_for_path("missing").is_none()); + } +} diff --git a/poet/src/find_front_matter_in_mdast.rs b/poet/src/find_front_matter_in_mdast.rs index 1ca37c8..a6fa2b7 100644 --- a/poet/src/find_front_matter_in_mdast.rs +++ b/poet/src/find_front_matter_in_mdast.rs @@ -21,3 +21,40 @@ pub fn find_front_matter_in_mdast( _ => Ok(None), } } + +#[cfg(test)] +mod tests { + use anyhow::Result; + use serde::Deserialize; + + use super::*; + use crate::string_to_mdast::string_to_mdast; + + #[derive(Deserialize)] + struct TestFrontMatter { + title: String, + } + + #[test] + fn extracts_toml_front_matter_from_document() -> Result<()> { + let mdast = string_to_mdast("+++\ntitle = \"Hello\"\n+++\n\nBody text")?; + let front_matter: Option = find_front_matter_in_mdast(&mdast)?; + + assert_eq!( + front_matter.map(|front_matter| front_matter.title), + Some("Hello".to_string()) + ); + + Ok(()) + } + + #[test] + fn returns_none_when_document_has_no_front_matter() -> Result<()> { + let mdast = string_to_mdast("Just body text")?; + let front_matter: Option = find_front_matter_in_mdast(&mdast)?; + + assert!(front_matter.is_none()); + + Ok(()) + } +} diff --git a/poet/src/find_table_of_contents_in_mdast.rs b/poet/src/find_table_of_contents_in_mdast.rs index e3ad99e..7f41107 100644 --- a/poet/src/find_table_of_contents_in_mdast.rs +++ b/poet/src/find_table_of_contents_in_mdast.rs @@ -96,3 +96,47 @@ pub fn find_table_of_contents_in_mdast( Ok(TableOfContents { headings }) } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use rhai::Engine; + use rhai_components::component_syntax::component_registry::ComponentRegistry; + use rhai_components::rhai_template_renderer_params::RhaiTemplateRendererParams; + use syntect::parsing::SyntaxSet; + + use super::*; + use crate::string_to_mdast::string_to_mdast; + + #[test] + fn extracts_headings_with_depth_and_id() -> Result<()> { + let component_context = ContentDocumentComponentContext::mock(); + let rhai_template_renderer = RhaiTemplateRenderer::build(RhaiTemplateRendererParams { + component_registry: Arc::new(ComponentRegistry::default()), + expression_engine: Engine::new_raw(), + })?; + let syntax_set = SyntaxSet::new(); + let mdast = string_to_mdast("# First Heading\n\nbody text\n\n## Second Heading")?; + + let table_of_contents = find_table_of_contents_in_mdast( + &mdast, + &component_context, + &rhai_template_renderer, + &syntax_set, + )?; + + assert_eq!(table_of_contents.headings.len(), 2); + assert!( + table_of_contents.headings[0] + .content + .contains("First Heading") + ); + assert_eq!(table_of_contents.headings[0].depth, 1); + assert_eq!(table_of_contents.headings[0].id, "first-heading"); + assert_eq!(table_of_contents.headings[1].depth, 2); + assert_eq!(table_of_contents.headings[1].id, "second-heading"); + + Ok(()) + } +} diff --git a/poet/src/find_text_content_in_mdast.rs b/poet/src/find_text_content_in_mdast.rs index 2ea16e7..699953d 100644 --- a/poet/src/find_text_content_in_mdast.rs +++ b/poet/src/find_text_content_in_mdast.rs @@ -48,3 +48,51 @@ pub fn find_text_content_in_mdast(mdast: &Node) -> Result { Ok(result) } + +#[cfg(test)] +mod tests { + use anyhow::Result; + + use super::*; + use crate::string_to_mdast::string_to_mdast; + + #[test] + fn concatenates_text_across_nested_inline_nodes() -> Result<()> { + let mdast = string_to_mdast("Hello **bold** and *italic*")?; + + assert_eq!(find_text_content_in_mdast(&mdast)?, "Hello bold and italic"); + + Ok(()) + } + + #[test] + fn skips_non_text_leaf_nodes() -> Result<()> { + let mdast = string_to_mdast("text `code` more")?; + + assert_eq!(find_text_content_in_mdast(&mdast)?, "text more"); + + Ok(()) + } + + #[test] + fn collects_text_across_block_container_nodes() -> Result<()> { + let mdast = string_to_mdast( + "# Heading text\n\n> quote text\n\n- list text\n\n[link text](https://example.com)\n\n| cell text |\n| --- |\n| data text |", + )?; + + let text = find_text_content_in_mdast(&mdast)?; + + for fragment in [ + "Heading text", + "quote text", + "list text", + "link text", + "cell text", + "data text", + ] { + assert!(text.contains(fragment), "missing fragment: {fragment}"); + } + + Ok(()) + } +} diff --git a/poet/src/flexible_datetime.rs b/poet/src/flexible_datetime.rs index a1a8166..5a759b8 100644 --- a/poet/src/flexible_datetime.rs +++ b/poet/src/flexible_datetime.rs @@ -135,4 +135,69 @@ mod tests { let serialized = serde_json::to_string(&test_struct).unwrap(); assert_eq!(serialized, r#"{"timestamp":"2025-09-25T14:30:45+00:00"}"#); } + + #[test] + fn test_serialize_none_outputs_null() { + let serialized = serde_json::to_string(&TestStruct { timestamp: None }).unwrap(); + + assert_eq!(serialized, r#"{"timestamp":null}"#); + } + + #[test] + fn parses_pattern_with_time_component() -> anyhow::Result<()> { + let expected = + DateTime::parse_from_rfc3339("2025-09-25T14:30:45+00:00")?.with_timezone(&Utc); + + assert_eq!( + deserialize_string("2025-09-25 14:30:45".to_string())?, + expected + ); + + Ok(()) + } + + #[test] + fn parses_rfc3339_with_offset_as_utc() -> anyhow::Result<()> { + let expected = + DateTime::parse_from_rfc3339("2025-09-25T12:30:45+00:00")?.with_timezone(&Utc); + + assert_eq!( + deserialize_string("2025-09-25T14:30:45+02:00".to_string())?, + expected + ); + + Ok(()) + } + + #[test] + fn parses_rfc2822_as_utc() -> anyhow::Result<()> { + let expected = + DateTime::parse_from_rfc3339("2003-07-01T08:52:37+00:00")?.with_timezone(&Utc); + + assert_eq!( + deserialize_string("Tue, 01 Jul 2003 10:52:37 +0200".to_string())?, + expected + ); + + Ok(()) + } + + #[test] + fn errors_on_unparseable_input() { + assert!(deserialize_string("not a date".to_string()).is_err()); + } + + #[test] + fn deserializes_explicit_null_as_none() -> anyhow::Result<()> { + let deserialized: TestStruct = serde_json::from_str(r#"{"timestamp":null}"#)?; + + assert_eq!(deserialized.timestamp, None); + + Ok(()) + } + + #[test] + fn deserialization_fails_for_invalid_datetime_field() { + assert!(serde_json::from_str::(r#"{"timestamp":"not a date"}"#).is_err()); + } } diff --git a/poet/src/generate_sitemap.rs b/poet/src/generate_sitemap.rs index a898956..a1b979b 100644 --- a/poet/src/generate_sitemap.rs +++ b/poet/src/generate_sitemap.rs @@ -39,3 +39,39 @@ pub fn create_sitemap<'a>( Ok(String::from_utf8(buf)?) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::content_document_front_matter::ContentDocumentFrontMatter; + + fn reference(basename: &str) -> ContentDocumentReference { + ContentDocumentReference { + basename_path: basename.into(), + front_matter: ContentDocumentFrontMatter::mock(basename), + generated_page_base_path: "https://example.com/".to_string(), + } + } + + #[test] + fn index_document_receives_high_priority() -> Result<()> { + let references = [reference("index")]; + let sitemap = create_sitemap(references.iter())?; + + assert!(sitemap.contains("https://example.com/")); + assert!(sitemap.contains("0.8")); + + Ok(()) + } + + #[test] + fn non_index_document_receives_default_priority() -> Result<()> { + let references = [reference("guide")]; + let sitemap = create_sitemap(references.iter())?; + + assert!(sitemap.contains("https://example.com/guide/")); + assert!(sitemap.contains("0.5")); + + Ok(()) + } +} diff --git a/poet/src/is_image_path.rs b/poet/src/is_image_path.rs new file mode 100644 index 0000000..3774093 --- /dev/null +++ b/poet/src/is_image_path.rs @@ -0,0 +1,18 @@ +pub fn is_image_path(path: &str) -> bool { + mime_guess::from_path(path).first_or_octet_stream().type_() == mime::IMAGE +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_image_extension() { + assert!(is_image_path("favicon_MWST2DE3.svg")); + } + + #[test] + fn rejects_non_image_extension() { + assert!(!is_image_path("chunk-Q4UAENVW.js")); + } +} diff --git a/poet/src/is_valid_desktop_entry_string.rs b/poet/src/is_valid_desktop_entry_string.rs index 2066140..09551de 100644 --- a/poet/src/is_valid_desktop_entry_string.rs +++ b/poet/src/is_valid_desktop_entry_string.rs @@ -2,3 +2,23 @@ pub fn is_valid_desktop_entry_string(input: &str) -> bool { !input.is_empty() && !input.chars().any(|char| char.is_control()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_printable_string() { + assert!(is_valid_desktop_entry_string("My Site")); + } + + #[test] + fn rejects_empty_string() { + assert!(!is_valid_desktop_entry_string("")); + } + + #[test] + fn rejects_string_with_control_character() { + assert!(!is_valid_desktop_entry_string("line\nbreak")); + } +} diff --git a/poet/src/lib.rs b/poet/src/lib.rs index 013c331..8eb6d79 100644 --- a/poet/src/lib.rs +++ b/poet/src/lib.rs @@ -47,6 +47,7 @@ pub mod flexible_datetime; pub mod generate_sitemap; pub mod holder; pub mod is_external_link; +pub mod is_image_path; pub mod is_valid_desktop_entry_string; pub mod mcp; pub mod mcp_resource_provider_content_documents; diff --git a/poet/src/mcp/accepts_all.rs b/poet/src/mcp/accepts_all.rs index 3da4634..9d134bc 100644 --- a/poet/src/mcp/accepts_all.rs +++ b/poet/src/mcp/accepts_all.rs @@ -51,6 +51,9 @@ pub fn accepts_all(req: &HttpRequest, mimes: Vec) -> Conclusion { #[cfg(test)] mod tests { + use actix_web::http::header::ACCEPT; + use actix_web::http::header::HeaderValue; + use actix_web::test::TestRequest; use anyhow::Result; use super::*; @@ -72,4 +75,46 @@ mod tests { Ok(()) } + + #[test] + fn all_requested_mimes_are_acceptable() -> Result<()> { + let request = TestRequest::default() + .insert_header((ACCEPT, "text/*")) + .to_http_request(); + + assert!(matches!( + accepts_all(&request, vec!["text/event-stream".parse()?]), + Conclusion::AllAcceptable + )); + + Ok(()) + } + + #[test] + fn rejects_when_a_requested_mime_is_not_acceptable() -> Result<()> { + let request = TestRequest::default() + .insert_header((ACCEPT, "text/plain")) + .to_http_request(); + + assert!(matches!( + accepts_all(&request, vec!["application/json".parse()?]), + Conclusion::NotAllAcceptable + )); + + Ok(()) + } + + #[test] + fn reports_header_parse_error_for_invalid_accept_header() -> Result<()> { + let request = TestRequest::default() + .insert_header((ACCEPT, HeaderValue::from_bytes(&[0xff])?)) + .to_http_request(); + + assert!(matches!( + accepts_all(&request, vec!["text/event-stream".parse()?]), + Conclusion::ErrorParsingHeader(_) + )); + + Ok(()) + } } diff --git a/poet/src/mcp/jsonrpc/response/success/tool_call_result/mod.rs b/poet/src/mcp/jsonrpc/response/success/tool_call_result/mod.rs index 52dce57..59c2115 100644 --- a/poet/src/mcp/jsonrpc/response/success/tool_call_result/mod.rs +++ b/poet/src/mcp/jsonrpc/response/success/tool_call_result/mod.rs @@ -133,4 +133,79 @@ mod tests { assert_eq!(serialized, serialized_correct); } + + #[test] + fn try_into_value_serializes_success_structured_content() -> Result<()> { + let result: ToolCallResult = ToolCallResult::Success(Success { + content: vec![], + structured_content: 7, + }); + + match result.try_into_value()? { + ToolCallResult::Success(success) => { + assert_eq!(success.structured_content, Value::from(7)); + } + ToolCallResult::Failure(_) => unreachable!("expected a success variant"), + } + + Ok(()) + } + + #[test] + fn try_into_value_preserves_failure() -> Result<()> { + let result: ToolCallResult = ToolCallResult::Failure(Failure { content: vec![] }); + + assert!(matches!( + result.try_into_value()?, + ToolCallResult::Failure(_) + )); + + Ok(()) + } + + #[test] + fn deserializes_success_when_is_error_is_false() -> Result<()> { + let result: ToolCallResult<()> = + serde_json::from_str(r#"{"content":[],"isError":false,"structuredContent":null}"#)?; + + assert!(matches!(result, ToolCallResult::Success(_))); + + Ok(()) + } + + #[test] + fn deserialization_fails_without_is_error_field() { + assert!(serde_json::from_str::>(r#"{"content":[]}"#).is_err()); + } + + #[test] + fn deserialization_fails_when_is_error_is_not_a_bool() { + assert!( + serde_json::from_str::>(r#"{"content":[],"isError":"yes"}"#) + .is_err() + ); + } + + #[test] + fn serializes_success_with_is_error_false() -> Result<()> { + let result: ToolCallResult<()> = ToolCallResult::Success(Success { + content: vec![], + structured_content: (), + }); + + assert!(serde_json::to_string(&result)?.contains(r#""isError":false"#)); + + Ok(()) + } + + #[test] + fn from_error_message_builds_failure_carrying_text() -> Result<()> { + let result: ToolCallResult<()> = ToolCallErrorMessage("boom").into(); + let serialized = serde_json::to_string(&result)?; + + assert!(serialized.contains("boom")); + assert!(serialized.contains(r#""isError":true"#)); + + Ok(()) + } } diff --git a/poet/src/mcp/jsonrpc/role.rs b/poet/src/mcp/jsonrpc/role.rs index c4a9691..0f01e04 100644 --- a/poet/src/mcp/jsonrpc/role.rs +++ b/poet/src/mcp/jsonrpc/role.rs @@ -30,3 +30,30 @@ impl TryFrom for Role { value.as_str().try_into() } } + +#[cfg(test)] +mod tests { + use anyhow::Result; + + use super::*; + + #[test] + fn converts_known_role_strings() -> Result<()> { + assert_eq!(Role::try_from("assistant")?, Role::Assistant); + assert_eq!(Role::try_from("user")?, Role::User); + + Ok(()) + } + + #[test] + fn rejects_unknown_role_string() { + assert!(Role::try_from("system").is_err()); + } + + #[test] + fn converts_from_owned_string() -> Result<()> { + assert_eq!(Role::try_from("user".to_string())?, Role::User); + + Ok(()) + } +} diff --git a/poet/src/mcp/list_resources_cursor/deserialize.rs b/poet/src/mcp/list_resources_cursor/deserialize.rs index 5bdd3ee..243e190 100644 --- a/poet/src/mcp/list_resources_cursor/deserialize.rs +++ b/poet/src/mcp/list_resources_cursor/deserialize.rs @@ -27,3 +27,55 @@ where None => Ok(None), } } + +#[cfg(test)] +mod tests { + use serde::Deserialize; + + use super::*; + + #[derive(Deserialize)] + struct Wrapper { + #[serde(deserialize_with = "deserialize")] + cursor: Option, + } + + fn parse(json: &str) -> Result { + serde_json::from_str(json) + } + + #[test] + fn returns_none_for_null_token() -> Result<(), serde_json::Error> { + let wrapper = parse("{\"cursor\": null}")?; + + assert!(wrapper.cursor.is_none()); + + Ok(()) + } + + #[test] + fn decodes_offset_and_per_page_from_valid_token() -> Result<(), serde_json::Error> { + let token = general_purpose::STANDARD.encode("{\"offset\":5,\"per_page\":10}"); + let wrapper = parse(&format!("{{\"cursor\": \"{token}\"}}"))?; + + assert_eq!(wrapper.cursor.as_ref().map(|cursor| cursor.offset), Some(5)); + assert_eq!( + wrapper.cursor.as_ref().map(|cursor| cursor.per_page), + Some(10) + ); + + Ok(()) + } + + #[test] + fn fails_for_token_that_is_not_valid_base64() { + assert!(parse("{\"cursor\": \"@@@invalid@@@\"}").is_err()); + } + + #[test] + fn fails_for_token_whose_decoded_bytes_are_not_valid_cursor_json() { + let token = general_purpose::STANDARD.encode("not json"); + + assert!(parse(&format!("{{\"cursor\": \"{token}\"}}")).is_err()); + } +} diff --git a/poet/src/mcp/log_level.rs b/poet/src/mcp/log_level.rs index bdff172..3540dda 100644 --- a/poet/src/mcp/log_level.rs +++ b/poet/src/mcp/log_level.rs @@ -35,3 +35,44 @@ impl PartialOrd for LogLevel { Some(self.cmp(other)) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sorts_levels_from_least_to_most_severe() { + let mut levels = vec![ + LogLevel::Emergency, + LogLevel::Debug, + LogLevel::Warning, + LogLevel::Info, + LogLevel::Alert, + LogLevel::Notice, + LogLevel::Critical, + LogLevel::Error, + ]; + + levels.sort(); + + assert_eq!( + levels, + vec![ + LogLevel::Debug, + LogLevel::Info, + LogLevel::Notice, + LogLevel::Warning, + LogLevel::Error, + LogLevel::Critical, + LogLevel::Alert, + LogLevel::Emergency, + ] + ); + } + + #[test] + fn compares_levels_by_severity() { + assert!(LogLevel::Debug < LogLevel::Info); + assert!(LogLevel::Emergency > LogLevel::Warning); + } +} diff --git a/poet/src/mcp/resource_list_aggregate.rs b/poet/src/mcp/resource_list_aggregate.rs index e373d4c..65b39e7 100644 --- a/poet/src/mcp/resource_list_aggregate.rs +++ b/poet/src/mcp/resource_list_aggregate.rs @@ -197,7 +197,7 @@ mod tests { &self, _: ResourceReference, ) -> Result> { - unimplemented!() + Ok(None) } async fn resource_update_notifier( @@ -395,4 +395,76 @@ mod tests { Ok(()) } + + fn aggregate_with_classes(classes: &[&str]) -> ResourceListAggregate { + classes + .iter() + .map(|class| { + Arc::new(TestResourceProvider { + class: class.to_string(), + total: 1, + }) as Arc + }) + .collect::>() + .into() + } + + #[test] + fn total_sums_every_provider_total() { + let aggregate: ResourceListAggregate = vec![ + Arc::new(TestResourceProvider { + class: "1".to_string(), + total: 3, + }) as Arc, + Arc::new(TestResourceProvider { + class: "2".to_string(), + total: 2, + }) as Arc, + ] + .into(); + + assert_eq!(aggregate.total(), 5); + } + + #[tokio::test] + async fn templates_list_has_one_entry_per_provider() -> Result<()> { + let templates = aggregate_with_classes(&["1", "2"]) + .read_resources_templates_list() + .await?; + + assert_eq!(templates.len(), 2); + + Ok(()) + } + + #[tokio::test] + async fn read_resource_contents_routes_to_matching_provider() -> Result<()> { + let contents = aggregate_with_classes(&["1"]) + .read_resource_contents("foo://1/example") + .await?; + + assert!(contents.is_none()); + + Ok(()) + } + + #[tokio::test] + async fn read_resource_contents_errors_for_malformed_uri() { + assert!( + aggregate_with_classes(&["1"]) + .read_resource_contents("not a uri") + .await + .is_err() + ); + } + + #[tokio::test] + async fn read_resource_contents_errors_when_no_provider_handles_scheme() { + assert!( + aggregate_with_classes(&["1"]) + .read_resource_contents("bar://1/example") + .await + .is_err() + ); + } } diff --git a/poet/src/mcp/resource_reference.rs b/poet/src/mcp/resource_reference.rs index f741239..1e1c499 100644 --- a/poet/src/mcp/resource_reference.rs +++ b/poet/src/mcp/resource_reference.rs @@ -38,3 +38,44 @@ impl TryFrom for ResourceReference { }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_scheme_class_and_stripped_path() -> Result<()> { + let reference = ResourceReference::try_from("res://documents/guide/intro".parse::()?)?; + + assert_eq!(reference.scheme, "res"); + assert_eq!(reference.class, "documents"); + assert_eq!(reference.path, "guide/intro"); + assert_eq!(reference.uri_string, "res://documents/guide/intro"); + + Ok(()) + } + + #[test] + fn keeps_empty_path_when_no_path_segment() -> Result<()> { + let reference = ResourceReference::try_from("res://documents".parse::()?)?; + + assert_eq!(reference.class, "documents"); + assert_eq!(reference.path, ""); + + Ok(()) + } + + #[test] + fn fails_when_scheme_is_missing() -> Result<()> { + assert!(ResourceReference::try_from("//documents/guide".parse::()?).is_err()); + + Ok(()) + } + + #[test] + fn fails_when_authority_is_missing() -> Result<()> { + assert!(ResourceReference::try_from("/guide".parse::()?).is_err()); + + Ok(()) + } +} diff --git a/poet/src/mcp/session.rs b/poet/src/mcp/session.rs index f22f57d..12157a0 100644 --- a/poet/src/mcp/session.rs +++ b/poet/src/mcp/session.rs @@ -114,3 +114,114 @@ impl Session { } } } + +#[cfg(test)] +mod tests { + use tokio::sync::mpsc; + + use super::*; + + fn message(level: LogLevel) -> Message { + Message { + jsonrpc: JSONRPC_VERSION.to_string(), + params: MessageParams { + data: "payload".to_string(), + level, + }, + } + } + + #[tokio::test] + async fn log_drops_messages_below_session_level() -> Result<()> { + let (notification_tx, mut notification_rx) = mpsc::channel(4); + let session = Session::new(notification_tx, "session-1".to_string()); + + session.log(message(LogLevel::Debug)).await?; + + assert!(notification_rx.try_recv().is_err()); + + Ok(()) + } + + #[tokio::test] + async fn log_sends_messages_at_or_above_session_level() -> Result<()> { + let (notification_tx, mut notification_rx) = mpsc::channel(4); + let session = Session::new(notification_tx, "session-1".to_string()); + + session.log(message(LogLevel::Error)).await?; + + assert!(notification_rx.try_recv().is_ok()); + + Ok(()) + } + + #[tokio::test] + async fn with_log_level_raises_filtering_threshold() -> Result<()> { + let (notification_tx, mut notification_rx) = mpsc::channel(4); + let session = + Session::new(notification_tx, "session-1".to_string()).with_log_level(LogLevel::Error); + + session.log(message(LogLevel::Info)).await?; + + assert!(notification_rx.try_recv().is_err()); + + Ok(()) + } + + #[tokio::test] + async fn log_message_wraps_params_into_notification() -> Result<()> { + let (notification_tx, mut notification_rx) = mpsc::channel(4); + let session = Session::new(notification_tx, "session-1".to_string()); + + session + .log_message(MessageParams { + data: "details".to_string(), + level: LogLevel::Warning, + }) + .await?; + + let ServerToClientNotification::Message(received) = notification_rx.try_recv()? else { + panic!("expected a logging message notification"); + }; + + assert_eq!(received.params.data, "details"); + + Ok(()) + } + + #[actix_web::test] + async fn subscribe_registers_token_and_rejects_duplicates() -> Result<()> { + let (notification_tx, _notification_rx) = mpsc::channel(4); + let session = Session::new(notification_tx, "session-1".to_string()); + + session + .subscribe_to_resource("res://documents/guide") + .await?; + + assert!(session.subscribe_token("res://documents/guide")?.is_some()); + assert!( + session + .subscribe_to_resource("res://documents/guide") + .await + .is_err() + ); + + Ok(()) + } + + #[actix_web::test] + async fn terminate_cancels_subscription_tokens() -> Result<()> { + let (notification_tx, _notification_rx) = mpsc::channel(4); + let session = Session::new(notification_tx, "session-1".to_string()); + + let cancellation_token = session + .subscribe_to_resource("res://documents/guide") + .await?; + + session.terminate().await; + + assert!(cancellation_token.is_cancelled()); + + Ok(()) + } +} diff --git a/poet/src/mcp/session_manager.rs b/poet/src/mcp/session_manager.rs index a7ef64a..409d959 100644 --- a/poet/src/mcp/session_manager.rs +++ b/poet/src/mcp/session_manager.rs @@ -71,3 +71,112 @@ impl SessionManager { self.session_storage.terminate_session(session).await } } + +#[cfg(test)] +mod tests { + use actix_web::error::ErrorInternalServerError; + use actix_web::test::TestRequest; + + use super::*; + use crate::mcp::jsonrpc::JSONRPC_VERSION; + use crate::mcp::jsonrpc::notification::message::Message; + use crate::mcp::jsonrpc::notification::message::MessageParams; + use crate::mcp::log_level::LogLevel; + + fn notification() -> ServerToClientNotification { + ServerToClientNotification::Message(Message { + jsonrpc: JSONRPC_VERSION.to_string(), + params: MessageParams { + data: "broadcast".to_string(), + level: LogLevel::Info, + }, + }) + } + + #[actix_web::test] + async fn start_new_session_generates_unique_prefixed_ids() -> Result<()> { + let manager = SessionManager::default(); + + let first = manager.start_new_session().await?; + let second = manager.start_new_session().await?; + + assert!(first.session.id().starts_with("poet-")); + assert_ne!(first.session.id(), second.session.id()); + + Ok(()) + } + + #[actix_web::test] + async fn restore_session_returns_none_without_header() -> Result<()> { + let manager = SessionManager::default(); + let request = TestRequest::default().to_srv_request(); + + assert!(manager.restore_session(&request).await?.is_none()); + + Ok(()) + } + + #[actix_web::test] + async fn restore_session_returns_none_for_unknown_id() -> Result<()> { + let manager = SessionManager::default(); + let request = TestRequest::default() + .insert_header((MCP_HEADER_SESSION, "poet-unknown")) + .to_srv_request(); + + assert!(manager.restore_session(&request).await?.is_none()); + + Ok(()) + } + + #[actix_web::test] + async fn restore_session_returns_stored_session_for_known_id() -> Result<()> { + let manager = SessionManager::default(); + let started = manager.start_new_session().await?; + let session_id = started.session.id(); + let request = TestRequest::default() + .insert_header((MCP_HEADER_SESSION, session_id.as_str())) + .to_srv_request(); + + let Some(restored) = manager.restore_session(&request).await? else { + panic!("expected a stored session"); + }; + + assert_eq!(restored.id(), session_id); + + Ok(()) + } + + #[actix_web::test] + async fn broadcast_delivers_notification_to_every_session() -> Result<()> { + let manager = SessionManager::default(); + let mut first = manager.start_new_session().await?; + let mut second = manager.start_new_session().await?; + + manager + .broadcast(notification()) + .await + .map_err(ErrorInternalServerError)?; + + assert!(first.notification_rx.try_recv().is_ok()); + assert!(second.notification_rx.try_recv().is_ok()); + + Ok(()) + } + + #[actix_web::test] + async fn terminate_session_removes_it_from_storage() -> Result<()> { + let manager = SessionManager::default(); + let started = manager.start_new_session().await?; + let session_id = started.session.id(); + + manager.terminate_session(started.session.clone()).await?; + + let request = TestRequest::default() + .insert_header((MCP_HEADER_SESSION, session_id.as_str())) + .to_srv_request(); + + assert!(manager.restore_session(&request).await?.is_none()); + + Ok(()) + } +} diff --git a/poet/src/mcp/tool_registry.rs b/poet/src/mcp/tool_registry.rs index c732426..5fb00fe 100644 --- a/poet/src/mcp/tool_registry.rs +++ b/poet/src/mcp/tool_registry.rs @@ -68,3 +68,110 @@ impl ToolRegistry { self.register(tool_arc.clone(), tool_arc); } } + +#[cfg(test)] +mod tests { + use async_trait::async_trait; + use schemars::JsonSchema; + use serde::Deserialize; + use serde::Serialize; + use serde_json::json; + + use super::*; + use crate::mcp::content_block::ContentBlock; + use crate::mcp::jsonrpc::response::success::tool_call_result::ToolCallResult; + use crate::mcp::jsonrpc::response::success::tool_call_result::success::Success; + + #[derive(Deserialize, JsonSchema, Serialize)] + struct EchoInput { + message: String, + } + + #[derive(Deserialize, JsonSchema, Serialize)] + struct EchoOutput { + echoed: String, + } + + struct EchoTool { + tool_name: String, + } + + impl ToolProvider for EchoTool { + type Input = EchoInput; + type Output = EchoOutput; + + fn name(&self) -> String { + self.tool_name.clone() + } + } + + #[async_trait] + impl ToolResponder for EchoTool { + async fn respond(&self, input: EchoInput) -> Result> { + Ok(ToolCallResult::Success(Success { + content: vec![ContentBlock::from(input.message.clone())], + structured_content: EchoOutput { + echoed: input.message, + }, + })) + } + } + + fn registry_with(tool_names: &[&str]) -> ToolRegistry { + let mut registry = ToolRegistry::default(); + + for tool_name in tool_names { + registry.register_owned(EchoTool { + tool_name: tool_name.to_string(), + }); + } + + registry + } + + #[tokio::test] + async fn call_tool_routes_input_to_registered_handler() -> Result<()> { + let ToolRegistryCallResult::Success(ToolCallResult::Success(success)) = + registry_with(&["echo"]) + .call_tool("echo", json!({ "message": "hello" })) + .await? + else { + panic!("expected a successful tool call"); + }; + + assert_eq!(success.structured_content, json!({ "echoed": "hello" })); + + Ok(()) + } + + #[tokio::test] + async fn call_tool_reports_unknown_tool() -> Result<()> { + assert!(matches!( + registry_with(&["echo"]) + .call_tool("missing", json!({ "message": "hello" })) + .await?, + ToolRegistryCallResult::NotFound + )); + + Ok(()) + } + + #[test] + fn list_tool_definitions_paginates_by_offset_and_per_page() { + let registry = registry_with(&["echo", "ping"]); + + let first_page = registry.list_tool_definitions(ListResourcesCursor { + offset: 0, + per_page: 1, + }); + let second_page = registry.list_tool_definitions(ListResourcesCursor { + offset: 1, + per_page: 1, + }); + + assert_eq!(first_page.len(), 1); + assert_eq!(first_page[0].name, "echo"); + assert_eq!(second_page.len(), 1); + assert_eq!(second_page[0].name, "ping"); + } +} diff --git a/poet/src/mcp_resource_provider_content_documents.rs b/poet/src/mcp_resource_provider_content_documents.rs index bf3c03b..97b9985 100644 --- a/poet/src/mcp_resource_provider_content_documents.rs +++ b/poet/src/mcp_resource_provider_content_documents.rs @@ -133,3 +133,112 @@ impl ResourceProvider for McpResourceProviderContentDocuments { self.0.total.load(atomic::Ordering::Relaxed) } } + +#[cfg(test)] +mod tests { + use std::path::Path; + + use tempfile::tempdir; + + use super::*; + use crate::asset_path_renderer::AssetPathRenderer; + use crate::build_authors::build_authors; + use crate::build_project::build_project; + use crate::build_project::build_project_params::BuildProjectParams; + use crate::build_project::build_project_result::BuildProjectResult; + use crate::compile_shortcodes::compile_shortcodes; + use crate::filesystem::Filesystem as _; + use crate::filesystem::storage::Storage; + use crate::mcp::resource_provider_list_params::ResourceProviderListParams; + + async fn build_result() -> Result { + let directory = tempdir()?; + let source_filesystem = Arc::new(Storage { + base_directory: directory.path().to_path_buf(), + }); + + source_filesystem + .set_file_contents( + Path::new("shortcodes/Layout.rhai"), + "fn template(context, props, content) { component { {content} } }", + ) + .await?; + source_filesystem + .set_file_contents( + Path::new("content/guide.md"), + "+++\ndescription = \"Guide description\"\nlayout = \"Layout\"\ntitle = \"Guide\"\n+++\n\nbody\n", + ) + .await?; + + let rhai_template_renderer = compile_shortcodes(source_filesystem.clone()).await?; + let authors = build_authors(source_filesystem.clone()).await?; + + Ok(build_project(BuildProjectParams { + asset_path_renderer: AssetPathRenderer { + base_path: "/".to_string(), + }, + authors, + esbuild_metafile: Default::default(), + generated_page_base_path: "/".to_string(), + generate_sitemap: false, + is_watching: false, + rhai_template_renderer, + source_filesystem, + }) + .await? + .into()) + } + + fn reference(path: &str) -> ResourceReference { + ResourceReference { + class: "content".to_string(), + path: path.to_string(), + scheme: "poet".to_string(), + uri_string: format!("poet://content/{path}"), + } + } + + #[tokio::test] + async fn lists_content_documents_as_resources() -> Result<()> { + let provider = McpResourceProviderContentDocuments::default(); + + provider.0.set(Some(build_result().await?)).await; + + assert_eq!(provider.total(), 1); + + let resources = provider + .list_resources(ResourceProviderListParams { + limit: 10, + offset: 0, + }) + .await?; + + assert_eq!(resources.len(), 1); + assert_eq!(resources[0].name, "guide"); + assert_eq!(resources[0].title, "Guide"); + + Ok(()) + } + + #[tokio::test] + async fn reads_existing_document_and_misses_unknown_one() -> Result<()> { + let provider = McpResourceProviderContentDocuments::default(); + + provider.0.set(Some(build_result().await?)).await; + + assert!( + provider + .read_resource_contents(reference("guide")) + .await? + .is_some() + ); + assert!( + provider + .read_resource_contents(reference("missing")) + .await? + .is_none() + ); + + Ok(()) + } +} diff --git a/poet/src/mdast_children_to_heading_id.rs b/poet/src/mdast_children_to_heading_id.rs index 83c4041..29effe8 100644 --- a/poet/src/mdast_children_to_heading_id.rs +++ b/poet/src/mdast_children_to_heading_id.rs @@ -13,3 +13,33 @@ pub fn mdast_children_to_heading_id(children: &Vec) -> Result { Ok(slugify(inner_text)) } + +#[cfg(test)] +mod tests { + use anyhow::Result; + use markdown::mdast::Text; + + use super::*; + + fn text_nodes(values: &[&str]) -> Vec { + values + .iter() + .map(|value| { + Node::Text(Text { + value: value.to_string(), + position: None, + }) + }) + .collect() + } + + #[test] + fn concatenates_child_text_before_slugifying() -> Result<()> { + assert_eq!( + mdast_children_to_heading_id(&text_nodes(&["Hello ", "World"]))?, + "hello-world" + ); + + Ok(()) + } +} diff --git a/poet/src/mdast_to_tantivy_document.rs b/poet/src/mdast_to_tantivy_document.rs index 52c9a39..a942351 100644 --- a/poet/src/mdast_to_tantivy_document.rs +++ b/poet/src/mdast_to_tantivy_document.rs @@ -90,3 +90,55 @@ pub fn mdast_to_tantivy_document(fields: Arc, mdast: &Node) - document } + +#[cfg(test)] +mod tests { + use anyhow::Result; + use tantivy::schema::Field; + use tantivy::schema::Value as _; + + use super::*; + use crate::search_index_schema::SearchIndexSchema; + use crate::string_to_mdast::string_to_mdast; + + fn field_text(document: &TantivyDocument, field: Field) -> Option { + document + .get_first(field) + .and_then(|value| value.as_str()) + .map(|text| text.to_string()) + } + + #[test] + fn routes_heading_text_to_header_and_paragraph_text_to_paragraph() -> Result<()> { + let fields = Arc::new(SearchIndexSchema::default().fields); + let mdast = string_to_mdast("# Title\n\nBody paragraph")?; + let document = mdast_to_tantivy_document(fields.clone(), &mdast); + + assert_eq!( + field_text(&document, fields.header), + Some("Title".to_string()) + ); + assert_eq!( + field_text(&document, fields.paragraph), + Some("Body paragraph".to_string()) + ); + + Ok(()) + } + + #[test] + fn does_not_index_text_outside_heading_or_paragraph() { + let fields = Arc::new(SearchIndexSchema::default().fields); + let mdast = Node::Root(Root { + children: vec![Node::Text(Text { + value: "loose".to_string(), + position: None, + })], + position: None, + }); + let document = mdast_to_tantivy_document(fields.clone(), &mdast); + + assert_eq!(field_text(&document, fields.header), None); + assert_eq!(field_text(&document, fields.paragraph), None); + } +} diff --git a/poet/src/prompt_document_component_context.rs b/poet/src/prompt_document_component_context.rs index 20ecc1d..a9aa3d4 100644 --- a/poet/src/prompt_document_component_context.rs +++ b/poet/src/prompt_document_component_context.rs @@ -139,3 +139,143 @@ impl CustomType for PromptDocumentComponentContext { .with_fn("switch_role_to", Self::rhai_switch_role_to); } } + +#[cfg(test)] +mod tests { + use esbuild_metafile::EsbuildMetaFile; + + use super::*; + use crate::asset_path_renderer::AssetPathRenderer; + use crate::content_document_front_matter::ContentDocumentFrontMatter; + use crate::content_document_reference::ContentDocumentReference; + + fn linker() -> ContentDocumentLinker { + let mut content_document_by_basename = HashMap::new(); + + content_document_by_basename.insert( + "guide".to_string().into(), + ContentDocumentReference { + basename_path: "guide".into(), + front_matter: ContentDocumentFrontMatter::mock("guide"), + generated_page_base_path: "/".to_string(), + }, + ); + + ContentDocumentLinker { + content_document_basename_by_id: Arc::new(HashMap::new()), + content_document_by_basename: Arc::new(content_document_by_basename), + } + } + + fn context() -> PromptDocumentComponentContext { + PromptDocumentComponentContext { + arguments: HashMap::new(), + asset_manager: AssetManager::from_esbuild_metafile( + Arc::new(EsbuildMetaFile::default()), + AssetPathRenderer { + base_path: "/".to_string(), + }, + ), + content_document_linker: linker(), + current_role: None, + front_matter: PromptDocumentFrontMatter { + arguments: HashMap::new(), + description: "description".to_string(), + title: "title".to_string(), + }, + prompt_messages: Vec::new(), + unprocessed_message_chunk: Arc::new(RwLock::new(String::new())), + } + } + + #[test] + fn flush_fails_when_chunk_present_without_role() -> Result<()> { + let mut context = context(); + + context.append_to_message("orphan".to_string())?; + + assert!(context.flush().is_err()); + + Ok(()) + } + + #[test] + fn switch_role_flushes_previous_message() -> Result<()> { + let mut context = context(); + + context.switch_role_to(Role::User)?; + context.append_to_message("hello".to_string())?; + context.switch_role_to(Role::Assistant)?; + + assert_eq!(context.prompt_messages.len(), 1); + assert_eq!(context.prompt_messages[0].role, Role::User); + + Ok(()) + } + + #[test] + fn rhai_append_accumulates_chunk() -> Result<()> { + let mut context = context(); + + context.rhai_append_to_message("piece".to_string())?; + + assert!( + context + .unprocessed_message_chunk + .read() + .expect("Unprocessed message lock is poisoned") + .contains("piece") + ); + + Ok(()) + } + + #[test] + fn rhai_link_resolves_internal_path() -> Result<()> { + let mut context = context(); + + assert_eq!(context.rhai_link_to("guide")?, "/guide/"); + + Ok(()) + } + + #[test] + fn rhai_link_fails_for_unknown_path() { + let mut context = context(); + + assert!(context.rhai_link_to("ghost").is_err()); + } + + #[test] + fn rhai_switch_role_accepts_known_role() -> Result<()> { + let mut context = context(); + + context.rhai_switch_role_to("assistant".to_string())?; + + assert_eq!(context.current_role, Some(Role::Assistant)); + + Ok(()) + } + + #[test] + fn rhai_switch_role_rejects_unknown_role() { + let mut context = context(); + + assert!( + context + .rhai_switch_role_to("moderator".to_string()) + .is_err() + ); + } + + #[test] + fn rhai_switch_role_fails_when_orphan_chunk_cannot_flush() -> Result<()> { + let mut context = context(); + + context.append_to_message("orphan".to_string())?; + + assert!(context.rhai_switch_role_to("user".to_string()).is_err()); + + Ok(()) + } +} diff --git a/poet/src/prompt_document_controller.rs b/poet/src/prompt_document_controller.rs index 7c62532..ac08581 100644 --- a/poet/src/prompt_document_controller.rs +++ b/poet/src/prompt_document_controller.rs @@ -117,9 +117,7 @@ mod tests { use crate::mcp::prompt_message::PromptMessage; use crate::rhai_template_renderer_factory::RhaiTemplateRendererFactory; - #[tokio::test] - async fn test_convert_to_prompt_messages() -> Result<()> { - let name: String = "help-me-finish-task".to_string(); + fn build_controller() -> Result { let contents: String = indoc! {r#" +++ description = "test prompt description" @@ -146,21 +144,40 @@ mod tests { let rhai_template_renderer: RhaiTemplateRenderer = rhai_template_factory.try_into()?; - let prompt_controller = - build_prompt_document_controller(BuildPromptDocumentControllerParams { - asset_path_renderer: AssetPathRenderer { - base_path: "https://example.com".to_string(), - }, - content_document_linker: Default::default(), - esbuild_metafile: Default::default(), - file: FileEntryStub { - contents, - relative_path: PathBuf::from("prompts/help-me-finish-task.md"), - } - .try_into()?, - name: name.clone(), - rhai_template_renderer, - })?; + build_prompt_document_controller(BuildPromptDocumentControllerParams { + asset_path_renderer: AssetPathRenderer { + base_path: "https://example.com".to_string(), + }, + content_document_linker: Default::default(), + esbuild_metafile: Default::default(), + file: FileEntryStub { + contents, + relative_path: PathBuf::from("prompts/help-me-finish-task.md"), + } + .try_into()?, + name: "help-me-finish-task".to_string(), + rhai_template_renderer, + }) + } + + #[test] + fn get_mcp_prompt_exposes_metadata_and_arguments() -> Result<()> { + let prompt = build_controller()?.get_mcp_prompt(); + + assert_eq!(prompt.name, "help-me-finish-task"); + assert_eq!(prompt.description, "test prompt description"); + assert_eq!(prompt.title, "Help me with finishing the task"); + assert_eq!(prompt.arguments.len(), 1); + assert_eq!(prompt.arguments[0].name, "objective"); + assert!(prompt.arguments[0].required); + + Ok(()) + } + + #[tokio::test] + async fn test_convert_to_prompt_messages() -> Result<()> { + let name: String = "help-me-finish-task".to_string(); + let prompt_controller = build_controller()?; let response = prompt_controller .respond_to(PromptsGet { diff --git a/poet/src/prompt_document_front_matter/mod.rs b/poet/src/prompt_document_front_matter/mod.rs index 4fa21ad..c98e77c 100644 --- a/poet/src/prompt_document_front_matter/mod.rs +++ b/poet/src/prompt_document_front_matter/mod.rs @@ -72,3 +72,50 @@ impl CustomType for PromptDocumentFrontMatter { .with_get("title", Self::rhai_title); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn front_matter_with_argument() -> PromptDocumentFrontMatter { + let mut arguments = HashMap::new(); + + arguments.insert( + "topic".to_string(), + Argument { + description: "The topic".to_string(), + required: true, + title: "Topic".to_string(), + }, + ); + + PromptDocumentFrontMatter { + arguments, + description: "description".to_string(), + title: "title".to_string(), + } + } + + #[test] + fn maps_provided_input_onto_argument() -> Result<()> { + let mut inputs = HashMap::new(); + + inputs.insert("topic".to_string(), "rust".to_string()); + + let mapped = front_matter_with_argument().map_arguments(inputs)?; + + assert_eq!(mapped["topic"].input, "rust"); + assert_eq!(mapped["topic"].title, "Topic"); + + Ok(()) + } + + #[test] + fn fails_when_required_argument_input_is_missing() { + assert!( + front_matter_with_argument() + .map_arguments(HashMap::new()) + .is_err() + ); + } +} diff --git a/poet/src/read_esbuild_metafile_or_default.rs b/poet/src/read_esbuild_metafile_or_default.rs index d2a5ec6..fde6311 100644 --- a/poet/src/read_esbuild_metafile_or_default.rs +++ b/poet/src/read_esbuild_metafile_or_default.rs @@ -32,3 +32,86 @@ pub async fn read_esbuild_metafile_or_default( } .into()) } + +#[cfg(test)] +mod tests { + use indoc::indoc; + use tempfile::tempdir; + + use super::*; + use crate::asset_manager::AssetManager; + use crate::asset_path_renderer::AssetPathRenderer; + + const METAFILE: &str = indoc! {r#" + { + "outputs": { + "static/logo_ABCDEF12.png": { + "imports": [], + "inputs": { "logo.png": {} } + } + } + } + "#}; + + fn asset_manager(metafile: Arc) -> AssetManager { + AssetManager::from_esbuild_metafile( + metafile, + AssetPathRenderer { + base_path: "/".to_string(), + }, + ) + } + + #[tokio::test] + async fn reads_and_parses_an_existing_metafile() -> Result<()> { + let directory = tempdir()?; + let storage = Storage { + base_directory: directory.path().to_path_buf(), + }; + + storage + .set_file_contents(&PathBuf::from("esbuild-meta.json"), METAFILE) + .await?; + + let metafile = read_esbuild_metafile_or_default(Arc::new(storage)).await?; + + assert_eq!( + asset_manager(metafile).file("logo.png"), + Ok("/static/logo_ABCDEF12.png".to_string()) + ); + + Ok(()) + } + + #[tokio::test] + async fn falls_back_to_default_when_metafile_is_missing() -> Result<()> { + let directory = tempdir()?; + let storage = Storage { + base_directory: directory.path().to_path_buf(), + }; + + let metafile = read_esbuild_metafile_or_default(Arc::new(storage)).await?; + + assert!(asset_manager(metafile).file("logo.png").is_err()); + + Ok(()) + } + + #[tokio::test] + async fn errors_when_metafile_path_is_a_directory() -> Result<()> { + let directory = tempdir()?; + let storage = Storage { + base_directory: directory.path().to_path_buf(), + }; + + std::fs::create_dir(directory.path().join("esbuild-meta.json"))?; + + assert!( + read_esbuild_metafile_or_default(Arc::new(storage)) + .await + .is_err() + ); + + Ok(()) + } +} diff --git a/poet/src/search_index.rs b/poet/src/search_index.rs index 2557c04..a405e20 100644 --- a/poet/src/search_index.rs +++ b/poet/src/search_index.rs @@ -96,6 +96,10 @@ impl SearchIndex { #[cfg(test)] mod tests { + use std::path::Path; + + use tempfile::tempdir; + use super::*; use crate::asset_path_renderer::AssetPathRenderer; use crate::build_authors::build_authors; @@ -103,6 +107,7 @@ mod tests { use crate::build_project::build_project_params::BuildProjectParams; use crate::build_project::build_project_result_stub::BuildProjectResultStub; use crate::compile_shortcodes::compile_shortcodes; + use crate::filesystem::Filesystem as _; use crate::filesystem::storage::Storage; use crate::search_index_query_params::SearchIndexQueryParams; @@ -149,4 +154,61 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn indexes_documents_and_finds_them_by_body_keyword() -> Result<()> { + let directory = tempdir()?; + let source_filesystem = Arc::new(Storage { + base_directory: directory.path().to_path_buf(), + }); + + source_filesystem + .set_file_contents( + Path::new("shortcodes/Layout.rhai"), + "fn template(context, props, content) { component { {content} } }", + ) + .await?; + source_filesystem + .set_file_contents( + Path::new("content/guide.md"), + "+++\ndescription = \"Guide description\"\nlayout = \"Layout\"\ntitle = \"Searchable Guide\"\n+++\n\nUnique body keyword zebra.\n", + ) + .await?; + + let rhai_template_renderer = compile_shortcodes(source_filesystem.clone()).await?; + let authors = build_authors(source_filesystem.clone()).await?; + + let BuildProjectResultStub { + content_document_sources, + .. + } = build_project(BuildProjectParams { + asset_path_renderer: AssetPathRenderer { + base_path: "/".to_string(), + }, + authors, + esbuild_metafile: Default::default(), + generated_page_base_path: "/".to_string(), + generate_sitemap: false, + is_watching: false, + rhai_template_renderer, + source_filesystem, + }) + .await?; + + let search_index_reader = + SearchIndex::create_in_memory(content_document_sources).index()?; + + let results = search_index_reader.query(SearchIndexQueryParams { + cursor: Default::default(), + query: "zebra".to_string(), + })?; + + assert_eq!(results.len(), 1); + assert_eq!( + results[0].content_document_reference.front_matter.title, + "Searchable Guide" + ); + + Ok(()) + } } diff --git a/poet/src/search_tool.rs b/poet/src/search_tool.rs index 674c7ee..e040b93 100644 --- a/poet/src/search_tool.rs +++ b/poet/src/search_tool.rs @@ -95,3 +95,117 @@ impl ToolResponder for SearchTool { } } } + +#[cfg(test)] +mod tests { + use std::path::Path; + use std::sync::Arc; + + use tempfile::tempdir; + + use super::*; + use crate::asset_path_renderer::AssetPathRenderer; + use crate::build_authors::build_authors; + use crate::build_project::build_project; + use crate::build_project::build_project_params::BuildProjectParams; + use crate::build_project::build_project_result_stub::BuildProjectResultStub; + use crate::compile_shortcodes::compile_shortcodes; + use crate::filesystem::Filesystem as _; + use crate::filesystem::storage::Storage; + use crate::search_index::SearchIndex; + + fn empty_search_tool() -> SearchTool { + SearchTool { + mcp_resource_provider_content_documents: Default::default(), + search_index_reader_holder: Default::default(), + } + } + + async fn search_tool_with_index() -> Result { + let directory = tempdir()?; + let source_filesystem = Arc::new(Storage { + base_directory: directory.path().to_path_buf(), + }); + + source_filesystem + .set_file_contents( + Path::new("shortcodes/Layout.rhai"), + "fn template(context, props, content) { component { {content} } }", + ) + .await?; + source_filesystem + .set_file_contents( + Path::new("content/guide.md"), + "+++\ndescription = \"Guide\"\nlayout = \"Layout\"\ntitle = \"Guide\"\n+++\n\nkeyword zebra body\n", + ) + .await?; + + let rhai_template_renderer = compile_shortcodes(source_filesystem.clone()).await?; + let authors = build_authors(source_filesystem.clone()).await?; + + let BuildProjectResultStub { + content_document_sources, + .. + } = build_project(BuildProjectParams { + asset_path_renderer: AssetPathRenderer { + base_path: "/".to_string(), + }, + authors, + esbuild_metafile: Default::default(), + generated_page_base_path: "/".to_string(), + generate_sitemap: false, + is_watching: false, + rhai_template_renderer, + source_filesystem, + }) + .await?; + + let search_index_reader = + SearchIndex::create_in_memory(content_document_sources).index()?; + let search_index_reader_holder = SearchIndexReaderHolder::default(); + + search_index_reader_holder + .set(Some(Arc::new(search_index_reader))) + .await; + + Ok(SearchTool { + mcp_resource_provider_content_documents: Default::default(), + search_index_reader_holder, + }) + } + + #[test] + fn tool_name_is_search() { + assert_eq!(empty_search_tool().name(), "search"); + } + + #[tokio::test] + async fn responds_with_failure_when_index_not_ready() -> Result<()> { + let result = empty_search_tool() + .respond(SearchToolProviderInput { + query: "anything".to_string(), + }) + .await?; + + assert!(matches!(result, ToolCallResult::Failure(_))); + + Ok(()) + } + + #[tokio::test] + async fn responds_with_resource_links_for_matches() -> Result<()> { + let result = search_tool_with_index() + .await? + .respond(SearchToolProviderInput { + query: "zebra".to_string(), + }) + .await?; + + match result { + ToolCallResult::Success(success) => assert_eq!(success.content.len(), 1), + ToolCallResult::Failure(_) => unreachable!("expected a successful search result"), + } + + Ok(()) + } +} diff --git a/rhai_components/src/builds_engine.rs b/rhai_components/src/builds_engine.rs index 6995107..c989d5f 100644 --- a/rhai_components/src/builds_engine.rs +++ b/rhai_components/src/builds_engine.rs @@ -57,7 +57,10 @@ mod tests { use crate::component_syntax::component_reference::ComponentReference; fn fixtures_path() -> String { - format!("{}/src/component_syntax/fixtures", env!("CARGO_MANIFEST_DIR")) + format!( + "{}/src/component_syntax/fixtures", + env!("CARGO_MANIFEST_DIR") + ) } struct TestEngineOk { @@ -109,9 +112,11 @@ mod tests { registry: registry_with(&["Note"]), }; - assert!(builder.create_engine().is_ok_and(|engine| engine - .eval::(r#"clsx(#{ ok: true })"#) - .is_ok_and(|result| result == "ok"))); + assert!(builder.create_engine().is_ok_and(|engine| { + engine + .eval::(r#"clsx(#{ ok: true })"#) + .is_ok_and(|result| result == "ok") + })); Ok(()) } @@ -123,7 +128,9 @@ mod tests { }; assert!(builder.create_engine().is_err_and(|error| { - error.to_string().contains("prepare_engine failed on purpose") + error + .to_string() + .contains("prepare_engine failed on purpose") })); Ok(()) diff --git a/rhai_components/src/component_syntax/combine_output_symbols.rs b/rhai_components/src/component_syntax/combine_output_symbols.rs index 17badf6..749a7cc 100644 --- a/rhai_components/src/component_syntax/combine_output_symbols.rs +++ b/rhai_components/src/component_syntax/combine_output_symbols.rs @@ -22,7 +22,7 @@ fn merge_adjacent_symbols(state: &Dynamic) -> Result, Ok(array) => array, Err(err) => { return Err( - LexError::Runtime(format!("Invalid state array {err}")).into_err(Position::NONE), + LexError::Runtime(format!("Invalid state array {err}")).into_err(Position::NONE) ); } }; @@ -255,9 +255,9 @@ mod tests { use super::OutputCombinedSymbol; use super::OutputSemanticSymbol; use super::OutputSymbol; + use super::assemble_semantic_symbols; use super::combine_output_symbols; use super::merge_adjacent_symbols; - use super::assemble_semantic_symbols; fn make_state(symbols: Vec) -> Dynamic { Dynamic::from_array(symbols.into_iter().map(Dynamic::from).collect()) @@ -267,8 +267,10 @@ mod tests { fn errs_when_state_is_not_an_array() -> Result<()> { let state = Dynamic::from(42_i64); - assert!(combine_output_symbols(&state) - .is_err_and(|error| error.to_string().contains("Invalid state array"))); + assert!( + combine_output_symbols(&state) + .is_err_and(|error| error.to_string().contains("Invalid state array")) + ); Ok(()) } @@ -277,8 +279,10 @@ mod tests { fn errs_when_state_array_contains_non_output_symbol() -> Result<()> { let state = Dynamic::from_array(vec![Dynamic::from(42_i64)]); - assert!(combine_output_symbols(&state) - .is_err_and(|error| error.to_string().contains("Unable to cast"))); + assert!( + combine_output_symbols(&state) + .is_err_and(|error| error.to_string().contains("Unable to cast")) + ); Ok(()) } @@ -313,8 +317,10 @@ mod tests { fn errs_on_unexpected_tag_opening_after_unsupported_predecessor() -> Result<()> { let state = make_state(vec![OutputSymbol::TagLeftAnglePlusWhitespace]); - assert!(combine_output_symbols(&state) - .is_err_and(|error| error.to_string().contains("Unexpected tag opening"))); + assert!( + combine_output_symbols(&state) + .is_err_and(|error| error.to_string().contains("Unexpected tag opening")) + ); Ok(()) } @@ -325,8 +331,10 @@ mod tests { "".to_string(), )]); - assert!(combine_output_symbols(&state) - .is_err_and(|error| error.to_string().contains("Unexpected tag closing"))); + assert!( + combine_output_symbols(&state) + .is_err_and(|error| error.to_string().contains("Unexpected tag closing")) + ); Ok(()) } @@ -335,8 +343,10 @@ mod tests { fn errs_on_unexpected_tag_name_with_no_open_tag() -> Result<()> { let state = make_state(vec![OutputSymbol::TagName("d".to_string())]); - assert!(combine_output_symbols(&state) - .is_err_and(|error| error.to_string().contains("Unexpected tag name"))); + assert!( + combine_output_symbols(&state) + .is_err_and(|error| error.to_string().contains("Unexpected tag name")) + ); Ok(()) } @@ -345,22 +355,27 @@ mod tests { fn errs_on_unexpected_attribute_name_with_no_open_tag() -> Result<()> { let state = make_state(vec![OutputSymbol::TagAttributeName("c".to_string())]); - assert!(combine_output_symbols(&state) - .is_err_and(|error| error.to_string().contains("Unexpected tag attribute name"))); + assert!( + combine_output_symbols(&state) + .is_err_and(|error| error.to_string().contains("Unexpected tag attribute name")) + ); Ok(()) } #[test] - fn errs_when_attribute_value_emitted_before_attribute_name_in_assemble_semantic_symbols() -> Result<()> { + fn errs_when_attribute_value_emitted_before_attribute_name_in_assemble_semantic_symbols() + -> Result<()> { let combined = vec![ OutputCombinedSymbol::Text("x".to_string()), OutputCombinedSymbol::TagLeftAngle, OutputCombinedSymbol::TagAttributeValue(AttributeValue::Text("v".to_string())), ]; - assert!(assemble_semantic_symbols(combined) - .is_err_and(|error| error.to_string().contains("Attribute value without name"))); + assert!( + assemble_semantic_symbols(combined) + .is_err_and(|error| error.to_string().contains("Attribute value without name")) + ); Ok(()) } @@ -371,8 +386,10 @@ mod tests { AttributeValue::Text("v".to_string()), )]; - assert!(assemble_semantic_symbols(combined) - .is_err_and(|error| error.to_string().contains("Unexpected tag attribute value"))); + assert!( + assemble_semantic_symbols(combined) + .is_err_and(|error| error.to_string().contains("Unexpected tag attribute value")) + ); Ok(()) } @@ -381,8 +398,10 @@ mod tests { fn errs_on_unexpected_self_close_with_no_open_tag() -> Result<()> { let state = make_state(vec![OutputSymbol::TagSelfClose]); - assert!(combine_output_symbols(&state) - .is_err_and(|error| error.to_string().contains("Unexpected self-closing tag"))); + assert!( + combine_output_symbols(&state) + .is_err_and(|error| error.to_string().contains("Unexpected self-closing tag")) + ); Ok(()) } @@ -437,12 +456,14 @@ mod tests { OutputCombinedSymbol::Text("b".to_string()), ]; - assert!(assemble_semantic_symbols(combined).is_ok_and(|mut semantic| { - let first = semantic.pop_front(); + assert!( + assemble_semantic_symbols(combined).is_ok_and(|mut semantic| { + let first = semantic.pop_front(); - matches!(first, Some(OutputSemanticSymbol::Text(text)) if text == "ab") - && semantic.is_empty() - })); + matches!(first, Some(OutputSemanticSymbol::Text(text)) if text == "ab") + && semantic.is_empty() + }) + ); Ok(()) } diff --git a/rhai_components/src/component_syntax/combine_tag_stack.rs b/rhai_components/src/component_syntax/combine_tag_stack.rs index 4fccd4a..1fcfd42 100644 --- a/rhai_components/src/component_syntax/combine_tag_stack.rs +++ b/rhai_components/src/component_syntax/combine_tag_stack.rs @@ -136,11 +136,15 @@ mod tests { #[test] fn errs_when_root_node_is_body_expression() -> Result<()> { - let mut root = TagStackNode::BodyExpression(ExpressionReference { expression_index: 0 }); + let mut root = TagStackNode::BodyExpression(ExpressionReference { + expression_index: 0, + }); assert!( combine_tag_stack(&mut root, &mut VecDeque::new(), &mut VecDeque::new()).is_err_and( - |error| error.to_string().contains("Cannot add child to body expression node") + |error| error + .to_string() + .contains("Cannot add child to body expression node") ) ); @@ -152,9 +156,8 @@ mod tests { let mut root = TagStackNode::Text("hi".to_string()); assert!( - combine_tag_stack(&mut root, &mut VecDeque::new(), &mut VecDeque::new()).is_err_and( - |error| error.to_string().contains("Cannot add child to text node") - ) + combine_tag_stack(&mut root, &mut VecDeque::new(), &mut VecDeque::new()) + .is_err_and(|error| error.to_string().contains("Cannot add child to text node")) ); Ok(()) @@ -217,9 +220,7 @@ mod tests { symbols.push_back(OutputSemanticSymbol::Tag(make_tag("br", false, false))); - assert!( - combine_tag_stack(&mut root, &mut VecDeque::new(), &mut symbols).is_ok() - ); + assert!(combine_tag_stack(&mut root, &mut VecDeque::new(), &mut symbols).is_ok()); assert!(matches!( &root, TagStackNode::Tag { children, .. } @@ -241,9 +242,7 @@ mod tests { symbols.push_back(OutputSemanticSymbol::Text(String::new())); - assert!( - combine_tag_stack(&mut root, &mut VecDeque::new(), &mut symbols).is_ok() - ); + assert!(combine_tag_stack(&mut root, &mut VecDeque::new(), &mut symbols).is_ok()); assert!(matches!( &root, TagStackNode::Tag { children, .. } if children.is_empty() diff --git a/rhai_components/src/component_syntax/eval_tag_stack_node.rs b/rhai_components/src/component_syntax/eval_tag_stack_node.rs index 8faf31f..2f0e1c8 100644 --- a/rhai_components/src/component_syntax/eval_tag_stack_node.rs +++ b/rhai_components/src/component_syntax/eval_tag_stack_node.rs @@ -158,7 +158,10 @@ mod tests { use crate::component_syntax::component_reference::ComponentReference; fn fixtures_path() -> String { - format!("{}/src/component_syntax/fixtures", env!("CARGO_MANIFEST_DIR")) + format!( + "{}/src/component_syntax/fixtures", + env!("CARGO_MANIFEST_DIR") + ) } #[derive(Clone, Default)] @@ -236,100 +239,115 @@ mod tests { "#, ); - assert!(result - .is_err_and(|error| error.to_string().contains("Failed to call component function"))); + assert!(result.is_err_and(|error| { + error + .to_string() + .contains("Failed to call component function") + })); Ok(()) } #[test] fn renders_component_with_expression_attribute() -> Result<()> { - assert!(render_with( - &["Note"], - r#" + assert!( + render_with( + &["Note"], + r#" fn template(context) { component { hi } } "#, - ) - .is_ok_and(|rendered| rendered.contains("note--warn") && rendered.contains("hi"))); + ) + .is_ok_and(|rendered| rendered.contains("note--warn") && rendered.contains("hi")) + ); Ok(()) } #[test] fn renders_component_with_no_value_attribute() -> Result<()> { - assert!(render_with( - &["Bare"], - r#" + assert!( + render_with( + &["Bare"], + r#" fn template(context) { component { hi } } "#, - ) - .is_ok_and(|rendered| { - rendered.contains("data-disabled=\"yes\"") && rendered.contains("hi") - })); + ) + .is_ok_and(|rendered| { + rendered.contains("data-disabled=\"yes\"") && rendered.contains("hi") + }) + ); Ok(()) } #[test] fn renders_array_body_expression_by_concatenating_items() -> Result<()> { - assert!(render_with( - &[], - r#" + assert!( + render_with( + &[], + r#" fn template(context) { component {
    {["a", "b", "c"]}
    } } "#, - ) - .is_ok_and(|rendered| rendered.contains("
    abc
    "))); + ) + .is_ok_and(|rendered| rendered.contains("
    abc
    ")) + ); Ok(()) } #[test] fn returns_error_when_body_expression_evaluation_fails() -> Result<()> { - assert!(render_with( - &[], - r#" + assert!( + render_with( + &[], + r#" fn template(context) { component {
    {nonexistent_variable}
    } } "#, - ) - .is_err()); + ) + .is_err() + ); Ok(()) } #[test] fn returns_error_when_attribute_expression_evaluation_fails() -> Result<()> { - assert!(render_with( - &[], - r#" + assert!( + render_with( + &[], + r#" fn template(context) { component {
    hi
    } } "#, - ) - .is_err()); + ) + .is_err() + ); Ok(()) } #[test] fn returns_error_when_component_attribute_expression_evaluation_fails() -> Result<()> { - assert!(render_with( - &["Bare"], - r#" + assert!( + render_with( + &["Bare"], + r#" fn template(context) { component { hi } } "#, - ) - .is_err()); + ) + .is_err() + ); Ok(()) } diff --git a/rhai_components/src/component_syntax/evaluator_factory.rs b/rhai_components/src/component_syntax/evaluator_factory.rs index 1c75d6a..15c97b8 100644 --- a/rhai_components/src/component_syntax/evaluator_factory.rs +++ b/rhai_components/src/component_syntax/evaluator_factory.rs @@ -75,7 +75,9 @@ mod tests { assert!(cast_state_to_tag_stack_node(&state).is_err_and(|boxed| { discriminant(boxed.as_ref()) == reference - && boxed.to_string().contains("Expected TagStackNode in tag state") + && boxed + .to_string() + .contains("Expected TagStackNode in tag state") })); Ok(()) @@ -95,9 +97,11 @@ mod tests { factory.create_component_evaluator(), ); - assert!(engine - .eval::("bad_syntax") - .is_err_and(|error| error.to_string().contains("Expected TagStackNode"))); + assert!( + engine + .eval::("bad_syntax") + .is_err_and(|error| error.to_string().contains("Expected TagStackNode")) + ); Ok(()) } diff --git a/rhai_components/src/component_syntax/mod.rs b/rhai_components/src/component_syntax/mod.rs index d6e40c7..36355cf 100644 --- a/rhai_components/src/component_syntax/mod.rs +++ b/rhai_components/src/component_syntax/mod.rs @@ -77,9 +77,7 @@ mod tests { fn build_minimal_engine() -> Engine { let component_registry = Arc::new(ComponentRegistry::default()); - let evaluator_factory = EvaluatorFactory { - component_registry, - }; + let evaluator_factory = EvaluatorFactory { component_registry }; let mut engine = Engine::new(); engine.set_fail_on_invalid_map_property(true); @@ -229,9 +227,11 @@ mod tests { fn parses_mismatched_closing_tag_as_parse_error() -> Result<()> { let engine = build_minimal_engine(); - assert!(engine - .compile(r#"component {
    }"#) - .is_err_and(|error| error.to_string().contains("Mismatched closing tag"))); + assert!( + engine + .compile(r#"component {
    }"#) + .is_err_and(|error| error.to_string().contains("Mismatched closing tag")) + ); Ok(()) } @@ -240,9 +240,11 @@ mod tests { fn parses_self_close_after_attribute_name_renders_self_closing_tag() -> Result<()> { let engine = build_minimal_engine(); - assert!(engine - .eval::(r#"component { }"#) - .is_ok_and(|rendered| rendered.contains("(r#"component { }"#) + .is_ok_and(|rendered| rendered.contains("".into())) } _ => { - push_to_state(state, OutputSymbol::TagAttributeName(last_symbol.to_string())); + push_to_state( + state, + OutputSymbol::TagAttributeName(last_symbol.to_string()), + ); state.set_tag(ParserState::TagAttributeName as i32); Ok(Some("$raw$".into())) @@ -220,7 +223,10 @@ pub fn parse_component( Ok(Some("$raw$".into())) } _ => { - push_to_state(state, OutputSymbol::TagAttributeName(last_symbol.to_string())); + push_to_state( + state, + OutputSymbol::TagAttributeName(last_symbol.to_string()), + ); state.set_tag(ParserState::TagAttributeName as i32); Ok(Some("$raw$".into())) @@ -244,7 +250,10 @@ pub fn parse_component( Ok(Some("$inner$".into())) } _ => { - push_to_state(state, OutputSymbol::TagAttributeName(last_symbol.to_string())); + push_to_state( + state, + OutputSymbol::TagAttributeName(last_symbol.to_string()), + ); state.set_tag(ParserState::TagContent as i32); Ok(Some("$raw$".into())) @@ -307,8 +316,10 @@ mod tests { fn errs_when_symbols_slice_is_empty() -> Result<()> { let mut state: Dynamic = Dynamic::UNIT; - assert!(parse_component(&[], &mut state) - .is_err_and(|error| error.to_string().contains("No symbols found"))); + assert!( + parse_component(&[], &mut state) + .is_err_and(|error| error.to_string().contains("No symbols found")) + ); Ok(()) } @@ -320,8 +331,10 @@ mod tests { state.set_tag(99); - assert!(parse_component(&inputs, &mut state) - .is_err_and(|error| error.to_string().contains("Invalid parser state"))); + assert!( + parse_component(&inputs, &mut state) + .is_err_and(|error| error.to_string().contains("Invalid parser state")) + ); Ok(()) } @@ -333,8 +346,10 @@ mod tests { state.set_tag(ParserState::BodyExpression as i32); - assert!(parse_component(&inputs, &mut state) - .is_err_and(|error| error.to_string().contains("Invalid expression block end"))); + assert!( + parse_component(&inputs, &mut state) + .is_err_and(|error| error.to_string().contains("Invalid expression block end")) + ); Ok(()) } @@ -346,8 +361,10 @@ mod tests { state.set_tag(ParserState::TagContent as i32); - assert!(parse_component(&inputs, &mut state) - .is_err_and(|error| error.to_string().contains("Invalid expression block start"))); + assert!( + parse_component(&inputs, &mut state) + .is_err_and(|error| error.to_string().contains("Invalid expression block start")) + ); Ok(()) } @@ -359,8 +376,10 @@ mod tests { state.set_tag(ParserState::TagSelfClose as i32); - assert!(parse_component(&inputs, &mut state) - .is_err_and(|error| error.to_string().contains("Invalid self-closing tag end"))); + assert!( + parse_component(&inputs, &mut state) + .is_err_and(|error| error.to_string().contains("Invalid self-closing tag end")) + ); Ok(()) } @@ -372,8 +391,10 @@ mod tests { state.set_tag(ParserState::Body as i32); - assert!(parse_component(&inputs, &mut state) - .is_err_and(|error| error.to_string().contains("Invalid state array"))); + assert!( + parse_component(&inputs, &mut state) + .is_err_and(|error| error.to_string().contains("Invalid state array")) + ); Ok(()) } @@ -385,9 +406,13 @@ mod tests { state.set_tag(ParserState::TagCloseBeforeNamePlusWhitespace as i32); - assert!(parse_component(&inputs, &mut state) - .is_ok_and(|next| next.as_deref() == Some("$raw$"))); - assert_eq!(state.tag(), ParserState::TagCloseBeforeNamePlusWhitespace as i32); + assert!( + parse_component(&inputs, &mut state).is_ok_and(|next| next.as_deref() == Some("$raw$")) + ); + assert_eq!( + state.tag(), + ParserState::TagCloseBeforeNamePlusWhitespace as i32 + ); Ok(()) } @@ -399,8 +424,9 @@ mod tests { state.set_tag(ParserState::TagAttributeName as i32); - assert!(parse_component(&inputs, &mut state) - .is_ok_and(|next| next.as_deref() == Some(">"))); + assert!( + parse_component(&inputs, &mut state).is_ok_and(|next| next.as_deref() == Some(">")) + ); assert_eq!(state.tag(), ParserState::TagSelfClose as i32); Ok(()) @@ -413,8 +439,9 @@ mod tests { state.set_tag(ParserState::TagAttributeValue as i32); - assert!(parse_component(&inputs, &mut state) - .is_ok_and(|next| next.as_deref() == Some("$raw$"))); + assert!( + parse_component(&inputs, &mut state).is_ok_and(|next| next.as_deref() == Some("$raw$")) + ); assert_eq!(state.tag(), ParserState::TagContent as i32); Ok(()) @@ -440,8 +467,10 @@ mod tests { state.set_tag(ParserState::Body as i32); - assert!(parse_component(&inputs, &mut state) - .is_err_and(|error| error.to_string().contains("Unexpected tag name"))); + assert!( + parse_component(&inputs, &mut state) + .is_err_and(|error| error.to_string().contains("Unexpected tag name")) + ); Ok(()) } diff --git a/rhai_components/src/component_syntax/tag_name.rs b/rhai_components/src/component_syntax/tag_name.rs index 9829642..e9db63d 100644 --- a/rhai_components/src/component_syntax/tag_name.rs +++ b/rhai_components/src/component_syntax/tag_name.rs @@ -65,8 +65,8 @@ mod tests { #[test] fn is_void_element_recognises_all_void_names_and_rejects_normal_name() -> Result<()> { let void_names = [ - "!DOCTYPE", "area", "base", "br", "col", "embed", "hr", "img", "input", "link", - "meta", "param", "source", "track", "wbr", + "!DOCTYPE", "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", + "param", "source", "track", "wbr", ]; for void_name in void_names { @@ -74,7 +74,10 @@ mod tests { name: void_name.to_string(), }; - assert!(tag_name.is_void_element(), "expected {void_name} to be void"); + assert!( + tag_name.is_void_element(), + "expected {void_name} to be void" + ); } let non_void = TagName { diff --git a/rhai_components/src/escape_html.rs b/rhai_components/src/escape_html.rs index 977ed06..87c77c2 100644 --- a/rhai_components/src/escape_html.rs +++ b/rhai_components/src/escape_html.rs @@ -25,10 +25,7 @@ mod tests { #[test] fn escapes_each_special_character_and_preserves_other() -> Result<()> { - assert_eq!( - escape_html("&<>\"'/x"), - "&<>"'/x" - ); + assert_eq!(escape_html("&<>\"'/x"), "&<>"'/x"); Ok(()) } diff --git a/rhai_components/src/rhai_call_template_function.rs b/rhai_components/src/rhai_call_template_function.rs index 589d600..2708f1c 100644 --- a/rhai_components/src/rhai_call_template_function.rs +++ b/rhai_components/src/rhai_call_template_function.rs @@ -29,7 +29,10 @@ mod tests { use super::rhai_call_template_function; fn fixtures_path() -> String { - format!("{}/src/component_syntax/fixtures", env!("CARGO_MANIFEST_DIR")) + format!( + "{}/src/component_syntax/fixtures", + env!("CARGO_MANIFEST_DIR") + ) } fn engine_with_fixtures() -> Engine { diff --git a/rhai_components/src/rhai_helpers/clsx.rs b/rhai_components/src/rhai_helpers/clsx.rs index 07d878a..bfcbebb 100644 --- a/rhai_components/src/rhai_helpers/clsx.rs +++ b/rhai_components/src/rhai_helpers/clsx.rs @@ -60,7 +60,9 @@ mod tests { let map = make_map(&[("a", Dynamic::from(1_i64))]); assert!(clsx(map).is_err_and(|error| { - error.to_string().contains("Expected only boolean map values") + error + .to_string() + .contains("Expected only boolean map values") })); Ok(()) diff --git a/rhai_components/src/rhai_helpers/error.rs b/rhai_components/src/rhai_helpers/error.rs index 8ddb4f3..5532cec 100644 --- a/rhai_components/src/rhai_helpers/error.rs +++ b/rhai_components/src/rhai_helpers/error.rs @@ -14,9 +14,9 @@ mod tests { #[test] fn always_returns_runtime_error_with_stringified_message() -> Result<()> { - assert!(error(Dynamic::from("boom")).is_err_and(|boxed| { - boxed.to_string().contains("boom") - })); + assert!( + error(Dynamic::from("boom")).is_err_and(|boxed| { boxed.to_string().contains("boom") }) + ); Ok(()) } diff --git a/rhai_components/src/rhai_template_renderer.rs b/rhai_components/src/rhai_template_renderer.rs index 366e9f0..a95bfab 100644 --- a/rhai_components/src/rhai_template_renderer.rs +++ b/rhai_components/src/rhai_template_renderer.rs @@ -112,7 +112,10 @@ mod tests { use crate::component_syntax::component_registry::ComponentRegistry; fn fixtures_path() -> String { - format!("{}/src/component_syntax/fixtures", env!("CARGO_MANIFEST_DIR")) + format!( + "{}/src/component_syntax/fixtures", + env!("CARGO_MANIFEST_DIR") + ) } #[derive(Clone)] @@ -227,7 +230,9 @@ mod tests { renderer .render_expression(DummyContext, "not valid @ rhai!") .is_err_and(|error| { - error.to_string().contains("Expression failed: 'not valid @ rhai!'") + error + .to_string() + .contains("Expression failed: 'not valid @ rhai!'") }) })); diff --git a/rust-toolchain.toml b/rust-toolchain.toml index c0b9b8e..1c638ee 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.93.0" +channel = "1.95.0" components = ["clippy", "rust-analyzer", "rustfmt"] diff --git a/rustfmt.toml b/rustfmt.toml index 3af15b9..745fb75 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,7 +1,2 @@ -group_imports = "StdExternalCrate" -imports_granularity = "Item" -imports_layout = "Vertical" -reorder_impl_items = true reorder_imports = true reorder_modules = true -unstable_features = true