From 6bf4aa4bdb74a02ecaab5e675e3b7c2310d59639 Mon Sep 17 00:00:00 2001 From: Myrddin Dundragon Date: Sun, 7 Sep 2025 17:18:50 -0400 Subject: [PATCH] Adjusting to make the database better tested. --- tavern/Cargo.toml | 1 + tavern/examples/generate_database.rs | 188 +++++++++++++++------------ tavern/src/adventurer.rs | 8 +- tavern/src/database.rs | 37 ++++-- tavern/src/lib.rs | 2 +- tavern/src/tale.rs | 25 ++-- tavern/src/tavern.rs | 2 +- tavern/tests/database.rs | 139 ++++++++++++++++++++ tavern/tests/serde.rs | 75 ++++++----- 9 files changed, 330 insertions(+), 147 deletions(-) create mode 100644 tavern/tests/database.rs diff --git a/tavern/Cargo.toml b/tavern/Cargo.toml index cb9521f..e2418a9 100644 --- a/tavern/Cargo.toml +++ b/tavern/Cargo.toml @@ -26,3 +26,4 @@ tokio = { version = "1", features = ["full"] } default = [] database = ["sqlx"] publisher = ["database"] +tester = ["publisher"] diff --git a/tavern/examples/generate_database.rs b/tavern/examples/generate_database.rs index af19c7d..8a451e0 100644 --- a/tavern/examples/generate_database.rs +++ b/tavern/examples/generate_database.rs @@ -1,51 +1,66 @@ -use std::path::{Path, PathBuf}; +#![cfg(feature = "publisher")] use chrono::NaiveDate; -use tavern::{Adventurer, Database, Legend, Lore, Tale, Tavern}; + +use tavern::{Adventurer, Legend, Lore, Story, Tale, Tavern}; +use tavern::Database; -#[cfg(feature = "publisher")] +/// This will generate a tavern that we can create a Toml file from. fn generate_tavern() -> Tavern { let legend: Legend = Legend { - profile: - String::from("https://cybermages.tech/about/myrddin"), - image: - String::from("https://cybermages.tech/about/myrddin/pic"), - blurb: String::from("I love code!") }; + profile: String::from("https://cybermages.tech/about/myrddin"), + image: String::from("https://cybermages.tech/about/myrddin/pic"), + blurb: String::from("I love code!") + }; - let author: Adventurer = - Adventurer { name: String::from("Jason Smith"), - handle: String::from("myrddin"), - legend }; - - let lore: Lore = - Lore { title: String::from("Test post"), - slug: String::from("test_post"), - author: author.handle.clone(), - summary: String::from("The Moon is made of cheese!"), - tags: vec![String::from("Space"), - String::from("Cheese")], - publish_date: - NaiveDate::from_ymd_opt(2025, 12, 25).unwrap() - .and_hms_opt(13, - 10, 41) - .unwrap() }; - - let tale: Tale = Tale { lore, - story: PathBuf::from("posts/test_post.md") }; - - - // Create a dummy posts directory and file for this example to work - if !Path::new("posts").exists() + let author: Adventurer = Adventurer { - std::fs::create_dir("posts").unwrap(); - } - std::fs::write("posts/the-rustacean.md", - "# Hello, Rust!\n\nThis is a **test** post.").unwrap(); + name: String::from("Jason Smith"), + handle: String::from("myrddin"), + legend: legend + }; + let lore: Lore = Lore + { + title: String::from("Test post"), + slug: String::from("test_post"), + author: author.handle.clone(), + summary: String::from("The Moon is made of cheese!"), + tags: vec![String::from("Space"), String::from("Cheese")], + publish_date: + NaiveDate::from_ymd_opt(2025, 12, 25).unwrap() + .and_hms_opt(13, 10, 41) + .unwrap() + }; + + let tale: Tale = Tale + { + lore: lore, + story: Story::Html("#Test Post\nThis is a test post.\n\n##Subsection\nMini post.\n* Test 1\n* Test 2\n* Test 3".to_string()) + }; + + let lore2: Lore = Lore + { + title: String::from("Test Tale"), + slug: String::from("test_tale"), + author: author.handle.clone(), + summary: String::from("The Moon is made of rocks!"), + tags: vec![String::from("Space"), String::from("Rocks")], + publish_date: + NaiveDate::from_ymd_opt(2025, 12, 25).unwrap() + .and_hms_opt(13, 10, 41) + .unwrap() + }; + + let tale2: Tale = Tale + { + lore: lore2, + story: Story::Html("#Test Tale\nThis is a test tale.\n\n##Subsection\nMini tale.\n* Test 1\n* Test 2\n* Test 3".to_string()) + }; Tavern { title: String::from("Runes & Ramblings"), description: String::from("Join software engineer Jason Smith \ @@ -53,14 +68,52 @@ fn generate_tavern() -> Tavern Explore program design, tech \ stacks, and more on this blog from \ CybeMages, LLC."), - tales: vec![tale], + tales: vec![tale, tale2], authors: vec![author] } } -#[cfg(feature = "publisher")] +fn create_temp_file

