diff --git a/Cargo.toml b/Cargo.toml index c08a0f0..7dcccea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ anyhow = "1.0" dirs = "6.0" futures-util = "0.3" indexmap = { version = "2", features = ["serde"] } -irc = { version = "1.1", default-features = false, features = ["ctcp", "tls-native", "channel-lists"] } +irc = { version = "1.1", default-features = false, features = ["ctcp", "tls-native"] } ratatui = "0.30" serde = { version = "1.0", features = ["derive"] } tokio = { version = "1", features = ["macros", "rt", "sync", "net", "time"] } diff --git a/README.md b/README.md index 612a30c..31fcd68 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ once at startup — restart to pick up changes. | `server` | yes | — | hostname | | `port` | yes | — | usually 6667 (plain) or 6697 (TLS) | | `use_tls` | no | `false` | connect over TLS | -| `channels` | yes | — | channels to join, in order | +| `channels` | yes | — | startup channel intent, in order; runtime changes are not persisted | | `queries` | no | `[]` | private conversations to show at startup, in order | `queries` contains individual nicknames, not channels or comma-separated targets. @@ -66,6 +66,13 @@ matching; the same nickname on different servers is a separate conversation. These entries only create sidebar conversations: they do not send JOIN or any private message, and do not indicate whether the other user is online. +`channels` seeds the channels to join at startup. Each entry must be one `#` or +`&` channel name with at least one character after the prefix; whitespace, +control characters, commas and colons are not allowed. Invalid configured names +are rejected when loading the config. Channel keys/passwords and multi-target +entries are unsupported. Runtime JOIN/PART changes stay in memory, never rewrite +the config, and disappear when the application exits. + Security notes: - The file holds a credential — on Unix, `chmod 600` it. @@ -88,10 +95,12 @@ terminal's own text selection. Inputs beginning with `/` after trimming surrounding whitespace are parsed as slash commands in both channels and the server console. Supported -identity and connection commands are: +channel, identity and connection commands are: | Command | Behavior | | --- | --- | +| `/join #channel` | Join or focus a channel on the current registered server; a new JOIN waits for server confirmation. | +| `/part [#channel] [reason]` | Leave the named channel, or the current channel when omitted; retain its buffer and history. | | `/query ` | Create or reopen a private conversation on the current server and focus its composer; works while disconnected. | | `/close` | Hide the current private conversation and return to its server console; not available on channels or server consoles. | | `/nick ` | Request a nickname change on the current server; local echoes use the new nickname after the server confirms it. | @@ -115,17 +124,17 @@ or history line. Normal server messages still appear in the server console. Each server and channel has a status dot (private conversations do not): - **Green:** server registration confirmed, or our JOIN confirmed for a channel. -- **Blinking grey:** connecting, waiting to retry, or waiting for a channel JOIN. -- **Red:** stopped, including manual disconnection, exhausted retries or a failed channel JOIN. +- **Blinking grey:** connecting, waiting to retry, or waiting for a channel JOIN/PART confirmation. +- **Red:** stopped, not joined, or uncertain channel membership after a timeout. Unexpected disconnections automatically retry up to three times, waiting 1, 2 and 4 seconds. A connection/registration attempt times out after -30 seconds; a missing channel JOIN confirmation also times out after -30 seconds. Registration nickname/password rejection or a server ban stops +30 seconds; each channel JOIN or PART has its own 30-second confirmation +timeout. Registration nickname/password rejection or a server ban stops automatic retries immediately. A connection stable for 30 seconds resets the retry budget. `/connect` or `/reconnect` starts a fresh retry budget after stopping. Reconnection retains the confirmed nickname in memory, -rejoins configured channels and clears away status. Queued messages from a +rejoins channels with current join intent and clears away status. Queued messages from a previous connection are discarded, never replayed on the new connection. Opening a channel jumps to its newest messages, and the view follows new @@ -138,9 +147,45 @@ Outgoing IRC lines are limited to 512 UTF-8 bytes including the command, target and terminating CRLF. Oversized lines retain the full draft and cursor for editing; they are not split or queued. Raw console commands preserve the spaces within their trailing parameter. Local echoes mean the message was -queued, not acknowledged by the server. Server errors for configured channels +queued, not acknowledged by the server. Server errors for known channels appear there once as system messages; other errors appear in the server console. +### Dynamic channels + +Use `/join #channel` from a channel, private conversation or server console. +Use `/part`, `/part leaving for lunch`, or `/part #channel leaving for lunch` +to leave. Without an explicit target, PART requires a current channel. +These commands are online-only: the server must have completed registration. +Only one `#` or `&` channel is accepted, following the configuration name rule; +keys/passwords, comma-separated targets and `JOIN 0` are not supported. +Console raw `JOIN #channel` and `PART #channel :reason` use the same managed +validation, intent and confirmation path, not an unmanaged escape hatch. + +Channel state is separate from join intent: + +| State | Meaning | +| --- | --- | +| `NotJoined` | No confirmed membership; join failure, PART, KICK or disconnection may lead here. | +| `Joining` | JOIN requested; channel chat remains disabled until our own server-confirmed JOIN. | +| `Joined` | Our JOIN is confirmed; channel chat is enabled while the server remains registered. | +| `Parting` | PART requested; channel chat is immediately disabled while confirmation is pending. | +| `Uncertain` | JOIN/PART timed out; membership is unknown, not proof that the operation failed. Chat remains disabled. | + +Each operation has an independent deadline. Late confirmations resolve uncertain +state; a late JOIN after leaving does not restore join intent and is followed by +PART when needed. A failed JOIN or KICK retains join intent but does not trigger +an automatic retry on the same connection. Explicitly use `/join` to retry. +Our own PART clears intent; PART after a timed-out JOIN also requests exit. +Reconnection includes runtime-added channels and excludes configured channels +you have parted. Operations already accepted just before disconnection still +update intent, but queued chat is never replayed. + +PART does not hide or delete the channel: history, draft and reading position +remain available. Joining an existing channel reuses its buffer. Incoming +messages for unknown channels are ignored; our own server-confirmed JOIN first +registers a newly discovered channel, so its first message is not lost. +There are no topic, names, modes or other new channel-management commands. + ### Private conversations The sidebar groups each server's channels followed by private conversations, @@ -193,7 +238,7 @@ ranges survive layout-text cache eviction; the 50,000-row cache budget does not limit history. Rendering constructs text only for visible rows. The composer uses the same display-width geometry for wrapping, sizing and mouse regions. -Dynamic channels, persistent history, SASL and IRCv3 capability negotiation +Persistent history, SASL and IRCv3 capability negotiation are not implemented in this refactor. Run local validation with cached dependencies: diff --git a/config.example.toml b/config.example.toml index 6d1283f..de9f04b 100644 --- a/config.example.toml +++ b/config.example.toml @@ -5,5 +5,7 @@ password = "" server = "irc.example.org" use_tls = true port = 6697 +# Startup join intent: individual # or & names, without keys or multiple targets. +# Runtime /join and /part changes are kept in memory, not written to this file. channels = ["#example"] queries = ["alice", "bob"] diff --git a/src/application.rs b/src/application.rs index 2917e9c..f511527 100644 --- a/src/application.rs +++ b/src/application.rs @@ -8,17 +8,22 @@ use crate::command::{ use crate::config::Config; use crate::connection::{ConnectionHandle, ConnectionState, IrcEvent}; use crate::core::{ - BufferId, BufferKind, ConnectionCommand, DeliveryState, Direction, Draft, Message, - MessageContent, MessageId, MessageKind, OutgoingMessage, RoutedMessage, ServerId, + BufferId, BufferKind, ChannelState, ChannelStatus, ConnectionCommand, DeliveryState, Direction, + Draft, Message, MessageContent, MessageId, MessageKind, OutgoingMessage, RoutedMessage, + ServerId, }; use crate::history::{HistoryChange, HistoryStore}; -use crate::protocol::{SendError, encode_outgoing, validate_control}; +use crate::protocol::{SendError, channel_control_from_raw, encode_outgoing, validate_control}; +mod channels; mod queries; #[cfg(test)] mod query_tests; +#[cfg(test)] +mod channel_tests; + /// Registered conversations retain their display names separately from identity. pub struct Buffer { pub id: BufferId, @@ -29,7 +34,8 @@ pub struct Buffer { pub hidden: bool, pub unread: bool, pub send_blocked: bool, - connection: ConnectionState, + pub channel_status: ChannelStatus, + pending_channel_changes: usize, } #[derive(Default)] @@ -79,6 +85,9 @@ impl Session { self.open_server(name); for channel in &server.channels { self.open_channel(name, channel); + if let Some(index) = self.channel_index(name, channel) { + self.buffers[index].channel_status.desired = true; + } } for nickname in &server.queries { self.open_query(name, nickname); @@ -128,12 +137,13 @@ impl Session { id, server: server_id, server_label: state.label.clone(), + channel_status: ChannelStatus::default(), kind, draft: Draft::default(), hidden: false, unread: false, send_blocked: false, - connection: ConnectionState::Stopped, + pending_channel_changes: 0, }); id } @@ -302,8 +312,14 @@ impl Session { let server = ServerId::new(server); self.clear_self_echoes(&server); for buffer in &mut self.buffers { - if buffer.server == server { - buffer.connection = connection; + if buffer.server == server && matches!(buffer.kind, BufferKind::Channel(_)) { + buffer.channel_status.state = if connection == ConnectionState::Connecting + && buffer.channel_status.desired + { + ChannelState::Joining + } else { + ChannelState::NotJoined + }; } } } @@ -332,14 +348,28 @@ impl Session { { self.update_connection_state(server, *connection) } - IrcEvent::Channel(server, channel, connection) + IrcEvent::ChannelControlApplied(server, channel) => { + if let Some(index) = self.channel_index(server, channel) { + let buffer = &mut self.buffers[index]; + buffer.pending_channel_changes = + buffer.pending_channel_changes.saturating_sub(1); + } + } + IrcEvent::Channel(server, channel, status) if !self.has_pending_connection_change(server) => { - let id = ServerId::new(server); - if let Some(buffer) = self.buffers.iter_mut().find(|b| { - b.server == id && b.kind.matches(&BufferKind::Channel(channel.clone())) - }) { - buffer.connection = *connection; + if let Some(index) = self.channel_index(server, channel) { + let buffer = &mut self.buffers[index]; + if buffer.pending_channel_changes == 0 { + buffer.channel_status = *status; + } + } else if crate::core::valid_channel_name(channel) { + let id = self.open_channel(server, channel); + self.buffers + .iter_mut() + .find(|buffer| buffer.id == id) + .unwrap() + .channel_status = *status; } } IrcEvent::Nickname(server, nickname) => { @@ -373,18 +403,21 @@ impl Session { } pub fn connection_state(&self, server: &str, channel: Option<&str>) -> ConnectionState { - let server = ServerId::new(server); match channel { - Some(channel) => self - .buffers - .iter() - .find(|b| { - b.server == server && b.kind.matches(&BufferKind::Channel(channel.to_owned())) - }) - .map_or(ConnectionState::Stopped, |b| b.connection), + Some(channel) => match self.channel_status(server, channel).state { + ChannelState::Joined + if self.connection_state(server, None) == ConnectionState::Connected => + { + ConnectionState::Connected + } + ChannelState::Joining | ChannelState::Parting => ConnectionState::Connecting, + ChannelState::Joined | ChannelState::NotJoined | ChannelState::Uncertain => { + ConnectionState::Stopped + } + }, None => self .servers - .get(&server) + .get(&ServerId::new(server)) .map_or(ConnectionState::Stopped, |s| s.connection), } } @@ -460,6 +493,35 @@ pub fn submit_composer( *status = format!("failed to send ({error})"); return SubmissionEffect::None; } + if let OutgoingMessage::Raw { line, .. } = &outgoing { + match channel_control_from_raw(line) { + Ok(Some(command)) => { + let action = match command { + ConnectionCommand::Join(channel) => CommandAction::Join(channel), + ConnectionCommand::Part { channel, reason } => CommandAction::Part { + channel: Some(channel), + reason, + }, + _ => return SubmissionEffect::None, + }; + return match execute_command(session, connections, action) { + Ok(effect) => { + session.clear_source_draft(source); + effect + } + Err(reason) => { + tracing::debug!(target: "termirc::slash", outcome = "rejected", reason, "channel command rejected"); + SubmissionEffect::None + } + }; + } + Err(error) => { + *status = format!("failed to send ({error})"); + return SubmissionEffect::None; + } + Ok(None) => {} + } + } let (server, target, text) = match &outgoing { OutgoingMessage::Privmsg { server, @@ -579,6 +641,18 @@ fn execute_command( CommandAction::Nick(nick) => (ConnectionCommand::Nick(nick), None), CommandAction::Away(reason) => (ConnectionCommand::Away(reason), None), CommandAction::Back => (ConnectionCommand::Back, None), + CommandAction::Join(channel) => return session.join_channel(server, &channel, handle), + CommandAction::Part { channel, reason } => { + let channel = channel + .or_else(|| { + session + .active_buffer() + .and_then(|buffer| buffer.kind.channel()) + .map(str::to_owned) + }) + .ok_or("no_channel")?; + return session.part_channel(server, &channel, reason, handle); + } CommandAction::Query(_) | CommandAction::Close => return Err("invalid_command"), }; validate_control(&command).map_err(|_| "invalid_control")?; @@ -782,7 +856,10 @@ mod tests { app.apply_connection_event(&IrcEvent::Channel( "srv".into(), "#a".into(), - ConnectionState::Connected, + ChannelStatus { + state: ChannelState::Joined, + desired: true, + }, )); let (outgoing, mut messages) = tokio::sync::mpsc::channel(8); let (control, _commands) = tokio::sync::mpsc::channel(8); @@ -803,7 +880,10 @@ mod tests { app.apply_connection_event(&IrcEvent::Channel( "srv".into(), "#a".into(), - ConnectionState::Connected, + ChannelStatus { + state: ChannelState::Joined, + desired: true, + }, )); assert_eq!(app.connection_state("srv", None), expected); assert_eq!(app.connection_state("srv", Some("#a")), expected); @@ -820,7 +900,10 @@ mod tests { app.apply_connection_event(&IrcEvent::Channel( "srv".into(), "#a".into(), - ConnectionState::Connected, + ChannelStatus { + state: ChannelState::Joined, + desired: true, + }, )); assert_eq!( app.connection_state("srv", None), @@ -947,7 +1030,10 @@ mod tests { app.apply_connection_event(&IrcEvent::Channel( "srv".into(), "#a".into(), - ConnectionState::Connected, + ChannelStatus { + state: ChannelState::Joined, + desired: true, + }, )); app.apply_connection_event(&IrcEvent::Nickname( "srv".into(), @@ -1023,7 +1109,10 @@ mod tests { session.apply_connection_event(&IrcEvent::Channel( "srv".into(), "#a".into(), - ConnectionState::Connected, + ChannelStatus { + state: ChannelState::Joined, + desired: true, + }, )); let (outgoing, messages) = tokio::sync::mpsc::channel(capacity); let (control, commands) = tokio::sync::mpsc::channel(capacity); @@ -1253,6 +1342,14 @@ mod tests { fn multiple_pending_connection_changes_require_all_worker_barriers() { let mut session = Session::default(); session.open_channel("SRV", "#a"); + session.apply_connection_event(&IrcEvent::Channel( + "srv".into(), + "#a".into(), + ChannelStatus { + state: ChannelState::Joined, + desired: true, + }, + )); session.begin_connection_change("srv", ConnectionState::Stopped); session.begin_connection_change("SRV", ConnectionState::Connecting); session.apply_connection_event(&IrcEvent::ControlApplied("srv".into())); @@ -1263,7 +1360,10 @@ mod tests { session.apply_connection_event(&IrcEvent::Channel( "srv".into(), "#A".into(), - ConnectionState::Connected, + ChannelStatus { + state: ChannelState::Joined, + desired: true, + }, )); assert_eq!( session.connection_state("srv", None), @@ -1281,7 +1381,10 @@ mod tests { session.apply_connection_event(&IrcEvent::Channel( "SRV".into(), "#a".into(), - ConnectionState::Connected, + ChannelStatus { + state: ChannelState::Joined, + desired: true, + }, )); assert_eq!( session.connection_state("SRV", None), diff --git a/src/application/channel_tests.rs b/src/application/channel_tests.rs new file mode 100644 index 0000000..69dc11d --- /dev/null +++ b/src/application/channel_tests.rs @@ -0,0 +1,475 @@ +use super::*; + +fn connected_session( + capacity: usize, +) -> ( + Session, + HashMap, + tokio::sync::mpsc::Receiver, + tokio::sync::mpsc::Receiver, +) { + let mut session = Session::default(); + let console = session.open_server("SRV"); + session.select_buffer_id(console); + session.apply_connection_event(&IrcEvent::Connection( + "srv".into(), + ConnectionState::Connected, + )); + let (outgoing, messages) = tokio::sync::mpsc::channel(capacity); + let (control, commands) = tokio::sync::mpsc::channel(capacity); + ( + session, + HashMap::from([("srv".into(), ConnectionHandle { outgoing, control })]), + messages, + commands, + ) +} + +fn submit( + session: &mut Session, + connections: &HashMap, + input: &str, +) -> SubmissionEffect { + session.restore_input_at(input.into(), 1); + submit_composer( + session, + &Config { + servers: Default::default(), + }, + connections, + &mut String::new(), + ) +} + +#[test] +fn join_opens_a_channel_and_clears_only_the_source_draft_without_echo() { + let (mut session, connections, mut messages, mut commands) = connected_session(8); + let console = session.active_buffer().unwrap().id; + let effect = submit(&mut session, &connections, "/join #New"); + let SubmissionEffect::Activate(channel) = effect else { + panic!("join must activate the new channel"); + }; + assert_eq!(session.buffer_count(), 2); + assert_eq!( + session.buffer(channel).unwrap().kind, + BufferKind::Channel("#New".into()) + ); + assert_eq!(session.active_buffer().unwrap().id, console); + assert!(session.input().is_empty()); + assert!(session.messages_for(console).is_empty()); + assert!(session.messages_for(channel).is_empty()); + assert!(messages.try_recv().is_err()); + assert!(commands.try_recv().is_ok()); + assert_eq!( + session.connection_state("srv", Some("#new")), + ConnectionState::Connecting + ); +} + +#[test] +fn raw_join_uses_channel_controls_without_a_raw_echo() { + let (mut session, connections, mut messages, mut commands) = connected_session(8); + assert!(matches!( + submit(&mut session, &connections, "JOIN #new"), + SubmissionEffect::Activate(_) + )); + assert!(messages.try_recv().is_err()); + assert!(commands.try_recv().is_ok()); + assert!(session.messages().is_empty()); + assert!(session.input().is_empty()); +} + +fn channel_event(session: &mut Session, channel: &str, state: ChannelState, desired: bool) { + session.handle_event(IrcEvent::Channel( + "srv".into(), + channel.into(), + ChannelStatus { state, desired }, + )); +} + +fn applied(session: &mut Session, channel: &str) { + session.handle_event(IrcEvent::ChannelControlApplied( + "SRV".into(), + channel.into(), + )); +} + +#[test] +fn joined_and_joining_channels_reopen_without_resending_or_losing_history_and_draft() { + for state in [ChannelState::Joined, ChannelState::Joining] { + let (mut session, connections, mut messages, mut commands) = connected_session(8); + let console = session.active_buffer().unwrap().id; + let channel = session.open_channel("srv", "#Room"); + session.select_buffer_id(channel); + session.restore_input_at("saved draft".into(), 4); + session.push_message(RoutedMessage::chat("srv", "#Room", "alice", "history")); + channel_event(&mut session, "#Room", state, true); + session.select_buffer_id(console); + assert_eq!( + submit(&mut session, &connections, "/join #ROOM"), + SubmissionEffect::Activate(channel) + ); + assert!(session.input().is_empty()); + assert!(commands.try_recv().is_err()); + assert!(messages.try_recv().is_err()); + assert_eq!(session.buffer_count(), 2); + assert_eq!(session.messages_for(channel)[0].text, "history"); + session.select_buffer_id(channel); + assert_eq!(session.input(), "saved draft"); + assert_eq!(session.input_cursor(), 4); + } +} + +#[test] +fn part_resolves_current_channel_and_explicit_channel_from_any_buffer() { + for (source, input) in [ + (BufferKind::Channel("#Room".into()), "/part"), + (BufferKind::Channel("#Room".into()), "/part bye all"), + (BufferKind::Server, "/part #ROOM bye all"), + (BufferKind::Query("alice".into()), "/part #ROOM bye all"), + ] { + let (mut session, connections, mut messages, mut commands) = connected_session(8); + let channel = session.open_channel("srv", "#Room"); + channel_event(&mut session, "#Room", ChannelState::Joined, true); + let source = session.open_buffer("srv", source); + session.select_buffer_id(source); + assert_eq!( + submit(&mut session, &connections, input), + SubmissionEffect::None + ); + assert_eq!( + commands.try_recv().unwrap(), + ConnectionCommand::Part { + channel: "#Room".into(), + reason: input.contains("bye").then(|| "bye all".into()), + } + ); + assert_eq!( + session.channel_status("SRV", "#room"), + ChannelStatus { + state: ChannelState::Parting, + desired: false + } + ); + assert_eq!(session.active_buffer().unwrap().id, source); + assert!(session.input().is_empty()); + assert!(messages.try_recv().is_err()); + assert!(!session.buffer(channel).unwrap().hidden); + assert!(session.messages_for(channel).is_empty()); + session.select_buffer_id(channel); + submit(&mut session, &connections, "must not send"); + assert_eq!(session.input(), "must not send"); + assert!(messages.try_recv().is_err()); + } +} + +#[test] +fn pending_channel_commands_ignore_stale_snapshots_until_matching_barrier() { + let (mut session, connections, mut messages, mut commands) = connected_session(8); + let channel = session.open_channel("srv", "#Room"); + channel_event(&mut session, "#Room", ChannelState::Joined, true); + session.select_buffer_id(channel); + submit(&mut session, &connections, "/part"); + commands.try_recv().unwrap(); + channel_event(&mut session, "#Room", ChannelState::Joined, true); + applied(&mut session, "#other"); + channel_event(&mut session, "#Room", ChannelState::Joined, true); + assert_eq!( + session.channel_status("srv", "#Room").state, + ChannelState::Parting + ); + submit(&mut session, &connections, "blocked"); + assert!(messages.try_recv().is_err()); + applied(&mut session, "#ROOM"); + channel_event(&mut session, "#Room", ChannelState::NotJoined, false); + assert_eq!( + session.channel_status("srv", "#room"), + ChannelStatus::default() + ); +} + +#[test] +fn multiple_pending_controls_require_every_channel_barrier() { + let (mut session, connections, _messages, mut commands) = connected_session(8); + let channel = session.open_channel("srv", "#Room"); + channel_event(&mut session, "#Room", ChannelState::NotJoined, true); + session.select_buffer_id(channel); + submit(&mut session, &connections, "/part"); + assert_eq!( + session.channel_status("srv", "#Room"), + ChannelStatus::default() + ); + assert_eq!( + submit(&mut session, &connections, "/join #ROOM"), + SubmissionEffect::Activate(channel) + ); + assert!(matches!( + commands.try_recv().unwrap(), + ConnectionCommand::Part { .. } + )); + assert_eq!( + commands.try_recv().unwrap(), + ConnectionCommand::Join("#Room".into()) + ); + applied(&mut session, "#room"); + channel_event(&mut session, "#Room", ChannelState::NotJoined, false); + assert_eq!( + session.channel_status("srv", "#Room"), + ChannelStatus { + state: ChannelState::Joining, + desired: true + } + ); + applied(&mut session, "#room"); + channel_event(&mut session, "#Room", ChannelState::Joined, true); + assert_eq!( + session.channel_status("srv", "#Room").state, + ChannelState::Joined + ); +} + +#[test] +fn opposite_in_flight_commands_preserve_draft_and_repeated_part_does_not_queue() { + for (state, desired, input, accepted) in [ + (ChannelState::Joining, true, "/part", false), + (ChannelState::Parting, false, "/join #Room", false), + (ChannelState::Parting, false, "/part", true), + (ChannelState::NotJoined, false, "/part", true), + ] { + let (mut session, connections, mut messages, mut commands) = connected_session(8); + let channel = session.open_channel("srv", "#Room"); + channel_event(&mut session, "#Room", state, desired); + session.select_buffer_id(channel); + submit(&mut session, &connections, input); + assert!(commands.try_recv().is_err()); + assert!(messages.try_recv().is_err()); + assert_eq!( + session.channel_status("srv", "#Room"), + ChannelStatus { state, desired } + ); + assert_eq!(session.input(), if accepted { "" } else { input }); + if !accepted { + assert_eq!(session.input_cursor(), 1); + } + } +} + +#[test] +fn invalid_and_offline_commands_preserve_source_draft_and_do_not_open_buffers() { + for state in [ + ConnectionState::Connected, + ConnectionState::Connecting, + ConnectionState::Stopped, + ] { + let (mut session, connections, mut messages, mut commands) = connected_session(8); + session.apply_connection_event(&IrcEvent::Connection("srv".into(), state)); + let mut inputs = vec![ + "/join #", + "/join #one key", + "/join #one,#two", + "/part", + "/part bye", + "/part #unknown", + "JOIN #one key", + "JOIN 0", + "PART #unknown", + "JOIN #one,#two", + "PART #one,#two", + ]; + if state != ConnectionState::Connected { + inputs.push("/join #one"); + inputs.push("JOIN #one"); + } + for input in inputs { + assert_eq!( + submit(&mut session, &connections, input), + SubmissionEffect::None, + "{state:?}: {input}" + ); + assert_eq!(session.input(), input); + assert_eq!(session.input_cursor(), 1); + assert_eq!(session.buffer_count(), 1); + assert!(session.messages().is_empty()); + assert!(commands.try_recv().is_err()); + assert!(messages.try_recv().is_err()); + } + } +} + +#[test] +fn queue_full_or_closed_and_wire_limit_fail_before_channel_mutation() { + for closed in [false, true] { + for input in ["/join #new", "/part #Room", "JOIN #new", "PART #Room"] { + let (mut session, connections, mut messages, mut commands) = connected_session(1); + let channel = session.open_channel("srv", "#Room"); + channel_event(&mut session, "#Room", ChannelState::Joined, true); + if closed { + commands.close(); + } else { + connections["srv"] + .control + .try_send(ConnectionCommand::Back) + .unwrap(); + } + submit(&mut session, &connections, input); + assert_eq!(session.input(), input); + assert_eq!(session.input_cursor(), 1); + assert_eq!(session.buffer_count(), 2); + assert_eq!( + session.channel_status("srv", "#Room").state, + ChannelState::Joined + ); + assert!(messages.try_recv().is_err()); + assert!(session.messages_for(channel).is_empty()); + } + } + let (mut session, connections, mut messages, mut commands) = connected_session(8); + session.open_channel("srv", "#Room"); + channel_event(&mut session, "#Room", ChannelState::Joined, true); + for input in [ + format!("/join #{}", "a".repeat(510)), + format!("/part #Room {}", "x".repeat(510)), + format!("JOIN #{}", "a".repeat(510)), + format!("PART #Room :{}", "x".repeat(510)), + ] { + submit(&mut session, &connections, &input); + assert_eq!(session.input(), input); + assert_eq!(session.input_cursor(), 1); + assert_eq!(session.buffer_count(), 2); + assert_eq!( + session.channel_status("srv", "#Room").state, + ChannelState::Joined + ); + assert!(commands.try_recv().is_err()); + assert!(messages.try_recv().is_err()); + } +} + +#[test] +fn raw_part_uses_same_control_and_blocks_channel_sending() { + let (mut session, connections, mut messages, mut commands) = connected_session(8); + let channel = session.open_channel("srv", "#Room"); + channel_event(&mut session, "#Room", ChannelState::Joined, true); + submit(&mut session, &connections, "PART #ROOM :bye all"); + assert_eq!( + commands.try_recv().unwrap(), + ConnectionCommand::Part { + channel: "#Room".into(), + reason: Some("bye all".into()) + } + ); + assert!(messages.try_recv().is_err()); + assert!(session.messages().is_empty()); + assert_eq!( + session.channel_status("srv", "#Room").state, + ChannelState::Parting + ); + session.select_buffer_id(channel); + submit(&mut session, &connections, "blocked"); + assert!(messages.try_recv().is_err()); +} + +#[test] +fn disconnect_preserves_intent_and_left_channels_never_enter_joining_on_reconnect() { + let (mut session, _connections, _messages, _commands) = connected_session(8); + session.open_channel("srv", "#stay"); + session.open_channel("srv", "#left"); + channel_event(&mut session, "#stay", ChannelState::Joined, true); + channel_event(&mut session, "#left", ChannelState::NotJoined, false); + session.apply_connection_event(&IrcEvent::Connection( + "srv".into(), + ConnectionState::Stopped, + )); + assert_eq!( + session.channel_status("srv", "#stay"), + ChannelStatus { + state: ChannelState::NotJoined, + desired: true + } + ); + assert_eq!( + session.channel_status("srv", "#left"), + ChannelStatus::default() + ); + session.apply_connection_event(&IrcEvent::Connection( + "srv".into(), + ConnectionState::Connecting, + )); + assert_eq!( + session.channel_status("srv", "#stay").state, + ChannelState::Joining + ); + assert_eq!( + session.channel_status("srv", "#left"), + ChannelStatus::default() + ); + assert_eq!( + session.connection_state("srv", Some("#left")), + ConnectionState::Stopped + ); +} + +#[test] +fn unknown_own_join_creates_buffer_before_following_message_without_stealing_focus() { + let (mut session, _connections, _messages, _commands) = connected_session(8); + let console = session.active_buffer().unwrap().id; + session.restore_input("unfinished".into()); + assert_eq!( + session.channel_status("srv", "#Unknown"), + ChannelStatus::default() + ); + channel_event(&mut session, "#Unknown", ChannelState::Joined, true); + session.handle_event(IrcEvent::Message(RoutedMessage::chat( + "srv", "#unknown", "me", "joined", + ))); + assert_eq!(session.buffer_count(), 2); + let channel = session.open_channel("srv", "#UNKNOWN"); + assert_eq!(session.messages_for(channel)[0].text, "joined"); + assert_eq!( + session.channel_status("srv", "#unknown").state, + ChannelState::Joined + ); + assert_eq!(session.active_buffer().unwrap().id, console); + assert_eq!(session.input(), "unfinished"); +} + +#[test] +fn server_barrier_also_blocks_unknown_channel_creation_and_status_snapshots() { + let (mut session, _connections, _messages, _commands) = connected_session(8); + session.begin_connection_change("srv", ConnectionState::Stopped); + channel_event(&mut session, "#new", ChannelState::Joined, true); + assert_eq!(session.buffer_count(), 1); + session.handle_event(IrcEvent::ControlApplied("srv".into())); + channel_event(&mut session, "#new", ChannelState::Joined, true); + assert_eq!(session.buffer_count(), 2); + assert_eq!( + session.connection_state("srv", Some("#new")), + ConnectionState::Stopped + ); +} + +#[test] +fn configuration_starts_desired_channels_joining_without_selecting_a_buffer() { + let config = Config::parse( + r##" +[servers.srv] +username="me" +nickname="me" +password="" +server="localhost" +port=6667 +channels=["#Room"] +"##, + ) + .unwrap(); + let mut session = Session::default(); + session.register_config(&config); + assert_eq!( + session.channel_status("srv", "#room"), + ChannelStatus { + state: ChannelState::Joining, + desired: true + } + ); + assert!(session.active_buffer().is_none()); +} diff --git a/src/application/channels.rs b/src/application/channels.rs new file mode 100644 index 0000000..ff08ef0 --- /dev/null +++ b/src/application/channels.rs @@ -0,0 +1,98 @@ +use super::*; + +impl Session { + pub(super) fn channel_index(&self, server: &str, channel: &str) -> Option { + let server = ServerId::new(server); + self.buffers.iter().position(|buffer| { + buffer.server == server + && buffer + .kind + .channel() + .is_some_and(|name| name.eq_ignore_ascii_case(channel)) + }) + } + + pub fn channel_status(&self, server: &str, channel: &str) -> ChannelStatus { + self.channel_index(server, channel) + .map_or_else(ChannelStatus::default, |index| { + self.buffers[index].channel_status + }) + } + + pub(super) fn join_channel( + &mut self, + server: &str, + channel: &str, + handle: &ConnectionHandle, + ) -> Result { + let index = self.channel_index(server, channel); + let name = index + .and_then(|index| self.buffers[index].kind.channel()) + .unwrap_or(channel); + let command = ConnectionCommand::Join(name.to_owned()); + validate_control(&command).map_err(|_| "invalid_control")?; + if let Some(index) = index { + match self.buffers[index].channel_status.state { + ChannelState::Joined | ChannelState::Joining => { + self.buffers[index].hidden = false; + return Ok(SubmissionEffect::Activate(self.buffers[index].id)); + } + ChannelState::Parting => return Err("channel_busy"), + ChannelState::NotJoined | ChannelState::Uncertain => {} + } + } + handle + .control + .try_send(command) + .map_err(|_| "disconnected_or_busy")?; + let id = self.open_channel(server, channel); + let buffer = self + .buffers + .iter_mut() + .find(|buffer| buffer.id == id) + .unwrap(); + buffer.channel_status = ChannelStatus { + state: ChannelState::Joining, + desired: true, + }; + buffer.pending_channel_changes += 1; + buffer.hidden = false; + Ok(SubmissionEffect::Activate(id)) + } + + pub(super) fn part_channel( + &mut self, + server: &str, + channel: &str, + reason: Option, + handle: &ConnectionHandle, + ) -> Result { + let index = self + .channel_index(server, channel) + .ok_or("unknown_channel")?; + let buffer = &mut self.buffers[index]; + let command = ConnectionCommand::Part { + channel: buffer.kind.channel().unwrap().to_owned(), + reason, + }; + validate_control(&command).map_err(|_| "invalid_control")?; + match buffer.channel_status.state { + ChannelState::Joining => return Err("channel_busy"), + ChannelState::Parting => return Ok(SubmissionEffect::None), + ChannelState::NotJoined if !buffer.channel_status.desired => { + return Ok(SubmissionEffect::None); + } + ChannelState::NotJoined | ChannelState::Joined | ChannelState::Uncertain => {} + } + handle + .control + .try_send(command) + .map_err(|_| "disconnected_or_busy")?; + buffer.channel_status.desired = false; + if buffer.channel_status.state != ChannelState::NotJoined { + buffer.channel_status.state = ChannelState::Parting; + } + buffer.pending_channel_changes += 1; + Ok(SubmissionEffect::None) + } +} diff --git a/src/application/query_tests.rs b/src/application/query_tests.rs index be4dcdf..5fd8722 100644 --- a/src/application/query_tests.rs +++ b/src/application/query_tests.rs @@ -65,13 +65,10 @@ channels=[] ("legacy", BufferKind::Server), ] ); - assert_eq!( - crate::connection::build_client_config( - &config.servers["first"], - &config.servers["first"].channels - ) - .channels, - vec!["#one"] + assert!( + crate::connection::build_client_config(&config.servers["first"]) + .channels + .is_empty() ); } diff --git a/src/command.rs b/src/command.rs index bf198a1..2c1a708 100644 --- a/src/command.rs +++ b/src/command.rs @@ -14,6 +14,11 @@ pub enum SlashParseError { #[derive(Debug, Clone, PartialEq, Eq)] pub enum CommandAction { + Join(String), + Part { + channel: Option, + reason: Option, + }, Query(String), Close, Nick(String), @@ -40,6 +45,27 @@ impl SlashCommand { let single = !args.is_empty() && !args.chars().any(char::is_whitespace); let optional = || (!args.is_empty()).then(|| args.to_string()); match self.name.as_str() { + "join" if crate::core::valid_channel_name(args) => Ok(Join(args.into())), + "join" => Err(CommandError::InvalidArguments), + "part" => { + let end = args.find(char::is_whitespace).unwrap_or(args.len()); + let first = &args[..end]; + if first.starts_with(['#', '&']) { + if !crate::core::valid_channel_name(first) { + return Err(CommandError::InvalidArguments); + } + let reason = args[end..].trim_start(); + Ok(Part { + channel: Some(first.into()), + reason: (!reason.is_empty()).then(|| reason.into()), + }) + } else { + Ok(Part { + channel: None, + reason: optional(), + }) + } + } "query" if crate::core::valid_query_nickname(args) => Ok(Query(args.into())), "close" if args.is_empty() => Ok(Close), "query" | "close" => Err(CommandError::InvalidArguments), @@ -83,6 +109,89 @@ pub fn parse_slash_command(input: &str) -> Option]` tables. #[derive(Clone, Deserialize)] @@ -69,6 +69,13 @@ impl Config { } } for server in config.servers.values() { + anyhow::ensure!( + server + .channels + .iter() + .all(|channel| valid_channel_name(channel)), + "channels must contain individual # or & channel names without passwords" + ); anyhow::ensure!( server .queries @@ -100,6 +107,24 @@ impl Config { mod tests { use super::*; + #[test] + fn rejects_unmanaged_configured_channels() { + for channel in [ + "", + "#", + "room", + "#one,#two", + "#one key", + "#a:b", + "#bad\u{7}", + ] { + let content = format!( + "[servers.test]\nusername='me'\nnickname='me'\npassword=''\nserver='localhost'\nport=6667\nchannels=[{channel:?}]\n" + ); + assert!(Config::parse(&content).is_err(), "accepted {channel:?}"); + } + } + #[test] fn rejects_invalid_query_targets() { for nickname in [ diff --git a/src/connection/channels.rs b/src/connection/channels.rs new file mode 100644 index 0000000..5a50d5d --- /dev/null +++ b/src/connection/channels.rs @@ -0,0 +1,348 @@ +use std::{sync::mpsc, time::Duration}; + +use irc::proto::Command; +use tokio::time::Instant; + +use crate::{ + connection::IrcEvent, + core::{ + BufferKind, ChannelState, ChannelStatus, ConnectionCommand, MessageContent, RoutedMessage, + ServerId, valid_channel_name, + }, +}; + +struct Channel { + name: String, + status: ChannelStatus, + deadline: Option, +} + +pub(super) struct Channels { + entries: Vec, + label: String, + sender: mpsc::Sender, + timeout: Duration, +} + +impl Channels { + pub(super) fn new( + names: &[String], + label: &str, + sender: mpsc::Sender, + timeout: Duration, + ) -> Self { + let mut channels = Self { + entries: Vec::new(), + label: label.into(), + sender, + timeout, + }; + for name in names { + if valid_channel_name(name) && channels.index(name).is_none() { + channels.register(name); + } + } + channels + } + + fn index(&self, name: &str) -> Option { + self.entries + .iter() + .position(|entry| entry.name.eq_ignore_ascii_case(name)) + } + + fn register(&mut self, name: &str) -> usize { + let index = self.entries.len(); + self.entries.push(Channel { + name: name.into(), + status: ChannelStatus { + state: ChannelState::NotJoined, + desired: true, + }, + deadline: None, + }); + index + } + + fn publish(&self, index: usize) { + let channel = &self.entries[index]; + let _ = self.sender.send(IrcEvent::Channel( + self.label.clone(), + channel.name.clone(), + channel.status, + )); + } + + fn transition(&mut self, index: usize, state: ChannelState) { + self.entries[index].status.state = state; + self.entries[index].deadline = + matches!(state, ChannelState::Joining | ChannelState::Parting) + .then(|| Instant::now() + self.timeout); + self.publish(index); + } + + pub(super) fn names(&self) -> Vec { + self.entries + .iter() + .map(|channel| channel.name.clone()) + .collect() + } + + pub(super) fn can_send(&self, name: &str) -> bool { + self.index(name).map_or(!valid_channel_name(name), |index| { + self.entries[index].status.state == ChannelState::Joined + }) + } + + pub(super) fn start(&mut self) -> Vec { + let mut commands = Vec::new(); + for index in 0..self.entries.len() { + if self.entries[index].status.desired { + self.transition(index, ChannelState::Joining); + commands.push(Command::JOIN(self.entries[index].name.clone(), None, None)); + } + } + commands + } + + pub(super) fn disconnected(&mut self) { + for index in 0..self.entries.len() { + self.transition(index, ChannelState::NotJoined); + } + } + + pub(super) fn request( + &mut self, + command: &ConnectionCommand, + connected: bool, + ) -> Option { + let (name, joining, reason) = match command { + ConnectionCommand::Join(name) => (name, true, None), + ConnectionCommand::Part { channel, reason } => (channel, false, reason.clone()), + _ => return None, + }; + let index = match self.index(name) { + Some(index) => index, + None if joining => self.register(name), + None => return None, + }; + let state = self.entries[index].status.state; + if connected + && matches!( + (joining, state), + ( + true, + ChannelState::Joined | ChannelState::Joining | ChannelState::Parting + ) | (false, ChannelState::Joining | ChannelState::Parting) + ) + { + self.publish(index); + return None; + } + self.entries[index].status.desired = joining; + if !connected || (!joining && state == ChannelState::NotJoined) { + self.transition(index, ChannelState::NotJoined); + return None; + } + self.transition( + index, + if joining { + ChannelState::Joining + } else { + ChannelState::Parting + }, + ); + let name = self.entries[index].name.clone(); + Some(if joining { + Command::JOIN(name, None, None) + } else { + Command::PART(name, reason) + }) + } + + pub(super) fn joined(&mut self, name: &str) -> Option { + if !valid_channel_name(name) { + return None; + } + let index = self.index(name).unwrap_or_else(|| self.register(name)); + if self.entries[index].status.desired { + self.transition(index, ChannelState::Joined); + None + } else if self.entries[index].status.state == ChannelState::Parting { + None + } else { + self.transition(index, ChannelState::Parting); + Some(Command::PART(self.entries[index].name.clone(), None)) + } + } + + pub(super) fn left(&mut self, name: &str, kicked: bool) { + if let Some(index) = self.index(name) { + if !kicked { + self.entries[index].status.desired = false; + } + self.transition(index, ChannelState::NotJoined); + } + } + + pub(super) fn error(&mut self, code: u16, name: &str) { + let Some(index) = self.index(name) else { + return; + }; + if code == 442 + || (self.entries[index].status.state == ChannelState::Joining + && matches!( + code, + 403 | 405 | 407 | 437 | 471 | 473 | 474 | 475 | 476 | 477 | 489 + )) + { + self.transition(index, ChannelState::NotJoined); + } + } + + pub(super) fn deadline(&self) -> Option { + self.entries + .iter() + .filter_map(|channel| channel.deadline) + .min() + } + + pub(super) fn expire(&mut self, now: Instant) { + for index in 0..self.entries.len() { + if self.entries[index] + .deadline + .is_some_and(|deadline| deadline <= now) + { + let operation = if self.entries[index].status.state == ChannelState::Joining { + "JOIN" + } else { + "PART" + }; + self.transition(index, ChannelState::Uncertain); + self.notice( + &self.entries[index].name, + format!("{operation} timed out; channel membership is unconfirmed"), + ); + } + } + } + + pub(super) fn notice(&self, name: &str, text: String) { + if let Some(index) = self.index(name) { + let _ = self.sender.send(IrcEvent::Message(RoutedMessage { + server: ServerId::new(&self.label), + target: BufferKind::Channel(self.entries[index].name.clone()), + content: MessageContent::console(text), + })); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn registry() -> (Channels, mpsc::Receiver) { + let (sender, receiver) = mpsc::channel(); + ( + Channels::new( + &["#First".into(), "#FIRST".into()], + "srv", + sender, + Duration::from_millis(100), + ), + receiver, + ) + } + + #[test] + fn runtime_intent_survives_sockets_but_parted_channels_do_not_rejoin() { + let (mut channels, _) = registry(); + assert_eq!(channels.names(), vec!["#First"]); + assert_eq!(channels.start().len(), 1); + channels.joined("#first"); + assert!( + channels + .request(&ConnectionCommand::Join("#dynamic".into()), true) + .is_some() + ); + channels.joined("#dynamic"); + assert!( + channels + .request( + &ConnectionCommand::Part { + channel: "#FIRST".into(), + reason: None + }, + true + ) + .is_some() + ); + channels.left("#First", false); + channels.disconnected(); + assert_eq!( + channels.start(), + vec![Command::JOIN("#dynamic".into(), None, None)] + ); + } + + #[test] + fn stale_join_confirmation_does_not_restore_cancelled_intent() { + let (mut channels, _) = registry(); + channels.request( + &ConnectionCommand::Part { + channel: "#first".into(), + reason: None, + }, + false, + ); + assert_eq!( + channels.joined("#FIRST"), + Some(Command::PART("#First".into(), None)) + ); + assert_eq!( + channels.entries[0].status, + ChannelStatus { + state: ChannelState::Parting, + desired: false + } + ); + assert!(channels.joined("#first").is_none()); + } + + #[test] + fn channel_deadlines_and_errors_are_scoped_to_the_operation() { + let (mut channels, _) = registry(); + channels.start(); + let first_deadline = channels.deadline().unwrap(); + channels.request(&ConnectionCommand::Join("#later".into()), true); + channels.entries[1].deadline = Some(first_deadline + Duration::from_secs(1)); + channels.expire(first_deadline); + assert_eq!(channels.entries[0].status.state, ChannelState::Uncertain); + assert_eq!(channels.entries[1].status.state, ChannelState::Joining); + channels.joined("#later"); + channels.error(404, "#later"); + assert_eq!(channels.entries[1].status.state, ChannelState::Joined); + channels.error(475, "#later"); + assert_eq!(channels.entries[1].status.state, ChannelState::Joined); + } + + #[test] + fn channel_send_permission_waits_for_confirmation_and_stops_on_part() { + let (mut channels, _) = registry(); + channels.start(); + assert!(!channels.can_send("#first")); + channels.joined("#first"); + assert!(channels.can_send("#FIRST")); + channels.request( + &ConnectionCommand::Part { + channel: "#first".into(), + reason: None, + }, + true, + ); + assert!(!channels.can_send("#First")); + assert!(channels.can_send("alice")); + assert!(!channels.can_send("#unknown")); + } +} diff --git a/src/connection/mod.rs b/src/connection/mod.rs index 87b0d36..c78f8ac 100644 --- a/src/connection/mod.rs +++ b/src/connection/mod.rs @@ -6,6 +6,7 @@ //! way through a bounded tokio channel (`OutgoingMessage`). One thread is //! spawned per configured server, each joining all of that server's channels. +mod channels; mod session; use std::sync::mpsc; @@ -14,7 +15,7 @@ use std::thread; use irc::client::prelude::Config as IrcClientConfig; use crate::config::ServerConfig; -use crate::core::{ConnectionCommand, OutgoingMessage, RoutedMessage}; +use crate::core::{ChannelStatus, ConnectionCommand, OutgoingMessage, RoutedMessage}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ConnectionState { @@ -52,7 +53,8 @@ pub enum IrcEvent { /// Ordered barrier: the worker has consumed a manual disconnect/reconnect. ControlApplied(String), Connection(String, ConnectionState), - Channel(String, String, ConnectionState), + Channel(String, String, ChannelStatus), + ChannelControlApplied(String, String), Nickname(String, String), PeerNickname(String, String, String), Away(String, bool), @@ -70,9 +72,8 @@ pub enum IrcEvent { /// starts failing (the UI never blocks on a send). const OUTGOING_CAPACITY: usize = 64; -/// Map our server settings onto the irc crate's client configuration, -/// joining every channel in `channels`. -pub fn build_client_config(server: &ServerConfig, channels: &[String]) -> IrcClientConfig { +/// Map transport settings without enabling the IRC library's automatic JOIN. +pub fn build_client_config(server: &ServerConfig) -> IrcClientConfig { IrcClientConfig { nickname: Some(server.nickname.clone()), username: Some(server.username.clone()), @@ -81,7 +82,7 @@ pub fn build_client_config(server: &ServerConfig, channels: &[String]) -> IrcCli port: Some(server.port), password: Some(server.password.clone()), use_tls: Some(server.use_tls), - channels: channels.to_vec(), + channels: Vec::new(), ..Default::default() } } @@ -147,7 +148,7 @@ mod tests { let server = server_config(); // Act - let client_config = build_client_config(&server, &server.channels); + let client_config = build_client_config(&server); // Assert assert_eq!(client_config.server.as_deref(), Some("irc.example.org")); @@ -159,15 +160,14 @@ mod tests { } #[test] - fn joins_every_configured_channel() { + fn leaves_automatic_join_to_the_worker() { // Arrange let server = server_config(); // Act - let client_config = build_client_config(&server, &server.channels); + let client_config = build_client_config(&server); - // Assert: both channels are joined, in config order. - assert_eq!(client_config.channels, server.channels); + assert!(client_config.channels.is_empty()); } #[test] @@ -176,7 +176,7 @@ mod tests { let server = server_config(); // Act - let client_config = build_client_config(&server, &server.channels); + let client_config = build_client_config(&server); // Assert assert_eq!(client_config.realname.as_deref(), Some("alice_")); diff --git a/src/connection/session.rs b/src/connection/session.rs index 77d97f2..99b1aef 100644 --- a/src/connection/session.rs +++ b/src/connection/session.rs @@ -1,10 +1,11 @@ //! Serial connection lifecycle: cancellation, bounded retries and confirmed state. +use super::channels::Channels; use crate::{ config::ServerConfig, connection::{ConnectionState, IrcEvent, RetryPolicy, build_client_config}, core::{ConnectionCommand, OutgoingMessage}, - protocol::{decode_message, encode_outgoing, validate_control}, + protocol::{channel_control_from_raw, decode_message, encode_outgoing, validate_control}, }; use futures_util::StreamExt; use irc::client::{Client, ClientStream}; @@ -37,6 +38,7 @@ pub(crate) async fn run( ) { let mut next = Some(Instant::now()); let mut failures = 0u32; + let mut channels = Channels::new(&channels, &label, tx.clone(), policy.timeout); loop { tokio::select! { biased; @@ -46,6 +48,7 @@ pub(crate) async fn run( continue; } acknowledge(&tx, &label, command.as_ref()); + if let Some(command) = command.as_ref() { channels.request(command, false); } match command { None => break, Some(ConnectionCommand::Connect) if next.is_none() => { failures = 0; next = Some(Instant::now()); } @@ -57,12 +60,17 @@ pub(crate) async fn run( _ => {} } }, - message = outgoing.recv() => { if message.is_none() { break; } }, + message = outgoing.recv() => { + let Some(message) = message else { break; }; + retain_channel_intent(&message, &mut channels); + }, _ = sleep_until(next.unwrap_or_else(Instant::now)), if next.is_some() => { // Never replay messages queued for a previous socket. - while outgoing.try_recv().is_ok() {} + discard_chat(&mut outgoing, &mut channels); state(&tx, &label, ConnectionState::Connecting); - match session(&mut server, &label, &channels, &tx, policy.timeout, &mut outgoing, &mut control).await { + let end = session(&mut server, &label, &mut channels, &tx, policy.timeout, &mut outgoing, &mut control).await; + channels.disconnected(); + match end { End::Shutdown => break, End::Stop => { next = None; state(&tx, &label, ConnectionState::Stopped); } End::Restart => { failures = 0; next = Some(Instant::now()); } @@ -106,14 +114,14 @@ async fn close(client: &Client, stream: &mut ClientStream, reason: &str) { async fn session( server: &mut ServerConfig, label: &str, - channels: &[String], + channels: &mut Channels, tx: &mpsc::Sender, limit: Duration, outgoing: &mut Receiver, control: &mut Receiver, ) -> End { let deadline = Instant::now() + limit; - let connecting = Client::from_config(build_client_config(server, channels)); + let connecting = Client::from_config(build_client_config(server)); tokio::pin!(connecting); let mut client = loop { tokio::select! { @@ -124,6 +132,7 @@ async fn session( continue; } acknowledge(tx, label, command.as_ref()); + if let Some(command) = command.as_ref() { channels.request(command, false); } match command { None => return End::Shutdown, Some(ConnectionCommand::Disconnect(_)) => return End::Stop, @@ -131,7 +140,10 @@ async fn session( _ => {} } }, - message = outgoing.recv() => { if message.is_none() { return End::Shutdown; } }, + message = outgoing.recv() => { + let Some(message) = message else { return End::Shutdown; }; + retain_channel_intent(&message, channels); + }, _ = sleep_until(deadline) => return failed(false, None), result = &mut connecting => match result { Ok(client) => break client, @@ -147,8 +159,6 @@ async fn session( }; let mut registered_at = None; let mut away = false; - let mut pending: Vec = channels.to_vec(); - let mut join_deadline = deadline; loop { tokio::select! { biased; @@ -158,6 +168,12 @@ async fn session( continue; } acknowledge(tx, label, command.as_ref()); + if let Some(command @ (ConnectionCommand::Join(_) | ConnectionCommand::Part { .. })) = command.as_ref() { + if channels.request(command, registered_at.is_some()).is_some_and(|wire| client.send(wire).is_err()) { + return failed(false, registered_at); + } + continue; + } let wire = match command { None => { close(&client, &mut stream, "").await; return End::Shutdown; } Some(ConnectionCommand::Disconnect(reason)) => { close(&client, &mut stream, &reason).await; return End::Stop; } @@ -171,18 +187,20 @@ async fn session( None => Some("Away".into()), }), Some(ConnectionCommand::Back) => Command::AWAY(None), + Some(ConnectionCommand::Join(_) | ConnectionCommand::Part { .. }) => unreachable!(), }; if client.send(wire).is_err() { return failed(false, registered_at); } }, _ = sleep_until(deadline), if registered_at.is_none() => return failed(false, None), - _ = sleep_until(join_deadline), if registered_at.is_some() && !pending.is_empty() => { - for channel in pending.drain(..) { - let _ = tx.send(IrcEvent::Channel(label.into(), channel, ConnectionState::Stopped)); - } + _ = sleep_until(channels.deadline().unwrap_or_else(Instant::now)), if registered_at.is_some() && channels.deadline().is_some() => { + channels.expire(Instant::now()); }, outgoing = outgoing.recv() => { let Some(message) = outgoing else { close(&client, &mut stream, "").await; return End::Shutdown; }; - if registered_at.is_none() { continue; } + if registered_at.is_none() { + retain_channel_intent(&message, channels); + continue; + } let wire = match encode_outgoing(&message) { Ok(wire) => wire, Err(error) => { @@ -190,6 +208,15 @@ async fn session( continue; } }; + if let Some(command) = raw_channel_control(&message) { + if channels.request(&command, true).is_some_and(|wire| client.send(wire).is_err()) { + return failed(false, registered_at); + } + continue; + } + if matches!(&message, OutgoingMessage::Privmsg { target, .. } if !channels.can_send(target)) { + continue; + } if client.send(wire).is_err() { return failed(false, registered_at); } }, message = stream.next() => { @@ -207,15 +234,17 @@ async fn session( match &message.command { Command::Response(Response::RPL_WELCOME, args) if registered_at.is_none() => { if let Some(nick) = args.first() { server.nickname.clone_from(nick); } - while outgoing.try_recv().is_ok() {} + discard_chat(outgoing, channels); registered_at = Some(Instant::now()); - join_deadline = Instant::now() + limit; let _ = tx.send(IrcEvent::Nickname(label.into(), server.nickname.clone())); let _ = tx.send(IrcEvent::Away(label.into(), false)); state(tx, label, ConnectionState::Connected); + for wire in channels.start() { + if client.send(wire).is_err() { return failed(false, registered_at); } + } } Command::Response(Response::ERR_PASSWDMISMATCH | Response::ERR_YOUREBANNEDCREEP, _) => { - if let Some(message) = decode_message(&message, label, channels, &server.nickname) { + if let Some(message) = decode_message(&message, label, &channels.names(), &server.nickname) { let _ = tx.send(IrcEvent::Message(message)); } return failed(true, registered_at); @@ -242,16 +271,26 @@ async fn session( tracing::debug!(target: "termirc::slash", outcome = "confirmed", away, "away state updated"); continue; } - Command::JOIN(channel, _, _) if own_message(&message, &server.nickname) => channel_state(tx, label, channel, ConnectionState::Connected, &mut pending), - Command::PART(channel, _) if own_message(&message, &server.nickname) => channel_state(tx, label, channel, ConnectionState::Stopped, &mut pending), - Command::KICK(channel, nick, _) if nick.eq_ignore_ascii_case(&server.nickname) => channel_state(tx, label, channel, ConnectionState::Stopped, &mut pending), - Command::Response(response, args) if matches!(*response as u16, 403 | 405 | 407 | 437 | 442 | 471 | 473 | 474 | 475 | 476 | 477 | 489) => { - if let Some(channel) = args.get(1) { channel_state(tx, label, channel, ConnectionState::Stopped, &mut pending); } + Command::JOIN(channel, _, _) if own_message(&message, &server.nickname) => { + if channels.joined(channel).is_some_and(|wire| client.send(wire).is_err()) { + return failed(false, registered_at); + } + }, + Command::PART(channel, _) if own_message(&message, &server.nickname) => channels.left(channel, false), + Command::KICK(channel, nick, reason) if nick.eq_ignore_ascii_case(&server.nickname) => { + channels.left(channel, true); + channels.notice(channel, format!("Kicked from {channel}: {}", reason.as_deref().unwrap_or("no reason given"))); + }, + Command::Response(response, args) => { + if let Some(channel) = args.get(1) { channels.error(*response as u16, channel); } + }, + Command::Raw(code, args) => { + if let (Ok(code), Some(channel)) = (code.parse::(), args.get(1)) { channels.error(code, channel); } } Command::ERROR(_) => return failed(false, registered_at), _ => {} } - if let Some(chat) = decode_message(&message, label, channels, &server.nickname) { + if let Some(chat) = decode_message(&message, label, &channels.names(), &server.nickname) { let _ = tx.send(IrcEvent::Message(chat)); } } @@ -273,18 +312,34 @@ fn own_message(message: &Message, nickname: &str) -> bool { .is_some_and(|nick| nick.eq_ignore_ascii_case(nickname)) } -fn channel_state( - tx: &mpsc::Sender, - label: &str, - channel: &str, - state: ConnectionState, - pending: &mut Vec, -) { - pending.retain(|name| !name.eq_ignore_ascii_case(channel)); - let _ = tx.send(IrcEvent::Channel(label.into(), channel.into(), state)); +fn retain_channel_intent(message: &OutgoingMessage, channels: &mut Channels) { + if let Some(command) = raw_channel_control(message) { + channels.request(&command, false); + } +} + +fn raw_channel_control(message: &OutgoingMessage) -> Option { + match message { + OutgoingMessage::Raw { line, .. } => channel_control_from_raw(line).ok().flatten(), + _ => None, + } +} + +fn discard_chat(outgoing: &mut Receiver, channels: &mut Channels) { + while let Ok(message) = outgoing.try_recv() { + retain_channel_intent(&message, channels); + } } fn acknowledge(tx: &mpsc::Sender, label: &str, command: Option<&ConnectionCommand>) { + if let Some(ConnectionCommand::Join(channel) | ConnectionCommand::Part { channel, .. }) = + command + { + let _ = tx.send(IrcEvent::ChannelControlApplied( + label.into(), + channel.clone(), + )); + } if matches!( command, Some(ConnectionCommand::Disconnect(_) | ConnectionCommand::Reconnect) diff --git a/src/core.rs b/src/core.rs index a9a6308..f11d827 100644 --- a/src/core.rs +++ b/src/core.rs @@ -264,6 +264,35 @@ pub enum ConnectionCommand { Nick(String), Away(Option), Back, + Join(String), + Part { + channel: String, + reason: Option, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ChannelState { + #[default] + NotJoined, + Joining, + Joined, + Parting, + Uncertain, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct ChannelStatus { + pub state: ChannelState, + pub desired: bool, +} + +pub fn valid_channel_name(channel: &str) -> bool { + channel.len() > 1 + && channel.starts_with(['#', '&']) + && !channel.chars().any(|character| { + character.is_whitespace() || character.is_control() || matches!(character, ',' | ':') + }) } /// A message the user wants to send through one of our connections. diff --git a/src/protocol.rs b/src/protocol.rs index 59a7d93..24fd1ac 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -6,7 +6,7 @@ use irc::proto::{Command, Message as WireMessage, Response}; use crate::core::{ BufferKind, ConnectionCommand, DeliveryState, Direction, MessageContent, MessageKind, - OutgoingMessage, RoutedMessage, ServerId, + OutgoingMessage, RoutedMessage, ServerId, valid_channel_name, }; /// Baseline IRC line limit, including the terminating CRLF. @@ -62,6 +62,7 @@ pub fn encode_outgoing(message: &OutgoingMessage) -> Result { validate_text(line)?; + channel_control_from_raw(line)?; let wire: WireMessage = line.parse().map_err(|_| SendError::InvalidCommand)?; if matches!(&wire.command, Command::Raw(command, _) if command.is_empty() || !command.bytes().all(|c| c.is_ascii_alphanumeric())) { @@ -95,10 +96,50 @@ pub fn validate_control(command: &ConnectionCommand) -> Result<(), SendError> { Command::AWAY(reason.clone()) } ConnectionCommand::Back => Command::AWAY(None), + ConnectionCommand::Join(channel) => { + if !valid_channel_name(channel) { + return Err(SendError::InvalidCommand); + } + Command::JOIN(channel.clone(), None, None) + } + ConnectionCommand::Part { channel, reason } => { + if !valid_channel_name(channel) { + return Err(SendError::InvalidCommand); + } + if let Some(reason) = reason { + validate_text(reason)?; + } + Command::PART(channel.clone(), reason.clone()) + } }; validate_wire(wire.into()).map(|_| ()) } +pub fn channel_control_from_raw(line: &str) -> Result, SendError> { + validate_text(line)?; + let wire: WireMessage = line.parse().map_err(|_| SendError::InvalidCommand)?; + let control = match &wire.command { + Command::JOIN(channel, None, None) => ConnectionCommand::Join(channel.clone()), + Command::PART(channel, reason) => ConnectionCommand::Part { + channel: channel.clone(), + reason: reason.clone(), + }, + Command::JOIN(..) => return Err(SendError::InvalidCommand), + Command::Raw(command, _) + if command.eq_ignore_ascii_case("JOIN") || command.eq_ignore_ascii_case("PART") => + { + return Err(SendError::InvalidCommand); + } + _ => return Ok(None), + }; + if wire.prefix.is_some() || wire.tags.is_some() { + return Err(SendError::InvalidCommand); + } + validate_wire(wire)?; + validate_control(&control)?; + Ok(Some(control)) +} + // Presence and automatic registration floods stay hidden. User-requested // replies and all 4xx/5xx errors remain available to the application. const IGNORED_NUMERICS: &[Response] = &[ @@ -118,13 +159,13 @@ const IGNORED_NUMERICS: &[Response] = &[ pub fn decode_message( message: &WireMessage, server: &str, - channels: &[String], + known_channels: &[String], own_nickname: &str, ) -> Option { let (target, nick, text, kind) = match &message.command { Command::PRIVMSG(target, body) => { let nick = message.source_nickname()?.to_owned(); - let destination = if channels + let destination = if known_channels .iter() .any(|channel| channel.eq_ignore_ascii_case(target)) { @@ -155,7 +196,7 @@ pub fn decode_message( ) } Command::Response(response, args) if (400..600).contains(&(*response as u16)) => { - error_payload(*response as u16, args, channels) + error_payload(*response as u16, args, known_channels) } // The IRC dependency treats unrecognized numerics as raw commands. Command::Raw(command, args) @@ -163,7 +204,7 @@ pub fn decode_message( .parse::() .is_ok_and(|code| (400..600).contains(&code)) => { - error_payload(command.parse().ok()?, args, channels) + error_payload(command.parse().ok()?, args, known_channels) } Command::Response(response, _) if IGNORED_NUMERICS.contains(response) => return None, Command::Response(_, args) => { @@ -269,6 +310,54 @@ fn error_payload( mod tests { use super::*; + #[test] + fn raw_channel_operations_reject_unmanaged_targets() { + for line in [ + "JOIN #one,#two", + "JOIN #one secret", + "JOIN 0", + "PART #one,#two :bye", + "JOIN #", + "PART &", + "JOIN :#one secret", + "@tag=value JOIN #one", + ":someone JOIN #one", + ] { + assert!(encode_outgoing(&raw(line)).is_err(), "accepted {line}"); + } + } + + #[test] + fn channel_controls_share_validation_and_preserve_part_reason() { + assert_eq!( + channel_control_from_raw("join #Room").unwrap(), + Some(ConnectionCommand::Join("#Room".into())) + ); + assert_eq!( + channel_control_from_raw("PART &local :gone for lunch").unwrap(), + Some(ConnectionCommand::Part { + channel: "&local".into(), + reason: Some("gone for lunch".into()) + }) + ); + assert!(channel_control_from_raw("WHOIS alice").unwrap().is_none()); + assert!(validate_control(&ConnectionCommand::Join("#".into())).is_err()); + assert!( + validate_control(&ConnectionCommand::Part { + channel: "#room".into(), + reason: Some("x".repeat(512)) + }) + .is_err() + ); + assert!( + validate_control(&ConnectionCommand::Part { + channel: "#room".into(), + reason: Some("bye\r\nQUIT".into()) + }) + .is_err() + ); + } + fn decode(line: &str) -> Option { decode_message( &line.parse().unwrap(), diff --git a/src/tui/render.rs b/src/tui/render.rs index 8c1a287..422ff19 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -1040,7 +1040,18 @@ mod tests { #[test] fn connection_dots_follow_confirmed_server_and_channel_states() { use crate::connection::{ConnectionState as S, IrcEvent as E}; + use crate::core::{ChannelState, ChannelStatus}; let mut app = test_app(22, 3); + for channel in ["#osu", "#chinese"] { + app.apply_connection_event(&E::Channel( + "osu_irc".into(), + channel.into(), + ChannelStatus { + state: ChannelState::NotJoined, + desired: true, + }, + )); + } app.apply_connection_event(&E::Connection("osu_irc".into(), S::Connecting)); let pending = render_sized(&app, "", 50, 10); assert_eq!(pending[(2, 0)].symbol(), "●"); @@ -1054,8 +1065,22 @@ mod tests { assert_eq!(pending[(x, 0)].symbol(), off[(x, 0)].symbol()); } app.apply_connection_event(&E::Connection("OSU_IRC".into(), S::Connected)); - app.apply_connection_event(&E::Channel("osu_irc".into(), "#osu".into(), S::Connected)); - app.apply_connection_event(&E::Channel("osu_irc".into(), "#chinese".into(), S::Stopped)); + app.apply_connection_event(&E::Channel( + "osu_irc".into(), + "#osu".into(), + ChannelStatus { + state: ChannelState::Joined, + desired: true, + }, + )); + app.apply_connection_event(&E::Channel( + "osu_irc".into(), + "#chinese".into(), + ChannelStatus { + state: ChannelState::NotJoined, + desired: true, + }, + )); let connected = render_sized(&app, "", 50, 10); assert_eq!(connected[(2, 0)].symbol(), "●"); assert_eq!(connected[(2, 0)].fg, Color::Green); @@ -1069,6 +1094,78 @@ mod tests { } } + #[test] + fn channel_dots_blink_only_for_pending_membership_and_require_connected_server_for_green() { + use crate::connection::{ConnectionState, IrcEvent}; + use crate::core::{ChannelState, ChannelStatus}; + for (state, color, blinking) in [ + (ChannelState::Joined, Color::Green, false), + (ChannelState::Joining, Color::Gray, true), + (ChannelState::Parting, Color::Gray, true), + (ChannelState::NotJoined, Color::Red, false), + (ChannelState::Uncertain, Color::Red, false), + ] { + let mut app = App::new(22, 3); + app.open_server("srv"); + app.open_channel("srv", "#room"); + app.apply_connection_event(&IrcEvent::Connection( + "srv".into(), + ConnectionState::Connected, + )); + app.apply_connection_event(&IrcEvent::Channel( + "srv".into(), + "#room".into(), + ChannelStatus { + state, + desired: true, + }, + )); + let on = render_sized(&app, "", 50, 10); + assert_eq!(on[(4, 1)].fg, color, "{state:?}"); + assert_eq!(on[(4, 1)].symbol(), "●"); + app.tick(std::time::Duration::from_millis(500)); + let off = render_sized(&app, "", 50, 10); + assert_eq!( + off[(4, 1)].symbol(), + if blinking { " " } else { "●" }, + "{state:?}" + ); + } + let mut app = App::new(22, 3); + app.open_server("srv"); + app.apply_connection_event(&IrcEvent::Channel( + "srv".into(), + "#room".into(), + ChannelStatus { + state: ChannelState::Joined, + desired: true, + }, + )); + let offline = render_sized(&app, "", 50, 10); + assert_eq!(offline[(4, 1)].fg, Color::Red); + } + + #[test] + fn intentionally_left_channel_stays_red_while_server_reconnects() { + use crate::connection::{ConnectionState, IrcEvent}; + use crate::core::ChannelStatus; + let mut app = App::new(22, 3); + app.open_server("srv"); + app.open_channel("srv", "#left"); + app.apply_connection_event(&IrcEvent::Channel( + "srv".into(), + "#left".into(), + ChannelStatus::default(), + )); + app.begin_connection_change("srv", ConnectionState::Connecting); + for elapsed in [0, 500] { + app.tick(std::time::Duration::from_millis(elapsed)); + let rendered = render_sized(&app, "", 50, 10); + assert_eq!(rendered[(4, 1)].symbol(), "●"); + assert_eq!(rendered[(4, 1)].fg, Color::Red); + } + } + #[test] fn sidebar_highlights_active_channel_row() { // Arrange diff --git a/src/tui/view/tests.rs b/src/tui/view/tests.rs index 7e5e3d9..f326131 100644 --- a/src/tui/view/tests.rs +++ b/src/tui/view/tests.rs @@ -3,7 +3,107 @@ use crate::application::InputSubmission; use crate::command::SlashCommand; use crate::connection::{ConnectionState, IrcEvent}; use crate::core::OutgoingMessage; -use crate::core::{BufferKind, MessageContent, RoutedMessage}; +use crate::core::{BufferKind, ChannelState, ChannelStatus, MessageContent, RoutedMessage}; + +#[test] +fn dynamic_channel_insertion_keeps_queries_after_channels_and_tracks_sidebar_identity() { + let mut app = App::new(40, 3); + app.open_server("srv"); + let query = app.open_query("srv", "alice"); + app.activate_buffer(query); + app.restore_input_at("query draft".into(), 3); + app.focus_composer(); + app.tab(); + app.set_hover_sidebar(Some(1)); + app.session.handle_event(IrcEvent::Channel( + "srv".into(), + "#dynamic".into(), + ChannelStatus { + state: ChannelState::Joined, + desired: true, + }, + )); + app.tick(std::time::Duration::ZERO); + let rows = app.sidebar_rows(); + assert_eq!( + rows.iter().map(|row| row.kind.clone()).collect::>(), + vec![ + BufferKind::Server, + BufferKind::Channel("#dynamic".into()), + BufferKind::Query("alice".into()) + ] + ); + assert_eq!(app.sidebar_cursor(), Some(2)); + assert_eq!(app.sidebar_hovered(), Some(2)); + assert_eq!(app.active_buffer().unwrap().id, query); + assert_eq!(app.input(), "query draft"); + assert_eq!(app.input_cursor(), 3); + app.sidebar_up(); + app.sidebar_enter(); + assert_eq!( + app.active_buffer().unwrap().kind, + BufferKind::Channel("#dynamic".into()) + ); + assert_eq!(app.focus(), Focus::Composer); +} + +#[test] +fn join_command_focuses_channel_and_reuses_saved_view_and_destination_draft() { + use crate::application::submit_composer; + use crate::connection::ConnectionHandle; + use crate::core::ConnectionCommand; + let mut app = App::new(40, 3); + let console = app.open_server("srv"); + app.open_query("srv", "alice"); + app.activate_buffer(console); + app.apply_connection_event(&IrcEvent::Connection( + "srv".into(), + ConnectionState::Connected, + )); + let (outgoing, mut messages) = tokio::sync::mpsc::channel(8); + let (control, mut commands) = tokio::sync::mpsc::channel(8); + let connections = + std::collections::HashMap::from([("srv".into(), ConnectionHandle { outgoing, control })]); + let config = crate::config::Config { + servers: Default::default(), + }; + app.restore_input("/join #New".into()); + let effect = submit_composer(&mut app.session, &config, &connections, &mut String::new()); + app.apply_submission_effect(effect); + let channel = app.active_buffer().unwrap().id; + assert_eq!( + commands.try_recv().unwrap(), + ConnectionCommand::Join("#New".into()) + ); + assert_eq!(app.focus(), Focus::Composer); + assert_eq!(app.sidebar_rows()[1].id, Some(channel)); + assert_eq!(app.sidebar_cursor(), None); + assert!(messages.try_recv().is_err()); + for index in 0..8 { + app.push_message(RoutedMessage::chat( + "srv", + "#New", + "alice", + &format!("message {index}"), + )); + } + app.click_message(5); + app.restore_input_at("channel draft".into(), 4); + let offset = app.scroll_offset(); + let selected = app.selected(); + app.apply_submission_effect(SubmissionEffect::Activate(console)); + app.restore_input("/join #NEW".into()); + let effect = submit_composer(&mut app.session, &config, &connections, &mut String::new()); + app.apply_submission_effect(effect); + assert_eq!(app.active_buffer().unwrap().id, channel); + assert_eq!(app.scroll_offset(), offset); + assert_eq!(app.selected(), selected); + assert_eq!(app.input(), "channel draft"); + assert_eq!(app.input_cursor(), 4); + assert!(app.buffer(console).unwrap().draft.text().is_empty()); + assert!(commands.try_recv().is_err()); + assert_eq!(app.messages().len(), 8); +} #[test] fn sidebar_groups_interleaved_registrations_by_first_server() { @@ -225,6 +325,14 @@ fn msg_to(server: &str, channel: &str, nick: &str, text: &str) -> RoutedMessage fn consecutive_connection_requests_wait_for_every_worker_acknowledgement() { let mut app = App::new(40, 10); app.open_channel("srv", "#a"); + app.apply_connection_event(&IrcEvent::Channel( + "srv".into(), + "#a".into(), + ChannelStatus { + state: ChannelState::Joined, + desired: true, + }, + )); app.begin_connection_change("srv", ConnectionState::Stopped); app.begin_connection_change("srv", ConnectionState::Connecting); app.apply_connection_event(&IrcEvent::ControlApplied("srv".into())); @@ -235,7 +343,10 @@ fn consecutive_connection_requests_wait_for_every_worker_acknowledgement() { app.apply_connection_event(&IrcEvent::Channel( "srv".into(), "#a".into(), - ConnectionState::Connected, + ChannelStatus { + state: ChannelState::Joined, + desired: true, + }, )); assert_eq!( app.connection_state("srv", None), @@ -253,7 +364,10 @@ fn consecutive_connection_requests_wait_for_every_worker_acknowledgement() { app.apply_connection_event(&IrcEvent::Channel( "srv".into(), "#a".into(), - ConnectionState::Connected, + ChannelStatus { + state: ChannelState::Joined, + desired: true, + }, )); assert_eq!( app.connection_state("srv", None), diff --git a/tests/connection_lifecycle.rs b/tests/connection_lifecycle.rs index ffbf3bd..8a994da 100644 --- a/tests/connection_lifecycle.rs +++ b/tests/connection_lifecycle.rs @@ -5,7 +5,7 @@ use std::time::{Duration, Instant}; use termirc::config::ServerConfig; use termirc::connection::{ConnectionState, IrcEvent, RetryPolicy, spawn_irc_with_policy}; -use termirc::core::ConnectionCommand; +use termirc::core::{ChannelState, ChannelStatus, ConnectionCommand}; const WAIT: Duration = Duration::from_secs(3); @@ -106,13 +106,13 @@ fn registration_and_channel_states_wait_for_server_confirmation() { socket.write_all(b":someone!u@h JOIN #ok\r\n:assigned!u@h JOIN #ok\r\n:mock 473 assigned #bad :Invite only\r\n").unwrap(); let events = receive_until( &rx, - |e| matches!(e, IrcEvent::Channel(_, c, ConnectionState::Stopped) if c == "#bad"), + |e| matches!(e, IrcEvent::Channel(_, c, ChannelStatus { state: ChannelState::NotJoined, desired: true }) if c == "#bad"), ); assert_eq!( events .iter() .filter( - |e| matches!(e, IrcEvent::Channel(_, c, ConnectionState::Connected) if c == "#ok") + |e| matches!(e, IrcEvent::Channel(_, c, ChannelStatus { state: ChannelState::Joined, desired: true }) if c == "#ok") ) .count(), 1 @@ -439,7 +439,7 @@ fn channel_errors_are_delivered_once_with_a_console_fallback() { &rx, |event| matches!(event, IrcEvent::Message(message) if message.content.text == "done"), ); - assert_eq!(events.iter().filter(|event| matches!(event, IrcEvent::Channel(_, channel, ConnectionState::Stopped) if channel == "#bad")).count(), 1); + assert_eq!(events.iter().filter(|event| matches!(event, IrcEvent::Channel(_, channel, ChannelStatus { state: ChannelState::NotJoined, desired: true }) if channel == "#bad")).count(), 1); let errors: Vec<_> = events .iter() .filter_map(|event| match event { diff --git a/tests/dynamic_channels.rs b/tests/dynamic_channels.rs new file mode 100644 index 0000000..cb1e258 --- /dev/null +++ b/tests/dynamic_channels.rs @@ -0,0 +1,762 @@ +use std::collections::HashMap; +use std::io::{BufRead, BufReader, ErrorKind, Write}; +use std::net::{Shutdown, TcpListener, TcpStream}; +use std::sync::mpsc; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use termirc::application::submit_composer; +use termirc::config::{Config, ServerConfig}; +use termirc::connection::{ + ConnectionHandle, ConnectionState, IrcEvent, RetryPolicy, spawn_irc_with_policy, +}; +use termirc::core::{ + BufferKind, ChannelState, ChannelStatus, ConnectionCommand, MessageKind, OutgoingMessage, +}; +use termirc::tui::App; + +const WAIT: Duration = Duration::from_secs(5); + +struct MockSession { + listener: TcpListener, + socket: Option>, + events: mpsc::Receiver, + handle: Option, + worker: Option>, + app: App, + config: Config, + status: String, + observed: Vec, +} + +impl MockSession { + fn start(channels: &[&str]) -> Self { + Self::with_timeout(channels, WAIT) + } + + fn with_timeout(channels: &[&str], timeout: Duration) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let server = ServerConfig { + username: "test".into(), + nickname: "test".into(), + password: String::new(), + server: "127.0.0.1".into(), + use_tls: false, + port: listener.local_addr().unwrap().port(), + channels: channels.iter().map(|channel| (*channel).into()).collect(), + queries: vec![], + }; + let config = Config { + servers: [("srv".into(), server.clone())].into_iter().collect(), + }; + let mut app = App::new(40, 5); + app.session.register_config(&config); + let console = app.open_server("srv"); + app.activate_buffer(console); + let (sender, events) = mpsc::channel(); + let (worker, handle) = spawn_irc_with_policy( + server.clone(), + "srv".into(), + server.channels, + sender, + RetryPolicy { + max_retries: 0, + delay: Duration::from_millis(30), + timeout, + }, + ); + let mut session = Self { + listener, + socket: None, + events, + handle: Some(handle), + worker: Some(worker), + app, + config, + status: String::new(), + observed: Vec::new(), + }; + session.accept(); + session.register(); + session + } + + fn accept(&mut self) { + let deadline = Instant::now() + WAIT; + let socket = loop { + match self.listener.accept() { + Ok((socket, _)) => break socket, + Err(error) if error.kind() == ErrorKind::WouldBlock => { + assert!(Instant::now() < deadline, "worker did not connect"); + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("accept failed: {error}"), + } + }; + // Windows: accepted sockets inherit the listener's non-blocking mode; + // restore blocking reads so read timeouts work on every platform. + socket.set_nonblocking(false).unwrap(); + socket.set_read_timeout(Some(WAIT)).unwrap(); + socket.set_write_timeout(Some(WAIT)).unwrap(); + self.socket = Some(BufReader::new(socket)); + let deadline = Instant::now() + WAIT; + while !self.read_line_until(deadline).starts_with("USER ") {} + } + + fn register(&mut self) { + self.send(":mock 001 test :Welcome\r\n:mock 376 test :End of MOTD\r\n"); + self.receive_until(|event| { + *event == IrcEvent::Connection("srv".into(), ConnectionState::Connected) + }); + self.sync_events(); + } + + fn send(&mut self, lines: &str) { + self.socket + .as_mut() + .unwrap() + .get_mut() + .write_all(lines.as_bytes()) + .unwrap(); + } + + fn read_line_until(&mut self, deadline: Instant) -> String { + let remaining = deadline.saturating_duration_since(Instant::now()); + assert!(!remaining.is_zero(), "wire deadline elapsed"); + let reader = self.socket.as_mut().unwrap(); + reader.get_ref().set_read_timeout(Some(remaining)).unwrap(); + let mut line = String::new(); + assert_ne!(reader.read_line(&mut line).unwrap(), 0, "socket closed"); + line.trim_end_matches(['\r', '\n']).into() + } + + fn control(&self, command: ConnectionCommand) { + self.handle + .as_ref() + .unwrap() + .control + .try_send(command) + .unwrap(); + } + + fn outgoing(&self, message: OutgoingMessage) { + self.handle + .as_ref() + .unwrap() + .outgoing + .try_send(message) + .unwrap(); + } + + fn wire(&mut self) -> Vec { + self.outgoing(OutgoingMessage::Raw { + server: "srv".into(), + line: "PING :wire-barrier".into(), + }); + let deadline = Instant::now() + WAIT; + let mut lines = Vec::new(); + loop { + let line = self.read_line_until(deadline); + if line.starts_with("PING ") && line.contains("wire-barrier") { + return lines; + } + lines.push(line); + } + } + + fn receive_until(&mut self, predicate: impl Fn(&IrcEvent) -> bool) -> Vec { + let deadline = Instant::now() + WAIT; + let mut received = Vec::new(); + loop { + let event = self + .events + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .unwrap_or_else(|error| panic!("missing event: {error}; received {received:?}")); + let done = predicate(&event); + self.app.session.handle_event(event.clone()); + self.observed.push(event.clone()); + received.push(event); + if done { + return received; + } + } + } + + fn sync_events(&mut self) -> Vec { + self.send(":mock NOTICE * :event-barrier\r\n"); + self.receive_until( + |event| matches!(event, IrcEvent::Message(message) if message.content.text == "event-barrier"), + ) + } + + fn wait_channel(&mut self, channel: &str, state: ChannelState, desired: bool) -> Vec { + self.receive_until(|event| channel_event(event, channel, state, desired)) + } + + fn assert_channel(&self, channel: &str, state: ChannelState, desired: bool) { + assert_eq!( + self.app.session.channel_status("srv", channel), + ChannelStatus { state, desired }, + "channel {channel}; observed {:?}", + self.observed + ); + } + + fn submit(&mut self, input: &str) { + self.app.restore_input(input.into()); + let handle = self.handle.as_ref().unwrap(); + let connections = HashMap::from([( + "srv".into(), + ConnectionHandle { + control: handle.control.clone(), + outgoing: handle.outgoing.clone(), + }, + )]); + let effect = submit_composer( + &mut self.app.session, + &self.config, + &connections, + &mut self.status, + ); + self.app.apply_submission_effect(effect); + } + + fn confirm_join(&mut self, channel: &str) { + self.send(&format!(":test!u@h JOIN {channel}\r\n")); + self.wait_channel(channel, ChannelState::Joined, true); + } + + fn reconnect(&mut self) { + self.control(ConnectionCommand::Reconnect); + if let Some(socket) = self.socket.take() { + let _ = socket.get_ref().shutdown(Shutdown::Both); + } + self.accept(); + self.register(); + } +} + +impl Drop for MockSession { + fn drop(&mut self) { + self.handle.take(); + if let Some(socket) = self.socket.take() { + let _ = socket.get_ref().shutdown(Shutdown::Both); + } + if let Some(worker) = self.worker.take() { + let deadline = Instant::now() + WAIT; + while !worker.is_finished() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); + } + if !std::thread::panicking() { + assert!( + worker.is_finished(), + "worker did not stop after handle drop" + ); + worker.join().unwrap(); + } else if worker.is_finished() { + let _ = worker.join(); + } + } + } +} + +fn channel_event(event: &IrcEvent, channel: &str, state: ChannelState, desired: bool) -> bool { + matches!(event, IrcEvent::Channel(server, name, status) + if server == "srv" && name.eq_ignore_ascii_case(channel) + && *status == ChannelStatus { state, desired }) +} + +#[test] +fn configured_channels_seed_intent_but_opening_a_buffer_does_not_join() { + let mut session = MockSession::start(&["#seed"]); + assert_eq!(session.wire(), ["JOIN #seed"]); + session.assert_channel("#SEED", ChannelState::Joining, true); + session.app.open_channel("srv", "#view-only"); + session.assert_channel("#view-only", ChannelState::NotJoined, false); + assert!(session.wire().is_empty()); + assert_eq!(session.config.servers["srv"].channels, ["#seed"]); +} + +#[test] +fn slash_join_waits_for_own_confirmation_before_chat_and_part_keeps_buffer() { + let mut session = MockSession::start(&[]); + session.submit("/join #Live"); + assert_eq!(session.wire(), ["JOIN #Live"]); + session.sync_events(); + session.assert_channel("#live", ChannelState::Joining, true); + assert!(session.observed.contains(&IrcEvent::ChannelControlApplied( + "srv".into(), + "#Live".into() + ))); + let channel = session.app.active_buffer().unwrap().id; + assert_eq!( + session.app.active_buffer().unwrap().kind, + BufferKind::Channel("#Live".into()) + ); + session.submit("not ready"); + assert_eq!(session.app.input(), "not ready"); + assert!(session.app.messages_for(channel).is_empty()); + assert!(session.wire().is_empty()); + session.send(":someone!u@h JOIN #Live\r\n"); + session.sync_events(); + session.assert_channel("#live", ChannelState::Joining, true); + session.confirm_join("#LIVE"); + session.submit("hello channel"); + assert_eq!(session.wire(), ["PRIVMSG #Live :hello channel"]); + assert_eq!(session.app.messages_for(channel).len(), 1); + session.submit("/part #Live leaving for lunch"); + assert_eq!(session.wire(), ["PART #Live :leaving for lunch"]); + session.sync_events(); + session.assert_channel("#live", ChannelState::Parting, false); + session.submit("keep this draft"); + assert_eq!(session.app.input(), "keep this draft"); + assert!(session.wire().is_empty()); + session.send(":test!u@h PART #live :leaving for lunch\r\n"); + session.wait_channel("#live", ChannelState::NotJoined, false); + assert_eq!(session.app.active_buffer().unwrap().id, channel); + assert!(!session.app.buffer(channel).unwrap().hidden); + assert_eq!(session.app.input(), "keep this draft"); + assert_eq!(session.app.messages_for(channel).len(), 1); + assert!(session.config.servers["srv"].channels.is_empty()); +} + +#[test] +fn own_unsolicited_join_creates_buffer_before_first_message_without_stealing_focus() { + let mut session = MockSession::start(&[]); + let console = session.app.active_buffer().unwrap().id; + session.app.restore_input("console draft".into()); + session.send(concat!( + ":other!u@h JOIN #unknown\r\n", + ":other!u@h PRIVMSG #unknown :drop this\r\n", + ":TEST!u@h JOIN #discovered\r\n", + ":Alice!u@h PRIVMSG #DISCOVERED :first message\r\n", + ":Alice!u@h PRIVMSG TEST :private message\r\n", + ":Alice!u@h PRIVMSG stranger :not for us\r\n", + )); + let events = session.sync_events(); + let joined = events + .iter() + .position(|event| channel_event(event, "#discovered", ChannelState::Joined, true)) + .unwrap(); + let first_message = events.iter().position(|event| matches!(event, IrcEvent::Message(message) if message.content.text == "first message")).unwrap(); + assert!(joined < first_message, "{events:?}"); + assert!(!events.iter().any(|event| matches!(event, IrcEvent::Message(message) if matches!(message.content.text.as_str(), "drop this" | "not for us")))); + assert_eq!(session.app.buffer_count(), 3); + let dynamic = session.app.open_channel("srv", "#discovered"); + assert_eq!(session.app.messages_for(dynamic).len(), 1); + assert_eq!(session.app.messages_for(dynamic)[0].text, "first message"); + assert!(events.iter().any(|event| matches!(event, IrcEvent::Message(message) if message.target == BufferKind::Query("Alice".into()) && message.content.text == "private message"))); + assert_eq!(session.app.active_buffer().unwrap().id, console); + assert_eq!(session.app.input(), "console draft"); + assert!(session.wire().is_empty()); +} + +#[test] +fn join_failures_keep_intent_and_route_once_only_for_known_channels() { + let mut session = MockSession::start(&["#seed"]); + assert_eq!(session.wire(), ["JOIN #seed"]); + session.send(":mock 473 test #seed :Invite only\r\n"); + session.wait_channel("#seed", ChannelState::NotJoined, true); + let codes = [403, 405, 407, 437, 471, 473, 474, 475, 476, 477, 489]; + for code in codes { + session.control(ConnectionCommand::Join("#runtime".into())); + assert_eq!(session.wire(), ["JOIN #runtime"]); + session.sync_events(); + session.send(&format!( + ":mock {code} test #RUNTIME :Join denied\r\n:mock {code} test #unknown :Unknown channel\r\n" + )); + let events = session.sync_events(); + assert_eq!( + events + .iter() + .filter(|event| channel_event(event, "#runtime", ChannelState::NotJoined, true)) + .count(), + 1, + "code {code}: {events:?}" + ); + assert!(!events.iter().any(|event| matches!(event, IrcEvent::Channel(_, channel, _) if channel.eq_ignore_ascii_case("#unknown"))), "code {code}: {events:?}"); + let errors: Vec<_> = events + .iter() + .filter_map(|event| match event { + IrcEvent::Message(message) + if matches!(message.content.kind, MessageKind::Error { .. }) => + { + Some(message) + } + _ => None, + }) + .collect(); + assert_eq!(errors.len(), 2, "code {code}: {events:?}"); + assert!( + errors[0] + .target + .matches(&BufferKind::Channel("#runtime".into())) + ); + assert_eq!(errors[1].target, BufferKind::Server); + assert!( + matches!(&errors[0].content.kind, MessageKind::Error { code: actual, target: Some(target), reason } + if *actual == code && target == "#RUNTIME" && reason == "Join denied") + ); + session.assert_channel("#runtime", ChannelState::NotJoined, true); + assert!( + session.wire().is_empty(), + "JOIN failure must not auto-retry" + ); + } + let channel = session.app.open_channel("srv", "#runtime"); + assert_eq!(session.app.messages_for(channel).len(), codes.len()); + assert_eq!(session.app.buffer_count(), 3); +} + +#[test] +fn cannot_send_error_does_not_clear_membership_and_not_on_channel_completes_part() { + let mut session = MockSession::start(&["#seed"]); + assert_eq!(session.wire(), ["JOIN #seed"]); + session.confirm_join("#seed"); + session.send(":mock 404 test #seed :Cannot send to channel\r\n"); + session.sync_events(); + session.assert_channel("#seed", ChannelState::Joined, true); + session.control(ConnectionCommand::Part { + channel: "#seed".into(), + reason: None, + }); + assert_eq!(session.wire(), ["PART #seed"]); + session.send(":mock 442 test #seed :Not on channel\r\n"); + session.wait_channel("#seed", ChannelState::NotJoined, false); + session.reconnect(); + assert!(session.wire().is_empty()); +} + +#[test] +fn join_deadlines_are_independent_and_late_confirmation_resolves_uncertainty() { + let mut session = MockSession::with_timeout(&[], Duration::from_millis(900)); + session.control(ConnectionCommand::Join("#first".into())); + assert_eq!(session.wire(), ["JOIN #first"]); + session.wait_channel("#first", ChannelState::Joining, true); + std::thread::sleep(Duration::from_millis(450)); + session.control(ConnectionCommand::Join("#second".into())); + assert_eq!(session.wire(), ["JOIN #second"]); + session.wait_channel("#second", ChannelState::Joining, true); + let events = session.wait_channel("#first", ChannelState::Uncertain, true); + assert!(!events.iter().any(|event| channel_event( + event, + "#second", + ChannelState::Uncertain, + true + ))); + session.assert_channel("#second", ChannelState::Joining, true); + let first = session.app.open_channel("srv", "#first"); + session.app.activate_buffer(first); + session.submit("no uncertain chat"); + assert_eq!(session.app.input(), "no uncertain chat"); + assert!(session.wire().is_empty()); + session.confirm_join("#first"); + session.assert_channel("#first", ChannelState::Joined, true); + session.wait_channel("#second", ChannelState::Uncertain, true); + session.assert_channel("#first", ChannelState::Joined, true); + assert!( + session.wire().is_empty(), + "timeouts must not automatically repeat JOIN" + ); +} + +#[test] +fn part_deadlines_are_independent_and_timeout_keeps_exit_intent() { + let mut session = MockSession::with_timeout(&["#first", "#second"], Duration::from_millis(900)); + assert_eq!(session.wire(), ["JOIN #first", "JOIN #second"]); + session.confirm_join("#first"); + session.confirm_join("#second"); + session.control(ConnectionCommand::Part { + channel: "#first".into(), + reason: None, + }); + assert_eq!(session.wire(), ["PART #first"]); + session.wait_channel("#first", ChannelState::Parting, false); + std::thread::sleep(Duration::from_millis(450)); + session.control(ConnectionCommand::Part { + channel: "#second".into(), + reason: None, + }); + assert_eq!(session.wire(), ["PART #second"]); + session.wait_channel("#second", ChannelState::Parting, false); + session.wait_channel("#first", ChannelState::Uncertain, false); + session.assert_channel("#second", ChannelState::Parting, false); + session.send(":test!u@h PART #first\r\n"); + session.wait_channel("#first", ChannelState::NotJoined, false); + session.wait_channel("#second", ChannelState::Uncertain, false); + assert!( + session.wire().is_empty(), + "timeouts must not automatically repeat PART" + ); + session.reconnect(); + assert!( + session.wire().is_empty(), + "part timeout must not restore configured intent" + ); +} + +#[test] +fn kick_keeps_intent_without_autoretry_but_unsolicited_own_part_clears_it() { + let mut session = + MockSession::with_timeout(&["#kicked", "#parted"], Duration::from_millis(250)); + assert_eq!(session.wire(), ["JOIN #kicked", "JOIN #parted"]); + session.confirm_join("#kicked"); + session.confirm_join("#parted"); + session.send(concat!( + ":op!u@h KICK #kicked somebody :not us\r\n", + ":op!u@h KICK #unknown test :unknown\r\n", + )); + session.sync_events(); + session.assert_channel("#kicked", ChannelState::Joined, true); + session.send(concat!( + ":op!u@h KICK #kicked TEST :Removed\r\n", + ":test!u@h PART #parted :Leaving\r\n", + )); + session.sync_events(); + session.assert_channel("#kicked", ChannelState::NotJoined, true); + session.assert_channel("#parted", ChannelState::NotJoined, false); + assert_eq!(session.app.buffer_count(), 3); + std::thread::sleep(Duration::from_millis(300)); + assert!(session.wire().is_empty()); + session.reconnect(); + assert_eq!(session.wire(), ["JOIN #kicked"]); +} + +#[test] +fn late_join_after_part_is_countered_by_part_without_restoring_join_intent() { + let mut session = MockSession::with_timeout(&[], Duration::from_millis(250)); + session.control(ConnectionCommand::Join("#late".into())); + assert_eq!(session.wire(), ["JOIN #late"]); + session.wait_channel("#late", ChannelState::Uncertain, true); + session.control(ConnectionCommand::Part { + channel: "#late".into(), + reason: Some("changed mind".into()), + }); + assert_eq!(session.wire(), ["PART #late :changed mind"]); + session.send(":mock 442 test #late :Not on channel\r\n"); + session.wait_channel("#late", ChannelState::NotJoined, false); + session.send(":test!u@h JOIN #late\r\n"); + session.sync_events(); + let lines = session.wire(); + assert_eq!(lines.len(), 1, "late JOIN must be countered: {lines:?}"); + assert!(lines[0] == "PART #late" || lines[0].starts_with("PART #late :")); + session.assert_channel("#late", ChannelState::Parting, false); + session.send(":test!u@h PART #late :Leaving\r\n"); + session.wait_channel("#late", ChannelState::NotJoined, false); + session.reconnect(); + assert!(session.wire().is_empty()); +} + +#[test] +fn reconnect_joins_runtime_intent_once_and_excludes_parted_configuration() { + let mut session = MockSession::start(&["#keep", "#leave"]); + assert_eq!(session.wire(), ["JOIN #keep", "JOIN #leave"]); + session.confirm_join("#keep"); + session.confirm_join("#leave"); + session.control(ConnectionCommand::Join("#runtime".into())); + assert_eq!(session.wire(), ["JOIN #runtime"]); + session.confirm_join("#runtime"); + session.control(ConnectionCommand::Part { + channel: "#leave".into(), + reason: None, + }); + assert_eq!(session.wire(), ["PART #leave"]); + session.send(":test!u@h PART #leave\r\n"); + session.wait_channel("#leave", ChannelState::NotJoined, false); + for reconnect in [false, true] { + if reconnect { + session.reconnect(); + let mut lines = session.wire(); + lines.sort(); + assert_eq!(lines, ["JOIN #keep", "JOIN #runtime"]); + } + session.send(concat!( + ":mock 001 test :Duplicate welcome\r\n", + ":mock 376 test :Duplicate end of MOTD\r\n", + ":mock 422 test :No MOTD\r\n", + ":mock 376 test :End again\r\n", + ":mock 422 test :Still no MOTD\r\n", + )); + session.sync_events(); + assert!( + session.wire().is_empty(), + "registration replies must not repeat JOIN" + ); + } + assert_eq!(session.config.servers["srv"].channels, ["#keep", "#leave"]); +} + +#[test] +fn controls_accepted_across_disconnect_preserve_intent_without_replaying_chat() { + let mut session = MockSession::start(&["#seed"]); + assert_eq!(session.wire(), ["JOIN #seed"]); + session.confirm_join("#seed"); + session.control(ConnectionCommand::Disconnect("pause".into())); + session.control(ConnectionCommand::Join("#queued".into())); + session.control(ConnectionCommand::Part { + channel: "#seed".into(), + reason: None, + }); + session.outgoing(OutgoingMessage::Privmsg { + server: "srv".into(), + target: "Alice".into(), + text: "old chat must not replay".into(), + }); + session.receive_until(|event| { + *event == IrcEvent::Connection("srv".into(), ConnectionState::Stopped) + }); + session.receive_until(|event| matches!(event, IrcEvent::ChannelControlApplied(server, channel) if server == "srv" && channel == "#seed")); + assert!(session.observed.contains(&IrcEvent::ChannelControlApplied( + "srv".into(), + "#queued".into() + ))); + session.control(ConnectionCommand::Connect); + if let Some(socket) = session.socket.take() { + let _ = socket.get_ref().shutdown(Shutdown::Both); + } + session.accept(); + session.register(); + assert_eq!(session.wire(), ["JOIN #queued"]); + session.assert_channel("#seed", ChannelState::NotJoined, false); + session.assert_channel("#queued", ChannelState::Joining, true); +} + +#[test] +fn console_raw_join_and_part_use_managed_state_without_console_echoes() { + let mut session = MockSession::start(&[]); + let console = session.app.active_buffer().unwrap().id; + let history_before = session.app.messages_for(console).len(); + session.submit("jOiN #raw"); + assert_eq!(session.wire(), ["JOIN #raw"]); + assert_eq!(session.app.messages_for(console).len(), history_before); + session.sync_events(); + session.assert_channel("#raw", ChannelState::Joining, true); + session.confirm_join("#raw"); + session.app.activate_buffer(console); + session.submit("pArT #raw :done for now"); + assert_eq!(session.wire(), ["PART #raw :done for now"]); + session.sync_events(); + session.assert_channel("#raw", ChannelState::Parting, false); + session.send(":test!u@h PART #raw :done\r\n"); + session.wait_channel("#raw", ChannelState::NotJoined, false); + session.reconnect(); + assert!(session.wire().is_empty()); +} + +#[test] +fn worker_raw_join_and_part_cannot_bypass_runtime_intent() { + let mut session = MockSession::start(&[]); + session.outgoing(OutgoingMessage::Raw { + server: "srv".into(), + line: "JOIN #direct".into(), + }); + assert_eq!(session.wire(), ["JOIN #direct"]); + session.sync_events(); + session.assert_channel("#direct", ChannelState::Joining, true); + session.confirm_join("#direct"); + session.outgoing(OutgoingMessage::Raw { + server: "srv".into(), + line: "PART #direct :done".into(), + }); + assert_eq!(session.wire(), ["PART #direct done"]); + session.send(":test!u@h PART #direct :done\r\n"); + session.wait_channel("#direct", ChannelState::NotJoined, false); + session.reconnect(); + assert!(session.wire().is_empty()); +} + +#[test] +fn keys_multiple_targets_and_raw_prefixes_are_rejected_without_side_effects() { + let mut session = MockSession::start(&[]); + let console = session.app.active_buffer().unwrap().id; + let history_before = session.app.messages_for(console).len(); + for input in [ + "/join", + "/join #one key", + "/join #one,#two", + "/join 0", + "/join peer", + "/part #one,#two reason", + "JOIN #one key", + "JOIN #one,#two", + "JOIN 0", + "JOIN #one :key", + "PART #one,#two :reason", + ":test JOIN #one", + "@tag=value JOIN #one", + "JOIN #one\r\nJOIN #two", + ] { + session.submit(input); + assert_eq!(session.app.input(), input, "accepted {input:?}"); + assert_eq!(session.app.active_buffer().unwrap().id, console); + assert_eq!( + session.app.buffer_count(), + 1, + "created buffer for {input:?}" + ); + } + assert_eq!(session.app.messages_for(console).len(), history_before); + assert!(session.wire().is_empty()); + for channel in ["", "0", "peer", "#one,#two", "#one key", "#one\r\nQUIT"] { + session.control(ConnectionCommand::Join(channel.into())); + session.control(ConnectionCommand::Part { + channel: channel.into(), + reason: None, + }); + } + session.control(ConnectionCommand::Part { + channel: "#one".into(), + reason: Some("bad\r\nQUIT".into()), + }); + assert!(session.wire().is_empty()); + let events = session.sync_events(); + assert!(!events.iter().any(|event| matches!( + event, + IrcEvent::Channel(..) | IrcEvent::ChannelControlApplied(..) + ))); + assert_eq!(session.app.buffer_count(), 1); +} + +#[test] +fn prioritized_part_prevents_queued_channel_chat_from_leaking_to_the_wire() { + let mut session = MockSession::start(&["#seed"]); + assert_eq!(session.wire(), ["JOIN #seed"]); + session.confirm_join("#seed"); + session.control(ConnectionCommand::Part { + channel: "#seed".into(), + reason: None, + }); + session.outgoing(OutgoingMessage::Privmsg { + server: "srv".into(), + target: "#seed".into(), + text: "queued before the UI saw PART confirmation".into(), + }); + assert_eq!(session.wire(), ["PART #seed"]); + session.wait_channel("#seed", ChannelState::Parting, false); +} + +#[test] +fn ui_join_and_part_are_online_only_while_existing_buffers_and_drafts_survive() { + let mut session = MockSession::start(&["#seed"]); + assert_eq!(session.wire(), ["JOIN #seed"]); + session.confirm_join("#seed"); + session.control(ConnectionCommand::Disconnect("pause".into())); + session.receive_until(|event| { + *event == IrcEvent::Connection("srv".into(), ConnectionState::Stopped) + }); + for input in [ + "/join #offline", + "/part #seed", + "JOIN #offline", + "PART #seed", + ] { + session.submit(input); + assert_eq!(session.app.input(), input); + assert_eq!(session.app.buffer_count(), 2); + } + session.control(ConnectionCommand::Connect); + session.accept(); + session.register(); + assert_eq!(session.wire(), ["JOIN #seed"]); +} diff --git a/tests/mock_irc_integration.rs b/tests/mock_irc_integration.rs index 2d56a45..76345a3 100644 --- a/tests/mock_irc_integration.rs +++ b/tests/mock_irc_integration.rs @@ -12,7 +12,9 @@ use std::time::Duration; use termirc::config::ServerConfig; use termirc::connection::{IrcEvent, spawn_irc}; -use termirc::core::{BufferKind, DeliveryState, MessageKind, OutgoingMessage, ServerId}; +use termirc::core::{ + BufferKind, ChannelState, ChannelStatus, DeliveryState, MessageKind, OutgoingMessage, ServerId, +}; const RECV_TIMEOUT: Duration = Duration::from_secs(5); @@ -72,7 +74,11 @@ fn spawn_mock_server() -> (u16, mpsc::Receiver) { writer.flush().unwrap(); greeted = true; } - if greeted && !joined && trimmed.starts_with("JOIN") { + if greeted && let Some(channel) = trimmed.strip_prefix("JOIN ") { + write!(writer, ":test!u@h JOIN {channel}\r\n").unwrap(); + writer.flush().unwrap(); + } + if greeted && !joined && trimmed == "JOIN #test" { // A names line and a topic line right before the chat, // mirroring real servers (giving the console both a // flood and a channel-scoped reply to resist). @@ -107,6 +113,19 @@ fn collect_client_lines_until( collected } +fn wait_for_confirmed_join(events: &mpsc::Receiver) { + let deadline = std::time::Instant::now() + RECV_TIMEOUT; + loop { + let event = events + .recv_timeout(deadline.saturating_duration_since(std::time::Instant::now())) + .expect("no confirmed JOIN"); + if matches!(event, IrcEvent::Channel(_, channel, ChannelStatus { state: ChannelState::Joined, desired: true }) if channel == "#test") + { + return; + } + } +} + #[test] fn connects_sends_pass_before_nick_and_joins_target_channel() { // Arrange @@ -345,7 +364,11 @@ fn spawn_mock_server_that_closes() -> u16 { writer.flush().unwrap(); } if line.starts_with("JOIN") { - write!(writer, ":alice!a@b PRIVMSG #test :hi\r\n").unwrap(); + write!( + writer, + ":test!u@h JOIN #test\r\n:alice!a@b PRIVMSG #test :hi\r\n" + ) + .unwrap(); writer.flush().unwrap(); break; // drop everything -> clean TCP close } @@ -428,7 +451,7 @@ fn reports_error_when_connection_is_refused() { fn outgoing_message_is_sent_as_privmsg_on_the_wire() { // Arrange: connect to the mock and wait for the JOIN to land. let (port, lines_rx) = spawn_mock_server(); - let (tx, _rx) = mpsc::channel(); + let (tx, rx) = mpsc::channel(); let (_handle, sender) = spawn_irc( server_config_for(port), "osu_irc".to_string(), @@ -436,6 +459,7 @@ fn outgoing_message_is_sent_as_privmsg_on_the_wire() { tx, ); let _ = collect_client_lines_until(&lines_rx, |l| l.starts_with("JOIN")); + wait_for_confirmed_join(&rx); // Act: submit a message through the outgoing channel. sender @@ -523,7 +547,7 @@ fn raw_line_marks_a_colon_last_param_as_trailing() { #[test] fn raw_trailing_parameter_retains_spaces_and_colon_semantics() { let (port, lines_rx) = spawn_mock_server(); - let (tx, _rx) = mpsc::channel(); + let (tx, rx) = mpsc::channel(); let (_worker, sender) = spawn_irc( server_config_for(port), "osu_irc".into(), @@ -531,6 +555,7 @@ fn raw_trailing_parameter_retains_spaces_and_colon_semantics() { tx, ); collect_client_lines_until(&lines_rx, |line| line.starts_with("JOIN")); + wait_for_confirmed_join(&rx); sender .outgoing .blocking_send(OutgoingMessage::Raw { @@ -550,7 +575,7 @@ fn raw_trailing_parameter_retains_spaces_and_colon_semantics() { #[test] fn invalid_direct_queue_messages_are_skipped_without_closing_connection() { let (port, lines_rx) = spawn_mock_server(); - let (tx, _rx) = mpsc::channel(); + let (tx, rx) = mpsc::channel(); let (_worker, sender) = spawn_irc( server_config_for(port), "osu_irc".into(), @@ -558,6 +583,7 @@ fn invalid_direct_queue_messages_are_skipped_without_closing_connection() { tx, ); collect_client_lines_until(&lines_rx, |line| line.starts_with("JOIN")); + wait_for_confirmed_join(&rx); for text in ["中".repeat(512), "bad\r\nPRIVMSG #test :injected".into()] { sender .outgoing diff --git a/tests/query_connection.rs b/tests/query_connection.rs index d09e8a5..8504990 100644 --- a/tests/query_connection.rs +++ b/tests/query_connection.rs @@ -264,6 +264,7 @@ fn private_reply_numerics_preserve_query_and_structured_error_destinations() { #[test] fn outgoing_private_message_reaches_wire_before_any_join_confirmation() { let mut session = MockSession::start(vec!["#pending".into()]); + assert_eq!(session.read_line(), "JOIN #pending\r\n"); session .handle .as_ref()