diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/._rust-chat-server b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/._rust-chat-server similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/._rust-chat-server rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/._rust-chat-server diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/._.DS_Store b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/._.DS_Store similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/._.DS_Store rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/._.DS_Store diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/._.git b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/._.git similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/._.git rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/._.git diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/._.gitignore b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/._.gitignore similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/._.gitignore rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/._.gitignore diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/._.idea b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/._.idea similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/._.idea rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/._.idea diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/._.vscode b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/._.vscode similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/._.vscode rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/._.vscode diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/._Cargo.lock b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/._Cargo.lock similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/._Cargo.lock rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/._Cargo.lock diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/._Cargo.toml b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/._Cargo.toml similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/._Cargo.toml rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/._Cargo.toml diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/._LICENSE b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/._LICENSE similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/._LICENSE rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/._LICENSE diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/._README.md b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/._README.md similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/._README.md rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/._README.md diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/._comms b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/._comms similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/._comms rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/._comms diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/._server b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/._server similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/._server rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/._server diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/._tui b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/._tui similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/._tui rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/._tui diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/.idea/._.gitignore b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/.idea/._.gitignore similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/.idea/._.gitignore rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/.idea/._.gitignore diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/.idea/._modules.xml b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/.idea/._modules.xml similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/.idea/._modules.xml rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/.idea/._modules.xml diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/.idea/._rust-chat-server.iml b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/.idea/._rust-chat-server.iml similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/.idea/._rust-chat-server.iml rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/.idea/._rust-chat-server.iml diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/.idea/._vcs.xml b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/.idea/._vcs.xml similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/.idea/._vcs.xml rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/.idea/._vcs.xml diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/.idea/._workspace.xml b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/.idea/._workspace.xml similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/.idea/._workspace.xml rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/.idea/._workspace.xml diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/.vscode/._settings.json b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/.vscode/._settings.json similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/.vscode/._settings.json rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/.vscode/._settings.json diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/comms/._.gitignore b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/comms/._.gitignore similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/comms/._.gitignore rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/comms/._.gitignore diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/comms/._Cargo.toml b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/comms/._Cargo.toml similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/comms/._Cargo.toml rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/comms/._Cargo.toml diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/comms/._README.md b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/comms/._README.md similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/comms/._README.md rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/comms/._README.md diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/comms/._src b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/comms/._src similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/comms/._src rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/comms/._src diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/comms/._tests b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/comms/._tests similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/comms/._tests rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/comms/._tests diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/comms/src/._command.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/comms/src/._command.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/comms/src/._command.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/comms/src/._command.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/comms/src/._event.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/comms/src/._event.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/comms/src/._event.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/comms/src/._event.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/comms/src/._lib.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/comms/src/._lib.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/comms/src/._lib.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/comms/src/._lib.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/comms/src/._transport b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/comms/src/._transport similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/comms/src/._transport rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/comms/src/._transport diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/comms/src/transport/._client.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/comms/src/transport/._client.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/comms/src/transport/._client.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/comms/src/transport/._client.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/comms/src/transport/._common.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/comms/src/transport/._common.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/comms/src/transport/._common.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/comms/src/transport/._common.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/comms/src/transport/._mod.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/comms/src/transport/._mod.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/comms/src/transport/._mod.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/comms/src/transport/._mod.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/comms/src/transport/._server.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/comms/src/transport/._server.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/comms/src/transport/._server.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/comms/src/transport/._server.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/comms/tests/._e2e_server_and_client_transport.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/comms/tests/._e2e_server_and_client_transport.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/comms/tests/._e2e_server_and_client_transport.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/comms/tests/._e2e_server_and_client_transport.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/._.gitignore b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/._.gitignore similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/._.gitignore rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/._.gitignore diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/._Cargo.toml b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/._Cargo.toml similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/._Cargo.toml rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/._Cargo.toml diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/._README.md b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/._README.md similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/._README.md rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/._README.md diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/._docs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/._docs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/._docs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/._docs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/._examples b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/._examples similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/._examples rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/._examples diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/._resources b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/._resources similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/._resources rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/._resources diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/._src b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/._src similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/._src rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/._src diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/docs/._high-level-architecture.plantuml b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/docs/._high-level-architecture.plantuml similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/docs/._high-level-architecture.plantuml rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/docs/._high-level-architecture.plantuml diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/docs/._high-level-architecture.svg b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/docs/._high-level-architecture.svg similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/docs/._high-level-architecture.svg rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/docs/._high-level-architecture.svg diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/examples/._stress_test.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/examples/._stress_test.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/examples/._stress_test.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/examples/._stress_test.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/resources/._chat_rooms_metadata.json b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/resources/._chat_rooms_metadata.json similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/resources/._chat_rooms_metadata.json rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/resources/._chat_rooms_metadata.json diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/src/._main.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/src/._main.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/src/._main.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/src/._main.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/src/._room_manager b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/src/._room_manager similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/src/._room_manager rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/src/._room_manager diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/src/._session b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/src/._session similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/src/._session rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/src/._session diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/src/room_manager/._mod.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/src/room_manager/._mod.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/src/room_manager/._mod.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/src/room_manager/._mod.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/src/room_manager/._room b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/src/room_manager/._room similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/src/room_manager/._room rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/src/room_manager/._room diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/src/room_manager/._room_manager.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/src/room_manager/._room_manager.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/src/room_manager/._room_manager.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/src/room_manager/._room_manager.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/src/room_manager/room/._chat_room.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/src/room_manager/room/._chat_room.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/src/room_manager/room/._chat_room.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/src/room_manager/room/._chat_room.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/src/room_manager/room/._mod.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/src/room_manager/room/._mod.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/src/room_manager/room/._mod.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/src/room_manager/room/._mod.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/src/room_manager/room/._user_registry.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/src/room_manager/room/._user_registry.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/src/room_manager/room/._user_registry.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/src/room_manager/room/._user_registry.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/src/room_manager/room/._user_session_handle.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/src/room_manager/room/._user_session_handle.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/src/room_manager/room/._user_session_handle.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/src/room_manager/room/._user_session_handle.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/src/session/._chat_session.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/src/session/._chat_session.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/src/session/._chat_session.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/src/session/._chat_session.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/src/session/._mod.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/src/session/._mod.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/server/src/session/._mod.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/server/src/session/._mod.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/._.gitignore b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/._.gitignore similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/._.gitignore rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/._.gitignore diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/._Cargo.toml b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/._Cargo.toml similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/._Cargo.toml rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/._Cargo.toml diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/._README.md b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/._README.md similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/._README.md rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/._README.md diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/._docs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/._docs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/._docs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/._docs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/._src b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/._src similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/._src rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/._src diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/docs/._high-level-architecture.plantuml b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/docs/._high-level-architecture.plantuml similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/docs/._high-level-architecture.plantuml rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/docs/._high-level-architecture.plantuml diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/docs/._high-level-architecture.svg b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/docs/._high-level-architecture.svg similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/docs/._high-level-architecture.svg rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/docs/._high-level-architecture.svg diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/docs/._tui.gif b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/docs/._tui.gif similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/docs/._tui.gif rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/docs/._tui.gif diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/._main.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/._main.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/._main.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/._main.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/._state_store b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/._state_store similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/._state_store rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/._state_store diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/._termination.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/._termination.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/._termination.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/._termination.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/._ui_management b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/._ui_management similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/._ui_management rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/._ui_management diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/state_store/._action.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/state_store/._action.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/state_store/._action.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/state_store/._action.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/state_store/._mod.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/state_store/._mod.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/state_store/._mod.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/state_store/._mod.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/state_store/._state.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/state_store/._state.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/state_store/._state.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/state_store/._state.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/state_store/._state_store.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/state_store/._state_store.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/state_store/._state_store.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/state_store/._state_store.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/._components b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/._components similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/._components rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/._components diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/._mod.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/._mod.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/._mod.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/._mod.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/._pages b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/._pages similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/._pages rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/._pages diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/._ui_manager.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/._ui_manager.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/._ui_manager.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/._ui_manager.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/components/._component.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/components/._component.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/components/._component.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/components/._component.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/components/._input_box.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/components/._input_box.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/components/._input_box.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/components/._input_box.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/components/._mod.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/components/._mod.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/components/._mod.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/components/._mod.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/pages/._chat_page b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/pages/._chat_page similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/pages/._chat_page rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/pages/._chat_page diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/pages/._connect_page b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/pages/._connect_page similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/pages/._connect_page rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/pages/._connect_page diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/pages/._mod.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/pages/._mod.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/pages/._mod.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/pages/._mod.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/._chat_page.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/._chat_page.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/._chat_page.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/._chat_page.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/._components b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/._components similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/._components rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/._components diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/._mod.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/._mod.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/._mod.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/._mod.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/._section b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/._section similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/._section rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/._section diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/components/._message_input_box.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/components/._message_input_box.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/components/._message_input_box.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/components/._message_input_box.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/components/._mod.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/components/._mod.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/components/._mod.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/components/._mod.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/components/._room_list.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/components/._room_list.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/components/._room_list.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/components/._room_list.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/section/._mod.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/section/._mod.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/section/._mod.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/section/._mod.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/section/._usage.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/section/._usage.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/section/._usage.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/pages/chat_page/section/._usage.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/pages/connect_page/._connect_page.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/pages/connect_page/._connect_page.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/pages/connect_page/._connect_page.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/pages/connect_page/._connect_page.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/pages/connect_page/._mod.rs b/spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/pages/connect_page/._mod.rs similarity index 100% rename from spring-2025/sen-109/00010/rust-chat-server/__MACOSX/rust-chat-server/tui/src/ui_management/pages/connect_page/._mod.rs rename to spring-2025/sen-109/00010/rust-chat-server-2/__MACOSX/rust-chat-server/tui/src/ui_management/pages/connect_page/._mod.rs diff --git a/spring-2025/sen-109/00010/rust-chat-server/.devcontainer/devcontainer.json b/spring-2025/sen-109/00010/rust-chat-server/.devcontainer/devcontainer.json new file mode 100644 index 0000000..f8fff10 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/.devcontainer/devcontainer.json @@ -0,0 +1,31 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/rust +{ + "name": "Rust", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/rust:1-1-bullseye" + + // Use 'mounts' to make the cargo cache persistent in a Docker Volume. + // "mounts": [ + // { + // "source": "devcontainer-cargo-cache-${devcontainerId}", + // "target": "/usr/local/cargo", + // "type": "volume" + // } + // ] + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "rustc --version", + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/spring-2025/sen-109/00010/rust-chat-server/.gitignore b/spring-2025/sen-109/00010/rust-chat-server/.gitignore new file mode 100644 index 0000000..ebd3c5a --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/.gitignore @@ -0,0 +1,98 @@ +# Created by https://www.toptal.com/developers/gitignore/api/rust,rust-analyzer,visualstudiocode,macos,vim +# Edit at https://www.toptal.com/developers/gitignore?templates=rust,rust-analyzer,visualstudiocode,macos,vim + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Rust ### +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +### rust-analyzer ### +# Can be generated by other build systems other than cargo (ex: bazelbuild/rust_rules) +rust-project.json + + +### Vim ### +# Swap +[._]*.s[a-v][a-z] +!*.svg # comment out if you don't need vector files +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim +Sessionx.vim + +# Temporary +.netrwhist +*~ +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +# End of https://www.toptal.com/developers/gitignore/api/rust,rust-analyzer,visualstudiocode,macos,vim diff --git a/spring-2025/sen-109/00010/rust-chat-server/.idea/.gitignore b/spring-2025/sen-109/00010/rust-chat-server/.idea/.gitignore new file mode 100644 index 0000000..1c2fda5 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/spring-2025/sen-109/00010/rust-chat-server/.idea/modules.xml b/spring-2025/sen-109/00010/rust-chat-server/.idea/modules.xml new file mode 100644 index 0000000..0fc65da --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/spring-2025/sen-109/00010/rust-chat-server/.idea/rust-chat-server.iml b/spring-2025/sen-109/00010/rust-chat-server/.idea/rust-chat-server.iml new file mode 100644 index 0000000..6da8f22 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/.idea/rust-chat-server.iml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/spring-2025/sen-109/00010/rust-chat-server/.idea/vcs.xml b/spring-2025/sen-109/00010/rust-chat-server/.idea/vcs.xml new file mode 100644 index 0000000..c8397c9 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/spring-2025/sen-109/00010/rust-chat-server/.vscode/settings.json b/spring-2025/sen-109/00010/rust-chat-server/.vscode/settings.json new file mode 100644 index 0000000..7fd1bb3 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "rust-analyzer.cargo.features": ["client", "server"] +} \ No newline at end of file diff --git a/spring-2025/sen-109/00010/rust-chat-server/Cargo.toml b/spring-2025/sen-109/00010/rust-chat-server/Cargo.toml new file mode 100644 index 0000000..b625b15 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/Cargo.toml @@ -0,0 +1,7 @@ +[workspace] +resolver = "2" +members = [ + "comms", + "tui", + "server", +] diff --git a/spring-2025/sen-109/00010/rust-chat-server/LICENSE b/spring-2025/sen-109/00010/rust-chat-server/LICENSE new file mode 100644 index 0000000..0518834 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Yiğitcan UÇUM + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/spring-2025/sen-109/00010/rust-chat-server/README.md b/spring-2025/sen-109/00010/rust-chat-server/README.md new file mode 100644 index 0000000..a15af58 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/README.md @@ -0,0 +1,29 @@ +# Rust Chat Server + +This project serves as a learning exercise in [Rust](https://www.rust-lang.org/), [Tokio](https://tokio.rs/), [Channels](https://tokio.rs/tokio/tutorial/channels), and TUI (Terminal User Interface) programming. It features a room-based chat server with a Terminal User Interface (TUI), utilizing technologies such as Tokio, Ratatui, and a Redux-inspired architecture. + +![TUI Demo](./tui/docs/tui.gif) + +**Note**: This project is not suitable for production use. It's designed strictly for educational purposes. + +## Setup Instructions + +To get the project up and running, follow these steps: + +1. Clone the repository: `git clone git@github.com:Yengas/rust-chat-server.git` +2. Make sure you have [Rust and Cargo](https://www.rust-lang.org/tools/install) installed. +3. Change to the project directory: `cd rust-chat-server` +4. Start the server: `cargo run --bin server` +5. Launch one or more TUI instances: `cargo run --bin tui` + +## Project Overview + +The project utilizes Rust Workspaces to divide itself into three subprojects, each with its own README that details the concepts and architecture. Below is a brief overview: + +- [comms](./comms/): This subproject houses a library crate that provides Events and Commands used for server-client communication. It also offers client/server socket utilities, enabled via feature flags, to assist in serializing and deserializing events and commands. +- [server](./server/): Built on the [Tokio Runtime](https://tokio.rs/) and using [Tokio Channels](https://tokio.rs/tokio/tutorial/channels), this subproject implements a single-instance chat server that manages room states and user participation. +- [tui](./tui/): Leveraging [Ratatui](https://github.com/ratatui-org/ratatui), this subproject implements a terminal-based user interface. Users can connect to a chat server, join rooms, and send/receive messages. The code follows a Redux-inspired structure to separate state management from TUI rendering. + +## License + +The project is distributed under the [MIT License](./LICENSE). diff --git a/spring-2025/sen-109/00010/rust-chat-server/comms/.gitignore b/spring-2025/sen-109/00010/rust-chat-server/comms/.gitignore new file mode 100644 index 0000000..9d08e3f --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/comms/.gitignore @@ -0,0 +1,2 @@ +/target +/Cargo.lock diff --git a/spring-2025/sen-109/00010/rust-chat-server/comms/Cargo.toml b/spring-2025/sen-109/00010/rust-chat-server/comms/Cargo.toml new file mode 100644 index 0000000..09e5182 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/comms/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "comms" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[features] +default = [] +client = ["serde_json", "tokio", "tokio-stream"] +server = ["serde_json", "tokio", "tokio-stream"] + +[dependencies] +anyhow = "1" +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", optional = true } +tokio = { version = "1.43.0", default-features = false, features = ["net"], optional = true } +tokio-stream = { version = "0.1.17", default-features = false, features = ["io-util"], optional = true } + +[dev-dependencies] +serde_json = "1.0" +tokio = { version = "1.43.0", features = ["full"] } +tokio-stream = { version = "0.1.17" } diff --git a/spring-2025/sen-109/00010/rust-chat-server/comms/README.md b/spring-2025/sen-109/00010/rust-chat-server/comms/README.md new file mode 100644 index 0000000..971a7b2 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/comms/README.md @@ -0,0 +1,66 @@ +# Comms Library + +The `comms` library, short for "communications," serves as an auxiliary module for the [rust-chat-server](../) project. It provides definitions and utilities for handling events and commands. + +## Features + +- Definitions and documentation for [events](./src/event.rs) and [commands](./src/command.rs) utilized by the [rust-chat-server](../). +- TCP transport support for both **events** and **commands**. + - [`comms::transport::client`](./src/transport/client.rs) assists in splitting a [tokio::net::TcpStream](https://docs.rs/tokio/latest/tokio/net/struct.TcpStream.html) into an **EventStream** and a **CommandWriter**. + - [`comms::transport::server`](./src/transport/server.rs) enables the partitioning of a [tokio::net::TcpStream](https://docs.rs/tokio/latest/tokio/net/struct.TcpStream.html) into a **CommandStream** and an **EventWriter**. + +## Example Usage + +Execute the e2e test for client and server with the following command: `cargo test --features="client,server"` + +[This e2e test](./tests/e2e_server_and_client_transport.rs) spawns a server and a client. The server accepts one client, sends it an event, and listens for commands until the connection is closed. Conversely, the client receives one event, sends two commands, and then terminates its connection. + +Here's a simplified pseudocode version of the [e2e test code](./tests/e2e_server_and_client_transport.rs): + +```rust +// full e2e test code: src/tests/e2e_server_and_client_transport.rs + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tokio::try_join!(server_example(), client_example())?; + // All examples succeed if this line is reached. + Ok(()) +} + +async fn server_example() -> anyhow::Result<()> { + let listener = /* Create a TcpListener */; + let tcp_stream = /* Accept a single client from `listener` */; + // Use comms::transport to elevate the TcpStream to a higher-level API. + let (mut command_stream, mut event_writer) = transport::server::split_tcp_stream(tcp_stream); + + event_writer.write(/* Login Successful Event */).await?; + + // Loop to read and print commands until the client closes the connection. + while let Some(result) = command_stream.next().await { + match result { + Ok(command) => println!("SERVER: Command received: {:?}", command), + Err(e) => println!("SERVER: Failed to read command: {}", e), + } + } + + Ok(()) +} + +async fn client_example() -> anyhow::Result<()> { + let tcp_stream = /* Connect to the server */; + // Use comms::transport to elevate the TcpStream to a higher-level API. + let (mut event_stream, mut command_writer) = transport::client::split_tcp_stream(tcp_stream); + + // Read and print a single event. + match event_stream.next().await { + Some(Ok(event)) => println!("CLIENT: Event received: {:?}", event), + Some(Err(e)) => println!("CLIENT: Failed to read event: {}", e), + None => return Err(anyhow::anyhow!("Server closed the connection")), + } + + command_writer.write(/* Join Room Command */).await?; + command_writer.write(/* Send Message Command */).await?; + + Ok(()) +} +``` \ No newline at end of file diff --git a/spring-2025/sen-109/00010/rust-chat-server/comms/src/command.rs b/spring-2025/sen-109/00010/rust-chat-server/comms/src/command.rs new file mode 100644 index 0000000..82542fd --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/comms/src/command.rs @@ -0,0 +1,95 @@ +use serde::{Deserialize, Serialize}; + +/// User Command for joining a room. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct JoinRoomCommand { + // The room to join. + #[serde(rename = "r")] + pub room: String, +} + +/// User Command for leaving a room. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LeaveRoomCommand { + // The room to leave. + #[serde(rename = "r")] + pub room: String, +} + +/// User Command for sending a message. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SendMessageCommand { + // The room to send the message to. + #[serde(rename = "r")] + pub room: String, + // The content of the message. + #[serde(rename = "c")] + pub content: String, +} + +pub struct GetHistory { + +} + +/// User Command for quitting the whole chat session. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct QuitCommand; + +/// A user command which can be sent to the server by a single user session. +/// All commands are processed in the context of the chat server paired with an individual user session. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "_ct", rename_all = "snake_case")] +pub enum UserCommand { + JoinRoom(JoinRoomCommand), + LeaveRoom(LeaveRoomCommand), + SendMessage(SendMessageCommand), + Quit(QuitCommand), +} + +#[cfg(test)] +mod tests { + use super::*; + + // given a command enum, and an expect string, asserts that command is serialized / deserialized appropriately + fn assert_command_serialization(command: &UserCommand, expected: &str) { + let serialized = serde_json::to_string(&command).unwrap(); + assert_eq!(serialized, expected); + let deserialized: UserCommand = serde_json::from_str(&serialized).unwrap(); + assert_eq!(deserialized, *command); + } + + #[test] + fn test_join_command() { + let command = UserCommand::JoinRoom(JoinRoomCommand { + room: "test".to_string(), + }); + + assert_command_serialization(&command, r#"{"_ct":"join_room","r":"test"}"#); + } + + #[test] + fn test_leave_command() { + let command = UserCommand::LeaveRoom(LeaveRoomCommand { + room: "test".to_string(), + }); + + assert_command_serialization(&command, r#"{"_ct":"leave_room","r":"test"}"#); + } + + #[test] + fn test_message_command() { + let command = UserCommand::SendMessage(SendMessageCommand { + room: "test".to_string(), + content: "test".to_string(), + }); + + assert_command_serialization(&command, r#"{"_ct":"send_message","r":"test","c":"test"}"#); + } + + #[test] + fn test_quit_command() { + let command = UserCommand::Quit(QuitCommand); + + assert_command_serialization(&command, r#"{"_ct":"quit"}"#); + } +} diff --git a/spring-2025/sen-109/00010/rust-chat-server/comms/src/event.rs b/spring-2025/sen-109/00010/rust-chat-server/comms/src/event.rs new file mode 100644 index 0000000..5e45c0b --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/comms/src/event.rs @@ -0,0 +1,169 @@ +use serde::{Deserialize, Serialize}; + +/// The detail of a given room +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RoomDetail { + /// The slug of the room + #[serde(rename = "n")] + pub name: String, + /// The description of the room + #[serde(rename = "d")] + pub description: String, +} + +/// A user has successfully logged in +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LoginSuccessfulReplyEvent { + /// The session id for the connection + #[serde(rename = "s")] + pub session_id: String, + /// The id of the user that has logged in + #[serde(rename = "u")] + pub user_id: String, + /// The list of rooms the user can participate, unique and ordered + #[serde(rename = "rs")] + pub rooms: Vec, +} + +/// Users new room participation status +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RoomParticipationStatus { + Joined, + Left, +} + +/// A user has joined or left a room +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RoomParticipationBroadcastEvent { + /// The slug of the room the user has joined or left + #[serde(rename = "r")] + pub room: String, + /// The id of the user that has joined or left + #[serde(rename = "u")] + pub user_id: String, + /// The new status of the user in the room + #[serde(rename = "s")] + pub status: RoomParticipationStatus, +} + +/// A reply to the user when they have joined a room +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct UserJoinedRoomReplyEvent { + /// The slug of the room the user has joined + #[serde(rename = "r")] + pub room: String, + /// The users currently in the room, unique and ordered + #[serde(rename = "us")] + pub users: Vec, +} + +/// A user has sent a message to a room +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct UserMessageBroadcastEvent { + /// The slug of the room the user has sent the message to + #[serde(rename = "r")] + pub room: String, + /// The id of the user that has sent the message + #[serde(rename = "u")] + pub user_id: String, + /// The content of the message + #[serde(rename = "c")] + pub content: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "_et", rename_all = "snake_case")] +/// Events that can be sent to the client +/// Events maybe related to different users and rooms, the recipient is a single chat session +pub enum Event { + LoginSuccessful(LoginSuccessfulReplyEvent), + RoomParticipation(RoomParticipationBroadcastEvent), + UserJoinedRoom(UserJoinedRoomReplyEvent), + UserMessage(UserMessageBroadcastEvent), +} + +#[cfg(test)] +mod tests { + use super::*; + + // given an event enum, and an expect string, asserts that event is serialized / deserialized appropriately + fn assert_event_serialization(event: &Event, expected: &str) { + let serialized = serde_json::to_string(&event).unwrap(); + assert_eq!(serialized, expected); + let deserialized: Event = serde_json::from_str(&serialized).unwrap(); + assert_eq!(deserialized, *event); + } + + #[test] + fn test_login_successful_event() { + let event = Event::LoginSuccessful(LoginSuccessfulReplyEvent { + session_id: "session-id-1".to_string(), + user_id: "user-id-1".to_string(), + rooms: vec![RoomDetail { + name: "room-1".to_string(), + description: "some description".to_string(), + }], + }); + + assert_event_serialization( + &event, + r#"{"_et":"login_successful","s":"session-id-1","u":"user-id-1","rs":[{"n":"room-1","d":"some description"}]}"#, + ); + } + + #[test] + fn test_room_participation_join_event() { + let event = Event::RoomParticipation(RoomParticipationBroadcastEvent { + room: "test".to_string(), + user_id: "test".to_string(), + status: RoomParticipationStatus::Joined, + }); + + assert_event_serialization( + &event, + r#"{"_et":"room_participation","r":"test","u":"test","s":"joined"}"#, + ); + } + + #[test] + fn test_room_participation_leave_event() { + let event = Event::RoomParticipation(RoomParticipationBroadcastEvent { + room: "test".to_string(), + user_id: "test".to_string(), + status: RoomParticipationStatus::Left, + }); + + assert_event_serialization( + &event, + r#"{"_et":"room_participation","r":"test","u":"test","s":"left"}"#, + ); + } + + #[test] + fn test_user_joined_room_event() { + let event = Event::UserJoinedRoom(UserJoinedRoomReplyEvent { + room: "test".to_string(), + users: vec!["test".to_string()], + }); + + assert_event_serialization( + &event, + r#"{"_et":"user_joined_room","r":"test","us":["test"]}"#, + ); + } + + #[test] + fn test_user_message_event() { + let event = Event::UserMessage(UserMessageBroadcastEvent { + room: "test".to_string(), + user_id: "test".to_string(), + content: "test".to_string(), + }); + + assert_event_serialization( + &event, + r#"{"_et":"user_message","r":"test","u":"test","c":"test"}"#, + ); + } +} diff --git a/spring-2025/sen-109/00010/rust-chat-server/comms/src/lib.rs b/spring-2025/sen-109/00010/rust-chat-server/comms/src/lib.rs new file mode 100644 index 0000000..8e11d06 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/comms/src/lib.rs @@ -0,0 +1,7 @@ +/// Set of commands which the server can receive and process +pub mod command; +/// Set of events split into Broadcast and Reply events according to their source +pub mod event; +/// Implementation of event and command transportation over TCP Streams. +/// Requires 'server' or 'client' features to be enabled and will bring in tokio dependency alongside with other dependencies +pub mod transport; diff --git a/spring-2025/sen-109/00010/rust-chat-server/comms/src/transport/client.rs b/spring-2025/sen-109/00010/rust-chat-server/comms/src/transport/client.rs new file mode 100644 index 0000000..193a7b9 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/comms/src/transport/client.rs @@ -0,0 +1,69 @@ +use anyhow::Context; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + net::{tcp::OwnedWriteHalf, TcpStream}, +}; +use tokio_stream::{wrappers::LinesStream, StreamExt}; + +use crate::{command, event}; + +use super::common::{BoxedStream, NEW_LINE}; + +/// [EventStream] is a stream of [event::Event]s sent by the server +/// +/// # Cancel Safety +/// +/// This stream is cancel-safe, meaning that it can be used in [tokio::select] +/// without the risk of missing events. +pub type EventStream = BoxedStream>; + +/// [CommandWriter] is a wrapper around a [TcpStream] which writes [command::UserCommand]s to the server +pub struct CommandWriter { + writer: OwnedWriteHalf, +} + +impl CommandWriter { + pub fn new(writer: OwnedWriteHalf) -> Self { + Self { writer } + } + + /// Send a [command::UserCommand] to the backing [TcpStream] + /// + /// # Cancel Safety + /// + /// This method is not cancellation safe. If it is used as the event + /// in a [tokio::select!] statement and some other + /// branch completes first, then the provided [command::UserCommand] may have been + /// partially written, but future calls to `write` will start over + /// from the beginning of the buffer. Causing undefined behaviour. + pub async fn write(&mut self, command: &command::UserCommand) -> anyhow::Result<()> { + let mut serialized_bytes = serde_json::to_vec(command)?; + serialized_bytes.extend_from_slice(NEW_LINE); + + self.writer.write_all(serialized_bytes.as_slice()).await?; + + Ok(()) + } +} + +/// Splits a TCP stream into a stream of events and a command writer. +/// +/// # Arguments +/// +/// - `stream` - A [TcpStream] to split +pub fn split_tcp_stream(stream: TcpStream) -> (EventStream, CommandWriter) { + let (reader, writer) = stream.into_split(); + + ( + Box::pin( + LinesStream::new(BufReader::new(reader).lines()).map(|line| { + line.context("could not read line from the server") + .and_then(|line| { + serde_json::from_str::(&line) + .context("failed to deserialize event from the server") + }) + }), + ), + CommandWriter::new(writer), + ) +} diff --git a/spring-2025/sen-109/00010/rust-chat-server/comms/src/transport/common.rs b/spring-2025/sen-109/00010/rust-chat-server/comms/src/transport/common.rs new file mode 100644 index 0000000..4168d97 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/comms/src/transport/common.rs @@ -0,0 +1,7 @@ +use std::pin::Pin; + +use tokio_stream::Stream; + +pub const NEW_LINE: &[u8; 2] = b"\r\n"; + +pub type BoxedStream = Pin + Send>>; diff --git a/spring-2025/sen-109/00010/rust-chat-server/comms/src/transport/mod.rs b/spring-2025/sen-109/00010/rust-chat-server/comms/src/transport/mod.rs new file mode 100644 index 0000000..531fdec --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/comms/src/transport/mod.rs @@ -0,0 +1,8 @@ +/// Transport over TCP implementation for a client to be able to interact with the server +#[cfg(feature = "client")] +pub mod client; +#[cfg(any(feature = "client", feature = "server"))] +mod common; +/// Transport over TCP implementation for a server to interact with a single client TCP Stream +#[cfg(feature = "server")] +pub mod server; diff --git a/spring-2025/sen-109/00010/rust-chat-server/comms/src/transport/server.rs b/spring-2025/sen-109/00010/rust-chat-server/comms/src/transport/server.rs new file mode 100644 index 0000000..b8d3c64 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/comms/src/transport/server.rs @@ -0,0 +1,69 @@ +use anyhow::Context; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + net::{tcp::OwnedWriteHalf, TcpStream}, +}; +use tokio_stream::{wrappers::LinesStream, StreamExt}; + +use crate::{command, event}; + +use super::common::{BoxedStream, NEW_LINE}; + +/// [CommandStream] is a stream of [command::UserCommand]s sent by the client +/// +/// # Cancel Safety +/// +/// This stream is cancel-safe, meaning that it can be used in [tokio::select!] +/// without the risk of missing commands. +pub type CommandStream = BoxedStream>; + +/// [EventWriter] is a wrapper around a [TcpStream] which writes [event::Event]s to the client +pub struct EventWriter { + writer: OwnedWriteHalf, +} + +impl EventWriter { + pub fn new(writer: OwnedWriteHalf) -> Self { + Self { writer } + } + + /// Send a [event::Event] to the backing [TcpStream] + /// + /// # Cancel Safety + /// + /// This method is not cancellation safe. If it is used as the event + /// in a [tokio::select!] statement and some other + /// branch completes first, then the provided [event::Event] may have been + /// partially written, but future calls to `write` will start over + /// from the beginning of the buffer. Causing undefined behaviour. + pub async fn write(&mut self, event: &event::Event) -> anyhow::Result<()> { + let mut serialized_bytes = serde_json::to_vec(event)?; + serialized_bytes.extend_from_slice(NEW_LINE); + + self.writer.write_all(serialized_bytes.as_slice()).await?; + + Ok(()) + } +} + +/// Splits a TCP stream into a stream of commands and an event writer. +/// +/// # Arguments +/// +/// - `stream` - A [TcpStream] to split +pub fn split_tcp_stream(stream: TcpStream) -> (CommandStream, EventWriter) { + let (reader, writer) = stream.into_split(); + + ( + Box::pin( + LinesStream::new(BufReader::new(reader).lines()).map(|line| { + line.context("could not read line from the client") + .and_then(|line| { + serde_json::from_str::(&line) + .context("failed to deserialize command from client") + }) + }), + ), + EventWriter::new(writer), + ) +} diff --git a/spring-2025/sen-109/00010/rust-chat-server/comms/tests/e2e_server_and_client_transport.rs b/spring-2025/sen-109/00010/rust-chat-server/comms/tests/e2e_server_and_client_transport.rs new file mode 100644 index 0000000..f47c957 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/comms/tests/e2e_server_and_client_transport.rs @@ -0,0 +1,120 @@ +use comms::{ + command::{self, UserCommand}, + event::{self, Event}, + transport, +}; +use tokio::net::{TcpListener, TcpStream}; +use tokio_stream::StreamExt; + +const PORT: usize = 8081; + +#[tokio::test] +async fn assert_server_client_transport() { + let (server_collected_commands, client_collected_events) = + tokio::join!(execute_server(), execute_client()); + + assert!(server_collected_commands.is_ok()); + assert!(client_collected_events.is_ok()); + + assert_eq!( + server_collected_commands.unwrap(), + vec![ + UserCommand::JoinRoom(command::JoinRoomCommand { + room: "room-1".into(), + }), + UserCommand::SendMessage(command::SendMessageCommand { + room: "room-1".into(), + content: "content-1".into(), + }), + ] + ); + + assert_eq!( + client_collected_events.unwrap(), + vec![Event::LoginSuccessful(event::LoginSuccessfulReplyEvent { + user_id: "user-id-1".into(), + session_id: "session-id-1".into(), + rooms: Vec::default(), + }),] + ); +} + +async fn execute_server() -> anyhow::Result> { + // bind to the example port to wait for client connection + let listener = TcpListener::bind(format!("0.0.0.0:{}", PORT)) + .await + .expect("could not bind to the port"); + + // accept the only client connection we will have + let tcp_stream = match listener.accept().await { + Ok((tcp_stream, _addr)) => tcp_stream, + Err(e) => return Err(anyhow::anyhow!("failed to accept client: {}", e)), + }; + + // break the client connection into higher level API for ease of use + let (mut command_stream, mut event_writer) = transport::server::split_tcp_stream(tcp_stream); + // store commands received from the client + let mut collected_commands = Vec::new(); + + // welcome the user with some login successful reply event + event_writer + .write(&Event::LoginSuccessful(event::LoginSuccessfulReplyEvent { + user_id: "user-id-1".into(), + session_id: "session-id-1".into(), + rooms: Vec::default(), + })) + .await?; + + // listen for commands from the client until the connection is closed + while let Some(result) = command_stream.next().await { + match result { + // client has sent a valid command which we could read and parse + Ok(command) => collected_commands.push(command), + // client has sent a command which we could not read or parse + // could be a bug in the client, malicious client, breaking api changes etc. + Err(e) => return Err(anyhow::anyhow!("failed to read command: {}", e)), + } + } + + Ok(collected_commands) +} + +async fn execute_client() -> anyhow::Result> { + // create a client connection to the server + let tcp_stream = match TcpStream::connect(format!("localhost:{}", PORT)).await { + Ok(tcp_stream) => tcp_stream, + Err(e) => return Err(anyhow::anyhow!("failed to connect to server: {}", e)), + }; + + // break the server connection into higher level API for ease of use + let (mut event_stream, mut command_writer) = transport::client::split_tcp_stream(tcp_stream); + // store events received from the server + let mut collected_events = Vec::new(); + + // read the welcome event from the server + match event_stream.next().await { + // server has sent a valid event which we could read and parse + Some(Ok(event)) => collected_events.push(event), + // server has sent an event which we could not read or parse + // could be a bug in the server, malicious server, breaking api changes etc. + Some(Err(e)) => return Err(anyhow::anyhow!("could not parse event: {}", e)), + // server has closed the connection, return an error + None => return Err(anyhow::anyhow!("server closed the connection")), + } + + // send some commands to the server + command_writer + .write(&UserCommand::JoinRoom(command::JoinRoomCommand { + room: "room-1".into(), + })) + .await?; + + command_writer + .write(&UserCommand::SendMessage(command::SendMessageCommand { + room: "room-1".into(), + content: "content-1".into(), + })) + .await?; + + Ok(collected_events) +} diff --git a/spring-2025/sen-109/00010/rust-chat-server/rust-chat-server b/spring-2025/sen-109/00010/rust-chat-server/rust-chat-server deleted file mode 160000 index 12b8850..0000000 --- a/spring-2025/sen-109/00010/rust-chat-server/rust-chat-server +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 12b88508ce69530bdebfef69d641e222842e93b1 diff --git a/spring-2025/sen-109/00010/rust-chat-server/server/.gitignore b/spring-2025/sen-109/00010/rust-chat-server/server/.gitignore new file mode 100644 index 0000000..0b42d2d --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/server/.gitignore @@ -0,0 +1 @@ +/target diff --git a/spring-2025/sen-109/00010/rust-chat-server/server/Cargo.toml b/spring-2025/sen-109/00010/rust-chat-server/server/Cargo.toml new file mode 100644 index 0000000..98c09ba --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/server/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "server" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = "1.0.75" +comms = { path = "../comms", features = ["server"] } +nanoid = "0.4.0" +serde = "1.0" +serde_json = "1.0" +tokio = { version = "1.43.0", features = ["full"] } +tokio-stream = { version = "0.1.17" } + +[dev-dependencies] +comms = { path = "../comms", features = ["client"] } +rand = "0.8.5" diff --git a/spring-2025/sen-109/00010/rust-chat-server/server/README.md b/spring-2025/sen-109/00010/rust-chat-server/server/README.md new file mode 100644 index 0000000..7417a9a --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/server/README.md @@ -0,0 +1,61 @@ +# Rust Chat Server - Server Binary + +The `server` binary is the backbone of the [rust-chat-server](../). It establishes a TCP server, listening for events and commands through our [comms library](../comms). + +--- + +## 🛠 Technical Overview + +- **Async I/O**: Utilizes [Tokio Runtime](https://tokio.rs/) and [Tokio Streams](https://tokio.rs/tokio/tutorial/streams) for asynchronous, non-blocking I/O. +- **Actor-like Model**: Uses [Tokio Channels](https://tokio.rs/tokio/tutorial/channels) for an actor-inspired, lightweight architecture. +- **Chat Rooms**: File-based (JSON) chat room definitions in the [resources/](./resources/chat_rooms_metadata.json) folder. + +## 🏗 High-Level Architecture + +![High Level Architecture Diagram](./docs/high-level-architecture.svg) + +1. **Bootstrap**: Reads from [resources/](./resources/chat_rooms_metadata.json) to initialize chat rooms. +2. **Server Start**: Handles a variable number of concurrent users. For a terminal-based client, see the [tui project](../tui/). + - **Commands**: Join, leave rooms or send room-specific messages. +3. **ChatSession**: Manages individual user commands and room subscriptions. + - Joins rooms via interaction with `RoomManager`, receiving a `broadcast::Receiver` and a `UserSessionHandle`. + - On room exit, `UserSessionHandle` is returned to `RoomManager`. +4. **Messaging**: Maintains an in-memory list of `UserSessionHandle`s for room messaging. + - Tasks are created to unify messages from different rooms into a single `mpsc::Receiver`. +5. **User Output**: Unified events are sent to the user through the TCP socket. + +## 🚀 Getting Started + +Run the server with `cargo run` or `cargo run --bin server` according to your working directory. Defaults to port `:8080`. Any bootstrap issues will result in an application exiting with error. + +## 🧪 Stress Testing + +- **Example**: Check [stress_test](./examples/stress_test.rs) in the examples directory. +- 🚨 **Socket Limits**: Ensure both server and stress test socket limits are configured for high user volumes. + +Run the stress test with `cargo run --example stress_test`. + +### 📈 Stress Test Outcomes + +> 🚫 No rigorous load testing was conducted, but several preliminary tests were done. + +Using a $4/month [DigitalOcean](https://www.digitalocean.com/pricing/droplets) Droplet, a test was run with 48 users (2 per room), each sending 200 messages per second. This resulted in **576k RPM Write** and **1.152M RPM Read**. + +CPU reached 100% utilization without any significant lag or memory impact. When the load was reduced by 50%, CPU utilization decreased to 56-60%. With higher loads, there were delivery lags. + +On an Apple Silicone M2 Pro, the system could easily handle 10k concurrent users with a lower message rate. + +## 📈 Scaling Further + +The server is currently optimized for vertical scaling by making full use of multiple cores. However, it can only scale so far within a single instance, bound by the hardware or code optimization limits. + +To truly scale horizontally, several strategies can be employed: + +1. **Sharding Rooms**: Distribute chat rooms among multiple server instances, directing users to the correct instance based on their room selection. +2. **Fan-Out Queue**: Incorporate a fan-out message queue architecture where each server instance consumes and broadcasts messages to its connected users. + +Your choice will depend on specific requirements: + +- **Limited Users, Multiple Rooms**: Option #1 is ideal for a Discord-like architecture with multiple rooms but limited users per server (e.g., up to 1,000). +- **Global Rooms, High Concurrency**: For a setup where room lists are global and user counts are high, option #2 offers better scalability. +- **High Volume, Hybrid Approach**: If you expect both high user counts and multiple servers, a hybrid approach of options #1 and #2 would provide the greatest scalability. diff --git a/spring-2025/sen-109/00010/rust-chat-server/server/docs/high-level-architecture.plantuml b/spring-2025/sen-109/00010/rust-chat-server/server/docs/high-level-architecture.plantuml new file mode 100644 index 0000000..f4f9c27 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/server/docs/high-level-architecture.plantuml @@ -0,0 +1,39 @@ +@startuml High Level Architecture - Diagram + +skinparam linetype orthogonally +skinparam orthogonalSpace 30 + +together { + entity "Bootstrap" as bootstrap <> + actor User as user +} + +together { + package "Room Manager" as rm_p { + package "Room" { + component "Chat Room" as chat_room + } + + component "Room Manager" as room_manager + } + + package "Session" as s_p { + component "Chat Session" as chat_session + } +} + +bootstrap -[hidden]d-> user +bootstrap -[hidden]r-> rm_p +user -[hidden]r-> s_p +rm_p -[hidden]d-> s_p + +bootstrap -> rm_p: 1. Setup Each Room and\nRoom Manager Globally +user -> s_p: 2. Connect to Server\nand send Commands + +chat_session -u-> room_manager: 3. Join/Leave Rooms per User Request,\n store and manage the **UserSessionHandle**\n returned for each room +room_manager -> chat_room: 3.1. Join/Leave Room utilizing\n the **UserSessionHandle** +chat_session -> chat_session: 4. Handle Message Sending and\nCreate Unified Event Stream\nof dynamic number of joined rooms + +user <- s_p: 5. Send Events + +@enduml \ No newline at end of file diff --git a/spring-2025/sen-109/00010/rust-chat-server/server/docs/high-level-architecture.svg b/spring-2025/sen-109/00010/rust-chat-server/server/docs/high-level-architecture.svg new file mode 100644 index 0000000..1214b49 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/server/docs/high-level-architecture.svg @@ -0,0 +1,67 @@ +Room ManagerRoomSessionBootstrap«Bootstrap»UserRoom ManagerChat RoomChat Session1. Setup Each Room andRoom Manager Globally2. Connect to Serverand send Commands5. Send Events3. Join/Leave Rooms per User Request,store and manage theUserSessionHandlereturned for each room3.1. Join/Leave Room utilizingtheUserSessionHandle4. Handle Message Sending andCreate Unified Event Streamof dynamic number of joined rooms \ No newline at end of file diff --git a/spring-2025/sen-109/00010/rust-chat-server/server/examples/stress_test.rs b/spring-2025/sen-109/00010/rust-chat-server/server/examples/stress_test.rs new file mode 100644 index 0000000..289f1f5 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/server/examples/stress_test.rs @@ -0,0 +1,168 @@ +use std::time::Duration; + +use comms::{ + command::{JoinRoomCommand, UserCommand}, + event::Event, + transport, +}; +use nanoid::nanoid; +use rand::{rngs::StdRng, Rng, SeedableRng}; +use serde::{Deserialize, Serialize}; +use tokio::{net::TcpStream, task::JoinSet}; +use tokio_stream::StreamExt; + +/// Stress Test for the Chat Server +/// +/// Generates synthetic load with users who joins and sends messages to random roms. +/// The number of users, number of rooms joined per user and chatting of users can be configured. +/// +/// !IMPORTANT! Be sure to check and configure your socket limits, before you run the tests + +const SERVER_ADDR: &str = "localhost:8080"; +const CHAT_ROOMS_METADATA: &str = include_str!("../resources/chat_rooms_metadata.json"); + +/// Stress Test Configuration +// The number of users to spawn, distributed across the load increments +const LOAD_INCREMENTS: &str = r#"[ + { "user_count": 1200, "after": { "secs": 60, "nanos": 0 }, "steps": 60 }, + { "user_count": 2400, "after": { "secs": 120, "nanos": 0 }, "steps": 60 } +]"#; +// How many rooms a user should join, this affects the total tokio task count +const NUMBER_OF_ROOMS_TO_JOIN: usize = 5; +// How many milliseconds to wait between each user message +const USER_CHAT_DELAY_MILLIS: u64 = 10_000; + +/// [RotatingIterator] is a simple iterator that rotates through a list of items +/// and starts from the beginning when the end is reached. +struct RotatingIterator { + items: Vec, + current: usize, +} + +impl RotatingIterator { + fn new(items: Vec) -> Self { + Self { items, current: 0 } + } +} + +impl Iterator for RotatingIterator { + type Item = T; + + fn next(&mut self) -> Option { + let item = self.items.get(self.current).cloned(); + self.current = (self.current + 1) % self.items.len(); + item + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ChatRoomMetadata { + name: String, + description: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct LoadIncrements { + user_count: usize, + after: Duration, + steps: usize, +} + +async fn spawn_single_user(rooms_to_join: Vec) -> anyhow::Result<()> { + let result = spawn_single_user_raw(rooms_to_join).await; + + match result.as_ref() { + Ok(_) => println!("exited without problems"), + Err(err) => println!("some error occurred = {}", err.to_string()), + } + + result +} + +async fn spawn_single_user_raw(rooms_to_join: Vec) -> anyhow::Result<()> { + let tcp_stream = TcpStream::connect(SERVER_ADDR).await?; + let (mut event_stream, mut command_writer) = transport::client::split_tcp_stream(tcp_stream); + + let _login_event = match event_stream.next().await { + Some(Ok(Event::LoginSuccessful(login_event))) => login_event, + _ => return Err(anyhow::anyhow!("server did not send login successful")), + }; + + for room_name in rooms_to_join.iter() { + command_writer + .write(&UserCommand::JoinRoom(JoinRoomCommand { + room: String::from(room_name), + })) + .await?; + } + + let join_handle = tokio::spawn({ + let mut rng = StdRng::from_entropy(); + let mut rooms_iterator = RotatingIterator::new(rooms_to_join); + let to_sleep = Duration::from_millis(USER_CHAT_DELAY_MILLIS); + + async move { + // sleep initially for a time to distribute the messaging times + tokio::time::sleep(Duration::from_millis( + rng.gen_range(1..USER_CHAT_DELAY_MILLIS), + )) + .await; + + loop { + let room_name = rooms_iterator.next().unwrap(); + let _ = command_writer + .write(&UserCommand::SendMessage( + comms::command::SendMessageCommand { + room: String::from(room_name), + content: nanoid!(), + }, + )) + .await; + + tokio::time::sleep(to_sleep).await; + } + } + }); + + while let Some(_) = event_stream.next().await {} + + join_handle.abort(); + Ok(()) +} + +#[tokio::main] +async fn main() { + let load_increments: Vec = + serde_json::from_str(LOAD_INCREMENTS).expect("could not parse the load increments"); + let chat_room_metadata: Vec = serde_json::from_str(CHAT_ROOMS_METADATA) + .expect("could not parse the chat rooms metadata"); + + let mut room_iterator = RotatingIterator::new(chat_room_metadata); + let mut join_set: JoinSet> = JoinSet::new(); + + let mut current: usize = 0; + for li in load_increments { + let diff = li.user_count - current; + let sleep_duration = + Duration::from_millis((li.after.as_millis() / li.steps as u128) as u64); + let to_increment = diff / li.steps; + + for _ in 0..li.steps { + for _ in 0..to_increment { + let rooms_to_join = room_iterator + .by_ref() + .take(NUMBER_OF_ROOMS_TO_JOIN) + .map(|metadata| metadata.name.clone()) + .collect(); + + join_set.spawn(spawn_single_user(rooms_to_join)); + } + + current += to_increment; + println!("total users: {}", current); + tokio::time::sleep(sleep_duration).await; + } + } + + while let Some(_) = join_set.join_next().await {} +} diff --git a/spring-2025/sen-109/00010/rust-chat-server/server/resources/chat_rooms_metadata.json b/spring-2025/sen-109/00010/rust-chat-server/server/resources/chat_rooms_metadata.json new file mode 100644 index 0000000..2163e9b --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/server/resources/chat_rooms_metadata.json @@ -0,0 +1,98 @@ +[ + { + "name": "general", + "description": "General discussions and community bonding" + }, + { + "name": "rust", + "description": "Talk about the Rust programming language" + }, + { + "name": "web-dev", + "description": "All about web development" + }, + { + "name": "ml", + "description": "Machine learning algorithms and research" + }, + { + "name": "tech-news", + "description": "Latest tech news and opinions" + }, + { + "name": "gaming", + "description": "Discuss games and gaming hardware" + }, + { + "name": "open-src", + "description": "Open source collaboration and projects" + }, + { + "name": "blockchain", + "description": "Blockchain and cryptocurrencies" + }, + { + "name": "startups", + "description": "Startup ideas and entrepreneurship" + }, + { + "name": "design", + "description": "Design principles and user experience" + }, + { + "name": "cloud-devops", + "description": "Cloud computing and DevOps practices" + }, + { + "name": "security", + "description": "Cybersecurity and ethical hacking" + }, + { + "name": "freelance", + "description": "Freelancing experiences and networking" + }, + { + "name": "hardware", + "description": "Hardware development and IoT" + }, + { + "name": "ai", + "description": "Discuss artificial intelligence topics" + }, + { + "name": "mobile-dev", + "description": "Mobile app development and tools" + }, + { + "name": "data-sci", + "description": "Data science techniques and tools" + }, + { + "name": "networking", + "description": "Networking protocols and technologies" + }, + { + "name": "os-dev", + "description": "Operating system development and kernel hacking" + }, + { + "name": "databases", + "description": "Database management and SQL" + }, + { + "name": "frontend", + "description": "Frontend development and frameworks" + }, + { + "name": "robotics", + "description": "Robotics engineering and automation" + }, + { + "name": "academia", + "description": "Research, papers, and academic discussions" + }, + { + "name": "career-advice", + "description": "Career growth and job-hunting tips" + } +] \ No newline at end of file diff --git a/spring-2025/sen-109/00010/rust-chat-server/server/src/main.rs b/spring-2025/sen-109/00010/rust-chat-server/server/src/main.rs new file mode 100644 index 0000000..3d71ea4 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/server/src/main.rs @@ -0,0 +1,50 @@ +use std::sync::Arc; + +use anyhow::Context; +use room_manager::RoomManagerBuilder; +use tokio::{net::TcpListener, signal::ctrl_c, sync::broadcast, task::JoinSet}; + +use crate::room_manager::ChatRoomMetadata; + +mod room_manager; +mod session; + +const PORT: u16 = 8080; +const CHAT_ROOMS_METADATA: &str = include_str!("../resources/chat_rooms_metadata.json"); + +#[tokio::main] +async fn main() { + let chat_room_metadata: Vec = serde_json::from_str(CHAT_ROOMS_METADATA) + .expect("could not parse the chat rooms metadatas"); + let room_manager = Arc::new( + chat_room_metadata + .into_iter() + .fold(RoomManagerBuilder::new(), |builder, metadata| { + builder.create_room(metadata) + }) + .build(), + ); + + let mut join_set: JoinSet> = JoinSet::new(); + let server = TcpListener::bind(format!("0.0.0.0:{}", PORT)) + .await + .expect("could not bind to the port"); + let (quit_tx, quit_rx) = broadcast::channel::<()>(1); + + println!("Listening on port {}", PORT); + loop { + tokio::select! { + Ok(_) = ctrl_c() => { + println!("Server interrupted. Gracefully shutting down."); + quit_tx.send(()).context("failed to send quit signal").unwrap(); + break; + } + Ok((socket, _)) = server.accept() => { + join_set.spawn(session::handle_user_session(Arc::clone(&room_manager), quit_rx.resubscribe(), socket)); + } + } + } + + while join_set.join_next().await.is_some() {} + println!("Server shut down"); +} diff --git a/spring-2025/sen-109/00010/rust-chat-server/server/src/room_manager/mod.rs b/spring-2025/sen-109/00010/rust-chat-server/server/src/room_manager/mod.rs new file mode 100644 index 0000000..c058c79 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/server/src/room_manager/mod.rs @@ -0,0 +1,47 @@ +use std::sync::Arc; + +use tokio::sync::Mutex; + +use self::room::ChatRoom; +pub use self::room::{ChatRoomMetadata, SessionAndUserId, UserSessionHandle}; + +pub use self::room_manager::RoomManager; + +mod room; +#[allow(clippy::module_inception)] +mod room_manager; + +#[derive(Debug)] +pub struct RoomManagerBuilder { + chat_rooms: Vec<(ChatRoomMetadata, Arc>)>, +} + +impl RoomManagerBuilder { + pub fn new() -> Self { + RoomManagerBuilder { + chat_rooms: Vec::new(), + } + } + + /// Add a room to the room manager + /// Will panic if a room with the same name already exists + pub fn create_room(mut self, metadata: ChatRoomMetadata) -> Self { + let chat_room = Arc::new(Mutex::new(ChatRoom::new(metadata.clone()))); + + if self + .chat_rooms + .iter() + .any(|(m, _)| m.name.eq(&metadata.name)) + { + panic!("room with the same name already exists"); + } + + self.chat_rooms.push((metadata, chat_room)); + + self + } + + pub fn build(self) -> RoomManager { + RoomManager::new(self.chat_rooms) + } +} diff --git a/spring-2025/sen-109/00010/rust-chat-server/server/src/room_manager/room/chat_room.rs b/spring-2025/sen-109/00010/rust-chat-server/server/src/room_manager/room/chat_room.rs new file mode 100644 index 0000000..1921bcf --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/server/src/room_manager/room/chat_room.rs @@ -0,0 +1,88 @@ +use comms::event::{self, Event}; +use serde::{Deserialize, Serialize}; +use tokio::sync::broadcast; + +use super::{ + user_registry::UserRegistry, user_session_handle::UserSessionHandle, SessionAndUserId, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +/// [ChatRoomMetadata] holds the metadata that identifies a chat room +pub struct ChatRoomMetadata { + pub name: String, + pub description: String, +} + +const BROADCAST_CHANNEL_CAPACITY: usize = 100; + +#[derive(Debug)] +/// [ChatRoom] handles the participants of a chat room and the primary broadcast channel +/// A [UserSessionHandle] is handed out to a user when they join the room +pub struct ChatRoom { + metadata: ChatRoomMetadata, + broadcast_tx: broadcast::Sender, + user_registry: UserRegistry, +} + +impl ChatRoom { + pub fn new(metadata: ChatRoomMetadata) -> Self { + let (broadcast_tx, _) = broadcast::channel(BROADCAST_CHANNEL_CAPACITY); + + ChatRoom { + metadata, + broadcast_tx, + user_registry: UserRegistry::new(), + } + } + + pub fn get_unique_user_ids(&self) -> Vec { + self.user_registry.get_unique_user_ids() + } + + /// Add a participant to the room and broadcast that they joined + /// + /// # Returns + /// + /// - A broadcast receiver for the user to receive messages from the room + /// - A [UserSessionHandle] for the user to be able to interact with the room + pub fn join( + &mut self, + session_and_user_id: &SessionAndUserId, + ) -> (broadcast::Receiver, UserSessionHandle) { + let broadcast_tx = self.broadcast_tx.clone(); + let broadcast_rx = broadcast_tx.subscribe(); + let user_session_handle = UserSessionHandle::new( + self.metadata.name.clone(), + broadcast_tx, + session_and_user_id.clone(), + ); + + // If the user is new e.g. they do not have another session with same user id, + // broadcast that they joined to all users + if self.user_registry.insert(&user_session_handle) { + let _ = self.broadcast_tx.send(Event::RoomParticipation( + event::RoomParticipationBroadcastEvent { + user_id: session_and_user_id.user_id.clone(), + room: self.metadata.name.clone(), + status: event::RoomParticipationStatus::Joined, + }, + )); + } + + (broadcast_rx, user_session_handle) + } + + /// Remove a participant from the room and broadcast that they left + /// Consume the [UserSessionHandle] to drop it + pub fn leave(&mut self, user_session_handle: UserSessionHandle) { + if self.user_registry.remove(&user_session_handle) { + let _ = self.broadcast_tx.send(Event::RoomParticipation( + event::RoomParticipationBroadcastEvent { + user_id: String::from(user_session_handle.user_id()), + room: self.metadata.name.clone(), + status: event::RoomParticipationStatus::Left, + }, + )); + } + } +} diff --git a/spring-2025/sen-109/00010/rust-chat-server/server/src/room_manager/room/mod.rs b/spring-2025/sen-109/00010/rust-chat-server/server/src/room_manager/room/mod.rs new file mode 100644 index 0000000..a9455a5 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/server/src/room_manager/room/mod.rs @@ -0,0 +1,6 @@ +mod chat_room; +mod user_registry; +mod user_session_handle; + +pub use self::chat_room::{ChatRoom, ChatRoomMetadata}; +pub use self::user_session_handle::{SessionAndUserId, UserSessionHandle}; diff --git a/spring-2025/sen-109/00010/rust-chat-server/server/src/room_manager/room/user_registry.rs b/spring-2025/sen-109/00010/rust-chat-server/server/src/room_manager/room/user_registry.rs new file mode 100644 index 0000000..586732a --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/server/src/room_manager/room/user_registry.rs @@ -0,0 +1,70 @@ +use std::collections::{HashMap, HashSet}; + +use super::user_session_handle::UserSessionHandle; + +#[derive(Debug)] +pub struct UserRegistry { + user_id_to_sessions: HashMap>, + user_ids: HashSet, +} + +/// [UserRegistry] is a smart container for keeping track of which unique list of users are in a room +/// +/// Since a user can have multiple sessions, we need to keep track of which sessions belong to which users +impl UserRegistry { + pub fn new() -> Self { + UserRegistry { + user_id_to_sessions: HashMap::new(), + user_ids: HashSet::new(), + } + } + + /// Add a user to the room, returns true if the user is a new user + pub fn insert(&mut self, user_session_handle: &UserSessionHandle) -> bool { + let user_id = String::from(user_session_handle.user_id()); + let session_id = String::from(user_session_handle.session_id()); + + let sessions = self + .user_id_to_sessions + .entry(user_id.clone()) + .or_insert_with(HashSet::new); + + sessions.insert(session_id); + + let is_new_user = sessions.len() == 1; + + if is_new_user { + self.user_ids.insert(user_id); + } + + is_new_user + } + + /// Removes a given session from the participant list, returns true if the user is no longer in the room + /// Does nothing and returns false if the user does not exist + pub fn remove(&mut self, user_session_handle: &UserSessionHandle) -> bool { + let user_id = String::from(user_session_handle.user_id()); + let session_id = String::from(user_session_handle.session_id()); + + let to_remove = self.user_id_to_sessions.get_mut(&user_id); + + if let Some(sessions) = to_remove { + sessions.remove(&session_id); + + if sessions.is_empty() { + self.user_id_to_sessions.remove(&user_id); + self.user_ids.remove(&user_id); + + true + } else { + false + } + } else { + false + } + } + + pub fn get_unique_user_ids(&self) -> Vec { + self.user_ids.iter().cloned().collect() + } +} diff --git a/spring-2025/sen-109/00010/rust-chat-server/server/src/room_manager/room/user_session_handle.rs b/spring-2025/sen-109/00010/rust-chat-server/server/src/room_manager/room/user_session_handle.rs new file mode 100644 index 0000000..4ddb0cb --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/server/src/room_manager/room/user_session_handle.rs @@ -0,0 +1,64 @@ +use anyhow::Context; +use comms::event; +use tokio::sync::broadcast; + +#[derive(Debug, Clone)] +pub struct SessionAndUserId { + pub session_id: String, + pub user_id: String, +} + +#[derive(Debug)] +/// [UserSessionHandle] is a handle that allows a specific user/session pair to +/// send messages to a specific room. +/// +/// It is created when a user joins a room and is handed out to the user. +pub struct UserSessionHandle { + /// The name of the room which is associated with this handle + room: String, + /// The channel to use for sending events to the all users of the room + broadcast_tx: broadcast::Sender, + /// The session and user id associated with this handle + session_and_user_id: SessionAndUserId, +} + +impl UserSessionHandle { + pub(super) fn new( + room: String, + broadcast_tx: broadcast::Sender, + session_and_user_id: SessionAndUserId, + ) -> Self { + UserSessionHandle { + room, + broadcast_tx, + session_and_user_id, + } + } + + pub fn room(&self) -> &str { + &self.room + } + + pub fn session_id(&self) -> &str { + &self.session_and_user_id.session_id + } + + pub fn user_id(&self) -> &str { + &self.session_and_user_id.user_id + } + + /// Send a message to the room + pub fn send_message(&self, content: String) -> anyhow::Result<()> { + self.broadcast_tx + .send(event::Event::UserMessage( + event::UserMessageBroadcastEvent { + room: self.room.clone(), + user_id: self.session_and_user_id.user_id.clone(), + content, + }, + )) + .context("could not write to the broadcast channel")?; + + Ok(()) + } +} diff --git a/spring-2025/sen-109/00010/rust-chat-server/server/src/room_manager/room_manager.rs b/spring-2025/sen-109/00010/rust-chat-server/server/src/room_manager/room_manager.rs new file mode 100644 index 0000000..f118a5c --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/server/src/room_manager/room_manager.rs @@ -0,0 +1,69 @@ +use std::{collections::HashMap, sync::Arc}; + +use comms::event::Event; +use tokio::sync::{broadcast, Mutex}; + +use super::room::{ChatRoom, ChatRoomMetadata, SessionAndUserId, UserSessionHandle}; + +pub type RoomJoinResult = (broadcast::Receiver, UserSessionHandle, Vec); + +#[derive(Debug, Clone)] +pub struct RoomManager { + chat_rooms: HashMap>>, + chat_room_metadata: Vec, +} + +impl RoomManager { + pub(super) fn new(chat_rooms: Vec<(ChatRoomMetadata, Arc>)>) -> RoomManager { + let chat_room_metadata = chat_rooms + .iter() + .map(|(metadata, _)| metadata.clone()) + .collect(); + + RoomManager { + chat_room_metadata, + chat_rooms: chat_rooms + .into_iter() + .map(|(metadata, chat_room)| (metadata.name.clone(), chat_room)) + .collect(), + } + } + + pub fn chat_room_metadata(&self) -> &Vec { + &self.chat_room_metadata + } + + /// Joins to a room given a user session + pub async fn join_room( + &self, + room_name: &str, + session_and_user_id: &SessionAndUserId, + ) -> anyhow::Result { + let room = self + .chat_rooms + .get(room_name) + .ok_or_else(|| anyhow::anyhow!("room '{}' not found", room_name))?; + + let mut room = room.lock().await; + let (broadcast_rx, user_session_handle) = room.join(session_and_user_id); + + Ok(( + broadcast_rx, + user_session_handle, + room.get_unique_user_ids().clone(), + )) + } + + pub async fn drop_user_session_handle(&self, handle: UserSessionHandle) -> anyhow::Result<()> { + let room = self + .chat_rooms + .get(handle.room()) + .ok_or_else(|| anyhow::anyhow!("room '{}' not found", handle.room()))?; + + let mut room = room.lock().await; + + room.leave(handle); + + Ok(()) + } +} diff --git a/spring-2025/sen-109/00010/rust-chat-server/server/src/session/chat_session.rs b/spring-2025/sen-109/00010/rust-chat-server/server/src/session/chat_session.rs new file mode 100644 index 0000000..8b0ac59 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/server/src/session/chat_session.rs @@ -0,0 +1,132 @@ +use std::{collections::HashMap, sync::Arc}; + +use anyhow::Context; +use comms::{ + command::UserCommand, + event::{self, Event}, +}; +use tokio::{ + sync::mpsc, + task::{AbortHandle, JoinSet}, +}; + +use crate::room_manager::{RoomManager, SessionAndUserId, UserSessionHandle}; + +pub(super) struct ChatSession { + session_and_user_id: SessionAndUserId, + room_manager: Arc, + joined_rooms: HashMap, + join_set: JoinSet<()>, + mpsc_tx: mpsc::Sender, + mpsc_rx: mpsc::Receiver, +} + +impl ChatSession { + pub fn new(session_id: &str, user_id: &str, room_manager: Arc) -> Self { + let (mpsc_tx, mpsc_rx) = mpsc::channel(100); + let session_and_user_id = SessionAndUserId { + session_id: String::from(session_id), + user_id: String::from(user_id), + }; + + ChatSession { + session_and_user_id, + room_manager, + joined_rooms: HashMap::new(), + join_set: JoinSet::new(), + mpsc_tx, + mpsc_rx, + } + } + + /// Handle a user command related to room management such as; join, leave, send message + pub async fn handle_user_command(&mut self, cmd: UserCommand) -> anyhow::Result<()> { + match cmd { + UserCommand::JoinRoom(cmd) => { + if self.joined_rooms.contains_key(&cmd.room) { + return Err(anyhow::anyhow!("already joined room '{}'", &cmd.room)); + } + + let (mut broadcast_rx, user_session_handle, user_ids) = self + .room_manager + .join_room(&cmd.room, &self.session_and_user_id) + .await?; + + // spawn a task to forward broadcast messages to the users' mpsc channel + // hence the user can receive messages from different rooms via single channel + let abort_handle = self.join_set.spawn({ + let mpsc_tx = self.mpsc_tx.clone(); + + // start with sending the user joined room event as a reply to the user + mpsc_tx + .send(Event::UserJoinedRoom(event::UserJoinedRoomReplyEvent { + room: cmd.room.clone(), + users: user_ids, + })) + .await?; + + async move { + while let Ok(event) = broadcast_rx.recv().await { + let _ = mpsc_tx.send(event).await; + } + } + }); + + // store references to the user session handle and abort handle + // this is used to send messages to the room and to cancel the task when user leaves the room + self.joined_rooms + .insert(cmd.room.clone(), (user_session_handle, abort_handle)); + } + UserCommand::SendMessage(cmd) => { + if let Some((user_session_handle, _)) = self.joined_rooms.get(&cmd.room) { + let _ = user_session_handle.send_message(cmd.content); + } + } + UserCommand::LeaveRoom(cmd) => { + // remove the room from joined rooms and drop user session handle for the room + if let Some(urp) = self.joined_rooms.remove(&cmd.room) { + self.cleanup_room(urp).await?; + } + } + _ => {} + } + + Ok(()) + } + + // TODO: optimize the performance of this function. leaving one by one may not be a good idea. + /// Leave all the rooms the user is currently participating in + pub async fn leave_all_rooms(&mut self) -> anyhow::Result<()> { + // drain the joined rooms to a variable, necessary to avoid borrowing self + let drained = self.joined_rooms.drain().collect::>(); + + for (_, urp) in drained { + self.cleanup_room(urp).await?; + } + + Ok(()) + } + + /// Cleanup the room by removing the user from the room and + /// aborting the task that forwards broadcast messages to the user + async fn cleanup_room( + &mut self, + (user_session_handle, abort_handle): (UserSessionHandle, AbortHandle), + ) -> anyhow::Result<()> { + self.room_manager + .drop_user_session_handle(user_session_handle) + .await?; + + abort_handle.abort(); + + Ok(()) + } + + /// Receive an event that may have originated from any of the rooms the user is actively participating in + pub async fn recv(&mut self) -> anyhow::Result { + self.mpsc_rx + .recv() + .await + .context("could not recv from the broadcast channel") + } +} diff --git a/spring-2025/sen-109/00010/rust-chat-server/server/src/session/mod.rs b/spring-2025/sen-109/00010/rust-chat-server/server/src/session/mod.rs new file mode 100644 index 0000000..54f62fe --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/server/src/session/mod.rs @@ -0,0 +1,88 @@ +use std::sync::Arc; + +use comms::{ + command::UserCommand, + event::{self, RoomDetail}, + transport, +}; +use nanoid::nanoid; +use tokio::{net::TcpStream, sync::broadcast}; +use tokio_stream::StreamExt; + +use crate::room_manager::RoomManager; + +use self::chat_session::ChatSession; + +mod chat_session; + +/// Given a tcp stream and a room manager, handles the user session +/// until the user quits the session, or the tcp stream is closed for some reason, or the server shuts down +pub async fn handle_user_session( + room_manager: Arc, + mut quit_rx: broadcast::Receiver<()>, + stream: TcpStream, +) -> anyhow::Result<()> { + let session_id = nanoid!(); + // Generate a random id for the user, since we don't have a login system + let user_id = String::from(&nanoid!()[0..5]); + // Split the tcp stream into a command stream and an event writer with better ergonomics + let (mut commands, mut event_writer) = transport::server::split_tcp_stream(stream); + + // Welcoming the user with a login successful event and necessary information about the server + event_writer + .write(&event::Event::LoginSuccessful( + event::LoginSuccessfulReplyEvent { + session_id: session_id.clone(), + user_id: user_id.clone(), + rooms: room_manager + .chat_room_metadata() + .iter() + .map(|metadata| RoomDetail { + name: metadata.name.clone(), + description: metadata.description.clone(), + }) + .collect(), + }, + )) + .await?; + + // Create a chat session with the given room manager + // Chat Session will abstract the user session handling logic for multiple rooms + let mut chat_session = ChatSession::new(&session_id, &user_id, room_manager); + + loop { + tokio::select! { + cmd = commands.next() => match cmd { + // If the user closes the tcp stream, or sends a quit cmd + // We need to clean up resources in a way that the other users are notified about the user's departure + None | Some(Ok(UserCommand::Quit(_))) => { + chat_session.leave_all_rooms().await?; + break; + } + // Handle a valid user command + Some(Ok(cmd)) => match cmd { + // For user session related commands, we need to handle them in the chat session + UserCommand::JoinRoom(_) | UserCommand::SendMessage(_) | UserCommand::LeaveRoom(_) => { + chat_session.handle_user_command(cmd).await?; + } + _ => {} + } + _ => {} + }, + // Aggregated events from the chat session are sent to the user + Ok(event) = chat_session.recv() => { + event_writer.write(&event).await?; + } + // If the server is shutting down, we can just close the tcp streams + // and exit the session handler. Since the server is shutting down, + // we don't need to notify other users about the user's departure or cleanup resources + Ok(_) = quit_rx.recv() => { + drop(event_writer); + println!("Gracefully shutting down user tcp stream."); + break; + } + } + } + + Ok(()) +} diff --git a/spring-2025/sen-109/00010/rust-chat-server/tui/.gitignore b/spring-2025/sen-109/00010/rust-chat-server/tui/.gitignore new file mode 100644 index 0000000..0b42d2d --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/tui/.gitignore @@ -0,0 +1 @@ +/target diff --git a/spring-2025/sen-109/00010/rust-chat-server/tui/Cargo.toml b/spring-2025/sen-109/00010/rust-chat-server/tui/Cargo.toml new file mode 100644 index 0000000..f7aecd2 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/tui/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "tui" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = "1.0" +circular-queue = "0.2.6" +comms = { path = "../comms", features = ["client"] } +crossterm = { version = "0.28.1", features = ["event-stream"] } +ratatui = { version = "0.29.0", features = ["all-widgets"] } +tokio = { version = "1.43.0", features = ["full"] } +tokio-stream = { version = "0.1.17" } diff --git a/spring-2025/sen-109/00010/rust-chat-server/tui/README.md b/spring-2025/sen-109/00010/rust-chat-server/tui/README.md new file mode 100644 index 0000000..7e16f02 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/tui/README.md @@ -0,0 +1,33 @@ +# 🎮 Rust Chat Server - TUI Client + +The `tui` binary provides a terminal-based UI for the [rust-chat-server](../). This interface communicates with the server using a TCP client via our [comms library](../comms). + +![TUI Demo](./docs/tui.gif) + +--- + +## 🛠 Technical Stack + +- **Async I/O**: Leverages [Tokio](https://tokio.rs/) for asynchronous tasks and non-blocking I/O, integrating both [Tokio Streams](https://tokio.rs/tokio/tutorial/streams) and [Crossterm's EventStream](https://docs.rs/crossterm/latest/crossterm/event/struct.EventStream.html). +- **Flux-inspired Design**: Implements a simplified [Flux Architecture](https://facebookarchive.github.io/flux/docs/in-depth-overview) to decouple UI and user/server interactions, facilitated by [Tokio Channels](https://tokio.rs/tokio/tutorial/channels). +- **TUI Rendering**: Utilizes [ratatui-org/ratatui](https://github.com/ratatui-org/ratatui) for terminal-based UI rendering. + +## 🏗 Architectural Overview + +![High Level Architecture](./docs/high-level-architecture.svg) + +1. **State Store & UI Management Loop**: On startup, the application initializes loops for both **State Store** and **UI Management**, and sets up channels for **State Updates** and **User Actions**. +2. **UI Management**: + - **Pages** and **Components** sub-modules separate UI-related logic, resembling the structural design in modern web SPAs. + - State-linked components can emit **User Actions** like server connection requests, room joins, and message sends. + - A dedicated loop orchestrates the UI updates based on both state changes and terminal events. +3. **State Store**: + - **State Store** subscribes to **User Actions** to manage server connections and commands. + - Upon processing **User Actions** or **Server Events**, **State Store** pushes the new state to the **State Channel** for UI updates. + +## 🚀 Quick Start + +Run the TUI client using `cargo run` or `cargo run --bin tui`. Upon bootstrap, you will be asked to enter a server address. The server address field will default to `localhost:8080`. Press `` after entering the server you want to connect to. + +Server disconnections will trigger a state reset, requiring re-login. + diff --git a/spring-2025/sen-109/00010/rust-chat-server/tui/docs/high-level-architecture.plantuml b/spring-2025/sen-109/00010/rust-chat-server/tui/docs/high-level-architecture.plantuml new file mode 100644 index 0000000..a50b6e7 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/tui/docs/high-level-architecture.plantuml @@ -0,0 +1,54 @@ +@startuml High Level Architecture - Diagram + +skinparam linetype orthogonally + +entity "Chat Server" as chat_server <> + +package "State Store" as ss_p { + component "Server Handle" as server_handle + component "State Store\nMain Loop" as state_store + component "State" as state + component "State Channel" as state_ch + + state_store -[hidden]u-> server_handle + state -[hidden]d-> state_ch +} + +chat_server <-d-> server_handle: TCP Communication\nvia the Comms Library + +package "UI Management" as um_p { + component "User Management\nMain Loop" as user_management + component "User Action\nChannel" as action_ch + + package "Pages" as pages { + component "Chat Page" as chat_page + component "Connect Page" as connect_page + + chat_page -[hidden]d-> connect_page + } + + package "Components" as components { + component "Input Box" as input_box + } + + action_ch -[hidden]d-> user_management + user_management -[hidden]l-> pages + components -[hidden]d-> pages + user_management -d-> pages: Render Pages\nAccording to the\nState Changes + pages -d-> components: ...Uses pure Components + pages -> action_ch: Send the User Actions +} + +actor User as user +user -d-> um_p: Interact via\nthe Terminal + +state_store <-u-> server_handle: Subscribe to the Server Events\nand Send Commands +state_store -> action_ch: Subscribe to the User Actions +state_store -> state: Update the State\nper user actions\nand server events +state_store -> state_ch: Send Updated State + +state_ch <- user_management: Subscribe to State Changes + +ss_p -[hidden]d-> um_p + +@enduml \ No newline at end of file diff --git a/spring-2025/sen-109/00010/rust-chat-server/tui/docs/high-level-architecture.svg b/spring-2025/sen-109/00010/rust-chat-server/tui/docs/high-level-architecture.svg new file mode 100644 index 0000000..aca6292 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/tui/docs/high-level-architecture.svg @@ -0,0 +1,96 @@ +State StoreUI ManagementPagesComponentsServer HandleState StoreMain LoopStateState ChannelUser ManagementMain LoopUser ActionChannelChat PageConnect PageInput BoxChat Server«Chat Server»UserSubscribe to the Server Eventsand Send CommandsTCP Communicationvia the Comms LibraryRender PagesAccording to theState Changes...Uses pure ComponentsSend the User ActionsInteract viathe TerminalSubscribe to the User ActionsUpdate the Stateper user actionsand server eventsSend Updated StateSubscribe to State Changes \ No newline at end of file diff --git a/spring-2025/sen-109/00010/rust-chat-server/tui/docs/tui.gif b/spring-2025/sen-109/00010/rust-chat-server/tui/docs/tui.gif new file mode 100644 index 0000000..46b7ae8 Binary files /dev/null and b/spring-2025/sen-109/00010/rust-chat-server/tui/docs/tui.gif differ diff --git a/spring-2025/sen-109/00010/rust-chat-server/tui/src/main.rs b/spring-2025/sen-109/00010/rust-chat-server/tui/src/main.rs new file mode 100644 index 0000000..7eb830a --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/tui/src/main.rs @@ -0,0 +1,32 @@ +use state_store::StateStore; +use termination::create_termination; +use ui_management::UiManager; + +mod state_store; +mod termination; +mod ui_management; + +use termination::{Interrupted, Terminator}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let (terminator, mut interrupt_rx) = create_termination(); + let (state_store, state_rx) = StateStore::new(); + let (ui_manager, action_rx) = UiManager::new(); + + tokio::try_join!( + state_store.main_loop(terminator, action_rx, interrupt_rx.resubscribe()), + ui_manager.main_loop(state_rx, interrupt_rx.resubscribe()), + )?; + + if let Ok(reason) = interrupt_rx.recv().await { + match reason { + Interrupted::UserInt => println!("exited per user request"), + Interrupted::OsSigInt => println!("exited because of an os sig int"), + } + } else { + println!("exited because of an unexpected error"); + } + + Ok(()) +} diff --git a/spring-2025/sen-109/00010/rust-chat-server/tui/src/state_store/action.rs b/spring-2025/sen-109/00010/rust-chat-server/tui/src/state_store/action.rs new file mode 100644 index 0000000..f8a09cb --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/tui/src/state_store/action.rs @@ -0,0 +1,7 @@ +#[derive(Debug, Clone)] +pub enum Action { + ConnectToServerRequest { addr: String }, + SendMessage { content: String }, + SelectRoom { room: String }, + Exit, +} diff --git a/spring-2025/sen-109/00010/rust-chat-server/tui/src/state_store/mod.rs b/spring-2025/sen-109/00010/rust-chat-server/tui/src/state_store/mod.rs new file mode 100644 index 0000000..69ef8ab --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/tui/src/state_store/mod.rs @@ -0,0 +1,7 @@ +pub use self::state::*; +pub use self::state_store::StateStore; + +pub mod action; +mod state; +#[allow(clippy::module_inception)] +mod state_store; diff --git a/spring-2025/sen-109/00010/rust-chat-server/tui/src/state_store/state.rs b/spring-2025/sen-109/00010/rust-chat-server/tui/src/state_store/state.rs new file mode 100644 index 0000000..697581f --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/tui/src/state_store/state.rs @@ -0,0 +1,189 @@ +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::fmt::Formatter; +use circular_queue::CircularQueue; +use comms::event; + +#[derive(Debug, Clone)] +pub enum MessageBoxItem { + Message { user_id: String, content: String }, + Notification(String), +} + +const MAX_MESSAGES_TO_STORE_PER_ROOM: usize = 100; + +/// RoomData holds the data for a room +#[derive(Debug, Clone)] +pub struct RoomData { + /// The name of the room + pub name: String, + /// The description of the Room + pub description: String, + /// List of users in the room + pub users: HashSet, + /// History of recorded messages + pub messages: CircularQueue, + /// Has joined the room + pub has_joined: bool, + /// Has unread messages + pub has_unread: bool, +} + +impl Default for RoomData { + fn default() -> Self { + RoomData { + name: String::new(), + description: String::new(), + users: HashSet::new(), + messages: CircularQueue::with_capacity(MAX_MESSAGES_TO_STORE_PER_ROOM), + has_joined: false, + has_unread: false, + } + } +} + +impl RoomData { + pub fn new(name: String, description: String) -> Self { + RoomData { + name, + description, + ..Default::default() + } + } +} + +#[derive(Debug, Clone)] +pub enum ServerConnectionStatus { + Uninitialized, + Connecting, + Connected { addr: String }, + Errored { err: String }, +} + +impl fmt::Display for ServerConnectionStatus { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + ServerConnectionStatus::Uninitialized => write!(f, "Uninitialized"), + ServerConnectionStatus::Connecting => write!(f, "Connecting"), + ServerConnectionStatus::Connected { addr } => write!(f, "Connected to {}", addr), + ServerConnectionStatus::Errored { err } => write!(f, "Errored: {}", err), + } + } +} + +/// State holds the state of the application +#[derive(Debug, Clone)] +pub struct State { + pub server_connection_status: ServerConnectionStatus, + /// Currently active room + pub active_room: Option, + /// The id of the user + pub user_id: String, + /// Storage of room data + pub room_data_map: HashMap, + /// Timer since app was opened + pub timer: usize, +} + +impl Default for State { + fn default() -> Self { + State { + server_connection_status: ServerConnectionStatus::Uninitialized, + active_room: None, + user_id: String::new(), + room_data_map: HashMap::new(), + timer: 0, + } + } +} + +impl State { + pub fn handle_server_event(&mut self, event: &event::Event) { + match event { + event::Event::LoginSuccessful(event) => { + self.user_id = event.user_id.clone(); + self.room_data_map = event + .rooms + .clone() + .into_iter() + .map(|r| (r.name.clone(), RoomData::new(r.name, r.description))) + .collect(); + } + event::Event::RoomParticipation(event) => { + if let Some(room_data) = self.room_data_map.get_mut(&event.room) { + match event.status { + event::RoomParticipationStatus::Joined => { + room_data.users.insert(event.user_id.clone()); + if event.user_id == self.user_id { + room_data.has_joined = true; + } + } + event::RoomParticipationStatus::Left => { + room_data.users.remove(&event.user_id); + if event.user_id == self.user_id { + room_data.has_joined = false; + } + } + } + + room_data + .messages + .push(MessageBoxItem::Notification(format!( + "{} has {} the room", + event.user_id, + match event.status { + event::RoomParticipationStatus::Joined => "joined", + event::RoomParticipationStatus::Left => "left", + } + ))); + } + } + event::Event::UserJoinedRoom(event) => { + self.room_data_map.get_mut(&event.room).unwrap().users = + event.users.clone().into_iter().collect(); + } + event::Event::UserMessage(event) => { + let room_data = self.room_data_map.get_mut(&event.room).unwrap(); + + room_data.messages.push(MessageBoxItem::Message { + user_id: event.user_id.clone(), + content: event.content.clone(), + }); + + if let Some(active_room) = self.active_room.as_ref() { + if !active_room.eq(&event.room) { + room_data.has_unread = true; + } + } + } + } + } + + pub fn mark_connection_request_start(&mut self) { + self.server_connection_status = ServerConnectionStatus::Connecting; + } + + /// Processes the result of a connection request to change the state of the application + pub fn process_connection_request_result(&mut self, result: anyhow::Result) { + self.server_connection_status = match result { + Ok(addr) => ServerConnectionStatus::Connected { addr: addr.clone() }, + Err(err) => ServerConnectionStatus::Errored { + err: err.to_string(), + }, + } + } + + /// Tries to set the active room as the given room. Returns the [RoomData] associated to the room. + pub fn try_set_active_room(&mut self, room: &str) -> Option<&RoomData> { + let room_data = self.room_data_map.get_mut(room)?; + room_data.has_unread = false; + + self.active_room = Some(String::from(room)); + + Some(room_data) + } + + pub fn tick_timer(&mut self) { + self.timer += 1; + } +} diff --git a/spring-2025/sen-109/00010/rust-chat-server/tui/src/state_store/state_store.rs b/spring-2025/sen-109/00010/rust-chat-server/tui/src/state_store/state_store.rs new file mode 100644 index 0000000..2ac5672 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/tui/src/state_store/state_store.rs @@ -0,0 +1,157 @@ +use std::time::Duration; + +use anyhow::Context; +use comms::{ + command, + transport::{ + self, + client::{CommandWriter, EventStream}, + }, +}; +use tokio::{ + net::TcpStream, + sync::{ + broadcast, + mpsc::{self, UnboundedReceiver, UnboundedSender}, + }, +}; +use tokio_stream::StreamExt; + +use crate::{Interrupted, Terminator}; + +use super::{action::Action, State}; + +pub struct StateStore { + state_tx: UnboundedSender, +} + +impl StateStore { + pub fn new() -> (Self, UnboundedReceiver) { + let (state_tx, state_rx) = mpsc::unbounded_channel::(); + + (StateStore { state_tx }, state_rx) + } +} + +type ServerHandle = (EventStream, CommandWriter); + +async fn create_server_handle(addr: &str) -> anyhow::Result { + let stream = TcpStream::connect(addr).await?; + let (event_stream, command_writer) = transport::client::split_tcp_stream(stream); + + Ok((event_stream, command_writer)) +} + +impl StateStore { + pub async fn main_loop( + self, + mut terminator: Terminator, + mut action_rx: UnboundedReceiver, + mut interrupt_rx: broadcast::Receiver, + ) -> anyhow::Result { + let mut opt_server_handle: Option = None; + let mut state = State::default(); + + // the initial state once + self.state_tx.send(state.clone())?; + + let mut ticker = tokio::time::interval(Duration::from_secs(1)); + + let result = loop { + if let Some((event_stream, command_writer)) = opt_server_handle.as_mut() { + tokio::select! { + // Handle the server events as they come in + maybe_event = event_stream.next() => match maybe_event { + Some(Ok(event)) => { + state.handle_server_event(&event); + }, + // server disconnected, we need to reset the state + None => { + opt_server_handle = None; + state = State::default(); + }, + _ => (), + }, + // Handle the actions coming from the UI + // and process them to do async operations + Some(action) = action_rx.recv() => match action { + Action::SendMessage { content } => { + if let Some(active_room) = state.active_room.as_ref() { + command_writer + .write(&command::UserCommand::SendMessage( + command::SendMessageCommand { + room: active_room.clone(), + content, + }, + )) + .await + .context("could not send message")?; + } + }, + Action::SelectRoom { room } => { + if let Some(false) = state.try_set_active_room(room.as_str()).map(|room_data| room_data.has_joined) { + command_writer + .write(&command::UserCommand::JoinRoom(command::JoinRoomCommand { + room, + })) + .await + .context("could not join room")?; + } + }, + Action::Exit => { + let _ = terminator.terminate(Interrupted::UserInt); + + break Interrupted::UserInt; + }, + _ => (), + }, + // Tick to terminate the select every N milliseconds + _ = ticker.tick() => { + state.tick_timer(); + }, + // Catch and handle interrupt signal to gracefully shutdown + Ok(interrupted) = interrupt_rx.recv() => { + break interrupted; + } + } + } else { + tokio::select! { + Some(action) = action_rx.recv() => match action { + Action::ConnectToServerRequest { addr } => { + state.mark_connection_request_start(); + // emit event to re-render any part depending on the connection status + self.state_tx.send(state.clone())?; + + match create_server_handle(&addr).await { + Ok(server_handle) => { + // set the server handle and change status for further processing + let _ = opt_server_handle.insert(server_handle); + state.process_connection_request_result(Ok(addr)); + // ticker needs to be reset to avoid showing time spent inputting and connecting to the server address + ticker.reset(); + }, + Err(err) => { + state.process_connection_request_result(Err(err)); + } + } + }, + Action::Exit => { + let _ = terminator.terminate(Interrupted::UserInt); + + break Interrupted::UserInt; + }, + _ => (), + }, + // Catch and handle interrupt signal to gracefully shutdown + Ok(interrupted) = interrupt_rx.recv() => { + break interrupted; + } + } + } + + self.state_tx.send(state.clone())?; + }; + + Ok(result) + } +} diff --git a/spring-2025/sen-109/00010/rust-chat-server/tui/src/termination.rs b/spring-2025/sen-109/00010/rust-chat-server/tui/src/termination.rs new file mode 100644 index 0000000..a1437f2 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/tui/src/termination.rs @@ -0,0 +1,49 @@ +#[cfg(unix)] +use tokio::signal::unix::signal; +use tokio::sync::broadcast; + +#[derive(Debug, Clone)] +pub enum Interrupted { + OsSigInt, + UserInt, +} + +#[derive(Debug, Clone)] +pub struct Terminator { + interrupt_tx: broadcast::Sender, +} + +impl Terminator { + pub fn new(interrupt_tx: broadcast::Sender) -> Self { + Self { interrupt_tx } + } + + pub fn terminate(&mut self, interrupted: Interrupted) -> anyhow::Result<()> { + self.interrupt_tx.send(interrupted)?; + + Ok(()) + } +} + +#[cfg(unix)] +async fn terminate_by_unix_signal(mut terminator: Terminator) { + let mut interrupt_signal = signal(tokio::signal::unix::SignalKind::interrupt()) + .expect("failed to create interrupt signal stream"); + + interrupt_signal.recv().await; + + terminator + .terminate(Interrupted::OsSigInt) + .expect("failed to send interrupt signal"); +} + +// create a broadcast channel for retrieving the application kill signal +pub fn create_termination() -> (Terminator, broadcast::Receiver) { + let (tx, rx) = broadcast::channel(1); + let terminator = Terminator::new(tx); + + #[cfg(unix)] + tokio::spawn(terminate_by_unix_signal(terminator.clone())); + + (terminator, rx) +} diff --git a/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/components/component.rs b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/components/component.rs new file mode 100644 index 0000000..85359d4 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/components/component.rs @@ -0,0 +1,22 @@ +use crossterm::event::KeyEvent; +use ratatui::Frame; +use tokio::sync::mpsc::UnboundedSender; + +use crate::state_store::{action::Action, State}; + +pub trait Component { + fn new(state: &State, action_tx: UnboundedSender) -> Self + where + Self: Sized; + fn move_with_state(self, state: &State) -> Self + where + Self: Sized; + + fn name(&self) -> &str; + + fn handle_key_event(&mut self, key: KeyEvent); +} + +pub trait ComponentRender { + fn render(&self, frame: &mut Frame, props: Props); +} diff --git a/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/components/input_box.rs b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/components/input_box.rs new file mode 100644 index 0000000..d65e685 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/components/input_box.rs @@ -0,0 +1,159 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; +use ratatui::{ + prelude::Rect, + style::{Color, Style, Stylize}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; +use ratatui::prelude::Position; +use tokio::sync::mpsc::UnboundedSender; + +use crate::state_store::{action::Action, State}; + +use super::{Component, ComponentRender}; + +pub struct InputBox { + /// Current value of the input box + text: String, + /// Position of cursor in the editor area. + cursor_position: usize, +} + +impl InputBox { + pub fn text(&self) -> &str { + &self.text + } + + pub fn set_text(&mut self, new_text: &str) { + self.text = String::from(new_text); + self.cursor_position = self.text.len(); + } + + pub fn reset(&mut self) { + self.cursor_position = 0; + self.text.clear(); + } + + pub fn is_empty(&self) -> bool { + self.text.is_empty() + } + + fn move_cursor_left(&mut self) { + let cursor_moved_left = self.cursor_position.saturating_sub(1); + self.cursor_position = self.clamp_cursor(cursor_moved_left); + } + + fn move_cursor_right(&mut self) { + let cursor_moved_right = self.cursor_position.saturating_add(1); + self.cursor_position = self.clamp_cursor(cursor_moved_right); + } + + fn enter_char(&mut self, new_char: char) { + self.text.insert(self.cursor_position, new_char); + + self.move_cursor_right(); + } + + fn delete_char(&mut self) { + let is_not_cursor_leftmost = self.cursor_position != 0; + if is_not_cursor_leftmost { + // Method "remove" is not used on the saved text for deleting the selected char. + // Reason: Using remove on String works on bytes instead of the chars. + // Using remove would require special care because of char boundaries. + + let current_index = self.cursor_position; + let from_left_to_current_index = current_index - 1; + + // Getting all characters before the selected character. + let before_char_to_delete = self.text.chars().take(from_left_to_current_index); + // Getting all characters after selected character. + let after_char_to_delete = self.text.chars().skip(current_index); + + // Put all characters together except the selected one. + // By leaving the selected one out, it is forgotten and therefore deleted. + self.text = before_char_to_delete.chain(after_char_to_delete).collect(); + self.move_cursor_left(); + } + } + + fn clamp_cursor(&self, new_cursor_pos: usize) -> usize { + new_cursor_pos.clamp(0, self.text.len()) + } +} + +impl Component for InputBox { + fn new(_state: &State, _action_tx: UnboundedSender) -> Self { + Self { + // + text: String::new(), + cursor_position: 0, + } + } + + fn move_with_state(self, _state: &State) -> Self + where + Self: Sized, + { + Self { ..self } + } + + fn name(&self) -> &str { + "Input Box" + } + + fn handle_key_event(&mut self, key: KeyEvent) { + if key.kind != KeyEventKind::Press { + return; + } + + match key.code { + KeyCode::Char(to_insert) => { + self.enter_char(to_insert); + } + KeyCode::Backspace => { + self.delete_char(); + } + KeyCode::Left => { + self.move_cursor_left(); + } + KeyCode::Right => { + self.move_cursor_right(); + } + _ => {} + } + } +} + +pub struct RenderProps { + pub title: String, + pub area: Rect, + pub border_color: Color, + pub show_cursor: bool, +} + +impl ComponentRender for InputBox { + fn render(&self, frame: &mut Frame, props: RenderProps) { + let input = Paragraph::new(self.text.as_str()) + .style(Style::default().fg(Color::Yellow)) + .block( + Block::default() + .borders(Borders::ALL) + .fg(props.border_color) + .title(props.title), + ); + frame.render_widget(input, props.area); + + // Cursor is hidden by default, so we need to make it visible if the input box is selected + if props.show_cursor { + // Make the cursor visible and ask ratatui to put it at the specified coordinates after + // rendering + frame.set_cursor_position(Position::new( + // Draw the cursor at the current position in the input field. + // This position is can be controlled via the left and right arrow key + props.area.x + self.cursor_position as u16 + 1, + // Move one line down, from the border to the input line + props.area.y + 1, + )) + } + } +} diff --git a/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/components/mod.rs b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/components/mod.rs new file mode 100644 index 0000000..e0d6418 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/components/mod.rs @@ -0,0 +1,4 @@ +mod component; + +pub mod input_box; +pub use component::{Component, ComponentRender}; diff --git a/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/mod.rs b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/mod.rs new file mode 100644 index 0000000..60751cf --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/mod.rs @@ -0,0 +1,5 @@ +pub use ui_manager::UiManager; + +mod components; +mod pages; +mod ui_manager; diff --git a/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/chat_page/chat_page.rs b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/chat_page/chat_page.rs new file mode 100644 index 0000000..871c31a --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/chat_page/chat_page.rs @@ -0,0 +1,457 @@ +use std::collections::HashMap; + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::{prelude::*, widgets::*, Frame}; +use tokio::sync::mpsc::UnboundedSender; + +use crate::state_store::{action::Action, MessageBoxItem, RoomData, ServerConnectionStatus, State}; + +use super::{ + components::{ + message_input_box::{self, MessageInputBox}, + room_list::{self, RoomList}, + }, + section::{ + usage::{widget_usage_to_text, HasUsageInfo, UsageInfo, UsageInfoLine}, + SectionActivation, + }, +}; +use crate::ui_management::components::{Component, ComponentRender}; + +#[derive(Debug, Clone, PartialEq)] +pub enum Section { + MessageInput, + RoomList, +} + +impl Section { + pub const COUNT: usize = 2; + + fn to_usize(&self) -> usize { + match self { + Section::MessageInput => 0, + Section::RoomList => 1, + } + } +} + +impl TryFrom for Section { + type Error = (); + + fn try_from(value: usize) -> Result { + match value { + 0 => Ok(Section::MessageInput), + 1 => Ok(Section::RoomList), + _ => Err(()), + } + } +} + +struct Props { + /// The logged-in user + user_id: String, + /// The currently active room + active_room: Option, + /// The timer for the chat page + timer: usize, + /// The room data map + room_data_map: HashMap, + /// Connection status for the current connection + connection_status: ServerConnectionStatus, +} + +impl From<&State> for Props { + fn from(state: &State) -> Self { + Props { + user_id: state.user_id.clone(), + active_room: state.active_room.clone(), + timer: state.timer, + room_data_map: state.room_data_map.clone(), + connection_status: state.server_connection_status.clone(), + } + } +} + +const DEFAULT_HOVERED_SECTION: Section = Section::MessageInput; + +/// ChatPage handles the UI and the state of the chat page +pub struct ChatPage { + /// Action sender + pub action_tx: UnboundedSender, + /// State Mapped ChatPage Props + props: Props, + // Internal State + /// Currently active section, handling input + pub active_section: Option
, + /// Section that is currently hovered + pub last_hovered_section: Section, + // Child Components + /// The room list widget that handles the listing of the rooms + pub room_list: RoomList, + /// The input box widget that handles the message input + pub message_input_box: MessageInputBox, +} + +impl ChatPage { + fn get_room_data(&self, name: &str) -> Option<&RoomData> { + self.props.room_data_map.get(name) + } + + fn get_component_for_section<'a>(&'a self, section: &Section) -> &'a dyn Component { + match section { + Section::MessageInput => &self.message_input_box, + Section::RoomList => &self.room_list, + } + } + + fn get_component_for_section_mut<'a>(&'a mut self, section: &Section) -> &'a mut dyn Component { + match section { + Section::MessageInput => &mut self.message_input_box, + Section::RoomList => &mut self.room_list, + } + } + + fn get_section_activation_for_section<'a>( + &'a mut self, + section: &Section, + ) -> &'a mut dyn SectionActivation { + match section { + Section::MessageInput => &mut self.message_input_box, + Section::RoomList => &mut self.room_list, + } + } + + fn hover_next(&mut self) { + let idx: usize = self.last_hovered_section.to_usize(); + let next_idx = (idx + 1) % Section::COUNT; + self.last_hovered_section = Section::try_from(next_idx).unwrap(); + } + + fn hover_previous(&mut self) { + let idx: usize = self.last_hovered_section.to_usize(); + let previous_idx = if idx == 0 { + Section::COUNT - 1 + } else { + idx - 1 + }; + self.last_hovered_section = Section::try_from(previous_idx).unwrap(); + } + + fn calculate_border_color(&self, section: Section) -> Color { + match (self.active_section.as_ref(), &self.last_hovered_section) { + (Some(active_section), _) if active_section.eq(§ion) => Color::Yellow, + (_, last_hovered_section) if last_hovered_section.eq(§ion) => Color::Blue, + _ => Color::Reset, + } + } + + fn disable_section(&mut self, section: &Section) { + self.get_section_activation_for_section(section) + .deactivate(); + + self.active_section = None; + } +} + +impl Component for ChatPage { + fn new(state: &State, action_tx: UnboundedSender) -> Self + where + Self: Sized, + { + ChatPage { + action_tx: action_tx.clone(), + // set the props + props: Props::from(state), + // internal component state + active_section: None, + last_hovered_section: DEFAULT_HOVERED_SECTION, + // child components + room_list: RoomList::new(state, action_tx.clone()), + message_input_box: MessageInputBox::new(state, action_tx), + } + .move_with_state(state) + } + + fn move_with_state(self, state: &State) -> Self + where + Self: Sized, + { + ChatPage { + props: Props::from(state), + // propagate the update to the child components + room_list: self.room_list.move_with_state(state), + message_input_box: self.message_input_box.move_with_state(state), + ..self + } + } + + fn name(&self) -> &str { + "Chat Page" + } + + fn handle_key_event(&mut self, key: KeyEvent) { + if key.kind != KeyEventKind::Press { + return; + } + + let active_section = self.active_section.clone(); + + match active_section { + None => match key.code { + KeyCode::Enter => { + let last_hovered_section = self.last_hovered_section.clone(); + + self.active_section = Some(last_hovered_section.clone()); + self.get_section_activation_for_section(&last_hovered_section) + .activate(); + } + KeyCode::Left => self.hover_previous(), + KeyCode::Right => self.hover_next(), + KeyCode::Char('q') => { + let _ = self.action_tx.send(Action::Exit); + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + let _ = self.action_tx.send(Action::Exit); + } + _ => {} + }, + Some(section) => { + self.get_component_for_section_mut(§ion) + .handle_key_event(key); + + // disable the section according to the action taken + // the section is disabled when escape is pressed + // or when enter is pressed on the room list + match section { + Section::RoomList if key.code == KeyCode::Enter => { + self.disable_section(§ion) + } + _ if key.code == KeyCode::Esc => self.disable_section(§ion), + _ => (), + } + } + } + } +} + +const NO_ROOM_SELECTED_MESSAGE: &str = "Join at least one room to start chatting!"; + +fn calculate_list_offset(height: u16, items_len: usize) -> usize { + // go back by (container height + 2 for borders) to get the offset + items_len.saturating_sub(height as usize - 2) +} + +impl ComponentRender<()> for ChatPage { + fn render(&self, frame: &mut Frame, _props: ()) { + let [left, middle, right] = *Layout::default() + .direction(Direction::Horizontal) + .constraints( + [ + Constraint::Percentage(20), + Constraint::Percentage(60), + Constraint::Percentage(20), + ] + .as_ref(), + ) + .split(frame.area()) + else { + panic!("The main layout should have 3 chunks") + }; + + let [container_room_list, container_user_info] = *Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(6)].as_ref()) + .split(left) + else { + panic!("The left layout should have 2 chunks") + }; + + self.room_list.render( + frame, + room_list::RenderProps { + border_color: self.calculate_border_color(Section::RoomList), + area: container_room_list, + }, + ); + + let user_info = Paragraph::new(Text::from(vec![ + Line::from(format!("User: @{}", self.props.user_id)), + Line::from(format!("Chatting for: {} secs", self.props.timer)), + Line::from(format!("Server: {}", self.props.connection_status)), + ])).wrap(Wrap { trim: false }) + .block( + Block::default() + .borders(Borders::ALL) + .title("User Information"), + ); + frame.render_widget(user_info, container_user_info); + + let [container_highlight, container_messages, container_input] = *Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(3), + Constraint::Min(1), + Constraint::Length(3), + ] + .as_ref(), + ) + .split(middle) + else { + panic!("The middle layout should have 3 chunks") + }; + + let top_line = if let Some(room_data) = self + .props + .active_room + .as_ref() + .and_then(|active_room| self.get_room_data(active_room)) + { + Line::from(vec![ + "on ".into(), + Span::from(format!("#{}", room_data.name)).bold(), + " for ".into(), + Span::from(format!(r#""{}""#, room_data.description)).italic(), + ]) + } else { + Line::from(NO_ROOM_SELECTED_MESSAGE) + }; + let text = Text::from(top_line); + + let help_message = Paragraph::new(text).block( + Block::default() + .borders(Borders::ALL) + .title("Active Room Information"), + ); + frame.render_widget(help_message, container_highlight); + + let messages = if let Some(active_room) = self.props.active_room.as_ref() { + self.get_room_data(active_room) + .map(|room_data| { + let message_offset = + calculate_list_offset(container_messages.height, room_data.messages.len()); + + room_data + .messages + .asc_iter() + .skip(message_offset) + .map(|mbi| { + let line = match mbi { + MessageBoxItem::Message { user_id, content } => { + Line::from(Span::raw(format!("@{}: {}", user_id, content))) + } + MessageBoxItem::Notification(content) => { + Line::from(Span::raw(content.clone()).italic()) + } + }; + + ListItem::new(line) + }) + .collect::>() + }) + .unwrap_or_default() + } else { + vec![ListItem::new(Line::from(NO_ROOM_SELECTED_MESSAGE))] + }; + + let messages = + List::new(messages).block(Block::default().borders(Borders::ALL).title("Messages")); + frame.render_widget(messages, container_messages); + + self.message_input_box.render( + frame, + message_input_box::RenderProps { + border_color: self.calculate_border_color(Section::MessageInput), + area: container_input, + show_cursor: self + .active_section + .as_ref() + .map(|active_section| active_section.eq(&Section::MessageInput)) + .unwrap_or(false), + }, + ); + + let [container_room_users, container_usage] = *Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(10)].as_ref()) + .split(right) + else { + panic!("The left layout should have 2 chunks") + }; + + let (room_users_list_items, room_users_len) = self + .props + .active_room + .as_ref() + .and_then(|active_room| { + self.get_room_data(active_room).map(|room_data| { + let room_users_len = room_data.users.len(); + let users_offset = + calculate_list_offset(container_room_users.height, room_users_len); + + ( + room_data + .users + .iter() + .skip(users_offset) + .map(|user_id| { + ListItem::new(Line::from(Span::raw(format!("@{user_id}")))) + }) + .collect::>>(), + room_users_len, + ) + }) + }) + .unwrap_or_else(|| (vec![], 0)); + + let room_users_list = List::new(room_users_list_items).block( + Block::default() + .borders(Borders::ALL) + .title(format!("Room Users ({})", room_users_len)), + ); + + frame.render_widget(room_users_list, container_room_users); + + let mut usage_text: Text = widget_usage_to_text(self.usage_info()); + usage_text = usage_text.patch_style(Style::default()); + let usage = Paragraph::new(usage_text) + .wrap(Wrap { trim: true }) + .block(Block::default().borders(Borders::ALL).title("Usage")); + frame.render_widget(usage, container_usage); + } +} + +impl HasUsageInfo for ChatPage { + fn usage_info(&self) -> UsageInfo { + if let Some(section) = self.active_section.as_ref() { + let handler: &dyn HasUsageInfo = match section { + Section::RoomList => &self.room_list, + Section::MessageInput => &self.message_input_box, + }; + + handler.usage_info() + } else { + UsageInfo { + description: Some("Select a widget".into()), + lines: vec![ + UsageInfoLine { + keys: vec!["q".into()], + description: "to exit".into(), + }, + UsageInfoLine { + keys: vec!["←".into(), "→".into()], + description: "to hover widgets".into(), + }, + UsageInfoLine { + keys: vec!["Enter".into()], + description: format!( + "to activate {}", + self.get_component_for_section(&self.last_hovered_section) + .name() + ), + }, + ], + } + } + } +} diff --git a/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/chat_page/components/message_input_box.rs b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/chat_page/components/message_input_box.rs new file mode 100644 index 0000000..b1eab66 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/chat_page/components/message_input_box.rs @@ -0,0 +1,148 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; +use ratatui::{ + prelude::Rect, + style::Color, + Frame, +}; +use tokio::sync::mpsc::UnboundedSender; + +use super::super::section::usage::{HasUsageInfo, UsageInfo, UsageInfoLine}; +use crate::ui_management::components::{ + input_box::{self, InputBox}, + Component, ComponentRender, +}; +use crate::{ + state_store::{action::Action, State}, + ui_management::pages::chat_page::section::SectionActivation, +}; + +struct Props { + /// Active room that the user is chatting in + active_room: Option, +} + +impl From<&State> for Props { + fn from(state: &State) -> Self { + Self { + active_room: state.active_room.clone(), + } + } +} + +pub struct MessageInputBox { + action_tx: UnboundedSender, + /// State Mapped MessageInputBox Props + props: Props, + // Internal State for the Component + pub input_box: InputBox, +} + +impl MessageInputBox { + fn submit_message(&mut self) { + if self.input_box.is_empty() { + return; + } + + // TODO: handle the error scenario + let _ = self.action_tx.send(Action::SendMessage { + content: String::from(self.input_box.text()), + }); + + self.input_box.reset(); + } +} + +impl Component for MessageInputBox { + fn new(state: &State, action_tx: UnboundedSender) -> Self { + Self { + action_tx: action_tx.clone(), + props: Props::from(state), + // + input_box: InputBox::new(state, action_tx), + } + } + + fn move_with_state(self, state: &State) -> Self + where + Self: Sized, + { + Self { + props: Props::from(state), + ..self + } + } + + fn name(&self) -> &str { + "Message Input" + } + + fn handle_key_event(&mut self, key: KeyEvent) { + if key.kind != KeyEventKind::Press { + return; + } + + if self.props.active_room.is_some() { + self.input_box.handle_key_event(key); + + if key.code == KeyCode::Enter { + self.submit_message(); + } + } + } +} + +impl SectionActivation for MessageInputBox { + fn activate(&mut self) {} + + fn deactivate(&mut self) { + self.input_box.reset(); + } +} + +pub struct RenderProps { + pub area: Rect, + pub border_color: Color, + pub show_cursor: bool, +} + +impl ComponentRender for MessageInputBox { + fn render(&self, frame: &mut Frame, props: RenderProps) { + self.input_box.render( + frame, + input_box::RenderProps { + title: "Message Input".into(), + area: props.area, + border_color: props.border_color, + show_cursor: props.show_cursor, + }, + ) + } +} + +impl HasUsageInfo for MessageInputBox { + fn usage_info(&self) -> UsageInfo { + if self.props.active_room.is_none() { + UsageInfo { + description: Some("You can not send a message until you enter a room.".into()), + lines: vec![UsageInfoLine { + keys: vec!["Esc".into()], + description: "to cancel".into(), + }], + } + } else { + UsageInfo { + description: Some("Type your message to send a message to the active room".into()), + lines: vec![ + UsageInfoLine { + keys: vec!["Esc".into()], + description: "to cancel".into(), + }, + UsageInfoLine { + keys: vec!["Enter".into()], + description: "to send your message".into(), + }, + ], + } + } + } +} diff --git a/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/chat_page/components/mod.rs b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/chat_page/components/mod.rs new file mode 100644 index 0000000..2375cc2 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/chat_page/components/mod.rs @@ -0,0 +1,2 @@ +pub mod message_input_box; +pub mod room_list; diff --git a/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/chat_page/components/room_list.rs b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/chat_page/components/room_list.rs new file mode 100644 index 0000000..6d66ff3 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/chat_page/components/room_list.rs @@ -0,0 +1,259 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; +use ratatui::{ + prelude::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState}, + Frame, +}; +use tokio::sync::mpsc::UnboundedSender; + +use super::super::section::usage::{HasUsageInfo, UsageInfo, UsageInfoLine}; +use crate::{ + state_store::{action::Action, State}, + ui_management::pages::chat_page::section::SectionActivation, +}; + +use crate::ui_management::components::{Component, ComponentRender}; + +pub struct RoomState { + pub name: String, + pub description: String, + pub has_joined: bool, + pub has_unread: bool, +} + +struct Props { + /// List of rooms and current state of those rooms + rooms: Vec, + /// Current active room + active_room: Option, +} + +impl From<&State> for Props { + fn from(state: &State) -> Self { + let mut rooms = state + .room_data_map + .iter() + .map(|(name, room_data)| RoomState { + name: name.clone(), + description: room_data.description.clone(), + has_joined: room_data.has_joined, + has_unread: room_data.has_unread, + }) + .collect::>(); + + rooms.sort_by(|room_a, room_b| room_a.name.cmp(&room_b.name)); + + Self { + rooms, + active_room: state.active_room.clone(), + } + } +} + +pub struct RoomList { + /// Sending actions to the state store + action_tx: UnboundedSender, + /// State Mapped RoomList Props + props: Props, + // Internal Component State + /// List with optional selection and current offset + pub list_state: ListState, +} + +impl RoomList { + fn next(&mut self) { + let i = match self.list_state.selected() { + Some(i) => { + if i >= self.props.rooms.len() - 1 { + 0 + } else { + i + 1 + } + } + None => 0, + }; + self.list_state.select(Some(i)); + } + + fn previous(&mut self) { + let i = match self.list_state.selected() { + Some(i) => { + if i == 0 { + self.props.rooms.len() - 1 + } else { + i - 1 + } + } + None => 0, + }; + + self.list_state.select(Some(i)); + } + + pub(super) fn rooms(&self) -> &Vec { + &self.props.rooms + } + + fn get_room_idx(&self, name: &str) -> Option { + self.props + .rooms + .iter() + .enumerate() + .find_map(|(idx, room_state)| { + if room_state.name == name { + Some(idx) + } else { + None + } + }) + } +} + +impl Component for RoomList { + fn new(state: &State, action_tx: UnboundedSender) -> Self { + Self { + action_tx, + props: Props::from(state), + // + list_state: ListState::default(), + } + } + + fn move_with_state(self, state: &State) -> Self + where + Self: Sized, + { + Self { + props: Props::from(state), + ..self + } + } + + fn name(&self) -> &str { + "Room List" + } + + fn handle_key_event(&mut self, key: KeyEvent) { + if key.kind != KeyEventKind::Press { + return; + } + + match key.code { + KeyCode::Up => { + self.previous(); + } + KeyCode::Down => { + self.next(); + } + KeyCode::Enter if self.list_state.selected().is_some() => { + let selected_idx = self.list_state.selected().unwrap(); + + let rooms = self.rooms(); + let room_state = rooms.get(selected_idx).unwrap(); + + // TODO: handle the error scenario somehow + let _ = self.action_tx.send(Action::SelectRoom { + room: room_state.name.clone(), + }); + } + _ => (), + } + } +} + +impl SectionActivation for RoomList { + fn activate(&mut self) { + let idx: usize = self + .props + .active_room + .as_ref() + .and_then(|room_name| self.get_room_idx(room_name.as_str())) + .unwrap_or(0); + + *self.list_state.offset_mut() = 0; + self.list_state.select(Some(idx)); + } + + fn deactivate(&mut self) { + *self.list_state.offset_mut() = 0; + self.list_state.select(None); + } +} + +pub struct RenderProps { + pub border_color: Color, + pub area: Rect, +} + +impl ComponentRender for RoomList { + fn render(&self, frame: &mut Frame, props: RenderProps) { + let active_room = self.props.active_room.clone(); + let room_list: Vec = self + .rooms() + .iter() + .map(|room_state| { + let room_tag = format!( + "#{}{}", + room_state.name, + if room_state.has_unread { "*" } else { "" } + ); + let content = Line::from(Span::raw(room_tag)); + + let style = if self.list_state.selected().is_none() + && active_room.is_some() + && active_room.as_ref().unwrap().eq(&room_state.name) + { + Style::default().add_modifier(Modifier::BOLD) + } else if room_state.has_unread { + Style::default().add_modifier(Modifier::SLOW_BLINK | Modifier::ITALIC) + } else { + Style::default() + }; + + ListItem::new(content).style(style.bg(Color::Reset)) + }) + .collect(); + + let room_list = List::new(room_list) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::new().fg(props.border_color)) + .title("Rooms"), + ) + .highlight_style( + Style::default() + // yellow that would work for both dark / light modes + .bg(Color::Rgb(255, 223, 102)) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(">"); + + let mut app_room_list_state = self.list_state.clone(); + frame.render_stateful_widget(room_list, props.area, &mut app_room_list_state); + } +} + +impl HasUsageInfo for RoomList { + fn usage_info(&self) -> UsageInfo { + UsageInfo { + description: Some("Select the room to talk in".into()), + lines: vec![ + UsageInfoLine { + keys: vec!["Esc".into()], + description: "to cancel".into(), + }, + UsageInfoLine { + keys: vec!["↑".into(), "↓".into()], + description: "to navigate".into(), + }, + UsageInfoLine { + keys: vec!["Enter".into()], + description: "to join room".into(), + }, + ], + } + } +} diff --git a/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/chat_page/mod.rs b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/chat_page/mod.rs new file mode 100644 index 0000000..8b05cf2 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/chat_page/mod.rs @@ -0,0 +1,6 @@ +#[allow(clippy::module_inception)] +mod chat_page; +mod components; +mod section; + +pub use chat_page::ChatPage; diff --git a/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/chat_page/section/mod.rs b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/chat_page/section/mod.rs new file mode 100644 index 0000000..c5e1131 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/chat_page/section/mod.rs @@ -0,0 +1,6 @@ +pub mod usage; + +pub trait SectionActivation { + fn activate(&mut self); + fn deactivate(&mut self); +} diff --git a/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/chat_page/section/usage.rs b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/chat_page/section/usage.rs new file mode 100644 index 0000000..1437f79 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/chat_page/section/usage.rs @@ -0,0 +1,62 @@ +use ratatui::{ + style::Stylize, + text::{Line, Span, Text}, +}; + +#[derive(Debug, Clone)] +pub struct UsageInfoLine { + pub keys: Vec, + pub description: String, +} + +#[derive(Debug, Clone)] +pub struct UsageInfo { + pub description: Option, + pub lines: Vec, +} + +pub trait HasUsageInfo { + fn usage_info(&self) -> UsageInfo; +} + +fn key_to_span<'a>(key: &String) -> Span<'a> { + Span::from(format!("({})", key)).bold() +} + +pub fn widget_usage_to_text<'a>(usage: UsageInfo) -> Text<'a> { + let mut lines: Vec = vec![]; + if let Some(description) = usage.description { + lines.push(Line::from(description)); + } + + for wuk in usage.lines { + let mut bindings: Vec = match wuk.keys.len() { + 0 => vec![], + 1 => vec![key_to_span(&wuk.keys[0])], + 2 => vec![ + key_to_span(&wuk.keys[0]), + " or ".into(), + key_to_span(&wuk.keys[1]), + ], + _ => { + let mut bindings: Vec = Vec::with_capacity(wuk.keys.len() * 2); + + for key in wuk.keys.iter().take(wuk.keys.len() - 1) { + bindings.push(key_to_span(key)); + bindings.push(", ".into()); + } + + bindings.push("or".into()); + bindings.push(key_to_span(wuk.keys.last().unwrap())); + + bindings + } + }; + + bindings.push(Span::from(format!(" {}", wuk.description))); + + lines.push(Line::from(bindings)); + } + + Text::from(lines) +} diff --git a/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/connect_page/connect_page.rs b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/connect_page/connect_page.rs new file mode 100644 index 0000000..f16ca14 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/connect_page/connect_page.rs @@ -0,0 +1,186 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::{prelude::*, widgets::*, Frame}; +use tokio::sync::mpsc::UnboundedSender; + +use crate::state_store::ServerConnectionStatus; +use crate::state_store::{action::Action, State}; + +use crate::ui_management::components::input_box; +use crate::ui_management::components::{input_box::InputBox, Component, ComponentRender}; + +struct Props { + error_message: Option, +} + +impl From<&State> for Props { + fn from(state: &State) -> Self { + Props { + error_message: if let ServerConnectionStatus::Errored { err } = + &state.server_connection_status + { + Some(err.to_string()) + } else { + None + }, + } + } +} + +/// ConnectPage handles the connection to the server +pub struct ConnectPage { + /// Action sender + pub action_tx: UnboundedSender, + // Mapped Props from State + props: Props, + // Internal Components + input_box: InputBox, +} + +impl ConnectPage { + fn connect_to_server(&mut self) { + if self.input_box.is_empty() { + return; + } + + let _ = self.action_tx.send(Action::ConnectToServerRequest { + addr: self.input_box.text().to_string(), + }); + } +} + +const DEFAULT_SERVER_ADDR: &str = "localhost:8080"; + +impl Component for ConnectPage { + fn new(state: &State, action_tx: UnboundedSender) -> Self + where + Self: Sized, + { + let mut input_box = InputBox::new(state, action_tx.clone()); + input_box.set_text(DEFAULT_SERVER_ADDR); + + ConnectPage { + action_tx: action_tx.clone(), + // + props: Props::from(state), + // + input_box, + } + .move_with_state(state) + } + + fn move_with_state(self, state: &State) -> Self + where + Self: Sized, + { + ConnectPage { + props: Props::from(state), + ..self + } + } + + fn name(&self) -> &str { + "Connect Page" + } + + fn handle_key_event(&mut self, key: KeyEvent) { + self.input_box.handle_key_event(key); + + if key.kind != KeyEventKind::Press { + return; + } + + match key.code { + KeyCode::Enter => { + self.connect_to_server(); + } + KeyCode::Char('q') => { + let _ = self.action_tx.send(Action::Exit); + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + let _ = self.action_tx.send(Action::Exit); + } + _ => {} + } + } +} + +impl ComponentRender<()> for ConnectPage { + fn render(&self, frame: &mut Frame, _props: ()) { + let [_, vertical_centered, _] = *Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Ratio(1, 3), + Constraint::Min(1), + Constraint::Ratio(1, 3), + ] + .as_ref(), + ) + .split(frame.area()) + else { + panic!("The main layout should have 3 chunks") + }; + + let [_, both_centered, _] = *Layout::default() + .direction(Direction::Horizontal) + .constraints( + [ + Constraint::Ratio(1, 3), + Constraint::Min(1), + Constraint::Ratio(1, 3), + ] + .as_ref(), + ) + .split(vertical_centered) + else { + panic!("The horizontal layout should have 3 chunks") + }; + + let [container_addr_input, container_help_text, container_error_message] = + *Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Min(1), + ] + .as_ref(), + ) + .split(both_centered) + else { + panic!("The left layout should have 3 chunks") + }; + + self.input_box.render( + frame, + input_box::RenderProps { + title: "Server Host and Port".into(), + area: container_addr_input, + border_color: Color::Yellow, + show_cursor: true, + }, + ); + + let help_text = Paragraph::new(Text::from(Line::from(vec![ + "Press ".into(), + "".bold(), + " to connect".into(), + ]))); + frame.render_widget(help_text, container_help_text); + + let error_message = Paragraph::new(if let Some(err) = self.props.error_message.as_ref() { + Text::from(format!("Error: {}", err.as_str())) + } else { + Text::from("") + }) + .wrap(Wrap { trim: true }) + .style( + Style::default() + .fg(Color::Red) + .add_modifier(Modifier::SLOW_BLINK | Modifier::ITALIC), + ); + + frame.render_widget(error_message, container_error_message); + } +} diff --git a/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/connect_page/mod.rs b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/connect_page/mod.rs new file mode 100644 index 0000000..d49052c --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/connect_page/mod.rs @@ -0,0 +1,4 @@ +#[allow(clippy::module_inception)] +mod connect_page; + +pub use connect_page::ConnectPage; diff --git a/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/mod.rs b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/mod.rs new file mode 100644 index 0000000..2f42dd8 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/pages/mod.rs @@ -0,0 +1,100 @@ +use crossterm::event::KeyEvent; +use ratatui::Frame; +use tokio::sync::mpsc::UnboundedSender; + +use crate::state_store::{action::Action, ServerConnectionStatus, State}; + +use self::{chat_page::ChatPage, connect_page::ConnectPage}; + +use super::components::{Component, ComponentRender}; + +mod chat_page; +mod connect_page; + +enum ActivePage { + ChatPage, + ConnectPage, +} + +struct Props { + active_page: ActivePage, +} + +impl From<&State> for Props { + fn from(state: &State) -> Self { + Props { + active_page: match state.server_connection_status { + ServerConnectionStatus::Connected { .. } => ActivePage::ChatPage, + _ => ActivePage::ConnectPage, + }, + } + } +} + +pub struct AppRouter { + props: Props, + // + chat_page: ChatPage, + connect_page: ConnectPage, +} + +impl AppRouter { + fn get_active_page_component(&self) -> &dyn Component { + match self.props.active_page { + ActivePage::ChatPage => &self.chat_page, + ActivePage::ConnectPage => &self.connect_page, + } + } + + fn get_active_page_component_mut(&mut self) -> &mut dyn Component { + match self.props.active_page { + ActivePage::ChatPage => &mut self.chat_page, + ActivePage::ConnectPage => &mut self.connect_page, + } + } +} + +impl Component for AppRouter { + fn new(state: &State, action_tx: UnboundedSender) -> Self + where + Self: Sized, + { + AppRouter { + props: Props::from(state), + // + chat_page: ChatPage::new(state, action_tx.clone()), + connect_page: ConnectPage::new(state, action_tx.clone()), + } + .move_with_state(state) + } + + fn move_with_state(self, state: &State) -> Self + where + Self: Sized, + { + AppRouter { + props: Props::from(state), + // + chat_page: self.chat_page.move_with_state(state), + connect_page: self.connect_page.move_with_state(state), + } + } + + // route all functions to the active page + fn name(&self) -> &str { + self.get_active_page_component().name() + } + + fn handle_key_event(&mut self, key: KeyEvent) { + self.get_active_page_component_mut().handle_key_event(key) + } +} + +impl ComponentRender<()> for AppRouter { + fn render(&self, frame: &mut Frame, props: ()) { + match self.props.active_page { + ActivePage::ChatPage => self.chat_page.render(frame, props), + ActivePage::ConnectPage => self.connect_page.render(frame, props), + } + } +} diff --git a/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/ui_manager.rs b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/ui_manager.rs new file mode 100644 index 0000000..42b8430 --- /dev/null +++ b/spring-2025/sen-109/00010/rust-chat-server/tui/src/ui_management/ui_manager.rs @@ -0,0 +1,112 @@ +use std::{ + io::{self, Stdout}, + time::Duration, +}; + +use anyhow::Context; +use crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture, Event, EventStream}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::prelude::*; +use tokio::sync::{ + broadcast, + mpsc::{self, UnboundedReceiver}, +}; +use tokio_stream::StreamExt; + +use crate::{ + state_store::{action::Action, State}, + ui_management::components::{Component, ComponentRender}, + Interrupted, +}; + +use super::pages::AppRouter; + +const RENDERING_TICK_RATE: Duration = Duration::from_millis(250); + +pub struct UiManager { + action_tx: mpsc::UnboundedSender, +} + +impl UiManager { + pub fn new() -> (Self, UnboundedReceiver) { + let (action_tx, action_rx) = mpsc::unbounded_channel(); + + (Self { action_tx }, action_rx) + } + + pub async fn main_loop( + self, + mut state_rx: UnboundedReceiver, + mut interrupt_rx: broadcast::Receiver, + ) -> anyhow::Result { + // consume the first state to initialize the ui app + let mut app_router = { + let state = state_rx.recv().await.unwrap(); + + AppRouter::new(&state, self.action_tx.clone()) + }; + + let mut terminal = setup_terminal()?; + let mut ticker = tokio::time::interval(RENDERING_TICK_RATE); + let mut crossterm_events = EventStream::new(); + + let result: anyhow::Result = loop { + tokio::select! { + // Tick to terminate the select every N milliseconds + _ = ticker.tick() => (), + // Catch and handle crossterm events + maybe_event = crossterm_events.next() => match maybe_event { + Some(Ok(Event::Key(key))) => { + app_router.handle_key_event(key); + }, + None => break Ok(Interrupted::UserInt), + _ => (), + }, + // Handle state updates + Some(state) = state_rx.recv() => { + app_router = app_router.move_with_state(&state); + }, + // Catch and handle interrupt signal to gracefully shutdown + Ok(interrupted) = interrupt_rx.recv() => { + break Ok(interrupted); + } + } + + if let Err(err) = terminal + .draw(|frame| app_router.render(frame, ())) + .context("could not render to the terminal") + { + break Err(err); + } + }; + + restore_terminal(&mut terminal)?; + + result + } +} + +fn setup_terminal() -> anyhow::Result>> { + let mut stdout = io::stdout(); + + enable_raw_mode()?; + + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + + Ok(Terminal::new(CrosstermBackend::new(stdout))?) +} + +fn restore_terminal(terminal: &mut Terminal>) -> anyhow::Result<()> { + disable_raw_mode()?; + + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + + Ok(terminal.show_cursor()?) +}