(filename: P) -> std::path::PathBuf + where P: AsRef +{ + let mut path = std::env::temp_dir(); + path.push(filename); + + path +} + +fn cleanup_temp_file

(path: P) + where P: AsRef +{ + match path.as_ref().try_exists() + { + Ok(exists) => + { + if exists + { + let _ = std::fs::remove_file(path); + } + } + + Err(e) => + { + eprintln!("{}", e); + } + } +} + +fn write_to_file

(tavern: Tavern, config_file: P) -> Result<(), Box> + where P: AsRef +{ + let toml_string = toml::to_string_pretty(&tavern)?; + + std::fs::write(&config_file, &toml_string)?; + + Ok(()) +} + fn read_from_file

(config_file: P) -> Tavern - where P: AsRef + where P: AsRef { // Read the previously written TOML file let toml_data = @@ -71,65 +124,36 @@ fn read_from_file

(config_file: P) -> Tavern } -#[cfg(feature = "publisher")] async fn create_database() -> Result<(), Box> { + // First we need to generate a TOML file to work with. + let config_path = create_temp_file("tavern.toml"); + cleanup_temp_file(&config_path); + + write_to_file(generate_tavern(), &config_path)?; + // This part would be the entry point of your CI/CD script // It would load your data and then save it to the database // Create a Tavern object - let _tavern = read_from_file("Tavern.toml"); - // let tavern = generate_tavern(); + let tavern = read_from_file(&config_path); // Open the database and save the Tavern content - let _db = Database::open("/home/myrddin/cybermages/blog/tavern.db").await?; - //db.insert_tavern(&tavern.title, &tavern.description)?; - //println!("Saved site settings: Title='{}', Description='{}'", - //tavern.title, tavern.description); - - //for author in &tavern.authors - // { - // db.insert_adventurer(author)?; - // println!("Saved adventurer: {}", author.name); - // } - // - // for tale in &tavern.tales - // { - // db.insert_tale(tale)?; - // println!("Saved tale: {}", tale.title); - // } + let db_path = create_temp_file("tavern.db"); + cleanup_temp_file(&db_path); + + let db = Database::open(db_path).await?; + db.insert_tavern(&tavern).await?; Ok(()) } -#[cfg(feature = "publisher")] #[tokio::main] pub async fn main() { - match std::env::set_current_dir("/home/myrddin/cybermages/blog/") - { - Ok(_) => - { - println!("Successfully changed working directory."); - } - Err(e) => - { - eprintln!("Failed to change directory: {}", e); - } - } - match create_database().await { - Ok(_) => - {} - Err(e) => - { - eprintln!("Error: {}", e); - } + Ok(_) => {} + Err(e) => { eprintln!("Error: {}", e); } } } - -#[cfg(not(feature = "publisher"))] -pub fn main() -{ -} diff --git a/tavern/src/adventurer.rs b/tavern/src/adventurer.rs index 987c83d..2bc339f 100644 --- a/tavern/src/adventurer.rs +++ b/tavern/src/adventurer.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; /// -#[derive(Deserialize, Serialize)] +#[derive(Clone, PartialEq, Deserialize, Serialize)] pub struct Legend { /// A link to the adventurer's profile (e.g., personal website, GitHub, @@ -20,7 +20,7 @@ pub struct Legend /// /// An `Adventurer` contains identifying and descriptive information /// such as their name, handle, profile URL, image, and a short blurb. -#[derive(Deserialize, Serialize)] +#[derive(Clone, PartialEq, Deserialize, Serialize)] pub struct Adventurer { /// The full name of the adventurer. @@ -34,7 +34,3 @@ pub struct Adventurer #[serde(flatten)] pub legend: Legend } - - - -impl Adventurer {} diff --git a/tavern/src/database.rs b/tavern/src/database.rs index 4613b84..662e474 100644 --- a/tavern/src/database.rs +++ b/tavern/src/database.rs @@ -4,20 +4,20 @@ use std::str::FromStr; use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions}; use sqlx::{Error, Result}; -#[cfg(not(feature = "publisher"))] +#[cfg(any(not(feature = "publisher"), feature = "tester"))] use sqlx::Row; use crate::adventurer::Adventurer; -use crate::tale::Tale; +use crate::tale::{Story, Tale}; #[cfg(feature = "publisher")] use crate::converter::Converter; #[cfg(feature = "publisher")] use crate::tavern::Tavern; -#[cfg(not(feature = "publisher"))] +#[cfg(any(not(feature = "publisher"), feature = "tester"))] use crate::tale::Lore; -#[cfg(not(feature = "publisher"))] +#[cfg(any(not(feature = "publisher"), feature = "tester"))] use crate::adventurer::Legend; @@ -140,13 +140,24 @@ impl Database -> Result<()> { // Convert the tales content from Markdown to HTML. - let markdown_content = std::fs::read_to_string(&tale.story)?; - let html_content: String = Converter::markdown_to_html(&markdown_content); + let html_content: std::borrow::Cow<'_, str> = match &tale.story + { + Story::Html(story) => { std::borrow::Cow::Borrowed(story) } + + Story::File(path) => + { + let markdown = std::fs::read_to_string(path)?; + std::borrow::Cow::Owned(Converter::markdown_to_html(&markdown)) + } + }; // Start a transaction. let mut tx = self.pool.begin().await?; // Store the tale. + // Pull the HTML content out first so that the str will last long enough. + // This get's around the macro lifetime issue. + let html_str = html_content.as_ref(); sqlx::query!( "INSERT OR REPLACE INTO tales ( slug, title, author, summary, publish_date, content @@ -156,7 +167,7 @@ impl Database tale.lore.author, tale.lore.summary, tale.lore.publish_date, - html_content + html_str ).execute(&mut *tx) // Pass mutable reference to the transaction .await?; @@ -263,7 +274,7 @@ impl Database Ok(()) } - #[cfg(not(feature = "publisher"))] + #[cfg(any(not(feature = "publisher"), feature = "tester"))] pub async fn get_tales_summary(&self, categories: &[String]) -> Result> { @@ -312,7 +323,7 @@ impl Database .unwrap_or_default(); let date_str: String = row.try_get("publish_date")?; - let publish_date = chrono::NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%dT%H:%M:%S") + let publish_date = chrono::NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%d %H:%M:%S") .map_err(|e| sqlx::Error::Decode(e.into()))?; tales.push(Lore { title: row.try_get("title")?, @@ -328,7 +339,7 @@ impl Database Ok(tales) } - #[cfg(not(feature = "publisher"))] + #[cfg(any(not(feature = "publisher"), feature = "tester"))] pub async fn get_tale_by_slug(&self, slug: &str) -> Result> { let mut tx = self.pool.begin().await?; @@ -356,7 +367,7 @@ impl Database .unwrap_or_default(); let date_str: String = row.try_get("publish_date")?; - let publish_date = chrono::NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%dT%H:%M:%S") + let publish_date = chrono::NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%d %H:%M:%S") .map_err(|e| Error::Decode(e.into()))?; let lore = Lore { title: row.try_get("title")?, @@ -367,7 +378,7 @@ impl Database tags }; Ok(Some(Tale { lore, - story: row.try_get("content")? })) + story: Story::Html(row.try_get("content")?) })) } else { @@ -375,7 +386,7 @@ impl Database } } - #[cfg(not(feature = "publisher"))] + #[cfg(any(not(feature = "publisher"), feature = "tester"))] pub async fn get_adventurer(&self, handle: &str) -> Result> { diff --git a/tavern/src/lib.rs b/tavern/src/lib.rs index fc9d6a2..0a2413c 100644 --- a/tavern/src/lib.rs +++ b/tavern/src/lib.rs @@ -17,5 +17,5 @@ pub use crate::adventurer::{Adventurer, Legend}; #[cfg(feature = "database")] pub use crate::database::Database; pub use crate::info::{get_name, get_version}; -pub use crate::tale::{Lore, Tale}; +pub use crate::tale::{Story, Lore, Tale}; pub use crate::tavern::Tavern; diff --git a/tavern/src/tale.rs b/tavern/src/tale.rs index 758eae1..6071f5d 100644 --- a/tavern/src/tale.rs +++ b/tavern/src/tale.rs @@ -1,17 +1,15 @@ use chrono::NaiveDateTime; + use serde::{Deserialize, Serialize}; -/// A type alias representing the path to a Markdown file. -/// This type is used to point to the location of the content of a `Tale`. -#[cfg(feature = "publisher")] -pub type Markdown = std::path::PathBuf; - - -/// A type alias representing the HTML content of the tale. -#[cfg(not(feature = "publisher"))] -pub type Markdown = String; +#[derive(Clone, PartialEq, Deserialize, Serialize)] +pub enum Story +{ + File(std::path::PathBuf), + Html(String) +} @@ -19,7 +17,7 @@ pub type Markdown = String; /// /// This includes details such as the title, author, summary, and /// associated tags. -#[derive(Deserialize, Serialize)] +#[derive(Clone, PartialEq, Deserialize, Serialize)] pub struct Lore { /// The title of the tale. @@ -44,13 +42,14 @@ pub struct Lore /// Represents a post or story in the application. -#[derive(Deserialize, Serialize)] +#[derive(Clone, PartialEq, Deserialize, Serialize)] pub struct Tale { /// Metadata of the post. #[serde(flatten)] pub lore: Lore, - /// The file path to the Markdown content of the tale. - pub story: Markdown + /// The story content for this tale.. + #[serde(flatten)] + pub story: Story } diff --git a/tavern/src/tavern.rs b/tavern/src/tavern.rs index 71f0853..90bb9ba 100644 --- a/tavern/src/tavern.rs +++ b/tavern/src/tavern.rs @@ -10,7 +10,7 @@ use crate::tale::Tale; /// A `Tavern` contains a list of all tales (posts) and their respective /// authors. It serves as the central structure for organizing and serializing /// the site's content. -#[derive(Deserialize, Serialize)] +#[derive(Clone, PartialEq, Deserialize, Serialize)] pub struct Tavern { /// diff --git a/tavern/tests/database.rs b/tavern/tests/database.rs new file mode 100644 index 0000000..acd03bd --- /dev/null +++ b/tavern/tests/database.rs @@ -0,0 +1,139 @@ +#![cfg(feature = "tester")] + +use chrono::NaiveDate; +use tavern::{Adventurer, Database, Legend, Lore, Story, Tale, Tavern}; + + + +fn generate_tavern() -> Tavern +{ + let legend: Legend = Legend + { + profile: String::from("https://cybermages.tech/about/myrddin"), + image: String::from("https://cybermages.tech/about/myrddin/pic"), + blurb: String::from("I love code!") + }; + + let author: Adventurer = Adventurer + { + name: String::from("Jason Smith"), + handle: String::from("myrddin"), + legend: legend + }; + + let lore: Lore = Lore + { + title: String::from("Test post"), + slug: String::from("test_post"), + author: author.handle.clone(), + summary: String::from("The Moon is made of cheese!"), + tags: vec![String::from("Space"), String::from("Cheese")], + publish_date: + NaiveDate::from_ymd_opt(2025, 12, 25).unwrap() + .and_hms_opt(13, 10, 41) + .unwrap() + }; + + let tale: Tale = Tale + { + lore: lore, + story: Story::Html("#Test Post\nThis is a test post.\n\n##Subsection\nMini post.\n* Test 1\n* Test 2\n* Test 3".to_string()) + }; + + let lore2: Lore = Lore + { + title: String::from("Test Tale"), + slug: String::from("test_tale"), + author: author.handle.clone(), + summary: String::from("The Moon is made of rocks!"), + tags: vec![String::from("Space"), String::from("Rocks")], + publish_date: + NaiveDate::from_ymd_opt(2025, 12, 25).unwrap() + .and_hms_opt(13, 10, 41) + .unwrap() + }; + + let tale2: Tale = Tale + { + lore: lore2, + story: Story::Html("#Test Tale\nThis is a test tale.\n\n##Subsection\nMini tale.\n* Test 1\n* Test 2\n* Test 3".to_string()) + }; + + Tavern { title: String::from("Runes & Ramblings"), + description: String::from("Join software engineer Jason Smith \ + on his Rust programming journey. \ + Explore program design, tech \ + stacks, and more on this blog from \ + CybeMages, LLC."), + tales: vec![tale, tale2], + authors: vec![author] } +} + + +fn create_temp_file(filename: S) -> std::path::PathBuf + where S: AsRef +{ + let mut path = std::env::temp_dir(); + path.push(filename); + + path +} + + +fn cleanup_temp_file(path: &std::path::PathBuf) +{ + if path.exists() + { + let _ = std::fs::remove_file(path); + } +} + + +#[tokio::test] +async fn tavern_tale() -> Result<(), Box> +{ + let tavern = generate_tavern(); + + // Save the TOML to a temporary file. + let path = create_temp_file("tavern_test_out.db"); + cleanup_temp_file(&path); + + let db = Database::open(&path).await?; + db.insert_tavern(&tavern).await?; + + // Write a Tale to the Tavern. + let lore: Lore = Lore + { + title: String::from("About Tavern"), + slug: String::from("about_tavern"), + author: String::from("myrddin"), + summary: String::from("Myrddin is awesome and wrote a blog system!"), + tags: vec![String::from("Blog"), String::from("Silly")], + publish_date: + NaiveDate::from_ymd_opt(2025, 12, 25).unwrap() + .and_hms_opt(13, 10, 41) + .unwrap() + }; + + let tale: Tale = Tale + { + lore: lore, + story: Story::Html("#Tavern Blog System\nThis is a great blogging system.\n\n##Subsection\nWritten by Myrddin himself!.\n* Test 1\n* Test 2\n* Test 3".to_string()) + }; + db.insert_tale(&tale).await?; + + // Read the Tale from the Tavern. + let tale = db.get_tale_by_slug(&tale.lore.slug).await?; + match tale + { + None => { panic!("Do something to say this test failed.") } + Some(tale) => + { + assert_eq!("Myrddin is awesome and wrote a blog system!", tale.lore.summary); + } + } + + cleanup_temp_file(&path); + + Ok(()) +} diff --git a/tavern/tests/serde.rs b/tavern/tests/serde.rs index 8002ccb..b5f20ab 100644 --- a/tavern/tests/serde.rs +++ b/tavern/tests/serde.rs @@ -1,37 +1,44 @@ #![cfg(feature = "publisher")] use chrono::NaiveDate; -use tavern::{Adventurer, FrontMatter, Tale, Tavern}; +use tavern::{Adventurer, Legend, Lore, Story, Tale, Tavern}; fn generate_tavern() -> Tavern { - let author: Adventurer = - Adventurer { name: String::from("Jason Smith"), - handle: String::from("myrddin"), - profile: - String::from("https://cybermages.tech/about/myrddin"), - image: - String::from("https://cybermages.tech/about/myrddin/pic"), - blurb: String::from("I love code!") }; - - - let fm: FrontMatter = FrontMatter { - title: String::from("Test post"), - slug: String::from("test_post"), - author: author.handle.clone(), - summary: String::from("The Moon is made of cheese!"), - tags: vec![String::from("Space"), String::from("Cheese")], - publish_date: - NaiveDate::from_ymd_opt(2025, 12, 25).unwrap() - .and_hms_opt(13, 10, 41) - .unwrap() + let legend: Legend = Legend + { + profile: String::from("https://cybermages.tech/about/myrddin"), + image: String::from("https://cybermages.tech/about/myrddin/pic"), + blurb: String::from("I love code!") }; - let tale: Tale = Tale { - front_matter: fm, - content: std::path::PathBuf::from("posts/test_post.md") }; + let author: Adventurer = Adventurer + { + name: String::from("Jason Smith"), + handle: String::from("myrddin"), + legend: legend + }; + + let lore: Lore = Lore + { + title: String::from("Test post"), + slug: String::from("test_post"), + author: author.handle.clone(), + summary: String::from("The Moon is made of cheese!"), + tags: vec![String::from("Space"), String::from("Cheese")], + publish_date: + NaiveDate::from_ymd_opt(2025, 12, 25).unwrap() + .and_hms_opt(13, 10, 41) + .unwrap() + }; + + let tale: Tale = Tale + { + lore: lore, + story: Story::File(std::path::PathBuf::from("posts/test_post.md")) + }; Tavern { title: String::from("Runes & Ramblings"), description: String::from("Join software engineer Jason Smith \ @@ -43,6 +50,14 @@ fn generate_tavern() -> Tavern authors: vec![author] } } +fn create_temp_file(filename: S) -> std::path::PathBuf + where S: AsRef +{ + let mut path = std::env::temp_dir(); + path.push(filename); + + path +} fn cleanup_temp_file(path: &std::path::PathBuf) { @@ -63,8 +78,7 @@ fn to_file() succeed"); // Save the TOML to a temporary file. - let mut path = std::env::temp_dir(); - path.push("tavern_test_out.toml"); + let path = create_temp_file("tavern_test_to.toml"); std::fs::write(&path, &toml_string).expect("Failed to write TOML to file"); cleanup_temp_file(&path); @@ -80,8 +94,7 @@ fn from_file() succeed"); // Save the TOML to a temporary file. - let mut path = std::env::temp_dir(); - path.push("tavern_test_in.toml"); + let path = create_temp_file("tavern_test_from.toml"); std::fs::write(&path, &toml_string).expect("Failed to write TOML to file"); // Read the previously written TOML file @@ -94,9 +107,9 @@ fn from_file() // Assert some known values to make this a real test let tale = &tavern.tales[0]; - assert_eq!(tale.front_matter.title, "Test post"); - assert_eq!(tale.front_matter.slug, "test_post"); - assert_eq!(tale.front_matter.author, "myrddin"); + assert_eq!(tale.lore.title, "Test post"); + assert_eq!(tale.lore.slug, "test_post"); + assert_eq!(tale.lore.author, "myrddin"); cleanup_temp_file(&path); }