//! # Logic for loading and validating application configuration use std::borrow::Cow; use std::collections::hash_map::DefaultHasher; use std::collections::HashMap; use std::fmt; use std::fs::File; use std::hash::{Hash, Hasher}; use std::io::BufReader; use std::path::{Path, PathBuf}; use std::process; use clap::Parser; use matrix_sdk::ruma::{OwnedRoomAliasId, OwnedRoomId, OwnedUserId, UserId}; use ratatui::style::{Color, Modifier as StyleModifier, Style}; use ratatui::text::Span; use ratatui_image::picker::ProtocolType; use serde::{de::Error as SerdeError, de::Visitor, Deserialize, Deserializer}; use tracing::Level; use url::Url; use super::base::{IambId, RoomInfo, SortColumn, SortFieldRoom, SortFieldUser, SortOrder}; macro_rules! usage { ( $($args: tt)* ) => { println!($($args)*); process::exit(2); } } const DEFAULT_MEMBERS_SORT: [SortColumn; 2] = [ SortColumn(SortFieldUser::PowerLevel, SortOrder::Ascending), SortColumn(SortFieldUser::UserId, SortOrder::Ascending), ]; const DEFAULT_ROOM_SORT: [SortColumn; 3] = [ SortColumn(SortFieldRoom::Favorite, SortOrder::Ascending), SortColumn(SortFieldRoom::LowPriority, SortOrder::Ascending), SortColumn(SortFieldRoom::Name, SortOrder::Ascending), ]; const DEFAULT_REQ_TIMEOUT: u64 = 120; const COLORS: [Color; 13] = [ Color::Blue, Color::Cyan, Color::Green, Color::LightBlue, Color::LightGreen, Color::LightCyan, Color::LightMagenta, Color::LightRed, Color::LightYellow, Color::Magenta, Color::Red, Color::Reset, Color::Yellow, ]; pub fn user_color(user: &str) -> Color { let mut hasher = DefaultHasher::new(); user.hash(&mut hasher); let color = hasher.finish() as usize % COLORS.len(); COLORS[color] } pub fn user_style_from_color(color: Color) -> Style { Style::default().fg(color).add_modifier(StyleModifier::BOLD) } fn is_profile_char(c: char) -> bool { c.is_ascii_alphanumeric() || c == '.' || c == '-' } fn validate_profile_name(name: &str) -> bool { if name.is_empty() { return false; } let mut chars = name.chars(); if !chars.next().map_or(false, |c| c.is_ascii_alphanumeric()) { return false; } name.chars().all(is_profile_char) } fn validate_profile_names(names: &HashMap) { for name in names.keys() { if validate_profile_name(name.as_str()) { continue; } usage!( "{:?} is not a valid profile name.\n\n\ Profile names can only contain the characters \ a-z, A-Z, and 0-9. Period (.) and hyphen (-) are allowed after the first character.", name ); } } const VERSION: &str = match option_env!("VERGEN_GIT_SHA") { None => env!("CARGO_PKG_VERSION"), Some(_) => concat!(env!("CARGO_PKG_VERSION"), " (", env!("VERGEN_GIT_SHA"), ")"), }; #[derive(Parser)] #[clap(version = VERSION, about, long_about = None)] #[clap(propagate_version = true)] pub struct Iamb { #[clap(short = 'P', long, value_parser)] pub profile: Option, #[clap(short = 'C', long, value_parser)] pub config_directory: Option, } #[derive(thiserror::Error, Debug)] pub enum ConfigError { #[error("Error reading configuration file: {0}")] IO(#[from] std::io::Error), #[error("Error loading configuration file: {0}")] Invalid(#[from] serde_json::Error), } #[derive(Clone, Debug, Eq, PartialEq)] pub struct LogLevel(pub Level); pub struct LogLevelVisitor; impl From for Level { fn from(level: LogLevel) -> Level { level.0 } } impl<'de> Visitor<'de> for LogLevelVisitor { type Value = LogLevel; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { formatter.write_str("a valid log level (e.g. \"warn\" or \"debug\")") } fn visit_str(self, value: &str) -> Result where E: SerdeError, { match value { "info" => Ok(LogLevel(Level::INFO)), "debug" => Ok(LogLevel(Level::DEBUG)), "warn" => Ok(LogLevel(Level::WARN)), "error" => Ok(LogLevel(Level::ERROR)), "trace" => Ok(LogLevel(Level::TRACE)), _ => Err(E::custom("Could not parse log level")), } } } impl<'de> Deserialize<'de> for LogLevel { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { deserializer.deserialize_str(LogLevelVisitor) } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct UserColor(pub Color); pub struct UserColorVisitor; impl<'de> Visitor<'de> for UserColorVisitor { type Value = UserColor; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { formatter.write_str("a valid color") } fn visit_str(self, value: &str) -> Result where E: SerdeError, { match value { "none" => Ok(UserColor(Color::Reset)), "red" => Ok(UserColor(Color::Red)), "black" => Ok(UserColor(Color::Black)), "green" => Ok(UserColor(Color::Green)), "yellow" => Ok(UserColor(Color::Yellow)), "blue" => Ok(UserColor(Color::Blue)), "magenta" => Ok(UserColor(Color::Magenta)), "cyan" => Ok(UserColor(Color::Cyan)), "gray" => Ok(UserColor(Color::Gray)), "dark-gray" => Ok(UserColor(Color::DarkGray)), "light-red" => Ok(UserColor(Color::LightRed)), "light-green" => Ok(UserColor(Color::LightGreen)), "light-yellow" => Ok(UserColor(Color::LightYellow)), "light-blue" => Ok(UserColor(Color::LightBlue)), "light-magenta" => Ok(UserColor(Color::LightMagenta)), "light-cyan" => Ok(UserColor(Color::LightCyan)), "white" => Ok(UserColor(Color::White)), _ => Err(E::custom("Could not parse color")), } } } impl<'de> Deserialize<'de> for UserColor { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { deserializer.deserialize_str(UserColorVisitor) } } #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] pub struct UserDisplayTunables { pub color: Option, pub name: Option, } pub type UserOverrides = HashMap; fn merge_sorts(a: SortOverrides, b: SortOverrides) -> SortOverrides { SortOverrides { chats: b.chats.or(a.chats), dms: b.dms.or(a.dms), rooms: b.rooms.or(a.rooms), spaces: b.spaces.or(a.spaces), members: b.members.or(a.members), } } fn merge_users(a: Option, b: Option) -> Option { match (a, b) { (Some(a), None) => Some(a), (None, Some(b)) => Some(b), (Some(mut a), Some(b)) => { for (k, v) in b { a.insert(k, v); } Some(a) }, (None, None) => None, } } #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] #[serde(rename_all = "lowercase")] pub enum UserDisplayStyle { // The Matrix username for the sender (e.g., "@user:example.com"). #[default] Username, // The localpart of the Matrix username (e.g., "@user"). LocalPart, // The display name for the Matrix user, calculated according to the rules from the spec. // // This is usually something like "Ada Lovelace" if the user has configured a display name, but // it can wind up being the Matrix username if there are display name collisions in the room, // in order to avoid any confusion. DisplayName, } #[derive(Clone)] pub struct ImagePreviewValues { pub size: ImagePreviewSize, pub protocol: Option, } #[derive(Clone, Default, Deserialize)] pub struct ImagePreview { pub size: Option, pub protocol: Option, } impl ImagePreview { fn values(self) -> ImagePreviewValues { ImagePreviewValues { size: self.size.unwrap_or_default(), protocol: self.protocol, } } } #[derive(Clone, Deserialize)] pub struct ImagePreviewSize { pub width: usize, pub height: usize, } impl Default for ImagePreviewSize { fn default() -> Self { ImagePreviewSize { width: 66, height: 10 } } } #[derive(Clone, Deserialize)] pub struct ImagePreviewProtocolValues { pub r#type: Option, pub font_size: Option<(u16, u16)>, } #[derive(Clone)] pub struct SortValues { pub chats: Vec>, pub dms: Vec>, pub rooms: Vec>, pub spaces: Vec>, pub members: Vec>, } #[derive(Clone, Default, Deserialize)] pub struct SortOverrides { pub chats: Option>>, pub dms: Option>>, pub rooms: Option>>, pub spaces: Option>>, pub members: Option>>, } impl SortOverrides { pub fn values(self) -> SortValues { let rooms = self.rooms.unwrap_or_else(|| Vec::from(DEFAULT_ROOM_SORT)); let chats = self.chats.unwrap_or_else(|| rooms.clone()); let dms = self.dms.unwrap_or_else(|| rooms.clone()); let spaces = self.spaces.unwrap_or_else(|| rooms.clone()); let members = self.members.unwrap_or_else(|| Vec::from(DEFAULT_MEMBERS_SORT)); SortValues { rooms, members, chats, dms, spaces } } } #[derive(Clone)] pub struct TunableValues { pub log_level: Level, pub reaction_display: bool, pub reaction_shortcode_display: bool, pub read_receipt_send: bool, pub read_receipt_display: bool, pub request_timeout: u64, pub sort: SortValues, pub typing_notice_send: bool, pub typing_notice_display: bool, pub users: UserOverrides, pub username_display: UserDisplayStyle, pub default_room: Option, pub open_command: Option>, pub image_preview: Option, } #[derive(Clone, Default, Deserialize)] pub struct Tunables { pub log_level: Option, pub reaction_display: Option, pub reaction_shortcode_display: Option, pub read_receipt_send: Option, pub read_receipt_display: Option, pub request_timeout: Option, #[serde(default)] pub sort: SortOverrides, pub typing_notice_send: Option, pub typing_notice_display: Option, pub users: Option, pub username_display: Option, pub default_room: Option, pub open_command: Option>, pub image_preview: Option, } impl Tunables { fn merge(self, other: Self) -> Self { Tunables { log_level: self.log_level.or(other.log_level), reaction_display: self.reaction_display.or(other.reaction_display), reaction_shortcode_display: self .reaction_shortcode_display .or(other.reaction_shortcode_display), read_receipt_send: self.read_receipt_send.or(other.read_receipt_send), read_receipt_display: self.read_receipt_display.or(other.read_receipt_display), request_timeout: self.request_timeout.or(other.request_timeout), sort: merge_sorts(self.sort, other.sort), typing_notice_send: self.typing_notice_send.or(other.typing_notice_send), typing_notice_display: self.typing_notice_display.or(other.typing_notice_display), users: merge_users(self.users, other.users), username_display: self.username_display.or(other.username_display), default_room: self.default_room.or(other.default_room), open_command: self.open_command.or(other.open_command), image_preview: self.image_preview.or(other.image_preview), } } fn values(self) -> TunableValues { TunableValues { log_level: self.log_level.map(Level::from).unwrap_or(Level::INFO), reaction_display: self.reaction_display.unwrap_or(true), reaction_shortcode_display: self.reaction_shortcode_display.unwrap_or(false), read_receipt_send: self.read_receipt_send.unwrap_or(true), read_receipt_display: self.read_receipt_display.unwrap_or(true), request_timeout: self.request_timeout.unwrap_or(DEFAULT_REQ_TIMEOUT), sort: self.sort.values(), typing_notice_send: self.typing_notice_send.unwrap_or(true), typing_notice_display: self.typing_notice_display.unwrap_or(true), users: self.users.unwrap_or_default(), username_display: self.username_display.unwrap_or_default(), default_room: self.default_room, open_command: self.open_command, image_preview: self.image_preview.map(ImagePreview::values), } } } #[derive(Clone)] pub struct DirectoryValues { pub cache: PathBuf, pub logs: PathBuf, pub downloads: Option, pub image_previews: PathBuf, } #[derive(Clone, Default, Deserialize)] pub struct Directories { pub cache: Option, pub logs: Option, pub downloads: Option, pub image_previews: Option, } impl Directories { fn merge(self, other: Self) -> Self { Directories { cache: self.cache.or(other.cache), logs: self.logs.or(other.logs), downloads: self.downloads.or(other.downloads), image_previews: self.image_previews.or(other.image_previews), } } fn values(self) -> DirectoryValues { let cache = self .cache .or_else(|| { let mut dir = dirs::cache_dir()?; dir.push("iamb"); dir.into() }) .expect("no dirs.cache value configured!"); let logs = self.logs.unwrap_or_else(|| { let mut dir = cache.clone(); dir.push("logs"); dir }); let downloads = self.downloads.or_else(dirs::download_dir); let image_previews = self.image_previews.unwrap_or_else(|| { let mut dir = cache.clone(); dir.push("image_preview_downloads"); dir }); DirectoryValues { cache, logs, downloads, image_previews } } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] #[serde(untagged)] pub enum WindowPath { AliasId(OwnedRoomAliasId), RoomId(OwnedRoomId), UserId(OwnedUserId), Window(IambId), } #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] #[serde(untagged, deny_unknown_fields)] pub enum WindowLayout { Window { window: WindowPath }, Split { split: Vec }, } #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] #[serde(rename_all = "lowercase", tag = "style")] pub enum Layout { /// Restore the layout from the previous session. #[default] Restore, /// Open a single window using the `default_room` value. New, /// Open the window layouts described under `tabs`. Config { tabs: Vec }, } #[derive(Clone, Deserialize)] pub struct ProfileConfig { pub user_id: OwnedUserId, pub url: Url, pub settings: Option, pub dirs: Option, pub layout: Option, } #[derive(Clone, Deserialize)] pub struct IambConfig { pub profiles: HashMap, pub default_profile: Option, pub settings: Option, pub dirs: Option, pub layout: Option, } impl IambConfig { pub fn load(config_json: &Path) -> Result { if !config_json.is_file() { usage!( "Please create a configuration file at {}\n\n\ For more information try '--help'", config_json.display(), ); } let file = File::open(config_json)?; let reader = BufReader::new(file); let config = serde_json::from_reader(reader)?; Ok(config) } } #[derive(Clone)] pub struct ApplicationSettings { pub matrix_dir: PathBuf, pub layout_json: PathBuf, pub session_json: PathBuf, pub profile_name: String, pub profile: ProfileConfig, pub tunables: TunableValues, pub dirs: DirectoryValues, pub layout: Layout, } impl ApplicationSettings { pub fn load(cli: Iamb) -> Result> { let mut config_dir = cli.config_directory.or_else(dirs::config_dir).unwrap_or_else(|| { usage!( "No user configuration directory found;\ please specify one via -C.\n\n For more information try '--help'" ); }); config_dir.push("iamb"); let mut config_json = config_dir.clone(); config_json.push("config.json"); let IambConfig { mut profiles, default_profile, dirs, settings: global, layout, } = IambConfig::load(config_json.as_path())?; validate_profile_names(&profiles); let (profile_name, mut profile) = if let Some(profile) = cli.profile.or(default_profile) { profiles.remove_entry(&profile).unwrap_or_else(|| { usage!( "No configured profile with the name {:?} in {}", profile, config_json.display() ); }) } else if profiles.len() == 1 { profiles.into_iter().next().unwrap() } else { usage!( "No profile specified. \ Please use -P or add \"default_profile\" to {}.\n\n\ For more information try '--help'", config_json.display() ); }; let layout = profile.layout.take().or(layout).unwrap_or_default(); let tunables = global.unwrap_or_default(); let tunables = profile.settings.take().unwrap_or_default().merge(tunables); let tunables = tunables.values(); let dirs = dirs.unwrap_or_default(); let dirs = profile.dirs.take().unwrap_or_default().merge(dirs); let dirs = dirs.values(); // Set up paths that live inside the profile's data directory. let mut profile_dir = config_dir.clone(); profile_dir.push("profiles"); profile_dir.push(profile_name.as_str()); let mut matrix_dir = profile_dir.clone(); matrix_dir.push("matrix"); let mut session_json = profile_dir; session_json.push("session.json"); // Set up paths that live inside the profile's cache directory. let mut cache_dir = dirs.cache.clone(); cache_dir.push("profiles"); cache_dir.push(profile_name.as_str()); let mut layout_json = cache_dir.clone(); layout_json.push("layout.json"); let settings = ApplicationSettings { matrix_dir, layout_json, session_json, profile_name, profile, tunables, dirs, layout, }; Ok(settings) } pub fn get_user_char_span<'a>(&self, user_id: &'a UserId) -> Span<'a> { let (color, c) = self .tunables .users .get(user_id) .map(|user| { ( user.color.as_ref().map(|c| c.0), user.name.as_ref().and_then(|s| s.chars().next()), ) }) .unwrap_or_default(); let color = color.unwrap_or_else(|| user_color(user_id.as_str())); let style = user_style_from_color(color); let c = c.unwrap_or_else(|| user_id.localpart().chars().next().unwrap_or(' ')); Span::styled(String::from(c), style) } pub fn get_user_overrides( &self, user_id: &UserId, ) -> (Option, Option>) { self.tunables .users .get(user_id) .map(|user| (user.color.as_ref().map(|c| c.0), user.name.clone().map(Cow::Owned))) .unwrap_or_default() } pub fn get_user_style(&self, user_id: &UserId) -> Style { let color = self .tunables .users .get(user_id) .and_then(|user| user.color.as_ref().map(|c| c.0)) .unwrap_or_else(|| user_color(user_id.as_str())); user_style_from_color(color) } pub fn get_user_span<'a>(&self, user_id: &'a UserId, info: &'a RoomInfo) -> Span<'a> { let (color, name) = self.get_user_overrides(user_id); let color = color.unwrap_or_else(|| user_color(user_id.as_str())); let style = user_style_from_color(color); let name = match (name, &self.tunables.username_display) { (Some(name), _) => name, (None, UserDisplayStyle::Username) => Cow::Borrowed(user_id.as_str()), (None, UserDisplayStyle::LocalPart) => Cow::Borrowed(user_id.localpart()), (None, UserDisplayStyle::DisplayName) => { if let Some(display) = info.display_names.get(user_id) { Cow::Borrowed(display.as_str()) } else { Cow::Borrowed(user_id.as_str()) } }, }; Span::styled(name, style) } } #[cfg(test)] mod tests { use super::*; use matrix_sdk::ruma::user_id; use std::convert::TryFrom; #[test] fn test_profile_name_invalid() { assert_eq!(validate_profile_name(""), false); assert_eq!(validate_profile_name(" "), false); assert_eq!(validate_profile_name("a b"), false); assert_eq!(validate_profile_name("foo^bar"), false); assert_eq!(validate_profile_name("FOO/BAR"), false); assert_eq!(validate_profile_name("-b-c"), false); assert_eq!(validate_profile_name("-B-c"), false); assert_eq!(validate_profile_name(".b-c"), false); assert_eq!(validate_profile_name(".B-c"), false); } #[test] fn test_profile_name_valid() { assert_eq!(validate_profile_name("foo"), true); assert_eq!(validate_profile_name("FOO"), true); assert_eq!(validate_profile_name("a-b-c"), true); assert_eq!(validate_profile_name("a-B-c"), true); assert_eq!(validate_profile_name("a.b-c"), true); assert_eq!(validate_profile_name("a.B-c"), true); } #[test] fn test_merge_users() { let a = None; let b = vec![(user_id!("@a:b.c").to_owned(), UserDisplayTunables { color: Some(UserColor(Color::Red)), name: Some("Hello".into()), })] .into_iter() .collect::>(); let c = vec![(user_id!("@a:b.c").to_owned(), UserDisplayTunables { color: Some(UserColor(Color::Green)), name: Some("World".into()), })] .into_iter() .collect::>(); let res = merge_users(a.clone(), a.clone()); assert_eq!(res, None); let res = merge_users(a.clone(), Some(b.clone())); assert_eq!(res, Some(b.clone())); let res = merge_users(Some(b.clone()), a.clone()); assert_eq!(res, Some(b.clone())); let res = merge_users(Some(b.clone()), Some(b.clone())); assert_eq!(res, Some(b.clone())); let res = merge_users(Some(b.clone()), Some(c.clone())); assert_eq!(res, Some(c.clone())); let res = merge_users(Some(c.clone()), Some(b.clone())); assert_eq!(res, Some(b.clone())); } #[test] fn test_parse_tunables() { let res: Tunables = serde_json::from_str("{}").unwrap(); assert_eq!(res.typing_notice_send, None); assert_eq!(res.typing_notice_display, None); assert_eq!(res.users, None); let res: Tunables = serde_json::from_str("{\"typing_notice_send\": true}").unwrap(); assert_eq!(res.typing_notice_send, Some(true)); assert_eq!(res.typing_notice_display, None); assert_eq!(res.users, None); let res: Tunables = serde_json::from_str("{\"typing_notice_send\": false}").unwrap(); assert_eq!(res.typing_notice_send, Some(false)); assert_eq!(res.typing_notice_display, None); assert_eq!(res.users, None); let res: Tunables = serde_json::from_str("{\"users\": {}}").unwrap(); assert_eq!(res.typing_notice_send, None); assert_eq!(res.typing_notice_display, None); assert_eq!(res.users, Some(HashMap::new())); let res: Tunables = serde_json::from_str( "{\"users\": {\"@a:b.c\": {\"color\": \"black\", \"name\": \"Tim\"}}}", ) .unwrap(); assert_eq!(res.typing_notice_send, None); assert_eq!(res.typing_notice_display, None); let users = vec![(user_id!("@a:b.c").to_owned(), UserDisplayTunables { color: Some(UserColor(Color::Black)), name: Some("Tim".into()), })]; assert_eq!(res.users, Some(users.into_iter().collect())); } #[test] fn test_parse_tunables_username_display() { let res: Tunables = serde_json::from_str("{\"username_display\": \"username\"}").unwrap(); assert_eq!(res.username_display, Some(UserDisplayStyle::Username)); let res: Tunables = serde_json::from_str("{\"username_display\": \"localpart\"}").unwrap(); assert_eq!(res.username_display, Some(UserDisplayStyle::LocalPart)); let res: Tunables = serde_json::from_str("{\"username_display\": \"displayname\"}").unwrap(); assert_eq!(res.username_display, Some(UserDisplayStyle::DisplayName)); } #[test] fn test_parse_tunables_sort() { let res: Tunables = serde_json::from_str( r#"{"sort": {"members": ["server","~localpart"],"spaces":["~favorite", "alias"]}}"#, ) .unwrap(); assert_eq!( res.sort.members, Some(vec![ SortColumn(SortFieldUser::Server, SortOrder::Ascending), SortColumn(SortFieldUser::LocalPart, SortOrder::Descending), ]) ); assert_eq!( res.sort.spaces, Some(vec![ SortColumn(SortFieldRoom::Favorite, SortOrder::Descending), SortColumn(SortFieldRoom::Alias, SortOrder::Ascending), ]) ); assert_eq!(res.sort.rooms, None); assert_eq!(res.sort.dms, None); // Check that we get the right default "rooms" and "dms" values. let res = res.values(); assert_eq!(res.sort.members, vec![ SortColumn(SortFieldUser::Server, SortOrder::Ascending), SortColumn(SortFieldUser::LocalPart, SortOrder::Descending), ]); assert_eq!(res.sort.spaces, vec![ SortColumn(SortFieldRoom::Favorite, SortOrder::Descending), SortColumn(SortFieldRoom::Alias, SortOrder::Ascending), ]); assert_eq!(res.sort.rooms, Vec::from(DEFAULT_ROOM_SORT)); assert_eq!(res.sort.dms, Vec::from(DEFAULT_ROOM_SORT)); } #[test] fn test_parse_layout() { let user = WindowPath::UserId(user_id!("@user:example.com").to_owned()); let alias = WindowPath::AliasId(OwnedRoomAliasId::try_from("#room:example.com").unwrap()); let room = WindowPath::RoomId(OwnedRoomId::try_from("!room:example.com").unwrap()); let dms = WindowPath::Window(IambId::DirectList); let welcome = WindowPath::Window(IambId::Welcome); let res: Layout = serde_json::from_str("{\"style\": \"restore\"}").unwrap(); assert_eq!(res, Layout::Restore); let res: Layout = serde_json::from_str("{\"style\": \"new\"}").unwrap(); assert_eq!(res, Layout::New); let res: Layout = serde_json::from_str( "{\"style\": \"config\", \"tabs\": [{\"window\":\"@user:example.com\"}]}", ) .unwrap(); assert_eq!(res, Layout::Config { tabs: vec![WindowLayout::Window { window: user.clone() }] }); let res: Layout = serde_json::from_str( "{\ \"style\": \"config\",\ \"tabs\": [\ {\"split\":[\ {\"window\":\"@user:example.com\"},\ {\"window\":\"#room:example.com\"}\ ]},\ {\"split\":[\ {\"window\":\"!room:example.com\"},\ {\"split\":[\ {\"window\":\"iamb://dms\"},\ {\"window\":\"iamb://welcome\"}\ ]}\ ]}\ ]}", ) .unwrap(); let split1 = WindowLayout::Split { split: vec![ WindowLayout::Window { window: user.clone() }, WindowLayout::Window { window: alias }, ], }; let split2 = WindowLayout::Split { split: vec![WindowLayout::Window { window: dms }, WindowLayout::Window { window: welcome, }], }; let split3 = WindowLayout::Split { split: vec![WindowLayout::Window { window: room }, split2], }; let tabs = vec![split1, split3]; assert_eq!(res, Layout::Config { tabs }); } }