diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..49c4833 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,4 @@ +[env] +# Live tests share one account's API rate limit, so run tests one at a time. +# An explicitly set RUST_TEST_THREADS still takes precedence. +RUST_TEST_THREADS = "1" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3994e79..83fb5f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,10 @@ First off, thank you for considering contributing to vimstoat! +## You Don't Have to Write Code + +Opening an issue is plenty of help on its own. Bug reports, feature ideas, questions, and "this felt weird" feedback are all welcome, and you don't need to follow up with a pull request. + ## Pull Request Process **CRITICAL:** All Pull Requests MUST be made against the `staging` branch. @@ -9,12 +13,20 @@ First off, thank you for considering contributing to vimstoat! Do **NOT** open Pull Requests directly against the `main` branch. The `main` branch is reserved for production releases only. ### Steps to Contribute: + 1. Fork the repository. 2. Create a new branch off `staging` for your feature or bugfix. 3. Make your changes and commit them using standard naming practices (e.g. `fix: the thing`). 4. Push your branch to your fork. 5. Open a Pull Request targeting the `staging` branch of the upstream repository using the same naming format for your PR title. +### Adding a Feature + +If your change is a `feat`, please also: + +- **Update the docs.** Document the new feature or keybind in `docs/vimstoat/src/` (e.g. `features.md`, `keybinds.md`). +- **Add unit tests** where relevant, covering the new behavior. + ## Commit & PR Naming Conventions Standard naming practices (Conventional Commits) are advised for both commit messages and PR titles: @@ -22,6 +34,7 @@ Standard naming practices (Conventional Commits) are advised for both commit mes Format: `: ` Common types: + - `fix: ` — Bug fixes (e.g. `fix: the thing`) - `feat: ` — New features - `docs: ` — Documentation changes @@ -30,7 +43,8 @@ Common types: - `chore: ` — Routine tasks, dependencies, or maintenance **Example:** -* `fix: the thing` -* `feat: add support for custom keybindings` + +- `fix: the thing` +- `feat: add support for custom keybindings` Thanks for helping make this project better! diff --git a/Cargo.toml b/Cargo.toml index 593bab9..8329d68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vimstoat" -version = "0.1.0" +version = "0.1.4" edition = "2024" [dependencies] @@ -34,6 +34,7 @@ serde = { version = "1", features = ["derive"] } tokio = { version = "1.52.3", features = ["full"] } serde_json = "1.0.150" ulid = "3.0.0" +toml = "1.1.8" [dev-dependencies] cargo-husky = { version = "1.5.0", default-features = false, features = [ diff --git a/README.md b/README.md index d6df6cb..220fdc3 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,13 @@ VimStoat connects to `https://api.stoat.chat` by default. For self-hosted instan ```toml [instance] url = "https://api.your-instance.com" +ws_url = "wss://events.your-instance.com" ``` +A default config file is generated on first run. Missing keys fall back to their defaults. If the file is invalid, VimStoat shows a warning and uses the defaults without modifying the file. + +The `API_BASE_URL` and `WS_BASE_URL` environment variables are no longer supported; set these values in the config file instead. + ## License [GPL-3.0](LICENSE) diff --git a/docs/vimstoat/src/SUMMARY.md b/docs/vimstoat/src/SUMMARY.md index 512e34e..f71ad2e 100644 --- a/docs/vimstoat/src/SUMMARY.md +++ b/docs/vimstoat/src/SUMMARY.md @@ -2,3 +2,4 @@ - [Features](./features.md) - [Keybinds](./keybinds.md) +- [Configuration](./config.md) diff --git a/docs/vimstoat/src/config.md b/docs/vimstoat/src/config.md new file mode 100644 index 0000000..295bedb --- /dev/null +++ b/docs/vimstoat/src/config.md @@ -0,0 +1,53 @@ +# Configuration + +VimStoat reads its settings from a TOML file in your OS config directory: + +| OS | Path | +| ------- | ------------------------------------------------------ | +| Linux | `~/.config/vimstoat/config.toml` (or `$XDG_CONFIG_HOME/vimstoat/config.toml`) | +| macOS | `~/Library/Application Support/vimstoat/config.toml` | +| Windows | `%APPDATA%\vimstoat\config.toml` | + +Anything described as **Planned** is not implemented yet. + +## First Run +If the file doesn't exist, VimStoat creates it with the default settings, creating the `vimstoat` directory if needed. It never overwrites an existing file. + +## Default Config + +```toml +# vimstoat configuration + +[instance] +# REST API base URL +url = "https://api.stoat.chat" +# Websocket events URL +ws_url = "wss://events.stoat.chat" +``` + +## Options + +### `[instance]` +The Stoat server to connect to. Change these to use a self-hosted instance. + +| Key | Default | Description | +| -------- | ------------------------- | --------------------------------------------------- | +| `url` | `https://api.stoat.chat` | REST API base URL. Must start with `http://` or `https://`. | +| `ws_url` | `wss://events.stoat.chat` | WebSocket events URL. Must start with `ws://` or `wss://`. | + +A trailing `/` on either URL is ignored. + +The `API_BASE_URL` and `WS_BASE_URL` environment variables are no longer supported. Set these values in the config file instead. + +## Missing and Invalid Settings +- **Missing keys**: Any key you leave out uses its default, so a config containing only `url` is valid. +- **Invalid file**: If the file can't be read, isn't valid TOML, has a value of the wrong type, contains an unknown key (for example a typo like `urll`), or has a URL with the wrong scheme, VimStoat: + - shows a **Config Warning** pop-up explaining what is wrong and where, which any key dismisses, + - writes the warning to the log file, + - uses **all** default settings for that run, + - leaves your file untouched so you can fix it. + +## Planned +These options are not implemented yet: +- **Silent typing**: Keep typing without telling other people you are typing. +- **Notification message content**: Choose whether desktop notifications show the message text. diff --git a/docs/vimstoat/src/features.md b/docs/vimstoat/src/features.md index 142fafb..3592e32 100644 --- a/docs/vimstoat/src/features.md +++ b/docs/vimstoat/src/features.md @@ -78,7 +78,7 @@ What you see in the UI is the live state built from the server and the real-time ## Vim-Native Interface - **Mode-Specific UI Themes**: Border colors dynamically shift based on the current mode (Blue for UI/Normal, Yellow for Insert, Green for Command). - **Cursor Shaping**: Hardware cursor shape changes depending on mode (blinking bar in Insert and Command mode, block otherwise). -- **Input Motions**: Features vim-like text composition motions like `i`, `I`, `a`, `A`, `o`, `O`, and `dd` (delete line), with an in-memory yank buffer. Pasting from the yank buffer is **Planned**. +- **Input Motions**: Features vim-like text composition motions like `i`, `I`, `a`, `A`, `o`, `O`, and `dd` (delete line), `yy` (yank line) and `p` (paste), with an in-memory yank buffer. - **Multi-line messages**: `Shift+Enter` or `Alt+Enter` inserts a newline; `Enter` sends. - **Screen Navigation**: `:q` goes back one screen at a time, `q` goes back from a conversation, and `:qa` quits globally. See [Keybinds](./keybinds.md). @@ -86,6 +86,5 @@ What you see in the UI is the live state built from the server and the real-time - Fuzzy finder (`fs`, `fc`, `ff`, `fm`) - Search (`/`) - Visual mode (`v`) -- Pasting the yank buffer - Browsing server channels - Broadcasting your own typing indicator diff --git a/docs/vimstoat/src/keybinds.md b/docs/vimstoat/src/keybinds.md index 4d6f88e..e80d40f 100644 --- a/docs/vimstoat/src/keybinds.md +++ b/docs/vimstoat/src/keybinds.md @@ -35,7 +35,9 @@ Keys marked **(Planned)** are not implemented yet. | Key | Action | | ---- | -------------------------------------------------------------------------------------------- | -| `dd` | Delete the current line of the message. The line is saved to the yank buffer (`p` to paste it back is **Planned**). | +| `dd` | Delete the current line of the message. The line is saved to the yank buffer. | +| `yy` | Copy the current line of the message to the yank buffer. | +| `p` | Paste the yank buffer as a new line below the current line. | ## Insert Mode @@ -61,10 +63,12 @@ Keys marked **(Planned)** are not implemented yet. Press `:` to enter Command mode, type a command, then press `Enter`. `Esc` cancels and `Backspace` edits the command. -| Command | Action | -| ------------------------------------ | ----------------------------------------------------------- | -| `:q`, `:quit`, `:q!` | Go back to the previous screen, or quit if at the top level | -| `:qa`, `:qall`, `:qa!`, `:qall!` | Quit the application immediately | +| Command | Action | +| ---------------------------------------------------------------------- | ----------------------------------------------------------- | +| `:q`, `:quit`, `:q!`, `:wq`, `:wq!` | Go back to the previous screen, or quit if at the top level | +| `:qa`, `:qall`, `:qa!`, `:qall!`, `:wqa`, `:wqall`, `:wqa!`, `:wqall!` | Quit the application immediately | + +`:wq` and `:wqa` are aliases for `:q` and `:qa`, so Vim muscle memory works. There is nothing to write, so they behave exactly like their quit counterparts. Unknown commands are ignored (a warning is written to the log file). diff --git a/src/action.rs b/src/action.rs index 9ae0263..663347a 100644 --- a/src/action.rs +++ b/src/action.rs @@ -12,6 +12,8 @@ pub enum Action { OpenNewLineBelow, OpenNewLineAbove, DeleteLine, + YankLine, + Paste, Enter, CursorLeft, CursorRight, diff --git a/src/api/auth.rs b/src/api/auth.rs index 0ffac48..dccc507 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -50,21 +50,7 @@ impl Auth { #[cfg(test)] mod tests { use super::*; - - /// Reads the user's real session token from the keyring (read-only). - /// Panics if the keyring or a stored token is unavailable. - async fn real_token() -> String { - let auth = Auth::new().expect("keyring must be available to run auth tests"); - - let token = auth - .token_entry - .get_secret() - .await - .expect("a vimstoat session token must be stored in the keyring"); - - assert!(!token.is_empty(), "stored keyring token is empty"); - token - } + use crate::test_helpers::keyring_token; /// Builds an `Auth` backed by a test-specific keyring entry. /// Never touches the real vimstoat entry. @@ -76,7 +62,7 @@ mod tests { #[tokio::test] async fn test_validate_token_accepts_real_token() { - let token = real_token().await; + let token = keyring_token("auth").await; let auth = Auth::new().unwrap(); let client = auth @@ -114,7 +100,7 @@ mod tests { #[tokio::test] async fn test_real_token_works_with_authenticated_endpoint() { - let token = real_token().await; + let token = keyring_token("auth").await; let client = ApiClient::new(token, None); let me = client diff --git a/src/api/channel.rs b/src/api/channel.rs index b6b7d81..4e2d18b 100644 --- a/src/api/channel.rs +++ b/src/api/channel.rs @@ -58,3 +58,254 @@ pub async fn ack_message(api_client: &ApiClient, channel_id: &str, message_id: & }) .await } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_helpers::{json_str, keyring_client}; + use serde_json::Value; + use tokio::sync::OnceCell; + + /// A real DM with at least `MIN_MESSAGES` messages, plus its latest page. + struct Live { + client: ApiClient, + channel_id: String, + last_message_id: String, + latest: Vec, + } + + const MIN_MESSAGES: usize = 5; + + static LIVE: OnceCell = OnceCell::const_new(); + + async fn live() -> &'static Live { + LIVE.get_or_init(|| async { + let client = keyring_client("channel").await; + let dms = client + .get::>(Endpoint::Dms) + .await + .expect("GET /users/dms should succeed with the real token"); + + let query = MessageHistoryQuery { + limit: Some(MIN_MESSAGES), + before: None, + after: None, + sort: None, + nearby: None, + }; + for dm in &dms { + let Some(last_message_id) = dm.get("last_message_id").and_then(Value::as_str) + else { + continue; + }; + let channel_id = json_str(dm, "_id").to_string(); + let latest = fetch_message_history(&client, &channel_id, Some(&query)) + .await + .expect("fetching history of a real DM should succeed"); + + if latest.len() == MIN_MESSAGES { + return Live { + client, + channel_id, + last_message_id: last_message_id.to_string(), + latest, + }; + } + } + panic!("channel tests need a DM with at least {MIN_MESSAGES} messages"); + }) + .await + } + + fn query() -> MessageHistoryQuery { + MessageHistoryQuery { + limit: None, + before: None, + after: None, + sort: None, + nearby: None, + } + } + + fn ids(messages: &[Value]) -> Vec<&str> { + messages.iter().map(|m| json_str(m, "_id")).collect() + } + + #[tokio::test] + async fn test_default_order_is_newest_first() { + let live = live().await; + let ids = ids(&live.latest); + + // The DM view acks and displays `messages[0]` as the latest message. + assert_eq!(ids[0], live.last_message_id); + assert!( + ids.windows(2).all(|w| w[0] > w[1]), + "default history is not sorted newest first" + ); + } + + #[tokio::test] + async fn test_messages_have_fields_the_dm_view_reads() { + let live = live().await; + + for msg in &live.latest { + assert_eq!(json_str(msg, "channel"), live.channel_id); + assert!(!json_str(msg, "author").is_empty(), "`author` is empty"); + assert!( + msg.get("content").is_some_and(Value::is_string) || msg.get("system").is_some(), + "message {} has neither `content` nor `system`", + json_str(msg, "_id") + ); + } + } + + #[tokio::test] + async fn test_limit_caps_page_size() { + let live = live().await; + let q = MessageHistoryQuery { + limit: Some(2), + ..query() + }; + + let page = fetch_message_history(&live.client, &live.channel_id, Some(&q)) + .await + .unwrap(); + + assert_eq!(ids(&page), ids(&live.latest)[..2]); + } + + #[tokio::test] + async fn test_before_pages_backwards() { + let live = live().await; + let pivot = json_str(&live.latest[1], "_id"); + let q = MessageHistoryQuery { + limit: Some(2), + before: Some(pivot.to_string()), + ..query() + }; + + let page = fetch_message_history(&live.client, &live.channel_id, Some(&q)) + .await + .unwrap(); + + assert_eq!(ids(&page), ids(&live.latest)[2..4]); + } + + #[tokio::test] + async fn test_after_with_oldest_sort_pages_forwards() { + let live = live().await; + let pivot = json_str(&live.latest[3], "_id"); + let q = MessageHistoryQuery { + limit: Some(2), + after: Some(pivot.to_string()), + sort: Some("Oldest".to_string()), + ..query() + }; + + let page = fetch_message_history(&live.client, &live.channel_id, Some(&q)) + .await + .unwrap(); + + let expected: Vec<&str> = ids(&live.latest)[1..3].iter().rev().copied().collect(); + assert_eq!(ids(&page), expected); + } + + #[tokio::test] + async fn test_nearby_includes_target_message() { + let live = live().await; + let target = json_str(&live.latest[2], "_id"); + let q = MessageHistoryQuery { + limit: Some(3), + nearby: Some(target.to_string()), + ..query() + }; + + let page = fetch_message_history(&live.client, &live.channel_id, Some(&q)) + .await + .unwrap(); + + assert!(ids(&page).contains(&target)); + } + + #[tokio::test] + async fn test_empty_query_matches_no_query() { + let live = live().await; + + let none = fetch_message_history(&live.client, &live.channel_id, None) + .await + .unwrap(); + let empty = fetch_message_history(&live.client, &live.channel_id, Some(&query())) + .await + .expect("an all-None query must not produce a malformed URL"); + + assert_eq!(ids(&empty), ids(&none)); + assert_eq!(ids(&none)[..MIN_MESSAGES], ids(&live.latest)); + } + + #[tokio::test] + async fn test_fetch_history_of_unknown_channel_fails() { + let live = live().await; + + let result = fetch_message_history(&live.client, "01AAAAAAAAAAAAAAAAAAAAAAAA", None).await; + + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_ack_already_read_message_keeps_read_state() { + let live = live().await; + let unreads_of = |unreads: &[Value]| -> Option { + unreads + .iter() + .find(|u| json_str(&u["_id"], "channel") == live.channel_id) + .cloned() + }; + + let before = unreads_of( + &live + .client + .get::>(Endpoint::SyncUnreads) + .await + .unwrap(), + ); + + // Only ack when the channel is already fully read, so the real read + // state is left exactly as it was. + let Some(before) = before.filter(|u| { + u["last_id"].as_str() == Some(&live.last_message_id) + && u["mentions"].as_array().is_none_or(Vec::is_empty) + }) else { + return; + }; + + ack_message(&live.client, &live.channel_id, &live.last_message_id) + .await + .expect("acking a real message should succeed"); + + let after = unreads_of( + &live + .client + .get::>(Endpoint::SyncUnreads) + .await + .unwrap(), + ); + assert_eq!( + after.as_ref().map(|u| &u["last_id"]), + Some(&before["last_id"]) + ); + } + + #[tokio::test] + async fn test_ack_unknown_channel_fails() { + let live = live().await; + + let result = ack_message( + &live.client, + "01AAAAAAAAAAAAAAAAAAAAAAAA", + &live.last_message_id, + ) + .await; + + assert!(result.is_err()); + } +} diff --git a/src/api/client.rs b/src/api/client.rs index 992f88d..14a55a3 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -1,9 +1,16 @@ +use std::time::Duration; + use anyhow::{Result, anyhow}; -use reqwest::Client; +use reqwest::{Client, RequestBuilder, Response, StatusCode}; use serde::de::DeserializeOwned; use crate::api::API_BASE_URL; +/// How many times a rate-limited request is retried in test builds. +const TEST_RATE_LIMIT_RETRIES: usize = 3; +/// Wait used when a 429 response carries no `retry_after`. +const DEFAULT_RETRY_AFTER_MS: u64 = 1000; + #[derive(Debug)] #[allow(unused)] pub enum Endpoint { @@ -70,10 +77,7 @@ impl ApiClient { let url = format!("{}{}", self.base_url, endpoint.path()); let response = self - .client - .get(&url) - .header("X-Session-Token", &self.token) - .send() + .send(|| self.client.get(&url).header("X-Session-Token", &self.token)) .await?; if response.status().is_success() { @@ -96,11 +100,12 @@ impl ApiClient { let url = format!("{}{}", self.base_url, endpoint.path()); let response = self - .client - .post(&url) - .header("X-Session-Token", &self.token) - .json(body) - .send() + .send(|| { + self.client + .post(&url) + .header("X-Session-Token", &self.token) + .json(body) + }) .await?; if response.status().is_success() { @@ -119,10 +124,7 @@ impl ApiClient { let url = format!("{}{}", self.base_url, endpoint.path()); let response = self - .client - .put(&url) - .header("X-Session-Token", &self.token) - .send() + .send(|| self.client.put(&url).header("X-Session-Token", &self.token)) .await?; if response.status().is_success() { @@ -136,6 +138,31 @@ impl ApiClient { } } + /// Sends the request produced by `build`. In test builds, a 429 is retried + /// after the server's `retry_after` delay so live tests wait out the rate + /// limit instead of failing; the last attempt's response is returned as-is. + async fn send(&self, build: impl Fn() -> RequestBuilder) -> reqwest::Result { + if cfg!(test) { + for _ in 0..TEST_RATE_LIMIT_RETRIES { + let response = build().send().await?; + if response.status() != StatusCode::TOO_MANY_REQUESTS { + return Ok(response); + } + + let body = response + .json::() + .await + .unwrap_or_default(); + let wait_ms = body["retry_after"] + .as_u64() + .unwrap_or(DEFAULT_RETRY_AFTER_MS); + tokio::time::sleep(Duration::from_millis(wait_ms)).await; + } + } + + build().send().await + } + pub fn clone_token(&self) -> String { self.token.clone() } diff --git a/src/api/dm.rs b/src/api/dm.rs index 2efa5b5..b4154bd 100644 --- a/src/api/dm.rs +++ b/src/api/dm.rs @@ -218,7 +218,10 @@ pub async fn fetch_dms( #[cfg(test)] mod tests { use super::*; - use crate::api::{auth::Auth, user::fetch_current_user}; + use crate::{ + api::user::fetch_current_user, + test_helpers::{json_str, keyring_client}, + }; use serde_json::Value; use std::collections::HashMap; use tokio::sync::OnceCell; @@ -234,20 +237,9 @@ mod tests { static LIVE: OnceCell = OnceCell::const_new(); - async fn real_client() -> ApiClient { - let auth = Auth::new().expect("keyring must be available to run dm tests"); - let token = auth - .token_entry - .get_secret() - .await - .expect("a vimstoat session token must be stored in the keyring"); - - ApiClient::new(token, None) - } - async fn live() -> &'static Live { LIVE.get_or_init(|| async { - let client = real_client().await; + let client = keyring_client("dm").await; let my_id = fetch_current_user(&client) .await .expect("GET /users/@me should succeed with the real token") @@ -271,18 +263,12 @@ mod tests { .await } - fn str_field<'a>(obj: &'a Value, key: &str) -> &'a str { - obj.get(key) - .and_then(Value::as_str) - .unwrap_or_else(|| panic!("`{key}` is missing or not a string")) - } - fn channel_type(raw: &Value) -> &str { - str_field(raw, "channel_type") + json_str(raw, "channel_type") } fn parsed<'a>(live: &'a Live, raw: &Value) -> &'a DirectMessageChannel { - let id = str_field(raw, "_id"); + let id = json_str(raw, "_id"); live.dms .iter() .find(|dm| dm.id == id) @@ -311,7 +297,7 @@ mod tests { async fn test_fetch_dms_returns_every_channel_once() { let live = live().await; - let mut raw_ids: Vec<&str> = live.raw_dms.iter().map(|c| str_field(c, "_id")).collect(); + let mut raw_ids: Vec<&str> = live.raw_dms.iter().map(|c| json_str(c, "_id")).collect(); let mut parsed_ids: Vec<&str> = live.dms.iter().map(|dm| dm.id.as_str()).collect(); raw_ids.sort_unstable(); parsed_ids.sort_unstable(); @@ -376,7 +362,7 @@ mod tests { let live = live().await; for raw in live.raw_dms.iter().filter(|c| channel_type(c) == "Group") { - assert_eq!(parsed(live, raw).name, str_field(raw, "name")); + assert_eq!(parsed(live, raw).name, json_str(raw, "name")); } } @@ -437,8 +423,8 @@ mod tests { // shape drifts, every DM silently shows as unread. for unread in &unreads { let key = unread.get("_id").expect("unread has no `_id`"); - str_field(key, "channel"); - assert_eq!(str_field(key, "user"), live.my_id); + json_str(key, "channel"); + assert_eq!(json_str(key, "user"), live.my_id); } } @@ -448,7 +434,7 @@ mod tests { let unreads = fetch_unreads(&live.client).await.unwrap(); let by_channel: HashMap<&str, &Value> = unreads .iter() - .map(|u| (str_field(&u["_id"], "channel"), u)) + .map(|u| (json_str(&u["_id"], "channel"), u)) .collect(); for dm in &live.dms { diff --git a/src/api/events.rs b/src/api/events.rs index 47d844e..c31396a 100644 --- a/src/api/events.rs +++ b/src/api/events.rs @@ -42,7 +42,7 @@ pub enum AuthEvent { #[allow(unused)] pub enum ServerEvent { Error { - error: String, + data: Value, }, Authenticated, Logout, diff --git a/src/api/user.rs b/src/api/user.rs index e056fb9..571ee09 100644 --- a/src/api/user.rs +++ b/src/api/user.rs @@ -75,7 +75,10 @@ pub async fn fetch_current_user(api_client: &ApiClient) -> Result { #[cfg(test)] mod tests { use super::*; - use crate::{api::auth::Auth, cache::Id}; + use crate::{ + cache::Id, + test_helpers::{json_str, keyring_client}, + }; use serde_json::Value; use tokio::sync::OnceCell; @@ -84,22 +87,11 @@ mod tests { static REAL_ME: OnceCell = OnceCell::const_new(); - async fn real_client() -> ApiClient { - let auth = Auth::new().expect("keyring must be available to run user tests"); - let token = auth - .token_entry - .get_secret() - .await - .expect("a vimstoat session token must be stored in the keyring"); - - ApiClient::new(token, None) - } - /// The real `/users/@me` payload, fetched once per test run. async fn real_me() -> &'static Value { REAL_ME .get_or_init(|| async { - real_client() + keyring_client("user") .await .get::(Endpoint::CurrentUser) .await @@ -113,12 +105,6 @@ mod tests { .unwrap_or_else(|| panic!("missing field `{key}`")) } - fn str_field<'a>(obj: &'a Value, key: &str) -> &'a str { - field(obj, key) - .as_str() - .unwrap_or_else(|| panic!("`{key}` is not a string")) - } - /// An optional key's value, treating absent and `null` the same. fn optional<'a>(obj: &'a Value, key: &str) -> Option<&'a Value> { obj.get(key).filter(|v| !v.is_null()) @@ -130,13 +116,13 @@ mod tests { let user = parse_user(me).expect("real /users/@me payload should parse"); - assert_eq!(user.id, str_field(me, "_id")); - assert_eq!(user.username, str_field(me, "username")); + assert_eq!(user.id, json_str(me, "_id")); + assert_eq!(user.username, json_str(me, "username")); } #[tokio::test] async fn test_fetch_user_wrappers_agree() { - let client = real_client().await; + let client = keyring_client("user").await; let current = fetch_current_user(&client).await.unwrap(); let by_id = fetch_user(&client, ¤t.id).await.unwrap(); @@ -150,17 +136,17 @@ mod tests { let me = real_me().await; assert!( - Id::::new(str_field(me, "_id")).is_ok(), + Id::::new(json_str(me, "_id")).is_ok(), "`_id` is not a valid id" ); - assert!(!str_field(me, "username").is_empty(), "`username` is empty"); + assert!(!json_str(me, "username").is_empty(), "`username` is empty"); - let discriminator = str_field(me, "discriminator"); + let discriminator = json_str(me, "discriminator"); assert!( discriminator.len() == 4 && discriminator.chars().all(|c| c.is_ascii_digit()), "`discriminator` is not 4 digits" ); - assert_eq!(str_field(me, "relationship"), "User"); + assert_eq!(json_str(me, "relationship"), "User"); } #[tokio::test] @@ -197,16 +183,13 @@ mod tests { }; for key in ["_id", "tag", "filename", "content_type"] { - assert!( - !str_field(avatar, key).is_empty(), - "`avatar.{key}` is empty" - ); + assert!(!json_str(avatar, key).is_empty(), "`avatar.{key}` is empty"); } assert!( field(avatar, "size").is_u64(), "`avatar.size` is not a number" ); - str_field(field(avatar, "metadata"), "type"); + json_str(field(avatar, "metadata"), "type"); } #[tokio::test] @@ -220,11 +203,11 @@ mod tests { for relation in relations { assert!( - Id::::new(str_field(relation, "_id")).is_ok(), + Id::::new(json_str(relation, "_id")).is_ok(), "`relations[]._id` is not a valid id" ); assert!( - !str_field(relation, "status").is_empty(), + !json_str(relation, "status").is_empty(), "`relations[].status` is empty" ); } diff --git a/src/app.rs b/src/app.rs index 4b3a058..607bd33 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,13 +10,13 @@ use tokio::time; use crate::{ Result, api::{ - API_BASE_URL, auth::Auth, client::ApiClient, events::{ClientEvent, ServerEvent}, ws::WsClient, }, cache::{CacheStore, Id}, + config::Config, input::{InputMode, InputState}, models::{DirectMessageChannel, Server}, }; @@ -84,6 +84,7 @@ pub struct App { pub should_quit: bool, pub input_state: InputState, pub api_base_url: String, + pub config_warning: Option, pub api_client: ApiClient, pub ws_client: WsClient, pub ws_rx: Receiver, @@ -98,22 +99,18 @@ pub struct App { } impl App { - pub async fn new() -> Result { - let api_base_url = std::env::var("API_BASE_URL").ok(); - let ws_base_url = std::env::var("WS_BASE_URL").ok(); - Self::new_with_urls(api_base_url, ws_base_url).await - } + pub async fn new(config: Config, config_warning: Option) -> Result { + let api_base_url = config.instance.url; - pub async fn new_with_urls( - api_base_url: Option, - ws_base_url: Option, - ) -> Result { let auth = Auth::new().map_err(|e| anyhow::anyhow!(e))?; - let mut api_client = ApiClient::new(String::new(), api_base_url.clone()); + let mut api_client = ApiClient::new(String::new(), Some(api_base_url.clone())); let state = if let Ok(token) = auth.token_entry.get_secret().await { - match auth.validate_token(&token, api_base_url.clone()).await { + match auth + .validate_token(&token, Some(api_base_url.clone())) + .await + { Ok(authenticated_client) => { api_client = authenticated_client; AppState::LoggedIn @@ -124,7 +121,7 @@ impl App { AppState::NeedsAuth }; - let (ws_client, ws_rx) = WsClient::connect(ws_base_url).await?; + let (ws_client, ws_rx) = WsClient::connect(Some(config.instance.ws_url)).await?; let cache = Arc::new(Mutex::new(CacheStore::new()?)); let (app_tx, app_rx) = mpsc::channel::(32); @@ -137,7 +134,8 @@ impl App { command_text: String::new(), auth, should_quit: false, - api_base_url: api_base_url.unwrap_or(API_BASE_URL.to_string()), + api_base_url, + config_warning, api_client, ws_client, ws_rx, @@ -206,42 +204,47 @@ impl App { is_authenticated = true; break; } - ServerEvent::Error { error } => { - error!("Error authenticating: {error}"); - return Err(anyhow::anyhow!("WebSocket authentication failed: {error}")); + ServerEvent::Error { data } => { + let reason = data["type"].as_str().unwrap_or("unknown error"); + error!("Error authenticating: {reason}"); + return Err(anyhow::anyhow!("WebSocket authentication failed: {reason}")); } _ => {} } } - if is_authenticated { - let tx_ping = self.ws_client.clone_sender(); + if !is_authenticated { + return Err(anyhow::anyhow!( + "WebSocket closed before authentication completed" + )); + } - tokio::spawn(async move { - let mut interval = time::interval(Duration::from_secs(20)); + let tx_ping = self.ws_client.clone_sender(); - loop { - interval.tick().await; + tokio::spawn(async move { + let mut interval = time::interval(Duration::from_secs(20)); - #[allow(clippy::cast_possible_truncation)] - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_millis() as u64; + loop { + interval.tick().await; - if tx_ping - .send(ClientEvent::Ping { data: timestamp }) - .await - .is_err() - { - warn!("Stopped pinging: channel closed."); - break; - } + #[allow(clippy::cast_possible_truncation)] + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + + if tx_ping + .send(ClientEvent::Ping { data: timestamp }) + .await + .is_err() + { + warn!("Stopped pinging: channel closed."); + break; } - }); + } + }); - debug!("Started pinging every 20s."); - } + debug!("Started pinging every 20s."); Ok(()) } @@ -285,6 +288,10 @@ impl App { } pub async fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> { + if self.config_warning.take().is_some() { + return Ok(()); + } + if matches!(self.input_state.input_mode, InputMode::Command) { crate::views::command::handle(self, key); return Ok(()); @@ -554,3 +561,483 @@ impl App { let _ = ratatui::crossterm::execute!(std::io::stdout(), style); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + api::API_BASE_URL, + models::{Message, User}, + }; + use serde_json::Value; + use std::collections::HashMap; + use tempfile::TempDir; + use tokio::sync::OnceCell; + + /// Real account data shared by every test, fetched once per run. + struct Live { + token: String, + me: User, + dms: Vec, + /// Stand-in users for every DM recipient, so `fetch_dms` makes no + /// per-user requests and the suite stays under the API rate limit. + placeholders: HashMap, + /// A fully read DM, so acking its latest message changes nothing. + read_dm: DirectMessageChannel, + /// The real recipient of `read_dm`. + recipient: User, + /// The latest messages of `read_dm`, newest first. + messages: Vec, + } + + static LIVE: OnceCell = OnceCell::const_new(); + + async fn live() -> &'static Live { + LIVE.get_or_init(|| async { + let token = crate::test_helpers::keyring_token("app").await; + let client = ApiClient::new(token.clone(), None); + + let me = crate::api::user::fetch_current_user(&client) + .await + .expect("GET /users/@me should succeed with the real token"); + let raw_dms = client + .get::>(crate::api::client::Endpoint::Dms) + .await + .expect("GET /users/dms should succeed with the real token"); + let placeholders: HashMap = raw_dms + .iter() + .filter_map(|dm| dm["recipients"].as_array()) + .flatten() + .filter_map(Value::as_str) + .map(|id| { + let user = User { + id: id.to_string(), + username: format!("placeholder-{id}"), + status: crate::models::UserStatus::default(), + status_text: None, + }; + (id.to_string(), user) + }) + .collect(); + let (dms, _) = crate::api::dm::fetch_dms(&client, &placeholders) + .await + .expect("fetch_dms should succeed with the real token"); + let unreads = crate::api::dm::fetch_unreads(&client).await.unwrap(); + + let query = crate::api::channel::MessageHistoryQuery { + limit: Some(3), + before: None, + after: None, + sort: None, + nearby: None, + }; + for dm in &dms { + let fully_read = unreads.iter().any(|u| { + u["_id"]["channel"].as_str() == Some(&dm.id) + && u["last_id"].as_str() == dm.last_message_id.as_deref() + && u["mentions"].as_array().is_none_or(Vec::is_empty) + }); + let Some(recipient_id) = &dm.recipient_id else { + continue; + }; + if dm.last_message_id.is_none() || !fully_read { + continue; + } + let history = + crate::api::channel::fetch_message_history(&client, &dm.id, Some(&query)) + .await + .unwrap(); + if history.len() == 3 { + let messages = history.iter().map(to_message).collect(); + let recipient = crate::api::user::fetch_user(&client, recipient_id) + .await + .expect("DM recipient should be fetchable"); + return Live { + token, + me, + read_dm: dm.clone(), + recipient, + dms, + placeholders, + messages, + }; + } + } + panic!("app tests need a fully read DM with at least 3 messages"); + }) + .await + } + + fn to_message(raw: &Value) -> Message { + let author_id = raw["author"].as_str().unwrap().to_string(); + Message { + id: raw["_id"].as_str().unwrap().to_string(), + author_name: author_id.clone(), + author_id, + content: raw["content"].as_str().unwrap_or_default().to_string(), + } + } + + /// A logged-in `App` on the real API and websocket, with a throwaway cache + /// so the user's real cache file is never read or written. + async fn test_app() -> (App, TempDir) { + let live = live().await; + let cache_dir = TempDir::new().expect("Failed to create a temporary directory"); + let (ws_client, ws_rx) = WsClient::connect(None) + .await + .expect("websocket should connect to the real Stoat server"); + let (app_tx, app_rx) = mpsc::channel::(32); + + let app = App { + state: AppState::LoggedIn, + input_text: String::new(), + input_cursor: 0, + yank_buffer: None, + command_text: String::new(), + auth: Auth::new().unwrap(), + should_quit: false, + input_state: InputState::default(), + api_base_url: API_BASE_URL.to_string(), + config_warning: None, + api_client: ApiClient::new(live.token.clone(), None), + ws_client, + ws_rx, + cache: Arc::new(Mutex::new( + CacheStore::new_temporary(cache_dir.path()).unwrap(), + )), + store: AppStore::default(), + selected_index: 0, + selected_dm_index: 0, + is_loading_dms: false, + is_loading_messages: false, + app_tx, + app_rx, + }; + (app, cache_dir) + } + + /// An `App` viewing `live.read_dm` with its history loaded. + async fn app_in_read_dm() -> (App, TempDir) { + let live = live().await; + let (mut app, dir) = test_app().await; + app.store.dm_channels = live.dms.clone(); + app.selected_dm_index = live + .dms + .iter() + .position(|dm| dm.id == live.read_dm.id) + .unwrap(); + app.store.current_dm_messages = live.messages.clone(); + app.state = AppState::Dm; + (app, dir) + } + + fn other_dm(live: &Live) -> &DirectMessageChannel { + live.dms + .iter() + .find(|dm| dm.id != live.read_dm.id) + .expect("app tests need at least two DMs") + } + + fn channel<'a>(app: &'a App, id: &str) -> &'a DirectMessageChannel { + app.store.dm_channels.iter().find(|c| c.id == id).unwrap() + } + + fn message_ids(messages: &[Message]) -> Vec<&str> { + messages.iter().map(|m| m.id.as_str()).collect() + } + + async fn next_app_event(app: &mut App) -> AppEvent { + time::timeout(Duration::from_secs(30), app.app_rx.recv()) + .await + .expect("timed out waiting for an AppEvent") + .expect("app event channel closed") + } + + /// Waits for a spawned write-through task to cache a channel's messages. + /// Single-threaded runtime: the task only runs while we sleep, so our + /// lock never makes its `try_lock` fail. + async fn cached_messages(app: &App, channel_id: &str) -> Vec { + for _ in 0..100 { + time::sleep(Duration::from_millis(10)).await; + if let Some(messages) = app.cache.lock().await.get_messages(channel_id) { + return messages; + } + } + panic!("messages for {channel_id} were never written to the cache"); + } + + #[tokio::test] + async fn test_authenticate_ws_accepts_real_token() { + let live = live().await; + let (mut app, _dir) = test_app().await; + + app.authenticate_ws(&live.token) + .await + .expect("real token should authenticate the websocket"); + } + + #[tokio::test] + async fn test_authenticate_ws_rejects_invalid_token() { + let (mut app, _dir) = test_app().await; + + let result = app + .authenticate_ws("definitely_not_a_real_session_token") + .await; + + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_setup_session_caches_me_and_loads_dms() { + let live = live().await; + let (mut app, _dir) = test_app().await; + // Seed users as if loaded from cache, so fetch_dms needn't refetch them. + app.store.users = live.placeholders.clone(); + + app.setup_session().await.unwrap(); + + // Compare identity only: presence flips while other tests' websockets + // authenticate as the same account. + let stored_me = &app.store.users[&live.me.id]; + assert_eq!(stored_me.username, live.me.username); + let cached_me = app + .cache + .lock() + .await + .get(Id::::new(&live.me.id).unwrap()) + .expect("current user should be written to the cache"); + assert_eq!(&cached_me, stored_me); + + let AppEvent::DmsLoaded(dms, _) = next_app_event(&mut app).await else { + panic!("setup_session should send DmsLoaded first"); + }; + assert_eq!(dms, live.dms); + } + + #[tokio::test] + async fn test_dms_loaded_stores_channels_and_refreshes_stale_users() { + let live = live().await; + let (mut app, _dir) = test_app().await; + app.is_loading_dms = true; + // One DM only: revalidation fetches every recipient, one request each. + let dms = vec![live.read_dm.clone()]; + let stale = User { + username: format!("stale-{}", live.recipient.id), + ..live.recipient.clone() + }; + + app.handle_app_event(AppEvent::DmsLoaded(dms.clone(), vec![stale.clone()])); + + assert_eq!(app.store.dm_channels, dms); + assert!(!app.is_loading_dms); + assert_eq!(app.store.users[&stale.id], stale); + + let AppEvent::UsersRefreshed(fresh) = next_app_event(&mut app).await else { + panic!("DmsLoaded should trigger a UsersRefreshed"); + }; + app.handle_app_event(AppEvent::UsersRefreshed(fresh)); + + assert_eq!( + app.store.users[&stale.id].username, live.recipient.username, + "stale user was not refreshed from the API" + ); + } + + #[tokio::test] + async fn test_dm_messages_loaded_writes_through_to_cache() { + let live = live().await; + let (mut app, _dir) = test_app().await; + app.is_loading_messages = true; + + app.handle_app_event(AppEvent::DmMessagesLoaded( + live.read_dm.id.clone(), + live.messages.clone(), + vec![live.me.clone()], + )); + + assert_eq!(app.store.current_dm_messages, live.messages); + assert!(!app.is_loading_messages); + assert!(app.store.users.contains_key(&live.me.id)); + assert_eq!(cached_messages(&app, &live.read_dm.id).await, live.messages); + } + + #[tokio::test] + async fn test_new_message_in_active_dm_is_shown_read_and_cached() { + let live = live().await; + let (mut app, _dir) = app_in_read_dm().await; + let (newest, older) = live.messages.split_first().unwrap(); + app.store.current_dm_messages = older.to_vec(); + + // Acks the already-read latest message, leaving real read state unchanged. + app.handle_app_event(AppEvent::NewMessage { + channel_id: live.read_dm.id.clone(), + message: newest.clone(), + new_user: None, + }); + + assert_eq!(app.store.current_dm_messages, live.messages); + let dm = channel(&app, &live.read_dm.id); + assert_eq!(dm.last_message_id.as_ref(), Some(&newest.id)); + assert!(!dm.has_unread, "message in the open DM marked it unread"); + assert_eq!(cached_messages(&app, &live.read_dm.id).await, live.messages); + } + + #[tokio::test] + async fn test_new_message_in_background_dm_marks_unread() { + let live = live().await; + let (mut app, _dir) = test_app().await; + app.store.dm_channels = live.dms.clone(); + app.state = AppState::DmList; + let newest = live.messages[0].clone(); + + app.handle_app_event(AppEvent::NewMessage { + channel_id: live.read_dm.id.clone(), + message: newest.clone(), + new_user: Some(live.me.clone()), + }); + + let dm = channel(&app, &live.read_dm.id); + assert!(dm.has_unread); + assert_eq!(dm.last_message_id.as_ref(), Some(&newest.id)); + assert!(app.store.current_dm_messages.is_empty()); + assert!(app.store.users.contains_key(&live.me.id)); + } + + #[tokio::test] + async fn test_message_updated_only_edits_active_dm() { + let live = live().await; + let (mut app, _dir) = app_in_read_dm().await; + let target = live.messages[1].id.clone(); + + app.handle_app_event(AppEvent::MessageUpdated { + channel_id: other_dm(live).id.clone(), + message_id: target.clone(), + content: "from another channel".to_string(), + }); + assert_eq!(app.store.current_dm_messages, live.messages); + + app.handle_app_event(AppEvent::MessageUpdated { + channel_id: live.read_dm.id.clone(), + message_id: target.clone(), + content: "edited".to_string(), + }); + for (msg, original) in app.store.current_dm_messages.iter().zip(&live.messages) { + let expected = if msg.id == target { + "edited" + } else { + &original.content + }; + assert_eq!(msg.content, expected); + } + } + + #[tokio::test] + async fn test_message_deleted_only_removes_from_active_dm() { + let live = live().await; + let (mut app, _dir) = app_in_read_dm().await; + let target = live.messages[1].id.clone(); + + app.handle_app_event(AppEvent::MessageDeleted { + channel_id: other_dm(live).id.clone(), + message_id: target.clone(), + }); + assert_eq!(app.store.current_dm_messages, live.messages); + + app.handle_app_event(AppEvent::MessageDeleted { + channel_id: live.read_dm.id.clone(), + message_id: target, + }); + assert_eq!( + message_ids(&app.store.current_dm_messages), + [live.messages[0].id.as_str(), live.messages[2].id.as_str()] + ); + } + + #[tokio::test] + async fn test_typing_is_tracked_per_channel() { + let live = live().await; + let (mut app, _dir) = test_app().await; + app.store.dm_channels = live.dms.clone(); + let typist = live.me.id.clone(); + + app.handle_app_event(AppEvent::TypingStart { + channel_id: live.read_dm.id.clone(), + user_id: typist.clone(), + }); + assert!( + channel(&app, &live.read_dm.id) + .typing_users + .contains(&typist) + ); + assert!(channel(&app, &other_dm(live).id).typing_users.is_empty()); + + app.handle_app_event(AppEvent::TypingStop { + channel_id: live.read_dm.id.clone(), + user_id: typist, + }); + assert!(channel(&app, &live.read_dm.id).typing_users.is_empty()); + } + + #[tokio::test] + async fn test_channel_acked_clears_unread() { + let live = live().await; + let (mut app, _dir) = test_app().await; + app.store.dm_channels = live.dms.clone(); + for dm in &mut app.store.dm_channels { + dm.has_unread = true; + } + + app.handle_app_event(AppEvent::ChannelAcked { + channel_id: live.read_dm.id.clone(), + message_id: live.messages[0].id.clone(), + }); + + let acked = channel(&app, &live.read_dm.id); + assert!(!acked.has_unread); + assert_eq!(acked.last_message_id.as_ref(), Some(&live.messages[0].id)); + assert!(channel(&app, &other_dm(live).id).has_unread); + } + + #[tokio::test] + async fn test_shutdown_persists_users_and_open_dm_to_disk() { + let live = live().await; + let (mut app, dir) = app_in_read_dm().await; + app.store.users.insert(live.me.id.clone(), live.me.clone()); + + app.shutdown().await; + + let on_disk = pickledb::PickleDb::load( + dir.path().join(crate::cache::DB_FILE), + pickledb::PickleDbDumpPolicy::NeverDump, + pickledb::SerializationMethod::Bin, + ) + .expect("shutdown should dump the cache to disk"); + assert_eq!( + on_disk.get::(&format!("user:{}", live.me.id)), + Some(live.me.clone()) + ); + assert_eq!( + on_disk.get::>(&format!("messages:{}", live.read_dm.id)), + Some(live.messages.clone()) + ); + } + + #[tokio::test] + async fn test_go_back_or_quit_walks_up_then_quits() { + let (mut app, _dir) = test_app().await; + app.state = AppState::Dm; + app.set_input_mode(InputMode::Insert); + + app.go_back_or_quit(); + assert!(matches!(app.state, AppState::DmList)); + assert_eq!(app.input_state.input_mode, InputMode::UI); + assert!(!app.should_quit); + + app.go_back_or_quit(); + assert!(matches!(app.state, AppState::LoggedIn)); + assert!(!app.should_quit); + + app.go_back_or_quit(); + assert!(app.should_quit); + } +} diff --git a/src/command.rs b/src/command.rs index 2a35540..b7773ba 100644 --- a/src/command.rs +++ b/src/command.rs @@ -17,8 +17,10 @@ impl Command { } let cmd = match trimmed { - "q" | "quit" | "q!" => Command::Quit, - "qa" | "qa!" | "qall" | "qall!" => Command::QuitAll, + "q" | "quit" | "q!" | "wq" | "wq!" => Command::Quit, + "qa" | "qa!" | "qall" | "qall!" | "wqa" | "wqa!" | "wqall" | "wqall!" => { + Command::QuitAll + } other => Command::Unknown(other.to_string()), }; diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..8ff3a0f --- /dev/null +++ b/src/config.rs @@ -0,0 +1,455 @@ +use std::{ + fs, + io::{self, Write}, + path::{Path, PathBuf}, +}; + +use log::{info, warn}; +use serde::Deserialize; + +use crate::{ + api::{API_BASE_URL, WS_BASE_URL}, + views::error::ConfigError, +}; + +pub const CONFIG_FILE: &str = "config.toml"; + +/// Written to disk when no config file exists. Must parse to `Config::default()`. +pub const DEFAULT_CONFIG: &str = r#"# vimstoat configuration + +[instance] +# REST API base URL +url = "https://api.stoat.chat" +# Websocket events URL +ws_url = "wss://events.stoat.chat" +"#; + +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Config { + pub instance: InstanceConfig, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct InstanceConfig { + pub url: String, + pub ws_url: String, +} + +impl Default for InstanceConfig { + fn default() -> Self { + Self { + url: API_BASE_URL.to_string(), + ws_url: WS_BASE_URL.to_string(), + } + } +} + +impl Config { + pub fn validate(&self) -> Result<(), ConfigError> { + check_url("instance.url", &self.instance.url, &["https://", "http://"])?; + check_url( + "instance.ws_url", + &self.instance.ws_url, + &["wss://", "ws://"], + )?; + Ok(()) + } + + /// Strips trailing slashes, since endpoint paths are appended with a leading `/`. + fn normalize(mut self) -> Self { + let trim = |s: &mut String| s.truncate(s.trim_end_matches('/').len()); + trim(&mut self.instance.url); + trim(&mut self.instance.ws_url); + self + } +} + +fn check_url(key: &str, value: &str, schemes: &[&str]) -> Result<(), ConfigError> { + let has_host = schemes.iter().any(|scheme| { + value + .strip_prefix(scheme) + .is_some_and(|rest| !rest.is_empty()) + }); + + if has_host { + Ok(()) + } else { + Err(ConfigError::Invalid(format!( + "`{key}` must be a URL starting with {}, got {value:?}", + schemes.join(" or ") + ))) + } +} + +pub fn config_path(config_dir: Option) -> Option { + config_dir.map(|p| p.join(env!("CARGO_PKG_NAME")).join(CONFIG_FILE)) +} + +fn parse(contents: &str) -> Result { + let config = toml::from_str::(contents) + .map_err(ConfigError::Parse)? + .normalize(); + config.validate()?; + Ok(config) +} + +/// Creates the file with `DEFAULT_CONFIG`, refusing to overwrite an existing one. +fn write_default(path: &Path) -> io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path)? + .write_all(DEFAULT_CONFIG.as_bytes()) +} + +fn read_or_create(path: &Path) -> Result { + match fs::read_to_string(path) { + Ok(contents) => parse(&contents), + Err(e) if e.kind() == io::ErrorKind::NotFound => { + write_default(path).map_err(ConfigError::Write)?; + info!("Created default config at {}", path.display()); + Ok(Config::default()) + } + Err(e) => Err(ConfigError::Read(e)), + } +} + +fn fallback(warning: String) -> (Config, Option) { + warn!("{warning}"); + (Config::default(), Some(warning)) +} + +/// Loads the config at `path`, generating a default one if it doesn't exist. +/// Any failure falls back to `Config::default()` and returns a warning for the user. +pub fn load_from(path: &Path) -> (Config, Option) { + match read_or_create(path) { + Ok(config) => (config, None), + Err(e) => fallback(format!("{}: {e}", path.display())), + } +} + +pub fn load() -> (Config, Option) { + match config_path(dirs::config_dir()) { + Some(path) => load_from(&path), + None => fallback(ConfigError::NoConfigDir.to_string()), + } +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + + fn with_urls(url: &str, ws_url: &str) -> Config { + Config { + instance: InstanceConfig { + url: url.to_string(), + ws_url: ws_url.to_string(), + }, + } + } + + /// Writes `contents` to a config file in a fresh temp dir. + fn write_config(contents: &str) -> (TempDir, PathBuf) { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join(CONFIG_FILE); + fs::write(&path, contents).unwrap(); + (tmp, path) + } + + /// Asserts the file falls back to defaults with a warning and is left untouched. + fn assert_rejected(contents: &str) -> String { + let (_tmp, path) = write_config(contents); + + let (config, warning) = load_from(&path); + + assert_eq!(config, Config::default()); + assert_eq!(fs::read_to_string(&path).unwrap(), contents); + warning.expect("invalid config should produce a warning") + } + + // --- Paths --- + + #[test] + fn config_path_uses_package_name_and_config_file() { + let base = PathBuf::from("/some/config"); + + assert_eq!( + config_path(Some(base.clone())), + Some(base.join(env!("CARGO_PKG_NAME")).join(CONFIG_FILE)) + ); + } + + #[test] + fn config_path_without_config_dir_is_none() { + assert_eq!(config_path(None), None); + } + + // --- Defaults --- + + #[test] + fn default_uses_api_constants() { + assert_eq!(Config::default(), with_urls(API_BASE_URL, WS_BASE_URL)); + } + + #[test] + fn default_config_template_matches_default() { + assert_eq!(parse(DEFAULT_CONFIG).unwrap(), Config::default()); + } + + #[test] + fn default_is_valid() { + assert!(Config::default().validate().is_ok()); + } + + // --- Generation --- + + #[test] + fn missing_file_generates_default_config() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join(CONFIG_FILE); + + let (config, warning) = load_from(&path); + + assert_eq!(config, Config::default()); + assert!(warning.is_none()); + assert_eq!(fs::read_to_string(&path).unwrap(), DEFAULT_CONFIG); + } + + #[test] + fn missing_file_creates_parent_directories() { + let tmp = TempDir::new().unwrap(); + let path = config_path(Some(tmp.path().join("nested").join("config"))).unwrap(); + assert!(!path.parent().unwrap().exists()); + + let (_, warning) = load_from(&path); + + assert!(warning.is_none()); + assert!(path.is_file()); + } + + #[test] + fn generated_config_loads_on_next_run() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join(CONFIG_FILE); + + load_from(&path); + let (config, warning) = load_from(&path); + + assert_eq!(config, Config::default()); + assert!(warning.is_none()); + } + + #[test] + fn write_default_does_not_overwrite_existing_file() { + let (_tmp, path) = write_config("existing"); + + let err = write_default(&path).unwrap_err(); + + assert_eq!(err.kind(), io::ErrorKind::AlreadyExists); + assert_eq!(fs::read_to_string(&path).unwrap(), "existing"); + } + + #[test] + fn write_default_fails_when_parent_is_a_file() { + let (tmp, blocker) = write_config(""); + let path = blocker.join(CONFIG_FILE); + + assert!(write_default(&path).is_err()); + assert!(tmp.path().join(CONFIG_FILE).is_file()); + } + + // --- Valid configs --- + + #[test] + fn valid_config_is_loaded() { + let contents = r#" +[instance] +url = "https://api.example.com" +ws_url = "wss://events.example.com" +"#; + let (_tmp, path) = write_config(contents); + + let (config, warning) = load_from(&path); + + assert_eq!( + config, + with_urls("https://api.example.com", "wss://events.example.com") + ); + assert!(warning.is_none()); + assert_eq!(fs::read_to_string(&path).unwrap(), contents); + } + + #[test] + fn partial_config_fills_missing_keys_with_defaults() { + let (_tmp, path) = write_config("[instance]\nurl = \"http://localhost:8000\"\n"); + + let (config, warning) = load_from(&path); + + assert_eq!(config, with_urls("http://localhost:8000", WS_BASE_URL)); + assert!(warning.is_none()); + } + + #[test] + fn empty_file_uses_defaults() { + let (_tmp, path) = write_config(""); + + let (config, warning) = load_from(&path); + + assert_eq!(config, Config::default()); + assert!(warning.is_none()); + } + + #[test] + fn trailing_slashes_are_trimmed() { + let config = parse( + "[instance]\nurl = \"https://api.example.com/\"\nws_url = \"wss://events.example.com//\"\n", + ) + .unwrap(); + + assert_eq!( + config, + with_urls("https://api.example.com", "wss://events.example.com") + ); + } + + // --- Invalid configs --- + + #[test] + fn malformed_toml_is_rejected() { + assert_rejected("[instance\nurl = "); + } + + #[test] + fn wrong_type_is_rejected() { + assert_rejected("[instance]\nurl = 5\n"); + } + + #[test] + fn unknown_key_is_rejected() { + assert_rejected("[instance]\nurll = \"https://api.example.com\"\n"); + } + + #[test] + fn unknown_table_is_rejected() { + assert_rejected("[instanse]\nurl = \"https://api.example.com\"\n"); + } + + #[test] + fn ws_scheme_on_api_url_is_rejected() { + let warning = assert_rejected("[instance]\nurl = \"wss://api.example.com\"\n"); + assert!(warning.contains("instance.url")); + } + + #[test] + fn http_scheme_on_ws_url_is_rejected() { + let warning = assert_rejected("[instance]\nws_url = \"https://events.example.com\"\n"); + assert!(warning.contains("instance.ws_url")); + } + + #[test] + fn empty_url_is_rejected() { + assert_rejected("[instance]\nurl = \"\"\n"); + } + + #[test] + fn warning_contains_config_path() { + let (_tmp, path) = write_config("not toml at all"); + + let (_, warning) = load_from(&path); + + assert!(warning.unwrap().contains(&path.display().to_string())); + } + + // --- I/O failures --- + + #[test] + fn unreadable_path_falls_back_to_default() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join(CONFIG_FILE); + fs::create_dir(&path).unwrap(); + + let (config, warning) = load_from(&path); + + assert_eq!(config, Config::default()); + assert!(warning.is_some()); + } + + #[test] + fn uncreatable_path_falls_back_to_default() { + let (_tmp, blocker) = write_config(""); + let path = blocker.join("nested").join(CONFIG_FILE); + + let (config, warning) = load_from(&path); + + assert_eq!(config, Config::default()); + assert!(warning.is_some()); + } + + // --- Validation --- + + #[test] + fn validate_accepts_http_and_https_api_urls() { + assert!( + with_urls("http://localhost", WS_BASE_URL) + .validate() + .is_ok() + ); + assert!( + with_urls("https://api.example.com", WS_BASE_URL) + .validate() + .is_ok() + ); + } + + #[test] + fn validate_accepts_ws_and_wss_urls() { + assert!(with_urls(API_BASE_URL, "ws://localhost").validate().is_ok()); + assert!( + with_urls(API_BASE_URL, "wss://events.example.com") + .validate() + .is_ok() + ); + } + + #[test] + fn validate_rejects_missing_scheme() { + assert!( + with_urls("api.example.com", WS_BASE_URL) + .validate() + .is_err() + ); + assert!( + with_urls(API_BASE_URL, "events.example.com") + .validate() + .is_err() + ); + } + + #[test] + fn validate_rejects_scheme_without_host() { + assert!(with_urls("https://", WS_BASE_URL).validate().is_err()); + assert!(with_urls(API_BASE_URL, "wss://").validate().is_err()); + } + + #[test] + fn validate_rejects_empty_urls() { + assert!(with_urls("", WS_BASE_URL).validate().is_err()); + assert!(with_urls(API_BASE_URL, "").validate().is_err()); + } + + #[test] + fn validate_error_is_invalid_variant() { + assert!(matches!( + with_urls("ftp://x", WS_BASE_URL).validate(), + Err(ConfigError::Invalid(_)) + )); + } +} diff --git a/src/input.rs b/src/input.rs index 5f82f90..4dc89bd 100644 --- a/src/input.rs +++ b/src/input.rs @@ -134,9 +134,22 @@ impl Default for KeyMaps { ), ]); + let mut normal_maps = ui_maps.clone(); + normal_maps.insert( + vec![KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)], + Action::Paste, + ); + normal_maps.insert( + vec![ + KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE), + ], + Action::YankLine, + ); + Self { - ui: ui_maps.clone(), - normal: ui_maps, + ui: ui_maps, + normal: normal_maps, visual: HashMap::new(), typing: HashMap::from([ ( diff --git a/src/main.rs b/src/main.rs index f402d41..8aa6719 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,10 +3,13 @@ mod api; mod app; mod cache; mod command; +mod config; mod input; mod logger; mod models; mod notification; +#[cfg(test)] +mod test_helpers; mod views; use app::App; @@ -16,9 +19,10 @@ pub type Result = anyhow::Result; #[tokio::main] async fn main() -> anyhow::Result<(), Box> { logger::init()?; + let (config, config_warning) = config::load(); let mut terminal = views::init_terminal()?; - let mut app = App::new().await?; + let mut app = App::new(config, config_warning).await?; let run_res = app.run(&mut terminal).await; diff --git a/src/test_helpers.rs b/src/test_helpers.rs new file mode 100644 index 0000000..9a6ce29 --- /dev/null +++ b/src/test_helpers.rs @@ -0,0 +1,35 @@ +//! Shared helpers for tests that run against the live Stoat API. + +use serde_json::Value; + +use crate::api::{auth::Auth, client::ApiClient}; + +/// Reads the user's real session token from the keyring (read-only). +/// `suite` names the calling test module in panic messages. +pub async fn keyring_token(suite: &str) -> String { + let auth = Auth::new() + .unwrap_or_else(|e| panic!("keyring must be available to run {suite} tests: {e}")); + + let token = auth.token_entry.get_secret().await.unwrap_or_else(|e| { + panic!("{suite} tests need a vimstoat session token stored in the keyring: {e}") + }); + + assert!( + !token.is_empty(), + "{suite} tests need a non-empty keyring token" + ); + token +} + +/// An `ApiClient` authenticated with the real keyring token. +pub async fn keyring_client(suite: &str) -> ApiClient { + ApiClient::new(keyring_token(suite).await, None) +} + +/// The string value of `key` in a JSON object. +pub fn json_str<'a>(obj: &'a Value, key: &str) -> &'a str { + obj.get(key) + .unwrap_or_else(|| panic!("missing field `{key}`")) + .as_str() + .unwrap_or_else(|| panic!("`{key}` is not a string")) +} diff --git a/src/views/dm.rs b/src/views/dm.rs index c3af94b..c6da4f0 100644 --- a/src/views/dm.rs +++ b/src/views/dm.rs @@ -439,6 +439,46 @@ pub fn handle(app: &mut App, key: KeyEvent) { } } } + Some(Action::YankLine) => { + let chars: Vec = app.input_text.chars().collect(); + let mut line_start = 0; + for i in (0..app.input_cursor).rev() { + if chars.get(i) == Some(&'\n') { + line_start = i + 1; + break; + } + } + + let mut line_end = chars.len(); + for (i, c) in chars.iter().enumerate().skip(app.input_cursor) { + if *c == '\n' { + line_end = i; + break; + } + } + + let yank_content: String = chars[line_start..line_end].iter().collect(); + app.yank_buffer = Some(format!("{yank_content}\n")); + } + Some(Action::Paste) => { + if let Some(yanked) = app.yank_buffer.clone() { + let mut chars: Vec = app.input_text.chars().collect(); + let mut line_end = chars.len(); + for (i, c) in chars.iter().enumerate().skip(app.input_cursor) { + if *c == '\n' { + line_end = i; + break; + } + } + + let content = yanked.strip_suffix('\n').unwrap_or(&yanked); + let pasted: Vec = std::iter::once('\n').chain(content.chars()).collect(); + chars.splice(line_end..line_end, pasted); + + app.input_text = chars.into_iter().collect(); + app.input_cursor = line_end + 1; + } + } Some(Action::AppendCharacter(c)) => { let mut chars: Vec = app.input_text.chars().collect(); if app.input_cursor <= chars.len() { diff --git a/src/views/error.rs b/src/views/error.rs index afac15a..7f8ff6b 100644 --- a/src/views/error.rs +++ b/src/views/error.rs @@ -1,8 +1,9 @@ use ratatui::{ Frame, crossterm::event::{KeyCode, KeyEvent}, + layout::Constraint, style::{Color, Style}, - widgets::{Block, Borders, Paragraph}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, }; use thiserror::Error; @@ -50,6 +51,24 @@ impl From for AuthError { } } +#[derive(Error, Debug)] +pub enum ConfigError { + #[error("Could not read config file: {0}")] + Read(std::io::Error), + + #[error("Could not write default config file: {0}")] + Write(std::io::Error), + + #[error("Could not parse config file: {0}")] + Parse(toml::de::Error), + + #[error("Invalid config: {0}")] + Invalid(String), + + #[error("Could not determine the config directory")] + NoConfigDir, +} + pub fn handle(app: &mut App, key: KeyEvent) { if matches!(key.code, KeyCode::Char(_) | KeyCode::Esc | KeyCode::Enter) { app.state = AppState::NeedsAuth; @@ -74,6 +93,23 @@ pub fn render(f: &mut Frame, message: &str) { f.render_widget(error_msg, f.area()); } +pub fn render_config_warning(f: &mut Frame, warning: &str) { + let area = f + .area() + .centered(Constraint::Percentage(70), Constraint::Percentage(50)); + + let text = format!("{warning}\n\nUsing default settings. Press any key to dismiss."); + let popup = Paragraph::new(text).wrap(Wrap { trim: false }).block( + Block::default() + .title(" Config Warning ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)), + ); + + f.render_widget(Clear, area); + f.render_widget(popup, area); +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/views/mod.rs b/src/views/mod.rs index 49be2f9..ed818c7 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -67,6 +67,10 @@ pub fn render(f: &mut Frame, app: &App) { AppState::Error(message) => error::render(f, &message.to_string()), } + if let Some(warning) = &app.config_warning { + error::render_config_warning(f, warning); + } + if let Some(cmd_area) = command_area { let cmd_widget = Paragraph::new(format!(":{}", app.command_text)) .style(