diff --git a/.zed/settings.json b/.zed/settings.json deleted file mode 100644 index e20b1da..0000000 --- a/.zed/settings.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "lsp": { - "rust-analyzer": { - "initialization_options": { - "rustfmt": { - "overrideCommand": ["leptosfmt", "--stdin", "--rustfmt"] - } - } - } - } -} diff --git a/Cargo.lock b/Cargo.lock index 817db54..65fd528 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1166,6 +1166,16 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + [[package]] name = "num-bigint-dig" version = "0.8.4" @@ -1225,6 +1235,12 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "parking" version = "2.2.1" @@ -1536,6 +1552,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1946,6 +1971,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", ] [[package]] @@ -2028,6 +2079,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vanth" version = "0.1.0" @@ -2041,6 +2098,7 @@ dependencies = [ "serde_json", "sqlx", "tempfile", + "tracing", "vanth_derive", ] @@ -2049,6 +2107,10 @@ name = "vanth_cli" version = "0.1.0" dependencies = [ "clap", + "serde_json", + "tempfile", + "tracing", + "tracing-subscriber", "vanth", ] @@ -2211,6 +2273,28 @@ dependencies = [ "wasite", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-sys" version = "0.48.0" diff --git a/Cargo.toml b/Cargo.toml index 767c87e..a65855b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,3 +23,5 @@ rusqlite = { version = "0.32.1", features = ["bundled"] } tempfile = "3.12.0" rand_core = "0.6.4" rand_chacha = { version = "0.3.1", features = ["serde1"] } +tracing = "0.1.41" +tracing-subscriber = "0.3.19" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 69a33d1..8ebdf94 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -5,4 +5,10 @@ edition.workspace = true [dependencies] clap.workspace = true +serde_json.workspace = true vanth = { path = "../vanth" } +tracing.workspace = true +tracing-subscriber.workspace = true + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 73dd09a..6af5562 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -1,4 +1,9 @@ -use clap::{Parser, Subcommand}; +use clap::{Args, Parser, Subcommand}; +use std::io::{self, Read}; +use std::path::PathBuf; +use std::process; +use vanth::{ContentHash, store::Store, Ty}; +use vanth::hash as vanth_hash; #[derive(Parser, Debug)] #[command(name = "vanth")] @@ -10,38 +15,187 @@ pub struct Cli { #[derive(Subcommand, Debug)] pub enum Commands { - #[command(about = "File system operations")] - Fs { - #[command(subcommand)] - command: FsCommands, - }, + #[command(about = "Write a value to the store")] + Write(WriteArgs), + #[command(about = "Get a value from the store")] + Get(GetArgs), + #[command(about = "Get all values of a type from the store")] + GetAll(GetAllArgs), + #[command(about = "Delete a value by hash from all types")] + Delete(DeleteArgs), + #[command(about = "Delete all values of a type")] + DeleteAll(DeleteAllArgs), } -#[derive(Subcommand, Debug)] -pub enum FsCommands { - #[command(about = "Read from path and print JSON to stdout")] - Open { - #[arg(help = "Path to read from")] - path: String, - }, - #[command(about = "Take JSON from stdin and write to path")] - Save { - #[arg(help = "Path to write to")] - path: String, - }, +#[derive(Args, Debug)] +pub struct WriteArgs { + #[arg(long, help = "Database file path")] + db: PathBuf, + #[arg(long, help = "Type name, e.g., path::to::Type")] + ty: String, + #[arg(long, help = "JSON value to write, optional (read from stdin if omitted)")] + value: Option, +} + +#[derive(Args, Debug)] +pub struct GetArgs { + #[arg(long, help = "Database file path")] + db: PathBuf, + #[arg(long, help = "Type name, e.g., path::to::Type")] + ty: String, + #[arg(help = "Content hash as 64-character hex string")] + content_hash: String, +} + +#[derive(Args, Debug)] +pub struct GetAllArgs { + #[arg(long, help = "Database file path")] + db: PathBuf, + #[arg(long, help = "Type name, e.g., path::to::Type")] + ty: String, +} + +#[derive(Args, Debug)] +pub struct DeleteArgs { + #[arg(long, help = "Database file path")] + db: PathBuf, + #[arg(help = "Content hash as 64-character hex string")] + content_hash: String, +} + +#[derive(Args, Debug)] +pub struct DeleteAllArgs { + #[arg(long, help = "Database file path")] + db: PathBuf, + #[arg(long, help = "Type name, e.g., path::to::Type")] + ty: String, } pub fn execute(cli: Cli) { match cli.command { - Commands::Fs { command } => match command { - FsCommands::Open { path } => { - // TODO: implement fs open functionality - println!("fs open: {}", path); - } - FsCommands::Save { path } => { - // TODO: implement fs save functionality - println!("fs save: {}", path); - } - }, + Commands::Write(args) => handle_write(&args), + Commands::Get(args) => handle_get(&args), + Commands::GetAll(args) => handle_get_all(&args), + Commands::Delete(args) => handle_delete(&args), + Commands::DeleteAll(args) => handle_delete_all(&args), } } + +fn parse_ty(s: &str) -> Ty { + Ty { + path: s.split("::").map(|p| p.to_string()).collect(), + } +} + +fn parse_hash(s: &str) -> ContentHash { + if s.len() != 64 { + eprintln!("Hash must be exactly 64 hexadecimal characters"); + process::exit(1); + } + let mut hash = [0u8; 32]; + for (i, byte) in hash.iter_mut().enumerate() { + let hex_slice = &s[i * 2..i * 2 + 2]; + *byte = u8::from_str_radix(hex_slice, 16).unwrap_or_else(|_| { + eprintln!("Invalid hexadecimal in hash: {}", hex_slice); + process::exit(1); + }); + } + ContentHash { hash } +} + +fn handle_write(args: &WriteArgs) { + let mut store = Store::sqlite_from_path(args.db.clone()).unwrap_or_else(|e| { + eprintln!("Error opening store: {:?}", e); + process::exit(1); + }); + let ty = parse_ty(&args.ty); + + let mut content = String::new(); + if let Some(val) = &args.value { + content = val.clone(); + } else { + io::stdin().read_to_string(&mut content).unwrap_or_else(|e| { + eprintln!("Error reading from stdin: {}", e); + process::exit(1); + }); + } + + let value: serde_json::Value = serde_json::from_str(&content).unwrap_or_else(|e| { + eprintln!("Invalid JSON: {}", e); + process::exit(1); + }); + let data = serde_json::to_vec(&value).unwrap(); + let content_hash = vanth_hash(&value); + + store.write_raw(ty, content_hash, data).unwrap_or_else(|e| { + eprintln!("Error writing to store: {:?}", e); + process::exit(1); + }); +} + +fn handle_get(args: &GetArgs) { + let mut store = Store::sqlite_from_path(args.db.clone()).unwrap_or_else(|e| { + eprintln!("Error opening store: {:?}", e); + process::exit(1); + }); + let ty = parse_ty(&args.ty); + let content_hash = parse_hash(&args.content_hash); + + let raw = store.get_from_hash_raw(ty, content_hash).unwrap_or_else(|e| { + eprintln!("Error getting from store: {:?}", e); + process::exit(1); + }); + match raw { + Some(data) => { + let output = String::from_utf8(data).unwrap_or_else(|e| { + eprintln!("Invalid UTF-8 in data: {}", e); + process::exit(1); + }); + println!("{}", output); + } + None => { + process::exit(1); + } + } +} + +fn handle_get_all(args: &GetAllArgs) { + let mut store = Store::sqlite_from_path(args.db.clone()).unwrap_or_else(|e| { + eprintln!("Error opening store: {:?}", e); + process::exit(1); + }); + let ty = parse_ty(&args.ty); + + let items = store.get_all_of_type_raw(ty).unwrap_or_else(|e| { + eprintln!("Error getting all from store: {:?}", e); + process::exit(1); + }); + for (_, data) in items { + let output = String::from_utf8(data).unwrap_or_else(|e| { + eprintln!("Invalid UTF-8 in data: {}", e); + process::exit(1); + }); + println!("{}", output); + } +} + +fn handle_delete(args: &DeleteArgs) { + let mut store = Store::sqlite_from_path(args.db.clone()).unwrap_or_else(|e| { + eprintln!("Error opening store: {:?}", e); + process::exit(1); + }); + let content_hash = parse_hash(&args.content_hash); +} + +fn handle_delete_all(args: &DeleteAllArgs) { + let mut store = Store::sqlite_from_path(args.db.clone()).unwrap_or_else(|e| { + eprintln!("Error opening store: {:?}", e); + process::exit(1); + }); + let ty = parse_ty(&args.ty); + + store.delete_all_raw(ty).unwrap_or_else(|e| { + eprintln!("Error deleting all from store: {:?}", e); + process::exit(1); + }); +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 79a19de..241d5fb 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -2,9 +2,13 @@ use clap::Parser; mod cli; -use cli::Cli; +pub use cli::*; fn main() { + tracing_subscriber::fmt() + .with_max_level(tracing::Level::TRACE) + .init(); + let cli = Cli::parse(); cli::execute(cli); } diff --git a/crates/cli/tests/integration/main.rs b/crates/cli/tests/integration/main.rs new file mode 100644 index 0000000..e69de29 diff --git a/crates/vanth/Cargo.toml b/crates/vanth/Cargo.toml index 9748cf6..14bcd20 100644 --- a/crates/vanth/Cargo.toml +++ b/crates/vanth/Cargo.toml @@ -15,7 +15,8 @@ serde_json.workspace = true blake3.workspace = true vanth_derive = { path = "../vanth_derive" } sqlx.workspace = true -rusqlite = { workspace = true } +rusqlite.workspace = true +tracing.workspace = true [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/vanth/src/store.rs b/crates/vanth/src/store.rs index 63ba1e6..f925743 100644 --- a/crates/vanth/src/store.rs +++ b/crates/vanth/src/store.rs @@ -4,6 +4,7 @@ use rusqlite::{Connection, named_params, params}; use bevy_ecs::prelude::*; use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use tracing::trace; use crate::{ComponentContents, ContentHash, Ty, Vanth, hash}; @@ -17,7 +18,8 @@ type Result = std::result::Result; #[derive(Debug, Deserialize, Serialize)] pub enum Error { Serializiation(String), - Database(String), + SqliteTableDoesNotExist { table_name: String }, + SqliteUnknown(String), } impl From for Error { @@ -28,13 +30,36 @@ impl From for Error { impl From for Error { fn from(err: rusqlite::Error) -> Self { - Error::Database(err.to_string()) + if let rusqlite::Error::SqliteFailure(_, Some(ref message)) = err + && let Some(table_name) = message.strip_prefix("no such table: ") + { + return Error::SqliteTableDoesNotExist { + table_name: table_name.into(), + }; + } + Error::SqliteUnknown(err.to_string()) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct StoreParams { + pub create_if_not_exists: bool, + pub read_only: bool, +} + +impl Default for StoreParams { + fn default() -> Self { + Self { + create_if_not_exists: true, + read_only: false, + } } } impl Store { /// Use an SQLite backend with a database file at the provided path. - pub fn from_path(path: PathBuf) -> Result { + // TODO: Params + pub fn sqlite_from_path(path: PathBuf) -> Result { Ok(Self { backend: Box::new(Sqlite::new(path)?), }) @@ -48,7 +73,7 @@ impl Store { } pub fn get_from_hash(&mut self, content_hash: ContentHash) -> Result> { - let Some(raw) = self.get_from_hash_raw::(content_hash)? else { + let Some(raw) = self.get_from_hash_raw(T::ty(), content_hash)? else { return Ok(None); }; @@ -56,8 +81,8 @@ impl Store { Ok(Some(deserialized)) } - pub fn get_from_hash_raw(&mut self, content_hash: ContentHash) -> Result>> { - self.backend.get_from_hash(T::ty(), content_hash) + pub fn get_from_hash_raw(&mut self, ty: Ty, content_hash: ContentHash) -> Result>> { + self.backend.get_from_hash(ty, content_hash) } pub fn get_all_of_type(&mut self) -> Result>> { @@ -79,8 +104,8 @@ impl Store { self.backend.write(T::ty(), content_hash, data) } - pub fn write_raw(&mut self, content_hash: ContentHash, content: Vec) -> Result<()> { - self.backend.write(T::ty(), content_hash, content) + pub fn write_raw(&mut self, ty: Ty, content_hash: ContentHash, content: Vec) -> Result<()> { + self.backend.write(ty, content_hash, content) } pub fn delete(&mut self, content_hash: ContentHash) -> Result<()> { @@ -90,6 +115,18 @@ impl Store { pub fn delete_all(&mut self) -> Result<()> { self.backend.delete_all_of_ty(T::ty()) } + + pub fn get_all_of_type_raw(&mut self, ty: Ty) -> Result)>> { + self.backend.get_all_of_ty(ty) + } + + pub fn delete_raw(&mut self, ty: Ty, content_hash: ContentHash) -> Result<()> { + self.backend.delete_by_hash(ty, content_hash) + } + + pub fn delete_all_raw(&mut self, ty: Ty) -> Result<()> { + self.backend.delete_all_of_ty(ty) + } } #[derive(Debug, Deserialize, Component, Serialize)] @@ -113,26 +150,18 @@ pub trait Backend: std::fmt::Debug { /// One table per type. Keys and values are both blobs. #[derive(Debug)] pub struct Sqlite { - conn: Connection, + connection: Connection, } impl Sqlite { pub fn new(path: PathBuf) -> Result { - let conn = Connection::open(path)?; - Ok(Self { conn }) - } - - fn ensure_table_exists(&self, ty: &Ty) -> Result<()> { - let table_name = Self::table_name(ty); - let query = format!( - "CREATE TABLE IF NOT EXISTS \"{}\" ( - content_hash BLOB PRIMARY KEY, - content BLOB NOT NULL - )", - table_name - ); - self.conn.execute(&query, [])?; - Ok(()) + use rusqlite::OpenFlags; + // Remove the `SQLITE_OPEN_CREATE` flag because we do not want to create databases if they don't exist. + let connection = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_URI | OpenFlags::SQLITE_OPEN_NO_MUTEX, + )?; + Ok(Self { connection }) } fn table_name(ty: &Ty) -> String { @@ -142,12 +171,11 @@ impl Sqlite { impl Backend for Sqlite { fn get_from_hash(&mut self, ty: Ty, content_hash: ContentHash) -> Result>> { - self.ensure_table_exists(&ty)?; let table_name = Self::table_name(&ty); let query = format!("SELECT content FROM \"{}\" WHERE content_hash = :hash", table_name); match self - .conn + .connection .query_row(&query, named_params! {":hash": content_hash.hash.as_slice()}, |row| { row.get::<_, Vec>(0) }) { @@ -158,12 +186,18 @@ impl Backend for Sqlite { } fn get_all_of_ty(&mut self, ty: Ty) -> Result)>> { - self.ensure_table_exists(&ty)?; + let transaction = self.connection.transaction()?; let table_name = Self::table_name(&ty); let query = format!("SELECT content_hash, content FROM \"{}\"", table_name); - let mut stmt = self.conn.prepare(&query)?; - let rows = stmt.query_map([], |row| { + trace!("Reading table {}", table_name); + + let mut statement = match transaction.prepare(&query).map_err(Into::into) { + Err(Error::SqliteTableDoesNotExist { .. }) => return Ok(Vec::new()), + other => other?, + }; + + let rows = statement.query_map([], |row| { let hash_bytes: Vec = row.get(0)?; let content: Vec = row.get(1)?; let mut hash_array = [0u8; 32]; @@ -175,17 +209,28 @@ impl Backend for Sqlite { for row in rows { results.push(row?); } - Ok(results) + + drop(statement); + + transaction.commit()?; + Ok(Vec::new()) } fn write(&mut self, ty: Ty, content_hash: ContentHash, content: Vec) -> Result<()> { - self.ensure_table_exists(&ty)?; let table_name = Self::table_name(&ty); + let create_table_query = format!( + "CREATE TABLE IF NOT EXISTS \"{}\" ( + content_hash BLOB PRIMARY KEY, + content BLOB NOT NULL + )", + table_name + ); + self.connection.execute(&create_table_query, [])?; let query = format!( "INSERT OR REPLACE INTO \"{}\" (content_hash, content) VALUES (:hash, :content)", table_name ); - self.conn.execute( + self.connection.execute( &query, named_params! {":hash": content_hash.hash.as_slice(), ":content": content}, )?; @@ -193,10 +238,9 @@ impl Backend for Sqlite { } fn delete_by_hash(&mut self, ty: Ty, content_hash: ContentHash) -> Result<()> { - self.ensure_table_exists(&ty)?; let table_name = Self::table_name(&ty); let query = format!("DELETE FROM \"{}\" WHERE content_hash = :hash", table_name); - self.conn + self.connection .execute(&query, named_params! {":hash": content_hash.hash.as_slice()})?; Ok(()) } @@ -204,7 +248,7 @@ impl Backend for Sqlite { fn delete_all_of_ty(&mut self, ty: Ty) -> Result<()> { let table_name = Self::table_name(&ty); let query = format!("DROP TABLE IF EXISTS \"{}\"", table_name); - self.conn.execute(&query, [])?; + self.connection.execute(&query, [])?; Ok(()) } } diff --git a/crates/vanth/tests/integration/store.rs b/crates/vanth/tests/integration/store.rs index c71a992..ff57d89 100644 --- a/crates/vanth/tests/integration/store.rs +++ b/crates/vanth/tests/integration/store.rs @@ -17,7 +17,7 @@ struct Bar { fn test_sqlite_store() { let dir = TempDir::new().unwrap(); let path = dir.path().join("test.db"); - let mut store = Store::from_path(path.clone()).unwrap(); + let mut store = Store::sqlite_from_path(path.clone()).unwrap(); let foo_1 = Foo { inner: 1 }; let foo_2 = Foo { inner: 2 };