From 69304f970da3b3c2e0715d59f45b4bded73957d4 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:44:28 -0500 Subject: [PATCH 01/12] test(api): add live tests for message history paging and acks --- src/api/channel.rs | 268 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 268 insertions(+) diff --git a/src/api/channel.rs b/src/api/channel.rs index b6b7d81..f28146c 100644 --- a/src/api/channel.rs +++ b/src/api/channel.rs @@ -58,3 +58,271 @@ pub async fn ack_message(api_client: &ApiClient, channel_id: &str, message_id: & }) .await } + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::auth::Auth; + 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 real_client() -> ApiClient { + let auth = Auth::new().expect("keyring must be available to run channel 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 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 = str_field(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 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 ids(messages: &[Value]) -> Vec<&str> { + messages.iter().map(|m| str_field(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!(str_field(msg, "channel"), live.channel_id); + assert!(!str_field(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`", + str_field(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 = str_field(&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 = str_field(&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 = str_field(&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| str_field(&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()); + } +} From 55272d4b796ef12cb775a844d5f2de03d28ee403 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:55:51 -0500 Subject: [PATCH 02/12] docs(contributing): note issues are welcome and feat PRs need docs and tests --- CONTRIBUTING.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) 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! From a281165dee3dc25a742af7f6cc8b78180118ee69 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:00:53 -0500 Subject: [PATCH 03/12] test(app): add live tests for session setup, app events and shutdown --- src/app.rs | 481 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 481 insertions(+) diff --git a/src/app.rs b/src/app.rs index 4b3a058..1085e20 100644 --- a/src/app.rs +++ b/src/app.rs @@ -554,3 +554,484 @@ impl App { let _ = ratatui::crossterm::execute!(std::io::stdout(), style); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::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 auth = Auth::new().expect("keyring must be available to run app tests"); + let token = auth + .token_entry + .get_secret() + .await + .expect("a vimstoat session token must be stored in the keyring"); + 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(), + 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); + } +} From 38b01d46dff72ba0c66b20c6d18aa29e01fe002f Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:04:55 -0500 Subject: [PATCH 04/12] feat(command): add :wq and :wqa aliases for quit commands --- docs/vimstoat/src/keybinds.md | 10 ++++++---- src/command.rs | 6 ++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/vimstoat/src/keybinds.md b/docs/vimstoat/src/keybinds.md index 4d6f88e..6d8d200 100644 --- a/docs/vimstoat/src/keybinds.md +++ b/docs/vimstoat/src/keybinds.md @@ -61,10 +61,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/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()), }; From f1275a2caf3f857c1386731e73bfe0ebed12f9a2 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:39:49 -0500 Subject: [PATCH 05/12] fix(ws): fail websocket auth on invalid session instead of returning ok --- src/api/events.rs | 2 +- src/app.rs | 62 +++++++++++++++++++++++------------------------ 2 files changed, 32 insertions(+), 32 deletions(-) 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/app.rs b/src/app.rs index 1085e20..9c043b3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -206,42 +206,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(()) } @@ -584,12 +589,7 @@ mod tests { async fn live() -> &'static Live { LIVE.get_or_init(|| async { - let auth = Auth::new().expect("keyring must be available to run app tests"); - let token = auth - .token_entry - .get_secret() - .await - .expect("a vimstoat session token must be stored in the keyring"); + 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) From 40357af18330a8155947f6dbe44a215e78e06c05 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:41:42 -0500 Subject: [PATCH 06/12] refactor(test): extract shared live-test helpers into test_helpers module --- src/api/auth.rs | 20 +++--------------- src/api/channel.rs | 39 ++++++++++-------------------------- src/api/dm.rs | 38 +++++++++++------------------------ src/api/user.rs | 49 +++++++++++++++------------------------------ src/main.rs | 2 ++ src/test_helpers.rs | 35 ++++++++++++++++++++++++++++++++ 6 files changed, 79 insertions(+), 104 deletions(-) create mode 100644 src/test_helpers.rs 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 f28146c..4e2d18b 100644 --- a/src/api/channel.rs +++ b/src/api/channel.rs @@ -62,7 +62,7 @@ pub async fn ack_message(api_client: &ApiClient, channel_id: &str, message_id: & #[cfg(test)] mod tests { use super::*; - use crate::api::auth::Auth; + use crate::test_helpers::{json_str, keyring_client}; use serde_json::Value; use tokio::sync::OnceCell; @@ -78,20 +78,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 channel 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("channel").await; let dms = client .get::>(Endpoint::Dms) .await @@ -109,7 +98,7 @@ mod tests { else { continue; }; - let channel_id = str_field(dm, "_id").to_string(); + 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"); @@ -138,14 +127,8 @@ mod tests { } } - 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 ids(messages: &[Value]) -> Vec<&str> { - messages.iter().map(|m| str_field(m, "_id")).collect() + messages.iter().map(|m| json_str(m, "_id")).collect() } #[tokio::test] @@ -166,12 +149,12 @@ mod tests { let live = live().await; for msg in &live.latest { - assert_eq!(str_field(msg, "channel"), live.channel_id); - assert!(!str_field(msg, "author").is_empty(), "`author` is empty"); + 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`", - str_field(msg, "_id") + json_str(msg, "_id") ); } } @@ -194,7 +177,7 @@ mod tests { #[tokio::test] async fn test_before_pages_backwards() { let live = live().await; - let pivot = str_field(&live.latest[1], "_id"); + let pivot = json_str(&live.latest[1], "_id"); let q = MessageHistoryQuery { limit: Some(2), before: Some(pivot.to_string()), @@ -211,7 +194,7 @@ mod tests { #[tokio::test] async fn test_after_with_oldest_sort_pages_forwards() { let live = live().await; - let pivot = str_field(&live.latest[3], "_id"); + let pivot = json_str(&live.latest[3], "_id"); let q = MessageHistoryQuery { limit: Some(2), after: Some(pivot.to_string()), @@ -230,7 +213,7 @@ mod tests { #[tokio::test] async fn test_nearby_includes_target_message() { let live = live().await; - let target = str_field(&live.latest[2], "_id"); + let target = json_str(&live.latest[2], "_id"); let q = MessageHistoryQuery { limit: Some(3), nearby: Some(target.to_string()), @@ -274,7 +257,7 @@ mod tests { let unreads_of = |unreads: &[Value]| -> Option { unreads .iter() - .find(|u| str_field(&u["_id"], "channel") == live.channel_id) + .find(|u| json_str(&u["_id"], "channel") == live.channel_id) .cloned() }; 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/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/main.rs b/src/main.rs index f402d41..8db78bf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,8 @@ mod input; mod logger; mod models; mod notification; +#[cfg(test)] +mod test_helpers; mod views; use app::App; 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")) +} From 770f15c13d526c3b0050a260c7af62eb666342c3 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:43:24 -0500 Subject: [PATCH 07/12] test: run tests serially and retry rate-limited api requests --- .cargo/config.toml | 4 ++++ src/api/client.rs | 55 ++++++++++++++++++++++++++++++++++------------ 2 files changed, 45 insertions(+), 14 deletions(-) create mode 100644 .cargo/config.toml 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/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() } From dd7622abc54e1d4afabb8fca232311c34df39d55 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:50:10 -0500 Subject: [PATCH 08/12] feat(input): add p to paste yank buffer below current line in normal mode --- docs/vimstoat/src/features.md | 3 +-- docs/vimstoat/src/keybinds.md | 3 ++- src/action.rs | 1 + src/input.rs | 10 ++++++++-- src/views/dm.rs | 19 +++++++++++++++++++ 5 files changed, 31 insertions(+), 5 deletions(-) diff --git a/docs/vimstoat/src/features.md b/docs/vimstoat/src/features.md index 142fafb..12fc962 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) 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 6d8d200..6b6a8bc 100644 --- a/docs/vimstoat/src/keybinds.md +++ b/docs/vimstoat/src/keybinds.md @@ -35,7 +35,8 @@ 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. | +| `p` | Paste the yank buffer as a new line below the current line. | ## Insert Mode diff --git a/src/action.rs b/src/action.rs index 9ae0263..59daf0e 100644 --- a/src/action.rs +++ b/src/action.rs @@ -12,6 +12,7 @@ pub enum Action { OpenNewLineBelow, OpenNewLineAbove, DeleteLine, + Paste, Enter, CursorLeft, CursorRight, diff --git a/src/input.rs b/src/input.rs index 5f82f90..53eeb22 100644 --- a/src/input.rs +++ b/src/input.rs @@ -134,9 +134,15 @@ impl Default for KeyMaps { ), ]); + let mut normal_maps = ui_maps.clone(); + normal_maps.insert( + vec![KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)], + Action::Paste, + ); + Self { - ui: ui_maps.clone(), - normal: ui_maps, + ui: ui_maps, + normal: normal_maps, visual: HashMap::new(), typing: HashMap::from([ ( diff --git a/src/views/dm.rs b/src/views/dm.rs index c3af94b..cf29d65 100644 --- a/src/views/dm.rs +++ b/src/views/dm.rs @@ -439,6 +439,25 @@ pub fn handle(app: &mut App, key: KeyEvent) { } } } + 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() { From 2740ff22f2b816c1662152a9bd35449044b9d2c5 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:56:05 -0500 Subject: [PATCH 09/12] feat(input): add yy to yank current line in normal mode --- docs/vimstoat/src/features.md | 2 +- docs/vimstoat/src/keybinds.md | 1 + src/action.rs | 1 + src/input.rs | 7 +++++++ src/views/dm.rs | 21 +++++++++++++++++++++ 5 files changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/vimstoat/src/features.md b/docs/vimstoat/src/features.md index 12fc962..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) and `p` (paste), with an in-memory yank buffer. +- **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). diff --git a/docs/vimstoat/src/keybinds.md b/docs/vimstoat/src/keybinds.md index 6b6a8bc..e80d40f 100644 --- a/docs/vimstoat/src/keybinds.md +++ b/docs/vimstoat/src/keybinds.md @@ -36,6 +36,7 @@ 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. | +| `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 diff --git a/src/action.rs b/src/action.rs index 59daf0e..663347a 100644 --- a/src/action.rs +++ b/src/action.rs @@ -12,6 +12,7 @@ pub enum Action { OpenNewLineBelow, OpenNewLineAbove, DeleteLine, + YankLine, Paste, Enter, CursorLeft, diff --git a/src/input.rs b/src/input.rs index 53eeb22..4dc89bd 100644 --- a/src/input.rs +++ b/src/input.rs @@ -139,6 +139,13 @@ impl Default for KeyMaps { 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, diff --git a/src/views/dm.rs b/src/views/dm.rs index cf29d65..c6da4f0 100644 --- a/src/views/dm.rs +++ b/src/views/dm.rs @@ -439,6 +439,27 @@ 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(); From 6d0b97b59a8d6f8321b52204d2421bf8cb9acd39 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:21:46 -0500 Subject: [PATCH 10/12] feat(config): load instance urls from config.toml and drop env var overrides --- Cargo.toml | 1 + README.md | 5 + src/app.rs | 36 ++-- src/config.rs | 455 +++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 4 +- src/views/error.rs | 38 +++- src/views/mod.rs | 4 + 7 files changed, 526 insertions(+), 17 deletions(-) create mode 100644 src/config.rs diff --git a/Cargo.toml b/Cargo.toml index 593bab9..81ace08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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/src/app.rs b/src/app.rs index 9c043b3..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, @@ -290,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(()); @@ -563,7 +565,10 @@ impl App { #[cfg(test)] mod tests { use super::*; - use crate::models::{Message, User}; + use crate::{ + api::API_BASE_URL, + models::{Message, User}, + }; use serde_json::Value; use std::collections::HashMap; use tempfile::TempDir; @@ -693,6 +698,7 @@ mod tests { 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, 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/main.rs b/src/main.rs index 8db78bf..8aa6719 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod api; mod app; mod cache; mod command; +mod config; mod input; mod logger; mod models; @@ -18,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/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( From e723c2fb48c79636c2779cc7c5e5edfef95e2042 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:24:52 -0500 Subject: [PATCH 11/12] docs(config): add configuration page with planned silent typing option --- docs/vimstoat/src/SUMMARY.md | 1 + docs/vimstoat/src/config.md | 53 ++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 docs/vimstoat/src/config.md 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. From a6ee28f23016ed5f0e644ac217ea7274a0ab84e7 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:29:42 -0500 Subject: [PATCH 12/12] chore(release): bump version to 0.1.4 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 81ace08..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]