From 74ff0cf3da4322dd4ad64fcacc8fd2b9eda5d971 Mon Sep 17 00:00:00 2001 From: Lucas Pickering Date: Mon, 11 Dec 2023 19:15:32 -0500 Subject: [PATCH] Store all collection DBs in a single file --- CHANGELOG.md | 4 +- README.md | 1 - docs/src/api/chain.md | 16 +- docs/src/api/profile.md | 12 +- docs/src/api/request_collection.md | 27 +- docs/src/api/request_recipe.md | 28 +- slumber.yml | 2 - src/cli.rs | 47 ++- src/collection.rs | 11 - src/collection/insomnia.rs | 2 - src/db.rs | 439 ++++++++++++++++++++++++++--- src/factory.rs | 6 +- src/http.rs | 6 +- src/http/record.rs | 18 +- src/main.rs | 2 +- src/template.rs | 8 +- src/tui.rs | 51 ++-- src/tui/context.rs | 8 +- src/tui/view/component/help.rs | 6 +- src/util.rs | 7 +- 20 files changed, 537 insertions(+), 164 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e013a09..64930e67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,8 @@ ### Changed - [BREAKING] Key profiles/chains/requests by ID in collection file -- [BREAKING] Move request history from `slumber/{id}.sqlite` to `slumber/{id}/state.sqlite` - - Request history will be lost. If you want to recover it, you can move the old file to the new location (use `slumber show` to find the directory location) +- [BREAKING] Merge request history into a single DB file + - Request history (and UI state) will be lost - [BREAKING] `show` subcommand now takes a `target` argument - Right now the only option is `slumber show dir`, which has the same behavior as the old `slumber show` (except now it prints the bare directory) - Hide sensitive chain values in preview diff --git a/README.md b/README.md index edcc863c..9d6a6e03 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@ Slumber is based around **collections**. A collection is a group of request **re ```yaml # slumber.yml -id: example requests: get: method: GET diff --git a/docs/src/api/chain.md b/docs/src/api/chain.md index f06bfac7..48fc1096 100644 --- a/docs/src/api/chain.md +++ b/docs/src/api/chain.md @@ -18,17 +18,17 @@ See the [`ChainSource`](./chain_source.md) docs for more detail. ```yaml # Load chained value from a file -id: username -source: !file ./username.txt +username: + source: !file ./username.txt --- # Prompt the user for a value whenever the request is made -id: password -source: !prompt Enter Password -sensitive: true +password: + source: !prompt Enter Password + sensitive: true --- # Use a value from another response # Assume the request recipe with ID `login` returns a body like `{"token": "foo"}` -id: auth_token -source: !request login -selector: $.token +auth_token: + source: !request login + selector: $.token ``` diff --git a/docs/src/api/profile.md b/docs/src/api/profile.md index 75249260..58ad376b 100644 --- a/docs/src/api/profile.md +++ b/docs/src/api/profile.md @@ -14,10 +14,10 @@ Profiles also support nested templates, via the `!template` tag. ## Examples ```yaml -id: local -name: Local -data: - host: localhost:5000 - url: !template "https://{{host}}" - user_guid: abc123 +local: + name: Local + data: + host: localhost:5000 + url: !template "https://{{host}}" + user_guid: abc123 ``` diff --git a/docs/src/api/request_collection.md b/docs/src/api/request_collection.md index e5aa7806..c764bed0 100644 --- a/docs/src/api/request_collection.md +++ b/docs/src/api/request_collection.md @@ -19,26 +19,33 @@ Whichever of those files is found _first_ will be used. If you want to use a dif slumber -c my-collection.yml ``` -## Collection ID +## Collection History & Migration -Each collection needs a unique ID (via the `id` field). This ID is used to tie the collection to its history. If the ID of a collection changes, you'll lose the history for it. If two collections share an ID, their request history could start interfering with each other. Make sure each collection used on your computer is unique. +Each collection needs a unique ID generated when the collection is first loaded by Slumber. This ID is used to persist request history and other data related to the collection. If you move a collection file, a new ID will be generated and it will be unlinked from its previous history. If you want to retain that history, you can migrate data from the old ID to the new one like so: + +```sh +slumber collections migrate /slumber/old.yml /slumber/new.yml +``` + +If you don't remember the path of the old file, you can list all known collections with: + +```sh +slumber collections list +``` ## Fields A request collection supports the following top-level fields: -| Field | Type | Description | Default | -| ---------- | ------------------------------------------------------- | ----------------------------- | -------- | -| `id` | `string` | Unique ID for this collection | Required | -| `profiles` | [`mapping[string, Profile]`](./profile.md) | Static template values | [] | -| `requests` | [`mapping[string, RequestRecipe]`](./request_recipe.md) | Requests Slumber can send | [] | -| `chains` | [`mapping[string, Chain]`](./chain.md) | Complex template values | [] | +| Field | Type | Description | Default | +| ---------- | ------------------------------------------------------- | ------------------------- | ------- | +| `profiles` | [`mapping[string, Profile]`](./profile.md) | Static template values | [] | +| `requests` | [`mapping[string, RequestRecipe]`](./request_recipe.md) | Requests Slumber can send | [] | +| `chains` | [`mapping[string, Chain]`](./chain.md) | Complex template values | [] | ## Examples ```yaml -id: example - profiles: local: name: Local diff --git a/docs/src/api/request_recipe.md b/docs/src/api/request_recipe.md index 9d675abf..f0320b60 100644 --- a/docs/src/api/request_recipe.md +++ b/docs/src/api/request_recipe.md @@ -16,18 +16,18 @@ A request recipe defines how to make a particular request. For a REST API, you'l ## Examples ```yaml -id: login -name: Login -method: POST -url: "{{host}}/anything/login" -headers: - accept: application/json - content-type: application/json -query: - root_access: yes_please -body: | - { - "username": "{{chains.username}}", - "password": "{{chains.password}}" - } +login: + name: Login + method: POST + url: "{{host}}/anything/login" + headers: + accept: application/json + content-type: application/json + query: + root_access: yes_please + body: | + { + "username": "{{chains.username}}", + "password": "{{chains.password}}" + } ``` diff --git a/slumber.yml b/slumber.yml index d874105d..93b24b00 100644 --- a/slumber.yml +++ b/slumber.yml @@ -1,5 +1,3 @@ -id: example - profiles: works: name: Works diff --git a/src/cli.rs b/src/cli.rs index 536ee4a9..5f49d1c9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -19,6 +19,7 @@ use std::{ /// A non-TUI command #[derive(Clone, Debug, clap::Subcommand)] pub enum Subcommand { + // TODO Break this apart into multiple files /// Execute a single request #[clap(aliases=&["req", "rq"])] Request { @@ -52,6 +53,12 @@ pub enum Subcommand { output_file: Option, }, + /// View and modify request collection history + Collections { + #[command(subcommand)] + subcommand: CollectionsSubcommand, + }, + /// Show meta information about slumber Show { #[command(subcommand)] @@ -65,6 +72,23 @@ pub enum ShowTarget { Dir, } +#[derive(Clone, Debug, clap::Subcommand)] +pub enum CollectionsSubcommand { + /// List all known request collections + #[command(visible_alias = "ls")] + List, + /// Move all data from one collection to another. + /// + /// The data from the source collection will be merged into the target + /// collection, then all traces of the source collection will be deleted! + Migrate { + /// The path the collection to migrate *from* + from: PathBuf, + /// The path the collection to migrate *into* + to: PathBuf, + }, +} + impl Subcommand { /// Execute a non-TUI command pub async fn execute( @@ -80,6 +104,8 @@ impl Subcommand { } => { let collection_path = RequestCollection::try_path(collection_override)?; + let database = + Database::load()?.into_collection(&collection_path)?; let mut collection = RequestCollection::load(collection_path).await?; @@ -101,7 +127,6 @@ impl Subcommand { )?; // Build the request - let database = Database::load(&collection.id)?; let overrides: IndexMap<_, _> = overrides.into_iter().collect(); let request = RequestBuilder::new( recipe, @@ -156,6 +181,8 @@ impl Subcommand { Ok(()) } + Subcommand::Collections { subcommand } => subcommand.execute(), + Subcommand::Show { target } => { match target { ShowTarget::Dir => println!("{}", Directory::root()), @@ -166,6 +193,24 @@ impl Subcommand { } } +impl CollectionsSubcommand { + fn execute(self) -> anyhow::Result<()> { + let database = Database::load()?; + match self { + CollectionsSubcommand::List => { + for path in database.get_collections()? { + println!("{}", path.display()); + } + } + CollectionsSubcommand::Migrate { from, to } => { + database.merge_collections(&from, &to)?; + println!("Migrated {} into {}", from.display(), to.display()); + } + } + Ok(()) + } +} + /// Prompt the user for input on the CLI #[derive(Debug)] struct CliPrompter; diff --git a/src/collection.rs b/src/collection.rs index 5e7b620b..ae56a3e4 100644 --- a/src/collection.rs +++ b/src/collection.rs @@ -38,9 +38,6 @@ pub struct RequestCollection { #[serde(skip)] source: S, - /// Unique ID for this collection. This should be unique for across all - /// collections used on one computer. - pub id: CollectionId, #[serde(default, deserialize_with = "cereal::deserialize_id_map")] pub profiles: IndexMap, #[serde(default, deserialize_with = "cereal::deserialize_id_map")] @@ -55,13 +52,6 @@ pub struct RequestCollection { pub recipes: IndexMap, } -/// A unique ID for a collection. This is necessary to give each collection its -/// own database. -#[derive( - Clone, Debug, Default, Deref, Display, From, Serialize, Deserialize, -)] -pub struct CollectionId(String); - /// Mutually exclusive hot-swappable config group #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Profile { @@ -220,7 +210,6 @@ impl RequestCollection { pub fn with_source(self, source: T) -> RequestCollection { RequestCollection { source, - id: self.id, profiles: self.profiles, chains: self.chains, recipes: self.recipes, diff --git a/src/collection/insomnia.rs b/src/collection/insomnia.rs index b5ff5581..eed87bae 100644 --- a/src/collection/insomnia.rs +++ b/src/collection/insomnia.rs @@ -10,7 +10,6 @@ use indexmap::IndexMap; use serde::Deserialize; use std::{fs::File, path::Path}; use tracing::info; -use uuid::Uuid; impl RequestCollection<()> { /// Convert an Insomnia exported collection into the slumber format. This @@ -56,7 +55,6 @@ impl RequestCollection<()> { Ok(RequestCollection { source: (), - id: Uuid::new_v4().to_string().into(), profiles, recipes, chains: IndexMap::new(), diff --git a/src/db.rs b/src/db.rs index d70797a5..da377290 100644 --- a/src/db.rs +++ b/src/db.rs @@ -2,24 +2,26 @@ //! responses. use crate::{ - collection::{CollectionId, RequestRecipeId}, + collection::RequestRecipeId, http::{RequestId, RequestRecord}, util::{Directory, ResultExt}, }; -use anyhow::Context; +use anyhow::{anyhow, Context}; +use derive_more::Display; use rusqlite::{ + named_params, types::{FromSql, FromSqlError, FromSqlResult, ToSqlOutput, ValueRef}, - Connection, OptionalExtension, Row, ToSql, + Connection, DatabaseName, OptionalExtension, Row, ToSql, }; use rusqlite_migration::{Migrations, M}; use serde::{de::DeserializeOwned, Serialize}; use std::{ - fmt::{Debug, Display}, + fmt::Debug, ops::Deref, - path::PathBuf, + path::{Path, PathBuf}, sync::{Arc, Mutex}, }; -use tracing::debug; +use tracing::{debug, info}; use uuid::Uuid; /// A SQLite database for persisting data. Generally speaking, any error that @@ -28,6 +30,10 @@ use uuid::Uuid; /// to enable calling from the view code. Do not call on every frame though, /// cache results in UI state for as long as they're needed. /// +/// There is only one database for an entire system. All collection share the +/// same DB, and can modify concurrently. Generally any data that is unique +/// to a collection should have an FK column to the `collections` table. +/// /// This uses an `Arc` internally, so it's safe and cheap to clone. #[derive(Clone, Debug)] pub struct Database { @@ -39,11 +45,23 @@ pub struct Database { connection: Arc>, } +/// A unique ID for a collection. This is generated when the collection is +/// inserted into the DB. +#[derive(Copy, Clone, Debug, Display)] +pub struct CollectionId(Uuid); + impl Database { /// Load the database. This will perform first-time setup, so this should /// only be called at the main session entrypoint. - pub fn load(collection_id: &CollectionId) -> anyhow::Result { - let mut connection = Connection::open(Self::path(collection_id)?)?; + pub fn load() -> anyhow::Result { + let path = Self::path()?; + info!(?path, "Loading database"); + let mut connection = Connection::open(path)?; + connection.pragma_update( + Some(DatabaseName::Main), + "foreign_keys", + "ON", + )?; // Use WAL for concurrency connection.pragma_update(None, "journal_mode", "WAL")?; Self::migrate(&mut connection)?; @@ -54,15 +72,22 @@ impl Database { /// Path to the database file. This will create the directory if it doesn't /// exist - fn path(collection_id: &CollectionId) -> anyhow::Result { - Ok(Directory::data(collection_id) - .create()? - .join("state.sqlite")) + fn path() -> anyhow::Result { + Ok(Directory::root().create()?.join("state.sqlite")) } /// Apply database migrations fn migrate(connection: &mut Connection) -> anyhow::Result<()> { let migrations = Migrations::new(vec![ + M::up( + // Path is the *canonicalzed* path to a collection file, + // guaranteeing it will be stable and unique + "CREATE TABLE collections ( + id UUID PRIMARY KEY NOT NULL, + path BLOB NOT NULL UNIQUE + )", + ) + .down("DROP TABLE collections"), M::up( // The request state kind is a bit hard to map to tabular data. // Everything that we need to query on (HTTP status code, @@ -70,21 +95,26 @@ impl Database { // will be serialized into msgpack bytes "CREATE TABLE requests ( id UUID PRIMARY KEY NOT NULL, + collection_id UUID NOT NULL, recipe_id TEXT NOT NULL, start_time TEXT NOT NULL, end_time TEXT NOT NULL, request BLOB NOT NULL, response BLOB NOT NULL, - status_code INTEGER NOT NULL + status_code INTEGER NOT NULL, + FOREIGN KEY(collection_id) REFERENCES collections(id) )", ) .down("DROP TABLE requests"), M::up( // Values will be serialized as msgpack "CREATE TABLE ui_state ( - key TEXT PRIMARY KEY NOT NULL, - value BLOB NOT NULL - )", + key TEXT NOT NULL, + collection_id UUID NOT NULL, + value BLOB NOT NULL, + PRIMARY KEY (key, collection_id), + FOREIGN KEY(collection_id) REFERENCES collections(id) + )", ) .down("DROP TABLE ui_state"), ]); @@ -97,17 +127,158 @@ impl Database { self.connection.lock().expect("Connection lock poisoned") } + /// Get a list of all collections + pub fn get_collections(&self) -> anyhow::Result> { + self.connection() + .prepare("SELECT path FROM collections")? + .query_map([], |row| Ok(row.get::<_, Bytes<_>>("path")?.0)) + .context("Error fetching collections")? + .collect::>>() + .context("Error extracting collection data") + } + + /// Get a collection ID by path. Return an error if there is no collection + /// with the given path + pub fn get_collection_id( + &self, + path: &Path, + ) -> anyhow::Result { + // Convert to canonicalize and make serializable + let path: CollectionPath = path.try_into()?; + + self.connection() + .query_row( + "SELECT id FROM collections WHERE path = :path", + named_params! {":path": &path}, + |row| row.get::<_, CollectionId>("id"), + ) + .map_err(|err| match err { + rusqlite::Error::QueryReturnedNoRows => { + // Use Display impl here because this will get shown in + // CLI output + anyhow!("Unknown collection `{path}`") + } + other => anyhow::Error::from(other) + .context("Error fetching collection ID"), + }) + .traced() + } + + /// Migrate all data for one collection into another, deleting the source + /// collection + pub fn merge_collections( + &self, + source: &Path, + target: &Path, + ) -> anyhow::Result<()> { + info!(?source, ?target, "Merging database state"); + + // Exchange each path for an ID + let source = self.get_collection_id(source)?; + let target = self.get_collection_id(target)?; + + // Update each table in individually + let connection = self.connection(); + connection + .execute( + "UPDATE requests SET collection_id = :target + WHERE collection_id = :source", + named_params! {":source": source, ":target": target}, + ) + .context("Error migrating table `requests`") + .traced()?; + connection + .execute( + // Overwrite UI state. Maybe this isn't the best UX, but sqlite + // doesn't provide an "UPDATE OR DELETE" so this is easiest and + // still reasonable + "UPDATE OR REPLACE ui_state SET collection_id = :target + WHERE collection_id = :source", + named_params! {":source": source, ":target": target}, + ) + .context("Error migrating table `ui_state`") + .traced()?; + + connection + .execute( + "DELETE FROM collections WHERE id = :source", + named_params! {":source": source}, + ) + .context("Error deleting source collection") + .traced()?; + Ok(()) + } + + /// Convert this database connection into a handle for a single collection + /// file. This will store the collection in the DB if it isn't already, + /// then grab its generated ID to create a [CollectionDatabase]. + pub fn into_collection( + self, + path: &Path, + ) -> anyhow::Result { + // Convert to canonicalize and make serializable + let path: CollectionPath = path.try_into()?; + + // We have to set/get in two separate queries, because RETURNING doesn't + // return anything if the insert didn't modify + self.connection() + .execute( + "INSERT INTO collections (id, path) VALUES (:id, :path) + ON CONFLICT(path) DO NOTHING", + named_params! { + ":id": CollectionId(Uuid::new_v4()), + ":path": &path, + }, + ) + .context("Error setting collection ID") + .traced()?; + let collection_id = self + .connection() + .query_row( + "SELECT id FROM collections WHERE path = :path", + named_params! {":path": &path}, + |row| row.get::<_, CollectionId>("id"), + ) + .context("Error fetching collection ID") + .traced()?; + + Ok(CollectionDatabase { + collection_id, + database: self, + }) + } +} + +/// A collection-specific database handle. This is a wrapper around a [Database] +/// that restricts all queries to a specific collection ID. Use +/// [Database::into_collection] to obtain one. +#[derive(Clone, Debug)] +pub struct CollectionDatabase { + collection_id: CollectionId, + database: Database, +} + +impl CollectionDatabase { + pub fn collection_id(&self) -> CollectionId { + self.collection_id + } + /// Get the most recent request+response for a recipe, or `None` if there /// has never been one received. pub fn get_last_request( &self, recipe_id: &RequestRecipeId, ) -> anyhow::Result> { - self.connection() + self.database + .connection() .query_row( - "SELECT * FROM requests WHERE recipe_id = ?1 + "SELECT * FROM requests + WHERE collection_id = :collection_id AND recipe_id = :recipe_id ORDER BY start_time DESC LIMIT 1", - [recipe_id], + named_params! { + ":collection_id": self.collection_id, + ":recipe_id": recipe_id, + }, |row| row.try_into(), ) .optional() @@ -122,15 +293,17 @@ impl Database { /// should not (and cannot) be stored. pub fn insert_request(&self, record: &RequestRecord) -> anyhow::Result<()> { debug!( - id = %record.id(), + id = %record.id, url = %record.request.url, "Adding request record to database", ); - self.connection() + self.database + .connection() .execute( "INSERT INTO requests ( id, + collection_id, recipe_id, start_time, end_time, @@ -138,16 +311,18 @@ impl Database { response, status_code ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - ( - record.id(), - &record.request.recipe_id, - &record.start_time, - &record.end_time, - &Bytes(&record.request), - &Bytes(&record.response), - record.response.status.as_u16(), - ), + VALUES (:id, :collection_id, :recipe_id, :start_time, + :end_time, :request, :response, :status_code)", + named_params! { + ":id": record.id, + ":collection_id": self.collection_id, + ":recipe_id": &record.request.recipe_id, + ":start_time": &record.start_time, + ":end_time": &record.end_time, + ":request": &Bytes(&record.request), + ":response": &Bytes(&record.response), + ":status_code": record.response.status.as_u16(), + }, ) .context("Error saving request to database") .traced()?; @@ -161,12 +336,17 @@ impl Database { V: Debug + DeserializeOwned, { let value = self + .database .connection() .query_row( - "SELECT value FROM ui_state WHERE key = ?1", - (key.to_string(),), + "SELECT value FROM ui_state + WHERE collection_id = :collection_id AND key = :key", + named_params! { + ":collection_id": self.collection_id, + ":key": key.to_string(), + }, |row| { - let value: Bytes = row.get(0)?; + let value: Bytes = row.get("value")?; Ok(value.0) }, ) @@ -184,12 +364,18 @@ impl Database { V: Debug + Serialize, { debug!(%key, ?value, "Setting UI state"); - self.connection() + self.database + .connection() .execute( // Upsert! - "INSERT INTO ui_state VALUES (?1, ?2) - ON CONFLICT(key) DO UPDATE SET value = excluded.value", - (key.to_string(), Bytes(value)), + "INSERT INTO ui_state (collection_id, key, value) + VALUES (:collection_id, :key, :value) + ON CONFLICT DO UPDATE SET value = excluded.value", + named_params! { + ":collection_id": self.collection_id, + ":key": key.to_string(), + ":value": Bytes(value), + }, ) .context("Error saving UI state to database") .traced()?; @@ -210,9 +396,32 @@ impl Database { } } +/// Test-only helpers +#[cfg(test)] +impl CollectionDatabase { + /// Create an in-memory DB, only for testing + pub fn testing() -> Self { + Database::testing() + .into_collection(Path::new("./slumber.yml")) + .expect("Error initializing DB collection") + } +} + +impl ToSql for CollectionId { + fn to_sql(&self) -> rusqlite::Result> { + self.0.to_sql() + } +} + +impl FromSql for CollectionId { + fn column_result(value: ValueRef<'_>) -> FromSqlResult { + Ok(Self(Uuid::column_result(value)?)) + } +} + impl ToSql for RequestId { fn to_sql(&self) -> rusqlite::Result> { - self.deref().to_sql() + self.0.to_sql() } } @@ -234,7 +443,37 @@ impl FromSql for RequestRecipeId { } } +/// Neat little wrapper for a collection path, to make sure it gets +/// canonicalized and serialized/deserialized consistently +#[derive(Debug, Display)] +#[display("{}", _0.0.display())] +struct CollectionPath(Bytes); + +impl TryFrom<&Path> for CollectionPath { + type Error = anyhow::Error; + + fn try_from(path: &Path) -> Result { + path.canonicalize() + .context(format!("Error canonicalizing path {path:?}")) + .traced() + .map(|path| Self(Bytes(path))) + } +} + +impl ToSql for CollectionPath { + fn to_sql(&self) -> rusqlite::Result> { + self.0.to_sql() + } +} + +impl FromSql for CollectionPath { + fn column_result(value: ValueRef<'_>) -> FromSqlResult { + Bytes::::column_result(value).map(Self) + } +} + /// A wrapper to serialize/deserialize a value as msgpack for DB storage +#[derive(Debug)] struct Bytes(T); impl ToSql for Bytes { @@ -270,3 +509,125 @@ impl<'a, 'b> TryFrom<&'a Row<'b>> for RequestRecord { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::factory::*; + use factori::create; + + #[test] + fn test_merge() { + let database = Database::testing(); + let path1 = Path::new("slumber.yml"); + let path2 = Path::new("README.md"); // Has to be a real file + let collection1 = database.clone().into_collection(path1).unwrap(); + let collection2 = database.clone().into_collection(path2).unwrap(); + + let record1 = create!(RequestRecord); + let record2 = create!(RequestRecord); + let recipe_id = &record1.request.recipe_id; + let ui_key = "key1"; + collection1.insert_request(&record1).unwrap(); + collection1.set_ui(ui_key, "value1").unwrap(); + collection2.insert_request(&record2).unwrap(); + collection2.set_ui(ui_key, "value2").unwrap(); + + // Sanity checks + assert_eq!( + collection1.get_last_request(recipe_id).unwrap().unwrap().id, + record1.id + ); + assert_eq!( + collection1.get_ui::<_, String>(ui_key).unwrap(), + Some("value1".into()) + ); + assert_eq!( + collection2.get_last_request(recipe_id).unwrap().unwrap().id, + record2.id + ); + assert_eq!( + collection2.get_ui::<_, String>(ui_key).unwrap(), + Some("value2".into()) + ); + + // Do the merge + database.merge_collections(path2, path1).unwrap(); + + // Collection 2 values should've overwritten + assert_eq!( + collection1.get_last_request(recipe_id).unwrap().unwrap().id, + record2.id + ); + assert_eq!( + collection1.get_ui::<_, String>(ui_key).unwrap(), + Some("value2".into()) + ); + + // Make sure collection2 was deleted + assert_eq!( + database.get_collections().unwrap(), + vec![path1.canonicalize().unwrap()] + ); + } + + /// Test request storage and retrieval + #[test] + fn test_request() { + let database = Database::testing(); + let collection1 = database + .clone() + .into_collection(Path::new("slumber.yml")) + .unwrap(); + let collection2 = database + .clone() + .into_collection(Path::new("README.md")) + .unwrap(); + + let record1 = create!(RequestRecord); + let record2 = create!(RequestRecord); + collection1.insert_request(&record1).unwrap(); + collection2.insert_request(&record2).unwrap(); + + // Make sure the two have a conflicting recipe ID, which should be + // de-conflicted via the collection ID + assert_eq!(record1.request.recipe_id, record2.request.recipe_id); + let recipe_id = &record1.request.recipe_id; + + assert_eq!( + collection1.get_last_request(recipe_id).unwrap().unwrap().id, + record1.id + ); + assert_eq!( + collection2.get_last_request(recipe_id).unwrap().unwrap().id, + record2.id + ); + } + + /// Test UI state storage and retrieval + #[test] + fn test_ui_state() { + let database = Database::testing(); + let collection1 = database + .clone() + .into_collection(Path::new("slumber.yml")) + .unwrap(); + let collection2 = database + .clone() + .into_collection(Path::new("README.md")) + .unwrap(); + + let ui_key = "key1"; + collection1.set_ui(ui_key, "value1").unwrap(); + collection2.set_ui(ui_key, "value2").unwrap(); + + assert_eq!( + collection1.get_ui::<_, String>(ui_key).unwrap(), + Some("value1".into()) + ); + assert_eq!( + collection2.get_ui::<_, String>(ui_key).unwrap(), + Some("value2".into()) + ); + } +} diff --git a/src/factory.rs b/src/factory.rs index 147e3aaa..616d9741 100644 --- a/src/factory.rs +++ b/src/factory.rs @@ -1,6 +1,6 @@ use crate::{ collection::{Chain, ChainSource, RequestRecipeId}, - db::Database, + db::CollectionDatabase, http::{Body, Request, RequestId, RequestRecord, Response}, template::{Prompt, Prompter, Template, TemplateContext}, }; @@ -12,7 +12,7 @@ use reqwest::{header::HeaderMap, Method, StatusCode}; factori!(Request, { default { id = RequestId::new(), - recipe_id = String::new().into(), + recipe_id = "recipe1".into(), method = Method::GET, url = "/url".into(), headers = HeaderMap::new(), @@ -62,7 +62,7 @@ factori!(TemplateContext, { profile = Default::default() chains = Default::default() prompter = Box::::default(), - database = Database::testing() + database = CollectionDatabase::testing() overrides = Default::default() } }); diff --git a/src/http.rs b/src/http.rs index f2da137c..23b8ec73 100644 --- a/src/http.rs +++ b/src/http.rs @@ -41,7 +41,7 @@ pub use record::*; use crate::{ collection::RequestRecipe, - db::Database, + db::CollectionDatabase, template::{Template, TemplateContext}, util::ResultExt, }; @@ -69,12 +69,12 @@ const USER_AGENT: &str = #[derive(Clone, Debug)] pub struct HttpEngine { client: Client, - database: Database, + database: CollectionDatabase, } impl HttpEngine { /// Build a new HTTP engine, which can be used for the entire program life - pub fn new(database: Database) -> Self { + pub fn new(database: CollectionDatabase) -> Self { Self { client: Client::builder() .user_agent(USER_AGENT) diff --git a/src/http/record.rs b/src/http/record.rs index 09068555..8fc73e78 100644 --- a/src/http/record.rs +++ b/src/http/record.rs @@ -7,7 +7,7 @@ use crate::{ }; use anyhow::Context; use chrono::{DateTime, Duration, Utc}; -use derive_more::{Deref, Display, From}; +use derive_more::{Display, From}; use indexmap::IndexMap; use reqwest::{ header::{self, HeaderMap, HeaderValue}, @@ -47,16 +47,7 @@ pub struct RequestError { /// Unique ID for a single launched request #[derive( - Copy, - Clone, - Debug, - Deref, - Display, - Eq, - Hash, - PartialEq, - Serialize, - Deserialize, + Copy, Clone, Debug, Display, Eq, Hash, PartialEq, Serialize, Deserialize, )] pub struct RequestId(pub Uuid); @@ -89,11 +80,6 @@ pub struct RequestRecord { } impl RequestRecord { - /// Unique ID for this record - pub fn id(&self) -> RequestId { - self.id - } - /// Get the elapsed time for this request pub fn duration(&self) -> Duration { self.end_time - self.start_time diff --git a/src/main.rs b/src/main.rs index ebf42bd0..32ed9f1d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,7 +48,7 @@ async fn main() -> anyhow::Result<()> { // Run the TUI None => { let collection_path = RequestCollection::try_path(args.collection)?; - Tui::start(collection_path).await; + Tui::start(collection_path).await?; Ok(()) } diff --git a/src/template.rs b/src/template.rs index c4a868aa..c03d4da4 100644 --- a/src/template.rs +++ b/src/template.rs @@ -9,7 +9,7 @@ pub use prompt::{Prompt, Prompter}; use crate::{ collection::{Chain, ChainId, ProfileValue}, - db::Database, + db::CollectionDatabase, template::{ error::TemplateParseError, parse::{TemplateInputChunk, CHAIN_PREFIX, ENV_PREFIX}, @@ -30,7 +30,7 @@ pub struct TemplateContext { /// Chained values from dynamic sources pub chains: IndexMap, /// Needed for accessing response bodies for chaining - pub database: Database, + pub database: CollectionDatabase, /// Additional key=value overrides passed directly from the user pub overrides: IndexMap, /// A conduit to ask the user questions @@ -266,7 +266,7 @@ mod tests { #[case] expected_value: &str, ) { let recipe_id: RequestRecipeId = "recipe1".into(); - let database = Database::testing(); + let database = CollectionDatabase::testing(); let response_body = json!({ "string": "Hello World!", "number": 6, @@ -342,7 +342,7 @@ mod tests { #[case] request_response: Option<(Request, Response)>, #[case] expected_error: &str, ) { - let database = Database::testing(); + let database = CollectionDatabase::testing(); if let Some((request, response)) = request_response { database .insert_request(&create!( diff --git a/src/tui.rs b/src/tui.rs index e43a811f..9ef209b0 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -5,7 +5,7 @@ mod view; use crate::{ collection::{ProfileId, RequestCollection, RequestRecipeId}, - db::Database, + db::{CollectionDatabase, Database}, http::{HttpEngine, RequestBuilder}, template::{Prompter, Template, TemplateChunk, TemplateContext}, tui::{ @@ -14,12 +14,12 @@ use crate::{ message::{Message, MessageSender}, view::{ModalPriority, PreviewPrompter, RequestState, View}, }, - util::{Replaceable, ResultExt}, + util::Replaceable, }; use anyhow::{anyhow, Context}; use crossterm::{ event::{DisableMouseCapture, EnableMouseCapture}, - terminal::{EnterAlternateScreen, LeaveAlternateScreen, SetTitle}, + terminal::{EnterAlternateScreen, LeaveAlternateScreen}, ExecutableCommand, }; use futures::Future; @@ -41,8 +41,6 @@ use std::{ use tokio::sync::mpsc::{self, UnboundedReceiver}; use tracing::{debug, error}; -const EXE_NAME: &str = env!("CARGO_BIN_NAME"); - /// Main controller struct for the TUI. The app uses a React-like architecture /// for the view, with a wrapping controller (this struct). The main loop goes /// through the following phases on each iteration: @@ -63,7 +61,9 @@ pub struct Tui { /// before the new one is created. view: Replaceable, collection: Rc, - database: Database, + /// We only ever need to run DB ops related to our collection, so we can + /// use a collection-restricted DB handle + database: CollectionDatabase, should_run: bool, } @@ -75,14 +75,21 @@ impl Tui { /// Start the TUI. Any errors that occur during startup will be panics, /// because they prevent TUI execution. - pub async fn start(collection_file: PathBuf) { + pub async fn start(collection_file: PathBuf) -> anyhow::Result<()> { initialize_panic_handler(); - let terminal = - initialize_terminal().expect("Error initializing terminal"); + + // ===== Initialize global state ===== + // This stuff only needs to be set up *once per session* // Create a message queue for handling async tasks let (messages_tx, messages_rx) = mpsc::unbounded_channel(); let messages_tx = MessageSender::new(messages_tx); + // Load a database for this particular collection + let database = Database::load()?.into_collection(&collection_file)?; + // Initialize global view context + TuiContext::init(messages_tx.clone(), database.clone()); + + // ===== Initialize collection & view ===== // If the collection fails to load, create an empty one just so we can // move along. We'll watch the file and hopefully the user can fix it @@ -95,13 +102,13 @@ impl Tui { .with_source(collection_file) }) .into(); + let view = View::new(Rc::clone(&collection)); - let database = Database::load(&collection.id).unwrap(); + // The code to revert the terminal takeover is in `Tui::drop`, so we + // shouldn't take over the terminal until right before creating the + // `Tui`. + let terminal = initialize_terminal()?; - // Initialize global view context - TuiContext::init(messages_tx.clone(), database.clone()); - - let view = View::new(Rc::clone(&collection)); let app = Tui { terminal, messages_rx, @@ -115,11 +122,7 @@ impl Tui { database, }; - app.set_terminal_title(); - - // Any error during execution that gets this far is fatal. We expect the - // error to already have context attached so we can just unwrap - app.run().unwrap(); + app.run() } /// Run the main TUI update loop. Any error returned from this is fatal. See @@ -292,7 +295,6 @@ impl Tui { /// Reload state with a new collection fn reload_collection(&mut self, collection: RequestCollection) { self.collection = Rc::new(collection); - self.set_terminal_title(); // Rebuild the whole view, because tons of things can change. Drop the // old one *first* to make sure UI state is saved before being restored @@ -445,15 +447,6 @@ impl Tui { prompter: Box::new(prompter), }) } - - /// Set the title of the terminal based on collection ID - fn set_terminal_title(&self) { - let title = format!("{} ({})", EXE_NAME, &self.collection.id); - // This error shouldn't be fatal - let _ = crossterm::execute!(io::stdout(), SetTitle(title)) - .context("Error setting terminal title") - .traced(); - } } /// Restore terminal on app exit diff --git a/src/tui/context.rs b/src/tui/context.rs index c7da9faf..321111a2 100644 --- a/src/tui/context.rs +++ b/src/tui/context.rs @@ -1,5 +1,5 @@ use crate::{ - db::Database, + db::CollectionDatabase, tui::{ input::InputEngine, message::{Message, MessageSender}, @@ -36,12 +36,12 @@ pub struct TuiContext { /// view. pub messages_tx: MessageSender, /// Persistence database - pub database: Database, + pub database: CollectionDatabase, } impl TuiContext { /// Initialize global context. Should be called only once, during startup. - pub fn init(messages_tx: MessageSender, database: Database) { + pub fn init(messages_tx: MessageSender, database: CollectionDatabase) { CONTEXT .set(Self { theme: Theme::default(), @@ -73,6 +73,6 @@ pub fn tui_context() { use tokio::sync::mpsc; TuiContext::init( MessageSender::new(mpsc::unbounded_channel().0), - Database::testing(), + CollectionDatabase::testing(), ); } diff --git a/src/tui/view/component/help.rs b/src/tui/view/component/help.rs index 4ac0ab66..78991299 100644 --- a/src/tui/view/component/help.rs +++ b/src/tui/view/component/help.rs @@ -80,6 +80,8 @@ impl EventHandler for HelpModal {} impl Draw for HelpModal { fn draw(&self, context: &mut DrawContext, _: (), area: Rect) { + let tui_context = TuiContext::get(); + // Create layout let [collection_area, _, keybindings_area] = layout( area, @@ -95,7 +97,7 @@ impl Draw for HelpModal { let collection_metadata = Table { title: Some("Collection"), rows: [ - ["ID", self.collection.id.as_str()], + ["ID", &tui_context.database.collection_id().to_string()], ["Path", &self.collection.path().display().to_string()], ], column_widths: &[Constraint::Length(5), Constraint::Max(100)], @@ -108,7 +110,7 @@ impl Draw for HelpModal { // Keybindings let keybindings = Table { title: Some("Keybindings"), - rows: TuiContext::get() + rows: tui_context .input_engine .bindings() .values() diff --git a/src/util.rs b/src/util.rs index 24e86408..71cfc8aa 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,4 +1,4 @@ -use crate::{collection::CollectionId, http::RequestError}; +use crate::http::RequestError; use std::{ fs, ops::Deref, @@ -76,11 +76,6 @@ impl Directory { Self(Self::root().0.join("log")) } - /// Directory to store collection-specific data files - pub fn data(collection_id: &CollectionId) -> Self { - Self(Self::root().0.join(collection_id.as_str())) - } - /// Create this directory, and return the path. This is the only way to /// access the path value directly, enforcing that it can't be used without /// being created.