diff --git a/Cargo.lock b/Cargo.lock index 4fb510a..7c9aaf8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -805,6 +805,7 @@ dependencies = [ "liquid-core", "pagefind", "pulldown-cmark", + "quick-xml 0.41.0", "rayon", "refinery", "reqwest", @@ -5638,6 +5639,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" diff --git a/Cargo.toml b/Cargo.toml index b8636d4..02b3bd3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ open = "5" pulldown-cmark = "0.13" liquid = "0.26" liquid-core = { version = "0.26", features = ["derive"] } +quick-xml = "0.41" rayon = "1.10" pagefind = "1.5.2" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } diff --git a/RUST_EXECUTION_BACKLOG.md b/RUST_EXECUTION_BACKLOG.md index 7d68173..5e211e3 100644 --- a/RUST_EXECUTION_BACKLOG.md +++ b/RUST_EXECUTION_BACKLOG.md @@ -227,7 +227,7 @@ Rules: ### `bds-ui/views` -- preview controls +- internal Markdown/Preview switch backed by Wry, plus external Open in Browser command - generation progress display - render errors and diagnostics - AI operation triggers in post editor (analysis, taxonomy), translation editor (translate), and media editor (alt text, translate) @@ -467,4 +467,4 @@ Code-vs-spec gaps from the 2026-07-18 spec sync. Each task names its authoritati ## Exit Rule -Do not start extension buckets until Milestones M5 and M6 are complete and the Rust app is already a credible replacement for the baseline authoring and publishing workflow. \ No newline at end of file +Do not start extension buckets until Milestones M5 and M6 are complete and the Rust app is already a credible replacement for the baseline authoring and publishing workflow. diff --git a/RUST_PLAN.md b/RUST_PLAN.md index 04a0853..365344f 100644 --- a/RUST_PLAN.md +++ b/RUST_PLAN.md @@ -25,7 +25,7 @@ This plan is split into multiple documents: ## Non-Negotiable Constraints 1. **Content compatibility is exact.** Existing SQLite data, markdown/frontmatter, translation files, media sidecars, templates, menu documents, generated HTML structure, feeds, and sitemaps must remain readable and writable by the Rust app. -2. **No JavaScript anywhere.** No npm, no webview, no JS runtime, no Electron. This is a supply-chain security constraint. The entire app is pure Rust plus native platform APIs. Pagefind is integrated as a Rust library dependency (`pagefind` crate), not as an external binary. +2. **No JavaScript application runtime.** No npm, Node.js, Electron, or remotely loaded application assets. The required in-editor and style preview panels use Wry to display the localhost-only Rust preview server in the operating system webview; the separate Open in Browser command opens the same preview in the system browser. Pagefind is integrated as a Rust library dependency (`pagefind` crate), not as an external binary. 3. **Script compatibility is intentionally broken.** Python/Pyodide is removed. Rust bDS is a green-field app and uses Lua for user-authored scripting. Existing Python scripts are not carried forward as compatible runtime artifacts. Built-in macros (gallery, youtube, vimeo, photo_archive, tag_cloud) are re-implemented as native Rust, not routed through Lua. 4. **Core release ships as a native desktop app.** Primary target is macOS, but the UI stack (Iced + muda + rfd) is cross-platform from day one. Native menus, key handling, open-file/deep-link handling, and platform command routing are part of core scope, not follow-up polish. 5. **Template editing is core scope.** Template rendering without template management UI is not sufficient. @@ -42,12 +42,14 @@ The application uses the following UI and platform integration stack: | Text editing | **ropey** + **syntect** + **cosmic-text** | Custom syntax-highlighting editor widget for markdown, Liquid templates, and Lua scripts | | Native menus | **muda** | Cross-platform native menu bar (NSMenu on macOS, Win32 menus on Windows, GTK/dbus on Linux) | | File dialogs | **rfd** | Cross-platform native file/folder dialogs (NSOpenPanel/NSSavePanel on macOS, equivalents elsewhere) | +| Internal preview | **wry** | Embedded post and style preview panels backed only by the localhost Rust preview server | | Platform lifecycle | **objc2** (macOS only, cfg-gated) | Thin shim for `application:openFile:`, `application:openURLs:`, and other `NSApplicationDelegate` hooks | ### Why this stack - **Iced** is a published crate with versioned releases, proper documentation, and a stable API. It uses wgpu for GPU-accelerated rendering and follows the Elm architecture (Message → update → view). - **muda** and **rfd** render through real platform APIs (NSMenu, NSOpenPanel, etc.) with zero fidelity loss versus hand-rolled platform code, while providing cross-platform support from day one. +- **wry** preserves the baseline app's internal Markdown/Preview workflow using the operating system webview. It is a presentation surface for the loopback preview server, not the application runtime; external-browser preview remains available separately. - **ropey + syntect + cosmic-text** gives full control over the editor experience: rope-based efficient text storage, Sublime Text syntax grammars (markdown, Liquid, Lua), and proper font shaping and layout via the same engine used by cosmic-DE. - The only platform-specific code is a small (~50 line) lifecycle shim for macOS app delegate hooks, conditionally compiled via `cfg(target_os = "macos")`. Linux and Windows equivalents are bounded and isolated to the same module. @@ -72,15 +74,15 @@ bds-rust/ - **bds-core**: all engines, models, persistence, rendering, publishing — zero UI dependencies. - **bds-editor**: reusable Iced custom widget for syntax-highlighting text editing. Depends on ropey, syntect, cosmic-text, and iced. Does not depend on bds-core. Can be extracted as a standalone crate. -- **bds-ui**: application shell, Iced views and components, message routing, platform integration (muda for menus, rfd for dialogs, objc2 shim for macOS lifecycle). Depends on bds-core and bds-editor. +- **bds-ui**: application shell, Iced views and components, embedded localhost preview (wry), message routing, platform integration (muda for menus, rfd for dialogs, objc2 shim for macOS lifecycle). Depends on bds-core and bds-editor. - **bds-cli**: headless automation surface. Depends on bds-core only. ## Distribution Characteristics -- **Single static binary.** No external runtime dependencies (no GTK, no Electron, no Node.js). Lua and SQLite are compiled into the binary. +- **Single application binary.** No Electron or Node.js runtime. Lua and SQLite are compiled into the binary; internal preview uses the operating system webview supplied by the target platform. - **Binary size:** ~15–25 MB. -- **Memory usage / startup:** no BEAM VM, no browser engine — a single native process. -- **Zero install dependencies:** users download one binary. No Homebrew, no system packages. +- **Memory usage / startup:** no BEAM VM or bundled browser engine — one native application process plus the operating system webview used while preview is visible. +- **Platform prerequisites:** macOS uses the system WebKit runtime. Windows/Linux packaging must account for Wry's platform webview prerequisites without adding an application-managed JavaScript runtime. ## Split Rationale @@ -98,7 +100,7 @@ The Rust rewrite is not considered successful until the core release can do all 1. Open a real project created by the baseline app. 2. Create and edit posts, translations, media, tags, templates, and settings. -3. Preview drafts and published content locally. +3. Preview drafts and published content both in the post editor's internal preview panel and in the external system browser. 4. Generate a complete site whose output matches the current app modulo approved normalization differences. 5. Publish the generated output to a remote target. 6. Rebuild the database from files and run metadata diff without losing information. diff --git a/RUST_PLAN_CORE.md b/RUST_PLAN_CORE.md index 7908340..df04a8c 100644 --- a/RUST_PLAN_CORE.md +++ b/RUST_PLAN_CORE.md @@ -757,6 +757,8 @@ By Wave 3, bds-editor must support the full feature set documented in the editor - preview server binds to `127.0.0.1:4123` (localhost only, fixed port) - preview and generated HTML use local assets only - preview must support drafts and language-prefixed routes +- each post editor can switch between Markdown and an internal Wry preview panel backed by the draft preview route +- Open in Browser starts or reuses the same preview server and opens its URL in the external system browser; it does not replace the internal panel - rendered language is controlled by project settings, not by UI locale ### One-shot AI operations @@ -792,12 +794,13 @@ Wave 4 introduces the one-shot AI client in `bds-core`. This is a minimal `reqwe - fixture-based generation comparisons against current app output - preview route tests for posts, drafts, assets, media, and language routes +- UI tests for switching a post between Markdown and internal preview, plus the external Open in Browser command - template compatibility tests using current `.liquid` templates ### Done when - the same project can be generated by both apps with matching output -- preview is accurate enough to be trusted as an authoring tool +- internal and external preview are accurate enough to be trusted as authoring tools - milestone M4 acceptance review passes ## Wave 5: Publishing And Operational Integrity @@ -920,4 +923,4 @@ Cross-platform packaging is not core scope, but the architecture does not accumu ## Supporting Docs - [RUST_EXECUTION_BACKLOG.md](RUST_EXECUTION_BACKLOG.md) -- [RUST_COMPATIBILITY_MATRIX_TEMPLATE.md](RUST_COMPATIBILITY_MATRIX_TEMPLATE.md) \ No newline at end of file +- [RUST_COMPATIBILITY_MATRIX_TEMPLATE.md](RUST_COMPATIBILITY_MATRIX_TEMPLATE.md) diff --git a/crates/bds-core/Cargo.toml b/crates/bds-core/Cargo.toml index e26a6fe..2601882 100644 --- a/crates/bds-core/Cargo.toml +++ b/crates/bds-core/Cargo.toml @@ -22,6 +22,7 @@ sys-locale = { workspace = true } pulldown-cmark = { workspace = true } liquid = { workspace = true } liquid-core = { workspace = true } +quick-xml = { workspace = true } rayon = { workspace = true } pagefind = { workspace = true } reqwest = { workspace = true } diff --git a/crates/bds-core/migrations/V3__align_ai_schema.sql b/crates/bds-core/migrations/V3__align_ai_schema.sql new file mode 100644 index 0000000..9c20d48 --- /dev/null +++ b/crates/bds-core/migrations/V3__align_ai_schema.sql @@ -0,0 +1,5 @@ +ALTER TABLE chat_messages ADD COLUMN cache_read_tokens INTEGER; +ALTER TABLE chat_messages ADD COLUMN cache_write_tokens INTEGER; + +ALTER TABLE ai_providers RENAME COLUMN npm TO package_ref; +ALTER TABLE ai_models RENAME COLUMN provider_npm TO provider_package_ref; diff --git a/crates/bds-core/src/db/connection.rs b/crates/bds-core/src/db/connection.rs index 9965753..5a11da0 100644 --- a/crates/bds-core/src/db/connection.rs +++ b/crates/bds-core/src/db/connection.rs @@ -1,6 +1,6 @@ +use crate::db::migrations; use rusqlite::Connection; use std::path::Path; -use crate::db::migrations; /// Database wrapper managing a SQLite connection. pub struct Database { @@ -11,7 +11,9 @@ impl Database { /// Open an existing bDS project database. pub fn open(path: &Path) -> Result { let conn = Connection::open(path)?; - conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;")?; + conn.execute_batch( + "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;", + )?; Ok(Self { conn }) } diff --git a/crates/bds-core/src/db/from_row.rs b/crates/bds-core/src/db/from_row.rs index 0cbe01f..e3cec6b 100644 --- a/crates/bds-core/src/db/from_row.rs +++ b/crates/bds-core/src/db/from_row.rs @@ -162,11 +162,9 @@ pub const POST_TRANSLATION_COLUMNS: &str = "\ id, project_id, translation_for, language, title, excerpt, content, \ status, file_path, checksum, created_at, updated_at, published_at"; -pub const POST_LINK_COLUMNS: &str = - "id, source_post_id, target_post_id, link_text, created_at"; +pub const POST_LINK_COLUMNS: &str = "id, source_post_id, target_post_id, link_text, created_at"; -pub const POST_MEDIA_COLUMNS: &str = - "id, project_id, post_id, media_id, sort_order, created_at"; +pub const POST_MEDIA_COLUMNS: &str = "id, project_id, post_id, media_id, sort_order, created_at"; pub const MEDIA_COLUMNS: &str = "\ id, project_id, filename, original_name, mime_type, size, \ @@ -189,8 +187,7 @@ pub const SCRIPT_COLUMNS: &str = "\ pub const SETTING_COLUMNS: &str = "key, value, updated_at"; -pub const GENERATED_FILE_HASH_COLUMNS: &str = - "project_id, relative_path, content_hash, updated_at"; +pub const GENERATED_FILE_HASH_COLUMNS: &str = "project_id, relative_path, content_hash, updated_at"; pub const DB_NOTIFICATION_COLUMNS: &str = "id, entity_type, entity_id, action, from_cli, seen_at, created_at"; @@ -418,7 +415,10 @@ mod tests { #[test] fn parse_post_status_valid() { assert_eq!(parse_post_status("draft").unwrap(), PostStatus::Draft); - assert_eq!(parse_post_status("published").unwrap(), PostStatus::Published); + assert_eq!( + parse_post_status("published").unwrap(), + PostStatus::Published + ); assert_eq!(parse_post_status("archived").unwrap(), PostStatus::Archived); } @@ -431,8 +431,14 @@ mod tests { fn parse_template_kind_valid() { assert_eq!(parse_template_kind("post").unwrap(), TemplateKind::Post); assert_eq!(parse_template_kind("list").unwrap(), TemplateKind::List); - assert_eq!(parse_template_kind("not_found").unwrap(), TemplateKind::NotFound); - assert_eq!(parse_template_kind("partial").unwrap(), TemplateKind::Partial); + assert_eq!( + parse_template_kind("not_found").unwrap(), + TemplateKind::NotFound + ); + assert_eq!( + parse_template_kind("partial").unwrap(), + TemplateKind::Partial + ); } #[test] @@ -442,36 +448,69 @@ mod tests { #[test] fn parse_template_status_valid() { - assert_eq!(parse_template_status("draft").unwrap(), TemplateStatus::Draft); - assert_eq!(parse_template_status("published").unwrap(), TemplateStatus::Published); + assert_eq!( + parse_template_status("draft").unwrap(), + TemplateStatus::Draft + ); + assert_eq!( + parse_template_status("published").unwrap(), + TemplateStatus::Published + ); } #[test] fn parse_script_kind_valid() { assert_eq!(parse_script_kind("macro").unwrap(), ScriptKind::Macro); assert_eq!(parse_script_kind("utility").unwrap(), ScriptKind::Utility); - assert_eq!(parse_script_kind("transform").unwrap(), ScriptKind::Transform); + assert_eq!( + parse_script_kind("transform").unwrap(), + ScriptKind::Transform + ); } #[test] fn parse_script_status_valid() { assert_eq!(parse_script_status("draft").unwrap(), ScriptStatus::Draft); - assert_eq!(parse_script_status("published").unwrap(), ScriptStatus::Published); + assert_eq!( + parse_script_status("published").unwrap(), + ScriptStatus::Published + ); } #[test] fn parse_notification_entity_valid() { - assert_eq!(parse_notification_entity("post").unwrap(), NotificationEntity::Post); - assert_eq!(parse_notification_entity("media").unwrap(), NotificationEntity::Media); - assert_eq!(parse_notification_entity("script").unwrap(), NotificationEntity::Script); - assert_eq!(parse_notification_entity("template").unwrap(), NotificationEntity::Template); + assert_eq!( + parse_notification_entity("post").unwrap(), + NotificationEntity::Post + ); + assert_eq!( + parse_notification_entity("media").unwrap(), + NotificationEntity::Media + ); + assert_eq!( + parse_notification_entity("script").unwrap(), + NotificationEntity::Script + ); + assert_eq!( + parse_notification_entity("template").unwrap(), + NotificationEntity::Template + ); } #[test] fn parse_notification_action_valid() { - assert_eq!(parse_notification_action("created").unwrap(), NotificationAction::Created); - assert_eq!(parse_notification_action("updated").unwrap(), NotificationAction::Updated); - assert_eq!(parse_notification_action("deleted").unwrap(), NotificationAction::Deleted); + assert_eq!( + parse_notification_action("created").unwrap(), + NotificationAction::Created + ); + assert_eq!( + parse_notification_action("updated").unwrap(), + NotificationAction::Updated + ); + assert_eq!( + parse_notification_action("deleted").unwrap(), + NotificationAction::Deleted + ); } // ── JSON helpers ───────────────────────────────────────────────── @@ -504,11 +543,13 @@ mod tests { VALUES ('p1', 'Blog', 'blog', 'My blog', '/data', 1, 1000, 2000)", [], ).unwrap(); - let p = c.query_row( - &format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE id = 'p1'"), - [], - project_from_row, - ).unwrap(); + let p = c + .query_row( + &format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE id = 'p1'"), + [], + project_from_row, + ) + .unwrap(); assert_eq!(p.id, "p1"); assert_eq!(p.name, "Blog"); assert_eq!(p.slug, "blog"); @@ -527,7 +568,8 @@ mod tests { "INSERT INTO projects (id, name, slug, is_active, created_at, updated_at) VALUES ('p1', 'B', 'b', 0, 1000, 1000)", [], - ).unwrap(); + ) + .unwrap(); c.execute( "INSERT INTO posts (id, project_id, title, slug, excerpt, content, status, author, language, do_not_translate, template_slug, file_path, checksum, @@ -541,11 +583,13 @@ mod tests { 1000, 2000, NULL)", [], ).unwrap(); - let p = c.query_row( - &format!("SELECT {POST_COLUMNS} FROM posts WHERE id = 'x'"), - [], - post_from_row, - ).unwrap(); + let p = c + .query_row( + &format!("SELECT {POST_COLUMNS} FROM posts WHERE id = 'x'"), + [], + post_from_row, + ) + .unwrap(); assert_eq!(p.id, "x"); assert_eq!(p.project_id, "p1"); assert_eq!(p.title, "Hello"); @@ -575,19 +619,23 @@ mod tests { "INSERT INTO projects (id, name, slug, is_active, created_at, updated_at) VALUES ('p1', 'B', 'b', 0, 1000, 1000)", [], - ).unwrap(); + ) + .unwrap(); c.execute( "INSERT INTO templates (id, project_id, slug, title, kind, enabled, version, file_path, status, content, created_at, updated_at) VALUES ('t1', 'p1', 'default', 'Default', 'not_found', 0, 3, 'templates/default.liquid', 'draft', 'html', 1000, 2000)", [], - ).unwrap(); - let t = c.query_row( - &format!("SELECT {TEMPLATE_COLUMNS} FROM templates WHERE id = 't1'"), - [], - template_from_row, - ).unwrap(); + ) + .unwrap(); + let t = c + .query_row( + &format!("SELECT {TEMPLATE_COLUMNS} FROM templates WHERE id = 't1'"), + [], + template_from_row, + ) + .unwrap(); assert_eq!(t.kind, TemplateKind::NotFound); assert!(!t.enabled); assert_eq!(t.version, 3); @@ -604,11 +652,15 @@ mod tests { VALUES ('media', 'm1', 'deleted', 1, 5000, 1000)", [], ).unwrap(); - let n = c.query_row( - &format!("SELECT {DB_NOTIFICATION_COLUMNS} FROM db_notifications WHERE entity_id = 'm1'"), - [], - db_notification_from_row, - ).unwrap(); + let n = c + .query_row( + &format!( + "SELECT {DB_NOTIFICATION_COLUMNS} FROM db_notifications WHERE entity_id = 'm1'" + ), + [], + db_notification_from_row, + ) + .unwrap(); assert_eq!(n.entity_type, NotificationEntity::Media); assert_eq!(n.entity_id, "m1"); assert_eq!(n.action, NotificationAction::Deleted); diff --git a/crates/bds-core/src/db/fts.rs b/crates/bds-core/src/db/fts.rs index df9bbc4..157eace 100644 --- a/crates/bds-core/src/db/fts.rs +++ b/crates/bds-core/src/db/fts.rs @@ -1,5 +1,7 @@ -use rust_stemmers::{Algorithm, Stemmer}; use rusqlite::Connection; +use rust_stemmers::{Algorithm, Stemmer}; + +use crate::util::calendar_range_unix_ms; /// Create FTS5 virtual tables at runtime (not in migrations per spec). /// @@ -22,7 +24,7 @@ pub fn ensure_fts_tables(conn: &Connection) -> rusqlite::Result<()> { caption, original_name, tags - );" + );", )?; Ok(()) } @@ -82,6 +84,10 @@ pub struct MediaTranslationFts { /// Index a post in the FTS table with separate columns per spec. /// /// Translation titles go to the title column, excerpts to excerpt, content to content. +#[expect( + clippy::too_many_arguments, + reason = "FTS columns mirror the persisted post fields" +)] pub fn index_post( conn: &Connection, post_id: &str, @@ -137,6 +143,10 @@ pub fn index_post( /// Index a media item in the FTS table with separate columns per spec. /// /// Translation titles go to the title column, alts to alt, captions to caption. +#[expect( + clippy::too_many_arguments, + reason = "FTS columns mirror the persisted media fields" +)] pub fn index_media( conn: &Connection, media_id: &str, @@ -209,14 +219,15 @@ pub fn remove_media_from_index(conn: &Connection, media_id: &str) -> rusqlite::R } /// Search posts by full-text query. Returns matching post IDs. -pub fn search_posts(conn: &Connection, query: &str, language: &str) -> rusqlite::Result> { +pub fn search_posts( + conn: &Connection, + query: &str, + language: &str, +) -> rusqlite::Result> { let stemmed = stem_text(query, language); - let mut stmt = conn.prepare( - "SELECT post_id FROM posts_fts WHERE posts_fts MATCH ?1 ORDER BY rank" - )?; - let rows = stmt.query_map(rusqlite::params![stemmed], |row| { - row.get::<_, String>(0) - })?; + let mut stmt = + conn.prepare("SELECT post_id FROM posts_fts WHERE posts_fts MATCH ?1 ORDER BY rank")?; + let rows = stmt.query_map(rusqlite::params![stemmed], |row| row.get::<_, String>(0))?; rows.collect() } @@ -255,16 +266,28 @@ pub fn search_posts_filtered( // Get FTS matches first let fts_ids = search_posts(conn, query, language)?; if fts_ids.is_empty() { - return Ok(SearchResults { post_ids: vec![], total: 0, offset: filters.offset.unwrap_or(0), limit: filters.limit.unwrap_or(0) }); + return Ok(SearchResults { + post_ids: vec![], + total: 0, + offset: filters.offset.unwrap_or(0), + limit: filters.limit.unwrap_or(0), + }); } // Apply filters by querying posts table - let placeholders: Vec = fts_ids.iter().enumerate().map(|(i, _)| format!("?{}", i + 1)).collect(); + let placeholders: Vec = fts_ids + .iter() + .enumerate() + .map(|(i, _)| format!("?{}", i + 1)) + .collect(); let mut sql = format!( "SELECT id FROM posts WHERE id IN ({}) ", placeholders.join(",") ); - let mut params: Vec> = fts_ids.iter().map(|id| Box::new(id.clone()) as Box).collect(); + let mut params: Vec> = fts_ids + .iter() + .map(|id| Box::new(id.clone()) as Box) + .collect(); let mut param_idx = fts_ids.len() + 1; if let Some(status) = filters.status { @@ -296,29 +319,21 @@ pub fn search_posts_filtered( } if let Some(year) = filters.year { - // Filter by year from created_at (unix ms) - let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis(); - let end = chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis(); - sql.push_str(&format!("AND created_at >= ?{param_idx} AND created_at < ?{} ", param_idx + 1)); + let (start, end) = + calendar_range_unix_ms(year, filters.month).ok_or(rusqlite::Error::InvalidQuery)?; + sql.push_str(&format!( + "AND created_at >= ?{param_idx} AND created_at < ?{} ", + param_idx + 1 + )); params.push(Box::new(start)); params.push(Box::new(end)); param_idx += 2; } - if let Some(month) = filters.month { - if let Some(year) = filters.year { - let (end_year, end_month) = if month == 12 { (year + 1, 1) } else { (year, month as i32 + 1) }; - let start = chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis(); - let end = chrono::NaiveDate::from_ymd_opt(end_year, end_month as u32, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis(); - sql.push_str(&format!("AND created_at >= ?{param_idx} AND created_at < ?{} ", param_idx + 1)); - params.push(Box::new(start)); - params.push(Box::new(end)); - param_idx += 2; - } - } - if let Some(lang) = filters.language { - sql.push_str(&format!("AND (language = ?{param_idx} OR language IS NULL) ")); + sql.push_str(&format!( + "AND (language = ?{param_idx} OR language IS NULL) " + )); params.push(Box::new(lang.to_string())); param_idx += 1; } @@ -349,7 +364,8 @@ pub fn search_posts_filtered( let count_sql = sql.replace("SELECT id FROM posts", "SELECT COUNT(*) FROM posts"); let total: usize = { let mut stmt = conn.prepare(&count_sql)?; - let params_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let params_refs: Vec<&dyn rusqlite::types::ToSql> = + params.iter().map(|p| p.as_ref()).collect(); stmt.query_row(params_refs.as_slice(), |row| row.get::<_, usize>(0))? }; @@ -365,22 +381,26 @@ pub fn search_posts_filtered( let mut stmt = conn.prepare(&sql)?; let params_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); - let rows = stmt.query_map(params_refs.as_slice(), |row| { - row.get::<_, String>(0) - })?; + let rows = stmt.query_map(params_refs.as_slice(), |row| row.get::<_, String>(0))?; let post_ids: Vec = rows.collect::>>()?; - Ok(SearchResults { post_ids, total, offset, limit }) + Ok(SearchResults { + post_ids, + total, + offset, + limit, + }) } /// Search media by full-text query. Returns matching media IDs. -pub fn search_media(conn: &Connection, query: &str, language: &str) -> rusqlite::Result> { +pub fn search_media( + conn: &Connection, + query: &str, + language: &str, +) -> rusqlite::Result> { let stemmed = stem_text(query, language); - let mut stmt = conn.prepare( - "SELECT media_id FROM media_fts WHERE media_fts MATCH ?1 ORDER BY rank" - )?; - let rows = stmt.query_map(rusqlite::params![stemmed], |row| { - row.get::<_, String>(0) - })?; + let mut stmt = + conn.prepare("SELECT media_id FROM media_fts WHERE media_fts MATCH ?1 ORDER BY rank")?; + let rows = stmt.query_map(rusqlite::params![stemmed], |row| row.get::<_, String>(0))?; rows.collect() } @@ -526,7 +546,8 @@ mod tests { language: "en".into(), }], "en", - ).unwrap(); + ) + .unwrap(); let results = search_posts(db.conn(), "spider", "en").unwrap(); assert_eq!(results, vec!["post-1"]); @@ -545,7 +566,8 @@ mod tests { &["nature".into()], &[], "en", - ).unwrap(); + ) + .unwrap(); let results = search_media(db.conn(), "sunset", "en").unwrap(); assert_eq!(results, vec!["media-1"]); @@ -561,10 +583,7 @@ mod tests { #[test] fn remove_from_index() { let db = setup(); - index_post( - db.conn(), "p1", "Test", None, None, - &[], &[], &[], "en", - ).unwrap(); + index_post(db.conn(), "p1", "Test", None, None, &[], &[], &[], "en").unwrap(); assert_eq!(search_posts(db.conn(), "test", "en").unwrap().len(), 1); remove_post_from_index(db.conn(), "p1").unwrap(); @@ -586,8 +605,13 @@ mod tests { let db = setup(); // German post with English translation index_post( - db.conn(), "p1", "Programmierung", None, Some("Deutsche Entwicklung"), - &[], &[], + db.conn(), + "p1", + "Programmierung", + None, + Some("Deutsche Entwicklung"), + &[], + &[], &[PostTranslationFts { title: "English development programming".into(), excerpt: None, @@ -595,7 +619,8 @@ mod tests { language: "en".into(), }], "de", - ).unwrap(); + ) + .unwrap(); // Search with English stemming should find via English translation let results = search_posts(db.conn(), "develop", "en").unwrap(); @@ -605,7 +630,18 @@ mod tests { #[test] fn search_by_title_field() { let db = setup(); - index_post(db.conn(), "p1", "Unique Title Here", None, Some("body text"), &[], &[], &[], "en").unwrap(); + index_post( + db.conn(), + "p1", + "Unique Title Here", + None, + Some("body text"), + &[], + &[], + &[], + "en", + ) + .unwrap(); // Search for title content let results = search_posts(db.conn(), "unique", "en").unwrap(); @@ -615,7 +651,18 @@ mod tests { #[test] fn search_by_tags() { let db = setup(); - index_post(db.conn(), "p1", "Post", None, None, &["photography".into()], &[], &[], "en").unwrap(); + index_post( + db.conn(), + "p1", + "Post", + None, + None, + &["photography".into()], + &[], + &[], + "en", + ) + .unwrap(); let results = search_posts(db.conn(), "photography", "en").unwrap(); assert_eq!(results, vec!["p1"]); @@ -624,7 +671,18 @@ mod tests { #[test] fn search_by_categories() { let db = setup(); - index_post(db.conn(), "p1", "Post", None, None, &[], &["article".into()], &[], "en").unwrap(); + index_post( + db.conn(), + "p1", + "Post", + None, + None, + &[], + &["article".into()], + &[], + "en", + ) + .unwrap(); let results = search_posts(db.conn(), "article", "en").unwrap(); assert_eq!(results, vec!["p1"]); @@ -647,8 +705,30 @@ mod tests { [], ).unwrap(); - index_post(db.conn(), "post1", "Test Post", None, None, &[], &[], &[], "en").unwrap(); - index_post(db.conn(), "post2", "Draft Post", None, None, &[], &[], &[], "en").unwrap(); + index_post( + db.conn(), + "post1", + "Test Post", + None, + None, + &[], + &[], + &[], + "en", + ) + .unwrap(); + index_post( + db.conn(), + "post2", + "Draft Post", + None, + None, + &[], + &[], + &[], + "en", + ) + .unwrap(); let filters = PostSearchFilters { status: Some("published"), @@ -670,19 +750,45 @@ mod tests { // 2023-06-15 in unix ms let ts_2023: i64 = 1686873600000; - db.conn().execute( - "INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at) + db.conn() + .execute( + "INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at) VALUES ('p2024', 'p1', 'Year 2024', 'y2024', 'draft', ?1, ?1)", - rusqlite::params![ts_2024], - ).unwrap(); - db.conn().execute( - "INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at) + rusqlite::params![ts_2024], + ) + .unwrap(); + db.conn() + .execute( + "INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at) VALUES ('p2023', 'p1', 'Year 2023', 'y2023', 'draft', ?1, ?1)", - rusqlite::params![ts_2023], - ).unwrap(); + rusqlite::params![ts_2023], + ) + .unwrap(); - index_post(db.conn(), "p2024", "Year 2024", None, None, &[], &[], &[], "en").unwrap(); - index_post(db.conn(), "p2023", "Year 2023", None, None, &[], &[], &[], "en").unwrap(); + index_post( + db.conn(), + "p2024", + "Year 2024", + None, + None, + &[], + &[], + &[], + "en", + ) + .unwrap(); + index_post( + db.conn(), + "p2023", + "Year 2023", + None, + None, + &[], + &[], + &[], + "en", + ) + .unwrap(); let filters = PostSearchFilters { year: Some(2024), @@ -707,7 +813,18 @@ mod tests { VALUES (?1, 'p1', 'Searchable', ?2, 'draft', ?3, ?3)", rusqlite::params![id, slug, 1700000000000i64 - i as i64 * 1000], ).unwrap(); - index_post(db.conn(), &id, "Searchable", None, None, &[], &[], &[], "en").unwrap(); + index_post( + db.conn(), + &id, + "Searchable", + None, + None, + &[], + &[], + &[], + "en", + ) + .unwrap(); } let filters = PostSearchFilters { diff --git a/crates/bds-core/src/db/migrations.rs b/crates/bds-core/src/db/migrations.rs index 7762d25..6f039f6 100644 --- a/crates/bds-core/src/db/migrations.rs +++ b/crates/bds-core/src/db/migrations.rs @@ -2,14 +2,16 @@ use rusqlite::Connection; mod embedded { use refinery::embed_migrations; - embed_migrations!("migrations"); + embed_migrations!("./migrations"); } /// Run all embedded migrations against the given connection using refinery. /// /// Creates the full bDS schema as specified in specs/schema.allium. /// Uses refinery for proper versioned migration tracking. -pub fn run_migrations(conn: &mut Connection) -> Result<(), Box> { +pub fn run_migrations( + conn: &mut Connection, +) -> Result<(), Box> { embedded::migrations::runner().run(conn)?; Ok(()) } @@ -63,19 +65,34 @@ mod tests { fn all_tables_exist() { let conn = setup(); let expected = [ - "projects", "posts", "post_translations", "media", "media_translations", - "tags", "templates", "scripts", "post_links", "post_media", "settings", - "generated_file_hashes", "chat_conversations", "chat_messages", "ai_providers", - "ai_models", "ai_model_modalities", "ai_catalog_meta", "embedding_keys", - "dismissed_duplicate_pairs", "import_definitions", "db_notifications", + "projects", + "posts", + "post_translations", + "media", + "media_translations", + "tags", + "templates", + "scripts", + "post_links", + "post_media", + "settings", + "generated_file_hashes", + "chat_conversations", + "chat_messages", + "ai_providers", + "ai_models", + "ai_model_modalities", + "ai_catalog_meta", + "embedding_keys", + "dismissed_duplicate_pairs", + "import_definitions", + "db_notifications", ]; for table in &expected { let count: i64 = conn - .query_row( - &format!("SELECT COUNT(*) FROM {table}"), - [], - |row| row.get(0), - ) + .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| { + row.get(0) + }) .unwrap_or_else(|e| panic!("table '{table}' should be queryable: {e}")); assert_eq!(count, 0, "table '{table}' should start empty"); } @@ -89,11 +106,9 @@ mod tests { fn refinery_schema_history_exists() { let conn = setup(); let count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM refinery_schema_history", - [], - |row| row.get(0), - ) + .query_row("SELECT COUNT(*) FROM refinery_schema_history", [], |row| { + row.get(0) + }) .unwrap(); assert!(count >= 1, "refinery should track at least one migration"); } @@ -124,7 +139,10 @@ mod tests { VALUES ('post2', 'p1', 'Other', 'hello', 'draft', 1000, 1000)", [], ); - assert!(err.is_err(), "duplicate post slug within same project must be rejected"); + assert!( + err.is_err(), + "duplicate post slug within same project must be rejected" + ); } #[test] @@ -156,7 +174,10 @@ mod tests { VALUES ('t2', 'p1', 'post1', 'de', 'Hallo2', 'draft', 1000, 1000)", [], ); - assert!(err.is_err(), "duplicate (translation_for, language) must be rejected"); + assert!( + err.is_err(), + "duplicate (translation_for, language) must be rejected" + ); } #[test] @@ -174,7 +195,10 @@ mod tests { VALUES ('mt2', 'p1', 'm1', 'de', 1000, 1000)", [], ); - assert!(err.is_err(), "duplicate (media translation_for, language) must be rejected"); + assert!( + err.is_err(), + "duplicate (media translation_for, language) must be rejected" + ); } #[test] @@ -185,13 +209,17 @@ mod tests { "INSERT INTO tags (id, project_id, name, created_at, updated_at) VALUES ('t1', 'p1', 'rust', 1000, 1000)", [], - ).unwrap(); + ) + .unwrap(); let err = conn.execute( "INSERT INTO tags (id, project_id, name, created_at, updated_at) VALUES ('t2', 'p1', 'rust', 1000, 1000)", [], ); - assert!(err.is_err(), "duplicate tag name within same project must be rejected"); + assert!( + err.is_err(), + "duplicate tag name within same project must be rejected" + ); } #[test] @@ -208,7 +236,10 @@ mod tests { VALUES ('tpl2', 'p1', 'default', 'Default2', 'list', 'templates/default.liquid', 1000, 1000)", [], ); - assert!(err.is_err(), "duplicate template slug within same project must be rejected"); + assert!( + err.is_err(), + "duplicate template slug within same project must be rejected" + ); } #[test] @@ -225,7 +256,10 @@ mod tests { VALUES ('s2', 'p1', 'gallery', 'Gallery2', 'utility', 'scripts/gallery.lua', 1000, 1000)", [], ); - assert!(err.is_err(), "duplicate script slug within same project must be rejected"); + assert!( + err.is_err(), + "duplicate script slug within same project must be rejected" + ); } #[test] @@ -238,13 +272,17 @@ mod tests { "INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at) VALUES ('pm1', 'p1', 'post1', 'm1', 0, 1000)", [], - ).unwrap(); + ) + .unwrap(); let err = conn.execute( "INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at) VALUES ('pm2', 'p1', 'post1', 'm1', 1, 1000)", [], ); - assert!(err.is_err(), "duplicate (post_id, media_id) must be rejected"); + assert!( + err.is_err(), + "duplicate (post_id, media_id) must be rejected" + ); } #[test] @@ -261,7 +299,10 @@ mod tests { VALUES ('p1', 'index.html', 'def456', 2000)", [], ); - assert!(err.is_err(), "duplicate (project_id, relative_path) must be rejected"); + assert!( + err.is_err(), + "duplicate (project_id, relative_path) must be rejected" + ); } #[test] @@ -278,7 +319,10 @@ mod tests { VALUES ('d2', 'p1', 'a', 'b', 2000)", [], ); - assert!(err.is_err(), "duplicate (project_id, post_id_a, post_id_b) must be rejected"); + assert!( + err.is_err(), + "duplicate (project_id, post_id_a, post_id_b) must be rejected" + ); } // ================================================================ @@ -323,19 +367,25 @@ mod tests { [], ).unwrap(); - let title: String = conn.query_row( - "SELECT title FROM posts WHERE id = 'post1'", [], |r| r.get(0) - ).unwrap(); + let title: String = conn + .query_row("SELECT title FROM posts WHERE id = 'post1'", [], |r| { + r.get(0) + }) + .unwrap(); assert_eq!(title, "Hello"); - let tags: String = conn.query_row( - "SELECT tags FROM posts WHERE id = 'post1'", [], |r| r.get(0) - ).unwrap(); + let tags: String = conn + .query_row("SELECT tags FROM posts WHERE id = 'post1'", [], |r| { + r.get(0) + }) + .unwrap(); assert_eq!(tags, "[\"rust\",\"blog\"]"); - let content: Option = conn.query_row( - "SELECT content FROM posts WHERE id = 'post1'", [], |r| r.get(0) - ).unwrap(); + let content: Option = conn + .query_row("SELECT content FROM posts WHERE id = 'post1'", [], |r| { + r.get(0) + }) + .unwrap(); assert_eq!(content.as_deref(), Some("Body text")); } @@ -349,10 +399,15 @@ mod tests { [], ).unwrap(); - let content: Option = conn.query_row( - "SELECT content FROM posts WHERE id = 'post1'", [], |r| r.get(0) - ).unwrap(); - assert!(content.is_none(), "published post content must be null in DB"); + let content: Option = conn + .query_row("SELECT content FROM posts WHERE id = 'post1'", [], |r| { + r.get(0) + }) + .unwrap(); + assert!( + content.is_none(), + "published post content must be null in DB" + ); } #[test] @@ -366,9 +421,13 @@ mod tests { [], ).unwrap(); - let (lang, title): (String, String) = conn.query_row( - "SELECT language, title FROM post_translations WHERE id = 't1'", [], |r| Ok((r.get(0)?, r.get(1)?)) - ).unwrap(); + let (lang, title): (String, String) = conn + .query_row( + "SELECT language, title FROM post_translations WHERE id = 't1'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); assert_eq!(lang, "de"); assert_eq!(title, "Hallo"); } @@ -386,10 +445,13 @@ mod tests { [], ).unwrap(); - let (orig, w, h, tags): (String, Option, Option, String) = conn.query_row( - "SELECT original_name, width, height, tags FROM media WHERE id = 'm1'", [], - |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)) - ).unwrap(); + let (orig, w, h, tags): (String, Option, Option, String) = conn + .query_row( + "SELECT original_name, width, height, tags FROM media WHERE id = 'm1'", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), + ) + .unwrap(); assert_eq!(orig, "photo.jpg"); assert_eq!(w, Some(1920)); assert_eq!(h, Some(1080)); @@ -407,10 +469,13 @@ mod tests { [], ).unwrap(); - let (title, alt): (Option, Option) = conn.query_row( - "SELECT title, alt FROM media_translations WHERE id = 'mt1'", [], - |r| Ok((r.get(0)?, r.get(1)?)) - ).unwrap(); + let (title, alt): (Option, Option) = conn + .query_row( + "SELECT title, alt FROM media_translations WHERE id = 'mt1'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); assert_eq!(title.as_deref(), Some("Sonnenuntergang")); assert_eq!(alt.as_deref(), Some("Ein Sonnenuntergang")); } @@ -425,10 +490,13 @@ mod tests { [], ).unwrap(); - let (name, color, tpl): (String, Option, Option) = conn.query_row( - "SELECT name, color, post_template_slug FROM tags WHERE id = 't1'", [], - |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)) - ).unwrap(); + let (name, color, tpl): (String, Option, Option) = conn + .query_row( + "SELECT name, color, post_template_slug FROM tags WHERE id = 't1'", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .unwrap(); assert_eq!(name, "rust"); assert_eq!(color.as_deref(), Some("#ff5733")); assert_eq!(tpl.as_deref(), Some("tag-tpl")); @@ -444,14 +512,20 @@ mod tests { [], ).unwrap(); - let (kind, ver, status, content): (String, i32, String, Option) = conn.query_row( - "SELECT kind, version, status, content FROM templates WHERE id = 'tpl1'", [], - |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)) - ).unwrap(); + let (kind, ver, status, content): (String, i32, String, Option) = conn + .query_row( + "SELECT kind, version, status, content FROM templates WHERE id = 'tpl1'", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), + ) + .unwrap(); assert_eq!(kind, "post"); assert_eq!(ver, 3); assert_eq!(status, "published"); - assert!(content.is_none(), "published template content should be null"); + assert!( + content.is_none(), + "published template content should be null" + ); } #[test] @@ -464,10 +538,13 @@ mod tests { [], ).unwrap(); - let (kind, ep, content): (String, String, Option) = conn.query_row( - "SELECT kind, entrypoint, content FROM scripts WHERE id = 's1'", [], - |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)) - ).unwrap(); + let (kind, ep, content): (String, String, Option) = conn + .query_row( + "SELECT kind, entrypoint, content FROM scripts WHERE id = 's1'", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .unwrap(); assert_eq!(kind, "macro"); assert_eq!(ep, "render"); assert_eq!(content.as_deref(), Some("return html")); @@ -483,12 +560,16 @@ mod tests { "INSERT INTO post_links (id, source_post_id, target_post_id, link_text, created_at) VALUES ('pl1', 'post1', 'post2', 'see also', 1000)", [], - ).unwrap(); + ) + .unwrap(); - let (src, tgt, txt): (String, String, Option) = conn.query_row( - "SELECT source_post_id, target_post_id, link_text FROM post_links WHERE id = 'pl1'", [], - |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)) - ).unwrap(); + let (src, tgt, txt): (String, String, Option) = conn + .query_row( + "SELECT source_post_id, target_post_id, link_text FROM post_links WHERE id = 'pl1'", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .unwrap(); assert_eq!(src, "post1"); assert_eq!(tgt, "post2"); assert_eq!(txt.as_deref(), Some("see also")); @@ -504,11 +585,16 @@ mod tests { "INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at) VALUES ('pm1', 'p1', 'post1', 'm1', 5, 1000)", [], - ).unwrap(); + ) + .unwrap(); - let order: i32 = conn.query_row( - "SELECT sort_order FROM post_media WHERE id = 'pm1'", [], |r| r.get(0) - ).unwrap(); + let order: i32 = conn + .query_row( + "SELECT sort_order FROM post_media WHERE id = 'pm1'", + [], + |r| r.get(0), + ) + .unwrap(); assert_eq!(order, 5); } @@ -518,11 +604,14 @@ mod tests { conn.execute( "INSERT INTO settings (key, value, updated_at) VALUES ('theme', 'dark', 1000)", [], - ).unwrap(); + ) + .unwrap(); - let val: String = conn.query_row( - "SELECT value FROM settings WHERE key = 'theme'", [], |r| r.get(0) - ).unwrap(); + let val: String = conn + .query_row("SELECT value FROM settings WHERE key = 'theme'", [], |r| { + r.get(0) + }) + .unwrap(); assert_eq!(val, "dark"); } @@ -550,11 +639,16 @@ mod tests { "INSERT INTO chat_conversations (id, title, model, created_at, updated_at) VALUES ('c1', 'Test Chat', 'gpt-4', 1000, 1000)", [], - ).unwrap(); + ) + .unwrap(); - let title: String = conn.query_row( - "SELECT title FROM chat_conversations WHERE id = 'c1'", [], |r| r.get(0) - ).unwrap(); + let title: String = conn + .query_row( + "SELECT title FROM chat_conversations WHERE id = 'c1'", + [], + |r| r.get(0), + ) + .unwrap(); assert_eq!(title, "Test Chat"); } @@ -565,28 +659,59 @@ mod tests { "INSERT INTO chat_conversations (id, title, created_at, updated_at) VALUES ('c1', 'Chat', 1000, 1000)", [], - ).unwrap(); + ) + .unwrap(); conn.execute( "INSERT INTO chat_messages (conversation_id, role, content, created_at) VALUES ('c1', 'user', 'Hello', 1000)", [], - ).unwrap(); + ) + .unwrap(); - let (role, content): (String, Option) = conn.query_row( - "SELECT role, content FROM chat_messages WHERE conversation_id = 'c1'", [], - |r| Ok((r.get(0)?, r.get(1)?)) - ).unwrap(); + let (role, content): (String, Option) = conn + .query_row( + "SELECT role, content FROM chat_messages WHERE conversation_id = 'c1'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); assert_eq!(role, "user"); assert_eq!(content.as_deref(), Some("Hello")); } + #[test] + fn ai_schema_uses_current_usage_and_package_columns() { + let conn = setup(); + let columns = |table: &str| { + let mut statement = conn + .prepare(&format!("PRAGMA table_info({table})")) + .unwrap(); + statement + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::>>() + .unwrap() + }; + + let message_columns = columns("chat_messages"); + assert!(message_columns.contains(&"cache_read_tokens".to_string())); + assert!(message_columns.contains(&"cache_write_tokens".to_string())); + let provider_columns = columns("ai_providers"); + assert!(provider_columns.contains(&"package_ref".to_string())); + assert!(!provider_columns.contains(&"npm".to_string())); + let model_columns = columns("ai_models"); + assert!(model_columns.contains(&"provider_package_ref".to_string())); + assert!(!model_columns.contains(&"provider_npm".to_string())); + } + #[test] fn roundtrip_ai_provider_and_model() { let conn = setup(); conn.execute( "INSERT INTO ai_providers (id, name, updated_at) VALUES ('openai', 'OpenAI', 1000)", [], - ).unwrap(); + ) + .unwrap(); conn.execute( "INSERT INTO ai_models (provider, model_id, name, context_window, max_input_tokens, max_output_tokens, updated_at) VALUES ('openai', 'gpt-4', 'GPT-4', 128000, 128000, 4096, 1000)", @@ -596,12 +721,16 @@ mod tests { "INSERT INTO ai_model_modalities (provider, model_id, direction, modality) VALUES ('openai', 'gpt-4', 'input', 'text')", [], - ).unwrap(); + ) + .unwrap(); - let name: String = conn.query_row( - "SELECT name FROM ai_models WHERE provider = 'openai' AND model_id = 'gpt-4'", - [], |r| r.get(0) - ).unwrap(); + let name: String = conn + .query_row( + "SELECT name FROM ai_models WHERE provider = 'openai' AND model_id = 'gpt-4'", + [], + |r| r.get(0), + ) + .unwrap(); assert_eq!(name, "GPT-4"); let modality: String = conn.query_row( @@ -617,11 +746,16 @@ mod tests { conn.execute( "INSERT INTO ai_catalog_meta (key, value) VALUES ('etag', 'abc')", [], - ).unwrap(); + ) + .unwrap(); - let val: String = conn.query_row( - "SELECT value FROM ai_catalog_meta WHERE key = 'etag'", [], |r| r.get(0) - ).unwrap(); + let val: String = conn + .query_row( + "SELECT value FROM ai_catalog_meta WHERE key = 'etag'", + [], + |r| r.get(0), + ) + .unwrap(); assert_eq!(val, "abc"); } @@ -632,11 +766,16 @@ mod tests { "INSERT INTO embedding_keys (label, post_id, project_id, content_hash, vector) VALUES (1, 'post1', 'p1', 'hash1', 'base64vector')", [], - ).unwrap(); + ) + .unwrap(); - let vec: String = conn.query_row( - "SELECT vector FROM embedding_keys WHERE label = 1", [], |r| r.get(0) - ).unwrap(); + let vec: String = conn + .query_row( + "SELECT vector FROM embedding_keys WHERE label = 1", + [], + |r| r.get(0), + ) + .unwrap(); assert_eq!(vec, "base64vector"); } @@ -650,10 +789,13 @@ mod tests { [], ).unwrap(); - let count: i64 = conn.query_row( - "SELECT COUNT(*) FROM dismissed_duplicate_pairs WHERE project_id = 'p1'", - [], |r| r.get(0) - ).unwrap(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM dismissed_duplicate_pairs WHERE project_id = 'p1'", + [], + |r| r.get(0), + ) + .unwrap(); assert_eq!(count, 1); } @@ -667,9 +809,13 @@ mod tests { [], ).unwrap(); - let name: String = conn.query_row( - "SELECT name FROM import_definitions WHERE id = 'i1'", [], |r| r.get(0) - ).unwrap(); + let name: String = conn + .query_row( + "SELECT name FROM import_definitions WHERE id = 'i1'", + [], + |r| r.get(0), + ) + .unwrap(); assert_eq!(name, "WP Import"); } @@ -680,7 +826,8 @@ mod tests { "INSERT INTO db_notifications (entity_type, entity_id, action, from_cli, created_at) VALUES ('post', 'post1', 'created', 1, 1000)", [], - ).unwrap(); + ) + .unwrap(); let (etype, action, cli): (String, String, i64) = conn.query_row( "SELECT entity_type, action, from_cli FROM db_notifications WHERE entity_id = 'post1'", @@ -715,7 +862,8 @@ mod tests { "INSERT INTO posts (id, project_id, title, slug, created_at, updated_at) VALUES ('post1', 'p1', 'Test', 'test', 1000, 1000)", [], - ).unwrap(); + ) + .unwrap(); let (status, file_path, tags, cats, dnt): (String, String, String, String, i64) = conn.query_row( "SELECT status, file_path, tags, categories, do_not_translate FROM posts WHERE id = 'post1'", @@ -736,12 +884,16 @@ mod tests { "INSERT INTO templates (id, project_id, slug, title, file_path, created_at, updated_at) VALUES ('tpl1', 'p1', 'test', 'Test', 'templates/test.liquid', 1000, 1000)", [], - ).unwrap(); + ) + .unwrap(); - let (kind, enabled, version, status): (String, i64, i64, String) = conn.query_row( - "SELECT kind, enabled, version, status FROM templates WHERE id = 'tpl1'", - [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)) - ).unwrap(); + let (kind, enabled, version, status): (String, i64, i64, String) = conn + .query_row( + "SELECT kind, enabled, version, status FROM templates WHERE id = 'tpl1'", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), + ) + .unwrap(); assert_eq!(kind, "post", "default kind must be 'post'"); assert_eq!(enabled, 1, "default enabled must be 1"); assert_eq!(version, 1, "default version must be 1"); @@ -756,12 +908,16 @@ mod tests { "INSERT INTO scripts (id, project_id, slug, title, file_path, created_at, updated_at) VALUES ('s1', 'p1', 'test', 'Test', 'scripts/test.lua', 1000, 1000)", [], - ).unwrap(); + ) + .unwrap(); - let (kind, ep, enabled, version, status): (String, String, i64, i64, String) = conn.query_row( - "SELECT kind, entrypoint, enabled, version, status FROM scripts WHERE id = 's1'", - [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)) - ).unwrap(); + let (kind, ep, enabled, version, status): (String, String, i64, i64, String) = conn + .query_row( + "SELECT kind, entrypoint, enabled, version, status FROM scripts WHERE id = 's1'", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)), + ) + .unwrap(); assert_eq!(kind, "utility", "default kind must be 'utility'"); assert_eq!(ep, "render", "default entrypoint must be 'render'"); assert_eq!(enabled, 1, "default enabled must be 1"); diff --git a/crates/bds-core/src/db/queries/generated_file_hash.rs b/crates/bds-core/src/db/queries/generated_file_hash.rs index 0203812..11b9923 100644 --- a/crates/bds-core/src/db/queries/generated_file_hash.rs +++ b/crates/bds-core/src/db/queries/generated_file_hash.rs @@ -1,6 +1,6 @@ -use rusqlite::{params, Connection}; +use rusqlite::{Connection, params}; -use crate::db::from_row::{generated_file_hash_from_row, GENERATED_FILE_HASH_COLUMNS}; +use crate::db::from_row::{GENERATED_FILE_HASH_COLUMNS, generated_file_hash_from_row}; use crate::model::GeneratedFileHash; pub fn get_generated_file_hash( @@ -17,13 +17,21 @@ pub fn get_generated_file_hash( ) } -pub fn upsert_generated_file_hash(conn: &Connection, hash: &GeneratedFileHash) -> rusqlite::Result<()> { +pub fn upsert_generated_file_hash( + conn: &Connection, + hash: &GeneratedFileHash, +) -> rusqlite::Result<()> { conn.execute( "INSERT INTO generated_file_hashes (project_id, relative_path, content_hash, updated_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(project_id, relative_path) DO UPDATE SET content_hash = excluded.content_hash, updated_at = excluded.updated_at", - params![hash.project_id, hash.relative_path, hash.content_hash, hash.updated_at], + params![ + hash.project_id, + hash.relative_path, + hash.content_hash, + hash.updated_at + ], )?; Ok(()) } @@ -54,8 +62,8 @@ pub fn list_generated_file_hashes_by_project( #[cfg(test)] mod tests { use super::*; - use crate::db::queries::project::{insert_project, make_test_project}; use crate::db::Database; + use crate::db::queries::project::{insert_project, make_test_project}; fn setup() -> Database { let mut db = Database::open_in_memory().unwrap(); @@ -91,4 +99,4 @@ mod tests { assert_eq!(stored.content_hash, "def"); assert_eq!(stored.updated_at, 99); } -} \ No newline at end of file +} diff --git a/crates/bds-core/src/db/queries/media.rs b/crates/bds-core/src/db/queries/media.rs index c6343a9..85a4c99 100644 --- a/crates/bds-core/src/db/queries/media.rs +++ b/crates/bds-core/src/db/queries/media.rs @@ -1,7 +1,8 @@ -use rusqlite::{params, Connection}; +use rusqlite::{Connection, params}; -use crate::db::from_row::{media_from_row, MEDIA_COLUMNS}; +use crate::db::from_row::{MEDIA_COLUMNS, media_from_row}; use crate::model::Media; +use crate::util::calendar_range_unix_ms; fn tags_to_json(tags: &[String]) -> String { serde_json::to_string(tags).unwrap_or_else(|_| "[]".into()) @@ -133,9 +134,7 @@ pub struct MediaFilterParams { impl MediaFilterParams { pub fn has_active_filters(&self) -> bool { - !self.search_query.is_empty() - || self.year.is_some() - || !self.tags.is_empty() + !self.search_query.is_empty() || self.year.is_some() || !self.tags.is_empty() } } @@ -161,49 +160,13 @@ pub fn list_media_filtered( } if let Some(year) = filters.year { - let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1) - .unwrap() - .and_hms_opt(0, 0, 0) - .unwrap() - .and_utc() - .timestamp() * 1000; - let end = chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1) - .unwrap() - .and_hms_opt(0, 0, 0) - .unwrap() - .and_utc() - .timestamp() * 1000; - - if let Some(month) = filters.month { - let m_start = chrono::NaiveDate::from_ymd_opt(year, month, 1) - .unwrap() - .and_hms_opt(0, 0, 0) - .unwrap() - .and_utc() - .timestamp() * 1000; - let next_month = if month == 12 { - chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1) - } else { - chrono::NaiveDate::from_ymd_opt(year, month + 1, 1) - } - .unwrap() - .and_hms_opt(0, 0, 0) - .unwrap() - .and_utc() - .timestamp() * 1000; - - let idx1 = param_values.len() + 1; - let idx2 = param_values.len() + 2; - conditions.push(format!("(created_at >= ?{idx1} AND created_at < ?{idx2})")); - param_values.push(Box::new(m_start)); - param_values.push(Box::new(next_month)); - } else { - let idx1 = param_values.len() + 1; - let idx2 = param_values.len() + 2; - conditions.push(format!("(created_at >= ?{idx1} AND created_at < ?{idx2})")); - param_values.push(Box::new(start)); - param_values.push(Box::new(end)); - } + let (start, end) = + calendar_range_unix_ms(year, filters.month).ok_or(rusqlite::Error::InvalidQuery)?; + let idx1 = param_values.len() + 1; + let idx2 = param_values.len() + 2; + conditions.push(format!("(created_at >= ?{idx1} AND created_at < ?{idx2})")); + param_values.push(Box::new(start)); + param_values.push(Box::new(end)); } for tag in &filters.tags { @@ -244,7 +207,7 @@ pub fn media_calendar_counts( FROM media WHERE project_id = ?1 GROUP BY y, m - ORDER BY y DESC, m DESC" + ORDER BY y DESC, m DESC", )?; let rows = stmt.query_map(params![project_id], |row| { Ok(( @@ -257,22 +220,16 @@ pub fn media_calendar_counts( } /// Collect all distinct tag values across media for a project. -pub fn distinct_media_tags( - conn: &Connection, - project_id: &str, -) -> rusqlite::Result> { - let mut stmt = conn.prepare( - "SELECT DISTINCT tags FROM media WHERE project_id = ?1 AND tags != '[]'" - )?; - let rows = stmt.query_map(params![project_id], |row| { - row.get::<_, String>(0) - })?; +pub fn distinct_media_tags(conn: &Connection, project_id: &str) -> rusqlite::Result> { + let mut stmt = + conn.prepare("SELECT DISTINCT tags FROM media WHERE project_id = ?1 AND tags != '[]'")?; + let rows = stmt.query_map(params![project_id], |row| row.get::<_, String>(0))?; let mut all_tags = std::collections::BTreeSet::new(); for json_str in rows { - if let Ok(json_str) = json_str { - if let Ok(tags) = serde_json::from_str::>(&json_str) { - all_tags.extend(tags); - } + if let Ok(json_str) = json_str + && let Ok(tags) = serde_json::from_str::>(&json_str) + { + all_tags.extend(tags); } } Ok(all_tags.into_iter().collect()) @@ -307,8 +264,8 @@ pub fn make_test_media(id: &str, project_id: &str) -> Media { #[cfg(test)] mod tests { use super::*; - use crate::db::queries::project::{insert_project, make_test_project}; use crate::db::Database; + use crate::db::queries::project::{insert_project, make_test_project}; fn setup() -> Database { let mut db = Database::open_in_memory().unwrap(); diff --git a/crates/bds-core/src/db/queries/media_translation.rs b/crates/bds-core/src/db/queries/media_translation.rs index aad8aec..0e8621b 100644 --- a/crates/bds-core/src/db/queries/media_translation.rs +++ b/crates/bds-core/src/db/queries/media_translation.rs @@ -1,12 +1,9 @@ -use rusqlite::{params, Connection}; +use rusqlite::{Connection, params}; -use crate::db::from_row::{media_translation_from_row, MEDIA_TRANSLATION_COLUMNS}; +use crate::db::from_row::{MEDIA_TRANSLATION_COLUMNS, media_translation_from_row}; use crate::model::MediaTranslation; -pub fn insert_media_translation( - conn: &Connection, - t: &MediaTranslation, -) -> rusqlite::Result<()> { +pub fn insert_media_translation(conn: &Connection, t: &MediaTranslation) -> rusqlite::Result<()> { conn.execute( "INSERT INTO media_translations ( id, project_id, translation_for, language, title, alt, caption, @@ -54,10 +51,7 @@ pub fn list_media_translations_by_media( rows.collect() } -pub fn upsert_media_translation( - conn: &Connection, - t: &MediaTranslation, -) -> rusqlite::Result<()> { +pub fn upsert_media_translation(conn: &Connection, t: &MediaTranslation) -> rusqlite::Result<()> { conn.execute( "INSERT INTO media_translations ( id, project_id, translation_for, language, title, alt, caption, @@ -98,9 +92,9 @@ pub fn delete_media_translation( #[cfg(test)] mod tests { use super::*; + use crate::db::Database; use crate::db::queries::media::{insert_media, make_test_media}; use crate::db::queries::project::{insert_project, make_test_project}; - use crate::db::Database; fn setup() -> Database { let mut db = Database::open_in_memory().unwrap(); @@ -164,8 +158,6 @@ mod tests { let db = setup(); insert_media_translation(db.conn(), &make_mt("mt1", "de")).unwrap(); delete_media_translation(db.conn(), "m1", "de").unwrap(); - assert!( - get_media_translation_by_media_and_language(db.conn(), "m1", "de").is_err() - ); + assert!(get_media_translation_by_media_and_language(db.conn(), "m1", "de").is_err()); } } diff --git a/crates/bds-core/src/db/queries/mod.rs b/crates/bds-core/src/db/queries/mod.rs index 60f4d47..a0b5ed7 100644 --- a/crates/bds-core/src/db/queries/mod.rs +++ b/crates/bds-core/src/db/queries/mod.rs @@ -1,12 +1,12 @@ -pub mod project; -pub mod post; -pub mod post_translation; +pub mod generated_file_hash; pub mod media; pub mod media_translation; -pub mod tag; +pub mod post; pub mod post_link; pub mod post_media; -pub mod template; +pub mod post_translation; +pub mod project; pub mod script; pub mod setting; -pub mod generated_file_hash; +pub mod tag; +pub mod template; diff --git a/crates/bds-core/src/db/queries/post.rs b/crates/bds-core/src/db/queries/post.rs index 40cc365..602cc0b 100644 --- a/crates/bds-core/src/db/queries/post.rs +++ b/crates/bds-core/src/db/queries/post.rs @@ -1,7 +1,8 @@ -use rusqlite::{params, Connection}; +use rusqlite::{Connection, params}; -use crate::db::from_row::{post_from_row, post_status_to_str, POST_COLUMNS}; +use crate::db::from_row::{POST_COLUMNS, post_from_row, post_status_to_str}; use crate::model::{Post, PostStatus}; +use crate::util::calendar_range_unix_ms; fn tags_to_json(tags: &[String]) -> String { serde_json::to_string(tags).unwrap_or_else(|_| "[]".into()) @@ -165,6 +166,10 @@ pub fn set_post_file_path( Ok(()) } +#[expect( + clippy::too_many_arguments, + reason = "arguments mirror the published snapshot columns" +)] pub fn set_published_snapshot( conn: &Connection, id: &str, @@ -182,7 +187,16 @@ pub fn set_published_snapshot( published_categories = ?4, published_excerpt = ?5, published_at = ?6, updated_at = ?7 WHERE id = ?8", - params![title, content, tags, categories, excerpt, published_at, updated_at, id], + params![ + title, + content, + tags, + categories, + excerpt, + published_at, + updated_at, + id + ], )?; Ok(()) } @@ -295,50 +309,15 @@ pub fn list_posts_filtered( } if let Some(year) = filters.year { - // created_at is unix ms; compute year range - let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1) - .unwrap() - .and_hms_opt(0, 0, 0) - .unwrap() - .and_utc() - .timestamp() * 1000; - let end = chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1) - .unwrap() - .and_hms_opt(0, 0, 0) - .unwrap() - .and_utc() - .timestamp() * 1000; - - if let Some(month) = filters.month { - let m_start = chrono::NaiveDate::from_ymd_opt(year, month, 1) - .unwrap() - .and_hms_opt(0, 0, 0) - .unwrap() - .and_utc() - .timestamp() * 1000; - let next_month = if month == 12 { - chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1) - } else { - chrono::NaiveDate::from_ymd_opt(year, month + 1, 1) - } - .unwrap() - .and_hms_opt(0, 0, 0) - .unwrap() - .and_utc() - .timestamp() * 1000; - - let idx1 = param_values.len() + 1; - let idx2 = param_values.len() + 2; - filter_conditions.push(format!("(p.created_at >= ?{idx1} AND p.created_at < ?{idx2})")); - param_values.push(Box::new(m_start)); - param_values.push(Box::new(next_month)); - } else { - let idx1 = param_values.len() + 1; - let idx2 = param_values.len() + 2; - filter_conditions.push(format!("(p.created_at >= ?{idx1} AND p.created_at < ?{idx2})")); - param_values.push(Box::new(start)); - param_values.push(Box::new(end)); - } + let (start, end) = + calendar_range_unix_ms(year, filters.month).ok_or(rusqlite::Error::InvalidQuery)?; + let idx1 = param_values.len() + 1; + let idx2 = param_values.len() + 2; + filter_conditions.push(format!( + "(p.created_at >= ?{idx1} AND p.created_at < ?{idx2})" + )); + param_values.push(Box::new(start)); + param_values.push(Box::new(end)); } for tag in &filters.tags { @@ -435,22 +414,16 @@ pub fn post_calendar_counts( } /// Collect all distinct tag values across posts for a project. -pub fn distinct_post_tags( - conn: &Connection, - project_id: &str, -) -> rusqlite::Result> { - let mut stmt = conn.prepare( - "SELECT DISTINCT tags FROM posts WHERE project_id = ?1 AND tags != '[]'" - )?; - let rows = stmt.query_map(params![project_id], |row| { - row.get::<_, String>(0) - })?; +pub fn distinct_post_tags(conn: &Connection, project_id: &str) -> rusqlite::Result> { + let mut stmt = + conn.prepare("SELECT DISTINCT tags FROM posts WHERE project_id = ?1 AND tags != '[]'")?; + let rows = stmt.query_map(params![project_id], |row| row.get::<_, String>(0))?; let mut all_tags = std::collections::BTreeSet::new(); for json_str in rows { - if let Ok(json_str) = json_str { - if let Ok(tags) = serde_json::from_str::>(&json_str) { - all_tags.extend(tags); - } + if let Ok(json_str) = json_str + && let Ok(tags) = serde_json::from_str::>(&json_str) + { + all_tags.extend(tags); } } Ok(all_tags.into_iter().collect()) @@ -462,17 +435,15 @@ pub fn distinct_post_categories( project_id: &str, ) -> rusqlite::Result> { let mut stmt = conn.prepare( - "SELECT DISTINCT categories FROM posts WHERE project_id = ?1 AND categories != '[]'" + "SELECT DISTINCT categories FROM posts WHERE project_id = ?1 AND categories != '[]'", )?; - let rows = stmt.query_map(params![project_id], |row| { - row.get::<_, String>(0) - })?; + let rows = stmt.query_map(params![project_id], |row| row.get::<_, String>(0))?; let mut all_cats = std::collections::BTreeSet::new(); for json_str in rows { - if let Ok(json_str) = json_str { - if let Ok(cats) = serde_json::from_str::>(&json_str) { - all_cats.extend(cats); - } + if let Ok(json_str) = json_str + && let Ok(cats) = serde_json::from_str::>(&json_str) + { + all_cats.extend(cats); } } Ok(all_cats.into_iter().collect()) @@ -481,8 +452,8 @@ pub fn distinct_post_categories( #[cfg(test)] mod tests { use super::*; - use crate::db::queries::project::{insert_project, make_test_project}; use crate::db::Database; + use crate::db::queries::project::{insert_project, make_test_project}; fn setup() -> Database { let mut db = Database::open_in_memory().unwrap(); @@ -603,9 +574,17 @@ mod tests { let db = setup(); insert_post(db.conn(), &make_post("x1", "hello")).unwrap(); set_published_snapshot( - db.conn(), "x1", "Pub Title", "Pub Body", - "[\"rust\"]", "[\"tech\"]", Some("Pub Excerpt"), 3000, 3000, - ).unwrap(); + db.conn(), + "x1", + "Pub Title", + "Pub Body", + "[\"rust\"]", + "[\"tech\"]", + Some("Pub Excerpt"), + 3000, + 3000, + ) + .unwrap(); let fetched = get_post_by_id(db.conn(), "x1").unwrap(); assert_eq!(fetched.published_title.as_deref(), Some("Pub Title")); assert_eq!(fetched.published_content.as_deref(), Some("Pub Body")); diff --git a/crates/bds-core/src/db/queries/post_link.rs b/crates/bds-core/src/db/queries/post_link.rs index 65daa91..3f5b263 100644 --- a/crates/bds-core/src/db/queries/post_link.rs +++ b/crates/bds-core/src/db/queries/post_link.rs @@ -1,6 +1,6 @@ -use rusqlite::{params, Connection}; +use rusqlite::{Connection, params}; -use crate::db::from_row::{post_link_from_row, POST_LINK_COLUMNS}; +use crate::db::from_row::{POST_LINK_COLUMNS, post_link_from_row}; use crate::model::PostLink; pub fn insert_post_link(conn: &Connection, link: &PostLink) -> rusqlite::Result<()> { @@ -51,8 +51,8 @@ pub fn list_links_by_target( #[cfg(test)] mod tests { use super::*; - use crate::db::queries::project::{insert_project, make_test_project}; use crate::db::Database; + use crate::db::queries::project::{insert_project, make_test_project}; fn setup() -> Database { let mut db = Database::open_in_memory().unwrap(); @@ -63,17 +63,20 @@ mod tests { "INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at) VALUES ('a', 'p1', 'A', 'a', 'draft', 1000, 1000)", [], - ).unwrap(); + ) + .unwrap(); c.execute( "INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at) VALUES ('b', 'p1', 'B', 'b', 'draft', 1000, 1000)", [], - ).unwrap(); + ) + .unwrap(); c.execute( "INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at) VALUES ('c', 'p1', 'C', 'c', 'draft', 1000, 1000)", [], - ).unwrap(); + ) + .unwrap(); db } diff --git a/crates/bds-core/src/db/queries/post_media.rs b/crates/bds-core/src/db/queries/post_media.rs index 7712cab..c042712 100644 --- a/crates/bds-core/src/db/queries/post_media.rs +++ b/crates/bds-core/src/db/queries/post_media.rs @@ -1,6 +1,6 @@ -use rusqlite::{params, Connection}; +use rusqlite::{Connection, params}; -use crate::db::from_row::{post_media_from_row, POST_MEDIA_COLUMNS}; +use crate::db::from_row::{POST_MEDIA_COLUMNS, post_media_from_row}; use crate::model::PostMedia; pub fn link_media(conn: &Connection, pm: &PostMedia) -> rusqlite::Result<()> { @@ -65,9 +65,9 @@ pub fn update_sort_order( #[cfg(test)] mod tests { use super::*; + use crate::db::Database; use crate::db::queries::media::{insert_media, make_test_media}; use crate::db::queries::project::{insert_project, make_test_project}; - use crate::db::Database; fn setup() -> Database { let mut db = Database::open_in_memory().unwrap(); diff --git a/crates/bds-core/src/db/queries/post_translation.rs b/crates/bds-core/src/db/queries/post_translation.rs index 6b564c6..62cf421 100644 --- a/crates/bds-core/src/db/queries/post_translation.rs +++ b/crates/bds-core/src/db/queries/post_translation.rs @@ -1,12 +1,11 @@ -use rusqlite::{params, Connection}; +use rusqlite::{Connection, params}; -use crate::db::from_row::{post_status_to_str, post_translation_from_row, POST_TRANSLATION_COLUMNS}; +use crate::db::from_row::{ + POST_TRANSLATION_COLUMNS, post_status_to_str, post_translation_from_row, +}; use crate::model::PostTranslation; -pub fn insert_post_translation( - conn: &Connection, - t: &PostTranslation, -) -> rusqlite::Result<()> { +pub fn insert_post_translation(conn: &Connection, t: &PostTranslation) -> rusqlite::Result<()> { if !t.status.is_valid_for_translation() { return Err(rusqlite::Error::InvalidParameterName( "translation status must be draft or published".to_string(), @@ -74,10 +73,7 @@ pub fn list_post_translations_by_post( rows.collect() } -pub fn update_post_translation( - conn: &Connection, - t: &PostTranslation, -) -> rusqlite::Result<()> { +pub fn update_post_translation(conn: &Connection, t: &PostTranslation) -> rusqlite::Result<()> { if !t.status.is_valid_for_translation() { return Err(rusqlite::Error::InvalidParameterName( "translation status must be draft or published".to_string(), @@ -104,10 +100,7 @@ pub fn update_post_translation( } pub fn delete_post_translation(conn: &Connection, id: &str) -> rusqlite::Result<()> { - conn.execute( - "DELETE FROM post_translations WHERE id = ?1", - params![id], - )?; + conn.execute("DELETE FROM post_translations WHERE id = ?1", params![id])?; Ok(()) } @@ -125,8 +118,8 @@ pub fn delete_all_translations_for_post( #[cfg(test)] mod tests { use super::*; - use crate::db::queries::project::{insert_project, make_test_project}; use crate::db::Database; + use crate::db::queries::project::{insert_project, make_test_project}; use crate::model::PostStatus; fn setup() -> Database { diff --git a/crates/bds-core/src/db/queries/project.rs b/crates/bds-core/src/db/queries/project.rs index 2aebe1e..8d106ce 100644 --- a/crates/bds-core/src/db/queries/project.rs +++ b/crates/bds-core/src/db/queries/project.rs @@ -1,6 +1,6 @@ -use rusqlite::{params, Connection}; +use rusqlite::{Connection, params}; -use crate::db::from_row::{project_from_row, PROJECT_COLUMNS}; +use crate::db::from_row::{PROJECT_COLUMNS, project_from_row}; use crate::model::Project; pub fn insert_project(conn: &Connection, project: &Project) -> rusqlite::Result<()> { diff --git a/crates/bds-core/src/db/queries/script.rs b/crates/bds-core/src/db/queries/script.rs index 27ee54c..5ee9a90 100644 --- a/crates/bds-core/src/db/queries/script.rs +++ b/crates/bds-core/src/db/queries/script.rs @@ -1,7 +1,7 @@ -use rusqlite::{params, Connection}; +use rusqlite::{Connection, params}; use crate::db::from_row::{ - script_from_row, script_kind_to_str, script_status_to_str, SCRIPT_COLUMNS, + SCRIPT_COLUMNS, script_from_row, script_kind_to_str, script_status_to_str, }; use crate::model::Script; @@ -44,9 +44,7 @@ pub fn get_script_by_slug( slug: &str, ) -> rusqlite::Result