diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..957a601 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,604 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "cc" +version = "1.2.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "js-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pai" +version = "0.1.0" +dependencies = [ + "chrono", + "clap", + "dirs", + "pai-core", + "rusqlite", +] + +[[package]] +name = "pai-core" +version = "0.1.0" +dependencies = [ + "thiserror", +] + +[[package]] +name = "pai-worker" +version = "0.1.0" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/Cargo.toml b/Cargo.toml index f88d050..2b9e25e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,38 @@ [workspace] resolver = "2" members = ["cli", "core", "worker"] + +[workspace.lints.clippy] +bool_comparison = "deny" +duplicate_mod = "deny" +inconsistent_struct_constructor = "deny" +invalid_regex = "deny" +mem_forget = "deny" +mixed_case_hex_literals = "deny" +suspicious_arithmetic_impl = "deny" +uninit_assumed_init = "deny" +suspicious_else_formatting = "deny" +suspicious_op_assign_impl = "deny" +suspicious_to_owned = "deny" +cmp_owned = "deny" +cmp_null = "deny" +manual_map = "deny" + +too_many_arguments = "warn" +cognitive_complexity = "warn" +large_enum_variant = "warn" +needless_borrow = "warn" +needless_pass_by_value = "warn" +redundant_clone = "warn" +unnecessary_cast = "warn" +inefficient_to_string = "warn" +or_fun_call = "warn" +unnecessary_to_owned = "warn" +map_clone = "warn" +flat_map_identity = "warn" +needless_collect = "warn" +vec_init_then_push = "warn" + +len_zero = "allow" +range_plus_one = "allow" +manual_range_contains = "allow" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index dccdf56..c23d953 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,15 @@ [package] -name = "pai-cli" +name = "pai" version = "0.1.0" -edition = "2024" +edition = "2021" + +[[bin]] +name = "pai" +path = "src/main.rs" [dependencies] +pai-core = { path = "../core" } +clap = { version = "4.5", features = ["derive"] } +rusqlite = { version = "0.37", features = ["bundled"] } +chrono = "0.4" +dirs = "6.0" diff --git a/cli/src/main.rs b/cli/src/main.rs index e7a11a9..7cbc05e 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,3 +1,221 @@ +mod paths; +mod storage; + +use clap::{Parser, Subcommand}; +use pai_core::{Config, ListFilter, PaiError, SourceKind}; +use std::path::PathBuf; +use storage::SqliteStorage; + +/// Personal Activity Index - POSIX-style CLI for content aggregation +#[derive(Parser, Debug)] +#[command(name = "pai")] +#[command(version, about, long_about = None)] +struct Cli { + /// Set configuration directory + #[arg(short = 'C', value_name = "DIR", global = true)] + config_dir: Option, + + /// Path to SQLite database file + #[arg(short = 'd', value_name = "PATH", global = true)] + db_path: Option, + + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// Fetch and store content from configured sources + Sync { + /// Sync all configured sources (default) + #[arg(short = 'a')] + all: bool, + + /// Sync only a particular source kind + #[arg(short = 'k', value_name = "KIND")] + kind: Option, + + /// Sync only a specific source instance + #[arg(short = 'S', value_name = "ID")] + source_id: Option, + }, + + /// Inspect stored items + List { + /// Filter by source kind + #[arg(short = 'k', value_name = "KIND")] + kind: Option, + + /// Filter by specific source ID + #[arg(short = 'S', value_name = "ID")] + source_id: Option, + + /// Maximum number of items to display + #[arg(short = 'n', value_name = "NUMBER", default_value = "20")] + limit: usize, + + /// Only show items published at or after this time + #[arg(short = 's', value_name = "TIME")] + since: Option, + + /// Filter items by substring in title/summary + #[arg(short = 'q', value_name = "PATTERN")] + query: Option, + }, + + /// Produce feeds or export files + Export { + /// Filter by source kind + #[arg(short = 'k', value_name = "KIND")] + kind: Option, + + /// Filter by specific source ID + #[arg(short = 'S', value_name = "ID")] + source_id: Option, + + /// Maximum number of items + #[arg(short = 'n', value_name = "NUMBER")] + limit: Option, + + /// Only items published at or after this time + #[arg(short = 's', value_name = "TIME")] + since: Option, + + /// Filter items by substring + #[arg(short = 'q', value_name = "PATTERN")] + query: Option, + + /// Output format + #[arg(short = 'f', value_name = "FORMAT", default_value = "json")] + format: String, + + /// Output file (default: stdout) + #[arg(short = 'o', value_name = "FILE")] + output: Option, + }, + + /// Self-host HTTP API + Serve { + /// Address to bind HTTP server to + #[arg(short = 'a', value_name = "ADDRESS", default_value = "127.0.0.1:8080")] + address: String, + }, + + /// Verify database schema and print statistics + DbCheck, +} + fn main() { - println!("Hello, world!"); + let cli = Cli::parse(); + + let result = match cli.command { + Commands::Sync { all, kind, source_id } => handle_sync(cli.config_dir, cli.db_path, all, kind, source_id), + Commands::List { kind, source_id, limit, since, query } => { + handle_list(cli.db_path, kind, source_id, limit, since, query) + } + Commands::Export { kind, source_id, limit, since, query, format, output } => { + handle_export(cli.db_path, kind, source_id, limit, since, query, format, output) + } + Commands::Serve { address } => handle_serve(cli.db_path, address), + Commands::DbCheck => handle_db_check(cli.db_path), + }; + + if let Err(e) = result { + eprintln!("Error: {e}"); + std::process::exit(1); + } +} + +fn handle_sync( + config_dir: Option, db_path: Option, _all: bool, _kind: Option, + _source_id: Option, +) -> Result<(), PaiError> { + let db_path = paths::resolve_db_path(db_path)?; + let _config_dir = paths::resolve_config_dir(config_dir)?; + + let storage = SqliteStorage::new(db_path)?; + let config = Config::default(); + + let count = pai_core::sync_all_sources(&config, &storage)?; + + println!("Synced {count} items"); + Ok(()) +} + +fn handle_list( + db_path: Option, kind: Option, source_id: Option, limit: usize, since: Option, + query: Option, +) -> Result<(), PaiError> { + let db_path = paths::resolve_db_path(db_path)?; + let storage = SqliteStorage::new(db_path)?; + + let filter = ListFilter { source_kind: kind, source_id, limit: Some(limit), since, query }; + + let items = pai_core::Storage::list_items(&storage, &filter)?; + + if items.is_empty() { + println!("No items found"); + return Ok(()); + } + + println!("Found {} items:\n", items.len()); + for item in items { + println!("ID: {}", item.id); + println!("Source: {} ({})", item.source_kind, item.source_id); + if let Some(title) = &item.title { + println!("Title: {title}"); + } + if let Some(author) = &item.author { + println!("Author: {author}"); + } + println!("URL: {}", item.url); + println!("Published: {}", item.published_at); + println!(); + } + + Ok(()) +} + +fn handle_export( + db_path: Option, kind: Option, source_id: Option, limit: Option, + since: Option, query: Option, format: String, output: Option, +) -> Result<(), PaiError> { + let db_path = paths::resolve_db_path(db_path)?; + let _storage = SqliteStorage::new(db_path)?; + + let filter = ListFilter { source_kind: kind, source_id, limit, since, query }; + + println!("export command - format: {format}, output: {output:?}, filter: {filter:?}"); + Ok(()) +} + +fn handle_serve(db_path: Option, address: String) -> Result<(), PaiError> { + let db_path = paths::resolve_db_path(db_path)?; + let _storage = SqliteStorage::new(db_path)?; + + println!("serve command - address: {address}"); + Ok(()) +} + +fn handle_db_check(db_path: Option) -> Result<(), PaiError> { + let db_path = paths::resolve_db_path(db_path)?; + let storage = SqliteStorage::new(db_path)?; + + println!("Verifying database schema..."); + storage.verify_schema()?; + println!("Schema verification: OK\n"); + + println!("Database statistics:"); + let total = storage.count_items()?; + println!(" Total items: {total}"); + + let stats = storage.get_stats()?; + if !stats.is_empty() { + println!("\nItems by source:"); + for (source_kind, count) in stats { + println!(" {source_kind}: {count}"); + } + } + + Ok(()) } diff --git a/cli/src/paths.rs b/cli/src/paths.rs new file mode 100644 index 0000000..dbb61e2 --- /dev/null +++ b/cli/src/paths.rs @@ -0,0 +1,80 @@ +use pai_core::{PaiError, Result}; +use std::path::PathBuf; + +/// Resolves the database file path, with XDG fallback +/// +/// Priority order: +/// 1. Explicit path provided via `-d` flag +/// 2. $XDG_DATA_HOME/pai/pai.db +/// 3. $HOME/.local/share/pai/pai.db +pub fn resolve_db_path(explicit_path: Option) -> Result { + if let Some(path) = explicit_path { + return Ok(path); + } + + if let Some(data_home) = dirs::data_dir() { + return Ok(data_home.join("pai").join("pai.db")); + } + + Err(PaiError::Config( + "Unable to determine database path: no XDG_DATA_HOME or HOME set".to_string(), + )) +} + +/// Resolves the config directory path, with XDG fallback +/// +/// Priority order: +/// 1. Explicit directory provided via `-C` flag +/// 2. $XDG_CONFIG_HOME/pai +/// 3. $HOME/.config/pai +pub fn resolve_config_dir(explicit_dir: Option) -> Result { + if let Some(dir) = explicit_dir { + return Ok(dir); + } + + if let Some(config_home) = dirs::config_dir() { + return Ok(config_home.join("pai")); + } + + Err(PaiError::Config( + "Unable to determine config directory: no XDG_CONFIG_HOME or HOME set".to_string(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn resolve_db_path_with_explicit() { + let explicit = Some(PathBuf::from("/custom/path/db.sqlite")); + let result = resolve_db_path(explicit).unwrap(); + assert_eq!(result, Path::new("/custom/path/db.sqlite")); + } + + #[test] + fn resolve_db_path_falls_back() { + let result = resolve_db_path(None); + assert!(result.is_ok()); + + let path = result.unwrap(); + assert!(path.ends_with("pai/pai.db")); + } + + #[test] + fn resolve_config_dir_with_explicit() { + let explicit = Some(PathBuf::from("/custom/config")); + let result = resolve_config_dir(explicit).unwrap(); + assert_eq!(result, Path::new("/custom/config")); + } + + #[test] + fn resolve_config_dir_falls_back() { + let result = resolve_config_dir(None); + assert!(result.is_ok()); + + let path = result.unwrap(); + assert!(path.ends_with("pai")); + } +} diff --git a/cli/src/storage/mod.rs b/cli/src/storage/mod.rs new file mode 100644 index 0000000..2a4ab4d --- /dev/null +++ b/cli/src/storage/mod.rs @@ -0,0 +1,3 @@ +mod sqlite; + +pub use sqlite::SqliteStorage; diff --git a/cli/src/storage/sqlite.rs b/cli/src/storage/sqlite.rs new file mode 100644 index 0000000..65df86e --- /dev/null +++ b/cli/src/storage/sqlite.rs @@ -0,0 +1,407 @@ +use pai_core::{Item, ListFilter, PaiError, Result, SourceKind, Storage}; +use rusqlite::{params, Connection, OptionalExtension}; +use std::path::Path; + +const SCHEMA_VERSION: i32 = 1; + +const INIT_SQL: &str = r#" +CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY +); + +CREATE TABLE IF NOT EXISTS items ( + id TEXT PRIMARY KEY, + source_kind TEXT NOT NULL, + source_id TEXT NOT NULL, + author TEXT, + title TEXT, + summary TEXT, + url TEXT NOT NULL, + content_html TEXT, + published_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_items_source_date + ON items (source_kind, source_id, published_at DESC); +"#; + +/// SQLite implementation of the Storage trait +/// +/// Manages persistent storage of items in a local SQLite database. +/// Handles schema initialization and migrations automatically on first connection. +pub struct SqliteStorage { + conn: Connection, +} + +impl SqliteStorage { + /// Opens or creates a SQLite database at the given path + /// + /// Initializes the schema if the database is new or runs migrations if needed. + pub fn new>(path: P) -> Result { + let path_ref = path.as_ref(); + + if let Some(parent) = path_ref.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| PaiError::Storage(format!("Failed to create database directory: {e}")))?; + } + + let conn = Connection::open(path).map_err(|e| PaiError::Storage(format!("Failed to open database: {e}")))?; + + let mut storage = Self { conn }; + storage.init_schema()?; + Ok(storage) + } + + /// Initializes the database schema + /// + /// Creates tables and indexes if they don't exist, and sets up version tracking. + fn init_schema(&mut self) -> Result<()> { + self.conn + .execute_batch(INIT_SQL) + .map_err(|e| PaiError::Storage(format!("Failed to initialize schema: {e}")))?; + + let version: Option = self + .conn + .query_row("SELECT version FROM schema_version LIMIT 1", [], |row| row.get(0)) + .optional() + .map_err(|e| PaiError::Storage(format!("Failed to check schema version: {e}")))?; + + match version { + None => { + self.conn + .execute( + "INSERT INTO schema_version (version) VALUES (?1)", + params![SCHEMA_VERSION], + ) + .map_err(|e| PaiError::Storage(format!("Failed to set schema version: {e}")))?; + } + Some(v) if v < SCHEMA_VERSION => { + return Err(PaiError::Storage(format!( + "Database migration needed: current={v}, required={SCHEMA_VERSION}" + ))); + } + _ => {} + } + + Ok(()) + } + + /// Gets basic statistics about stored items + pub fn get_stats(&self) -> Result> { + let mut stmt = self + .conn + .prepare("SELECT source_kind, COUNT(*) FROM items GROUP BY source_kind ORDER BY source_kind") + .map_err(|e| PaiError::Storage(format!("Failed to prepare stats query: {e}")))?; + + let stats = stmt + .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, usize>(1)?))) + .map_err(|e| PaiError::Storage(format!("Failed to query stats: {e}")))? + .collect::, _>>() + .map_err(|e| PaiError::Storage(format!("Failed to collect stats: {e}")))?; + + Ok(stats) + } + + /// Gets total item count + pub fn count_items(&self) -> Result { + self.conn + .query_row("SELECT COUNT(*) FROM items", [], |row| row.get(0)) + .map_err(|e| PaiError::Storage(format!("Failed to count items: {e}"))) + } + + /// Verifies schema integrity + /// + /// Checks that required tables and indexes exist. + pub fn verify_schema(&self) -> Result<()> { + let tables = vec!["schema_version", "items"]; + for table in tables { + let exists: bool = self + .conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1", + params![table], + |row| { + let count: i32 = row.get(0)?; + Ok(count > 0) + }, + ) + .map_err(|e| PaiError::Storage(format!("Failed to verify table {table}: {e}")))?; + + if !exists { + return Err(PaiError::Storage(format!("Missing table: {table}"))); + } + } + + Ok(()) + } +} + +impl Storage for SqliteStorage { + fn insert_or_replace_item(&self, item: &Item) -> Result<()> { + self.conn + .execute( + "INSERT OR REPLACE INTO items + (id, source_kind, source_id, author, title, summary, url, content_html, published_at, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![ + item.id, + item.source_kind.to_string(), + item.source_id, + item.author, + item.title, + item.summary, + item.url, + item.content_html, + item.published_at, + item.created_at, + ], + ) + .map_err(|e| PaiError::Storage(format!("Failed to insert item: {e}")))?; + + Ok(()) + } + + fn list_items(&self, filter: &ListFilter) -> Result> { + let mut sql = String::from("SELECT id, source_kind, source_id, author, title, summary, url, content_html, published_at, created_at FROM items WHERE 1=1"); + let mut conditions = Vec::new(); + + if filter.source_kind.is_some() { + sql.push_str(" AND source_kind = ?"); + conditions.push(filter.source_kind.unwrap().to_string()); + } + + if let Some(ref source_id) = filter.source_id { + sql.push_str(" AND source_id = ?"); + conditions.push(source_id.clone()); + } + + if let Some(ref since) = filter.since { + sql.push_str(" AND published_at >= ?"); + conditions.push(since.clone()); + } + + if let Some(ref query) = filter.query { + sql.push_str(" AND (title LIKE ? OR summary LIKE ?)"); + let pattern = format!("%{query}%"); + conditions.push(pattern.clone()); + conditions.push(pattern); + } + + sql.push_str(" ORDER BY published_at DESC"); + + if let Some(limit) = filter.limit { + sql.push_str(&format!(" LIMIT {limit}")); + } + + let mut stmt = self + .conn + .prepare(&sql) + .map_err(|e| PaiError::Storage(format!("Failed to prepare query: {e}")))?; + + let params_refs: Vec<&dyn rusqlite::ToSql> = conditions.iter().map(|s| s as &dyn rusqlite::ToSql).collect(); + + let items = stmt + .query_map(params_refs.as_slice(), |row| { + let source_kind_str: String = row.get(1)?; + let source_kind = source_kind_str.parse::().map_err(|e| { + rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e)) + })?; + + Ok(Item { + id: row.get(0)?, + source_kind, + source_id: row.get(2)?, + author: row.get(3)?, + title: row.get(4)?, + summary: row.get(5)?, + url: row.get(6)?, + content_html: row.get(7)?, + published_at: row.get(8)?, + created_at: row.get(9)?, + }) + }) + .map_err(|e| PaiError::Storage(format!("Failed to query items: {e}")))? + .collect::, _>>() + .map_err(|e| PaiError::Storage(format!("Failed to collect items: {e}")))?; + + Ok(items) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + + fn create_test_storage() -> SqliteStorage { + SqliteStorage::new(":memory:").expect("Failed to create in-memory database") + } + + fn create_test_item(id: &str, source_kind: SourceKind, source_id: &str) -> Item { + Item { + id: id.to_string(), + source_kind, + source_id: source_id.to_string(), + author: Some("Test Author".to_string()), + title: Some("Test Title".to_string()), + summary: Some("Test summary".to_string()), + url: format!("https://example.com/{id}"), + content_html: Some("

Test content

".to_string()), + published_at: Utc::now().to_rfc3339(), + created_at: Utc::now().to_rfc3339(), + } + } + + #[test] + fn new_database_initializes_schema() { + let storage = create_test_storage(); + assert!(storage.verify_schema().is_ok()); + } + + #[test] + fn insert_and_retrieve_item() { + let storage = create_test_storage(); + let item = create_test_item("test-1", SourceKind::Substack, "test.substack.com"); + + storage.insert_or_replace_item(&item).expect("Failed to insert item"); + + let filter = ListFilter::default(); + let items = storage.list_items(&filter).expect("Failed to list items"); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].id, "test-1"); + assert_eq!(items[0].source_kind, SourceKind::Substack); + } + + #[test] + fn insert_replaces_existing_item() { + let storage = create_test_storage(); + let mut item = create_test_item("test-1", SourceKind::Substack, "test.substack.com"); + + storage.insert_or_replace_item(&item).expect("Failed to insert item"); + + item.title = Some("Updated Title".to_string()); + storage.insert_or_replace_item(&item).expect("Failed to replace item"); + + let filter = ListFilter::default(); + let items = storage.list_items(&filter).expect("Failed to list items"); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].title, Some("Updated Title".to_string())); + } + + #[test] + fn filter_by_source_kind() { + let storage = create_test_storage(); + + storage + .insert_or_replace_item(&create_test_item("test-1", SourceKind::Substack, "test.substack.com")) + .expect("Failed to insert"); + storage + .insert_or_replace_item(&create_test_item("test-2", SourceKind::Bluesky, "test.bsky.social")) + .expect("Failed to insert"); + + let filter = ListFilter { source_kind: Some(SourceKind::Substack), ..Default::default() }; + let items = storage.list_items(&filter).expect("Failed to list items"); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].source_kind, SourceKind::Substack); + } + + #[test] + fn filter_by_source_id() { + let storage = create_test_storage(); + + storage + .insert_or_replace_item(&create_test_item("test-1", SourceKind::Leaflet, "source1.leaflet.pub")) + .expect("Failed to insert"); + storage + .insert_or_replace_item(&create_test_item("test-2", SourceKind::Leaflet, "source2.leaflet.pub")) + .expect("Failed to insert"); + + let filter = ListFilter { source_id: Some("source1.leaflet.pub".to_string()), ..Default::default() }; + let items = storage.list_items(&filter).expect("Failed to list items"); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].source_id, "source1.leaflet.pub"); + } + + #[test] + fn filter_with_limit() { + let storage = create_test_storage(); + + for i in 0..5 { + storage + .insert_or_replace_item(&create_test_item( + &format!("test-{i}"), + SourceKind::Substack, + "test.substack.com", + )) + .expect("Failed to insert"); + } + + let filter = ListFilter { limit: Some(3), ..Default::default() }; + let items = storage.list_items(&filter).expect("Failed to list items"); + + assert_eq!(items.len(), 3); + } + + #[test] + fn filter_by_query() { + let storage = create_test_storage(); + + let mut item1 = create_test_item("test-1", SourceKind::Substack, "test.substack.com"); + item1.title = Some("Rust Programming".to_string()); + storage.insert_or_replace_item(&item1).expect("Failed to insert"); + + let mut item2 = create_test_item("test-2", SourceKind::Substack, "test.substack.com"); + item2.title = Some("Python Tutorial".to_string()); + storage.insert_or_replace_item(&item2).expect("Failed to insert"); + + let filter = ListFilter { query: Some("Rust".to_string()), ..Default::default() }; + let items = storage.list_items(&filter).expect("Failed to list items"); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].id, "test-1"); + } + + #[test] + fn get_stats_returns_counts_by_source() { + let storage = create_test_storage(); + + storage + .insert_or_replace_item(&create_test_item("test-1", SourceKind::Substack, "test.substack.com")) + .expect("Failed to insert"); + storage + .insert_or_replace_item(&create_test_item("test-2", SourceKind::Substack, "test.substack.com")) + .expect("Failed to insert"); + storage + .insert_or_replace_item(&create_test_item("test-3", SourceKind::Bluesky, "test.bsky.social")) + .expect("Failed to insert"); + + let stats = storage.get_stats().expect("Failed to get stats"); + + assert_eq!(stats.len(), 2); + assert!(stats.iter().any(|(k, v)| k == "bluesky" && *v == 1)); + assert!(stats.iter().any(|(k, v)| k == "substack" && *v == 2)); + } + + #[test] + fn count_items_returns_total() { + let storage = create_test_storage(); + + for i in 0..3 { + storage + .insert_or_replace_item(&create_test_item( + &format!("test-{i}"), + SourceKind::Substack, + "test.substack.com", + )) + .expect("Failed to insert"); + } + + let count = storage.count_items().expect("Failed to count items"); + assert_eq!(count, 3); + } +} diff --git a/core/Cargo.toml b/core/Cargo.toml index 0b5d798..3176eff 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "pai-core" version = "0.1.0" -edition = "2024" +edition = "2021" [dependencies] +thiserror = "2.0.17" diff --git a/core/src/lib.rs b/core/src/lib.rs index b93cf3f..b703270 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -1,5 +1,125 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right +use std::fmt; +use thiserror::Error; + +/// Errors that can occur in the Personal Activity Index +#[derive(Error, Debug)] +pub enum PaiError { + #[error("Unknown source kind: {0}")] + UnknownSourceKind(String), + + #[error("Storage error: {0}")] + Storage(String), + + #[error("Fetch error: {0}")] + Fetch(String), + + #[error("Parse error: {0}")] + Parse(String), + + #[error("Configuration error: {0}")] + Config(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), +} + +pub type Result = std::result::Result; + +/// Represents the different source types supported by the indexer +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SourceKind { + Substack, + Bluesky, + Leaflet, +} + +impl fmt::Display for SourceKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SourceKind::Substack => write!(f, "substack"), + SourceKind::Bluesky => write!(f, "bluesky"), + SourceKind::Leaflet => write!(f, "leaflet"), + } + } +} + +impl std::str::FromStr for SourceKind { + type Err = PaiError; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "substack" => Ok(SourceKind::Substack), + "bluesky" => Ok(SourceKind::Bluesky), + "leaflet" => Ok(SourceKind::Leaflet), + _ => Err(PaiError::UnknownSourceKind(s.to_string())), + } + } +} + +/// Represents a single content item from any source +#[derive(Debug, Clone)] +pub struct Item { + /// Unique identifier for the item + pub id: String, + /// The source type this item came from + pub source_kind: SourceKind, + /// The specific source instance identifier (e.g., domain or handle) + pub source_id: String, + /// Author of the content + pub author: Option, + /// Title of the content + pub title: Option, + /// Summary or excerpt of the content + pub summary: Option, + /// Canonical URL for the content + pub url: String, + /// Full HTML content + pub content_html: Option, + /// When the content was published (ISO 8601) + pub published_at: String, + /// When this item was created in our database (ISO 8601) + pub created_at: String, +} + +/// Filter criteria for listing items +#[derive(Debug, Default, Clone)] +pub struct ListFilter { + /// Filter by source kind + pub source_kind: Option, + /// Filter by specific source ID + pub source_id: Option, + /// Maximum number of items to return + pub limit: Option, + /// Only items published at or after this time (ISO 8601) + pub since: Option, + /// Substring search on title/summary + pub query: Option, +} + +/// Storage trait for persisting and retrieving items +pub trait Storage { + /// Insert or replace an item in storage + fn insert_or_replace_item(&self, item: &Item) -> Result<()>; + + /// List items matching the given filter + fn list_items(&self, filter: &ListFilter) -> Result>; +} + +/// Trait for fetching content from a specific source +pub trait SourceFetcher { + /// Synchronize content from this source into storage + fn sync(&self, storage: &dyn Storage) -> Result<()>; +} + +/// Configuration for all sources +#[derive(Debug, Default)] +pub struct Config {} + +/// Synchronize all enabled sources +/// +/// Calls each configured source fetcher to retrieve and store content. +pub fn sync_all_sources(_config: &Config, _storage: &dyn Storage) -> Result { + Ok(0) } #[cfg(test)] @@ -7,8 +127,34 @@ mod tests { use super::*; #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); + fn source_kind_display() { + assert_eq!(SourceKind::Substack.to_string(), "substack"); + assert_eq!(SourceKind::Bluesky.to_string(), "bluesky"); + assert_eq!(SourceKind::Leaflet.to_string(), "leaflet"); + } + + #[test] + fn source_kind_parse() { + assert_eq!("substack".parse::().unwrap(), SourceKind::Substack); + assert_eq!("BLUESKY".parse::().unwrap(), SourceKind::Bluesky); + assert_eq!("Leaflet".parse::().unwrap(), SourceKind::Leaflet); + assert!("invalid".parse::().is_err()); + } + + #[test] + fn error_unknown_source_kind() { + let err = "unknown".parse::().unwrap_err(); + assert!(matches!(err, PaiError::UnknownSourceKind(_))); + assert_eq!(err.to_string(), "Unknown source kind: unknown"); + } + + #[test] + fn list_filter_default() { + let filter = ListFilter::default(); + assert!(filter.source_kind.is_none()); + assert!(filter.source_id.is_none()); + assert!(filter.limit.is_none()); + assert!(filter.since.is_none()); + assert!(filter.query.is_none()); } } diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..b3b7482 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,10 @@ +max_width = 120 +fn_params_layout = "Compressed" +fn_single_line = true +fn_args_layout = "Compressed" +format_strings = true +single_line_if_else_max_width = 100 +single_line_let_else_max_width = 100 +struct_field_align_threshold = 20 +use_field_init_shorthand = true +struct_lit_width=100