From 384c5880b2b670217927cb4bd69dc21dcbdd80f0 Mon Sep 17 00:00:00 2001 From: Dan Ballard Date: Thu, 14 Jul 2022 16:33:41 -0700 Subject: [PATCH] typed IDs for most things, docs for all --- src/event.rs | 375 +++++++++++++++++++++++++++++++++++++++++---------- src/lib.rs | 1 + 2 files changed, 304 insertions(+), 72 deletions(-) diff --git a/src/event.rs b/src/event.rs index dd7e3df..1a677d9 100644 --- a/src/event.rs +++ b/src/event.rs @@ -4,13 +4,69 @@ use chrono::{DateTime, FixedOffset}; use chrono::format::Fixed::TimezoneOffset; use chrono::prelude::*; use chrono::offset::LocalResult; +use std::convert::From; use crate::structs::{ACL, ConnectionState, CwtchEvent}; -pub type Identity = String; -pub type Handle = String; -pub type ConversationID = i32; -pub type FileKey = String; +#[derive(Debug)] +/// Profile ID used to refer to profiles in Cwtch +pub struct ProfileIdentity(String); + +impl From for ProfileIdentity { + fn from(x: String) -> Self { + ProfileIdentity(x) + } +} + +#[derive(Debug)] +/// Contact ID used to refer to contacts in Cwtch +pub struct ContactIdentity(String); + +impl From for ContactIdentity { + fn from(x: String) -> Self { + ContactIdentity(x) + } +} + +#[derive(Debug)] +/// Conversation ID user to refer to a conversation with a Contact or Group in Cwtch +pub struct ConversationID(i32) ; + +impl From for ConversationID { + fn from(x: i32) -> Self { + ConversationID(x) + } +} + +#[derive(Debug)] +/// Group ID used to refer to a Group in Cwtch +pub struct GroupID(String) ; + +impl From for GroupID { + fn from(x: String) -> Self { + GroupID(x) + } +} + +#[derive(Debug)] +/// Server ID user to refer to a server in Cwtch +pub struct ServerIdentity(String); + +impl From for ServerIdentity { + fn from(x: String) -> Self { + ServerIdentity(x) + } +} + +#[derive(Debug)] +/// FileKey ID user to refer to a file share in Cwtch +pub struct FileKey(String); + +impl From for FileKey { + fn from(x: String) -> Self { + FileKey(x) + } +} #[derive(Debug)] /// Enum for type of notification a UI/client should emit for a new message @@ -104,31 +160,44 @@ pub enum Event { CwtchStarted, /// A new peer has been loaded, details of peer. Identity should be stored so further API calls can be made NewPeer { - identity: Identity, + /// identity field + profile: ProfileIdentity, + /// optional client specified tag tag: String, + /// is this a newly created profile event, or a load created: bool, + /// user supplied name of the profile name: String, + /// default picture path default_picture: String, + /// user supplied picture path picture: String, + /// is the profile online online: String, + /// json of the contacts contacts_json: String, + /// json of known servers server_json: String, //ServerList }, /// Cwtch had an error at the app level (not profile level), usually in response to an API call AppError { + /// details of the app error that occured error: String }, /// Global settings being emited from lcg, usually in response to them being sent to be saved by client UpdateGlobalSettings { + /// map of setting names to values (https://git.openprivacy.ca/cwtch.im/libcwtch-go/src/branch/trunk/utils/settings.go) settings: HashMap, }, /// A profile has an error, usually emited in response to an API call PeerError { + /// details of the peer error that occured error: String }, /// A profile was successfully deleted, it is no longer usable PeerDeleted { - identity: String + /// identity of deleted peer + profile: ProfileIdentity }, /// Cwtch is shutting down, stop making API calls Shutdown, @@ -141,7 +210,8 @@ pub enum Event { }, /// Version of the ACN (currently tor) ACNVersion { - verstion: String, + /// version string from ACN app + version: String, }, /// Notice from libCwtch that before completing load of a profile a storage migration is occuring so Start will take longer StartingStorageMiragtion, @@ -152,28 +222,42 @@ pub enum Event { /// A new server has been loaded NewServer { - onion: Identity, + /// identity of server + server: ServerIdentity, + /// sharable / importable server bundle server_bundle: String, + /// user supplied description description: String, + /// storage mode the server is using storage_type: ServerStorageType, + /// does/should the server auto start on cwtch start autostart: bool, + /// is the server running running: bool, }, /// Response to request for server intent change, indicating the server is indending the new state ServerIntentUpdate { - identity: Identity, + /// identity of server + server: ServerIdentity, + /// intent of the server to be running or not intent: ServerIntent }, /// Notice a server was deleted (in response to an API call) and is no longer usable ServerDeleted { - identity: Identity, + /// identity of server + server: ServerIdentity, + /// was deletion a success success: bool, + /// optional error string in case of failure to delete error: Option, }, /// Stats info for a server, periodically emited ServerStatsUpdate { - identity: Identity, + /// identity of server + server: ServerIdentity, + /// count of total messages on the server total_messages: i32, + /// count of total current connections to the server connections: i32, }, @@ -181,151 +265,277 @@ pub enum Event { /// A new message was received NewMessageFromPeer { - conversation_id: i32, - handle: String, + /// identity field + profile: ProfileIdentity, + /// conversation id + conversation_id: ConversationID, + /// contact id + contact: ContactIdentity, + /// name of contact nick: String, + /// time message was received timestamp_received: DateTime, + /// the message message: String, + /// notification instructions (based on settings) notification: MessageNotification, + /// path to picture for the contact picture: String, }, /// A new contact has been created (imported, added, or contacted by) ContactCreated { - conversation_id: i32, - remote_peer: Identity, + /// identity field + profile: ProfileIdentity, + /// conversation id + conversation_id: ConversationID, + /// contact id + contact: ContactIdentity, + /// name of group nick: String, + /// connection status to group server status: ConnectionState, + /// number of unread messages in group unread: i32, + /// path to picture for group picture: String, + /// path to default picture for group default_picture: String, + /// total number of messages in group num_messages: i32, + /// has the user accepted the group accepted: bool, + /// ACL for the group access_control_list: ACL, + /// is the group blocked blocked: bool, + /// is the group syncing loading: bool, + /// time of last message from the group last_msg_time: DateTime, }, /// A peer has changed state PeerStateChange { - remote_peer: Identity, + /// identity field + profile: ProfileIdentity, + /// contact id + contact: ContactIdentity, + /// connection status to contact connection_state: ConnectionState, }, /// Notice from the network check plugin, a profile self check test by attempting to connecting to itself NetworkStatus { - address: Identity, + /// profile the check was performed on + profile: ProfileIdentity, // it's technically a profile self check + /// error if there was one (can be empty) error: String, + /// status of profile self connection check status: NetworkCheckStatus, }, /// Information from the ACN about a peer ACNInfo { - handle: Identity, + /// identity field + profile: ProfileIdentity, + /// contact id + contact: ContactIdentity, + /// key of info key: String, + /// data of info data: String, }, /// a profile attribute has been updated with a new value UpdatedProfileAttribute { + /// identity field + profile: ProfileIdentity, + /// attribute key key: String, + /// attribute new value value: String, }, /// emited to confirm ack of a message succeeded IndexedAcknowledgement { + /// identity field + profile: ProfileIdentity, + /// conversation id conversation_id: i32, + /// index of message acked index: i32, }, /// emited to signal failure to ack a message IndexedFailure { - conversation_id: i32, + /// identity field + profile: ProfileIdentity, + /// conversation id + conversation_id: ConversationID, + /// index of failure of message to ack index: i32, - handle: Identity, + /// contact id + contact: ContactIdentity, + /// error string error: String }, /// a peer has acked a message PeerAcknowledgement { + /// identity field + profile: ProfileIdentity, + /// message id this is an ack to event_id: String, - remote_peer: Identity, + /// contact id + contact: ContactIdentity, + /// conversation id conversation_id: i32, }, /// New message received on a group NewMessageFromGroup { - conversation_id: i32, + /// identity field + profile: ProfileIdentity, + /// conversation id + conversation_id: ConversationID, + /// time of message timestamp_sent: DateTime, - remote_peer: Identity, + /// contact id + contact: ContactIdentity, + /// message index index: i32, + /// the message message: String, + /// hash of the message content_hash: String, + /// path to picture for sender picture: String, + /// name of sender nick: String, + /// notification policy (based on settings) notification: MessageNotification, }, /// notice a group has been created GroupCreated { - conversation_id: i32, - group_id: String, + /// identity field + profile: ProfileIdentity, + /// conversation id + conversation_id: ConversationID, + /// group id + group_id: GroupID, + /// server the group is on group_server: String, + /// name of group group_name: String, + /// path to picture for group picture: String, + /// Access Control List for group access_control_list: ACL, }, /// notice a new group exists NewGroup { - conversation_id: i32, - group_id: String, + /// identity field + profile: ProfileIdentity, + /// conversation id + conversation_id: ConversationID, + /// group id + group_id: GroupID, + /// server the group is on group_server: String, + /// invite string group_invite: String, + /// group name group_name: String, + /// path to group picture picture: String, + /// Access Control List for group access_control_list: ACL, }, /// a server connection state has changed ServerStateChange { + /// identity field + profile: ProfileIdentity, + /// server the group is on group_server: String, + /// state of connection to server state: ConnectionState }, /// A getval call to a peer has returned a response NewRetValMessageFromPeer { - remote_peer: Identity, + /// identity field + profile: ProfileIdentity, + /// conversation id + contact: ContactIdentity, + /// scope of the val scope: String, + /// path of the val (zone.key) path: String, + /// does the queried for value exist exists: bool, + /// value data: String, + /// optional filepath if there was a downloaded component file_path: Option }, /// result of a call to share a file, the filekey and manifest to operate on it ShareManifest { - filekey: String, + /// identity field + profile: ProfileIdentity, + /// filekey + filekey: FileKey, + /// serialized manifest of the share serializedManifest: String, }, /// Information on a peer fileshare has been received ManifestSizeReceived { - filekey: String, + /// identity field + profile: ProfileIdentity, + /// filekey + filekey: FileKey, + /// size of manifest received for a share manifest_size: i32, - handle: String, + /// contact id + contact: ContactIdentity, }, /// An error has occured while trying to parse a peer sharefile ManifestError { - handle: Identity, - filekey: String, + /// identity field + profile: ProfileIdentity, + /// contact id + contact: ContactIdentity, + /// filekey + filekey: FileKey, }, /// A peer message about a shared file has been received ManifestReceived { - handle: Identity, - filekey: String, + /// identity field + profile: ProfileIdentity, + /// contact id + contact: ContactIdentity, + /// filekey + filekey: FileKey, + /// serialized manifest serialized_manifest: String, }, /// a received manfiest has been saved ManifestSaved { - handle: Identity, - filekey: String, + /// identity field + profile: ProfileIdentity, + /// contact id + contact: ContactIdentity, + /// filekey + filekey: FileKey, + /// serialized manifest serialized_manifest: String, + /// temporary storage path for share download temp_file: String, + /// contact suggested share file name name_suggestion: String, }, /// periodically emited status updates about an active download of a shared file FileDownloadProgressUpdate { - filekey: String, + /// identity field + profile: ProfileIdentity, + /// filekey + filekey: FileKey, + /// progress of download of share in chunks progress: i32, + /// size of share in chunks filesize_in_chunks: i32, + /// contact suggested name for file name_suggestion: String, }, // FileDownloaded, ?? @@ -333,7 +543,9 @@ pub enum Event { /// Indicates an event was sent from libCwtch that we don't handle/didn't anticipate /// still passing it on giving the user a chance to react, but should be reported so we can handle it properly ErrUnhandled { + /// name of unhandled event name: String, + /// map of key:val attributes of unhandled event data: HashMap, }, } @@ -343,7 +555,7 @@ impl From<&CwtchEvent> for Event { match cwtch_event.event_type.as_str() { "CwtchStarted" => Event::CwtchStarted, "NewPeer" => Event::NewPeer { - identity: cwtch_event.data["Identity"].clone(), + profile: cwtch_event.data["Identity"].clone().into(), tag: cwtch_event.data["tag"].clone(), created: cwtch_event.data["Created"] == "true", name: cwtch_event.data["name"].clone(), @@ -354,8 +566,9 @@ impl From<&CwtchEvent> for Event { server_json: cwtch_event.data["ServerList"].clone(), }, "NewMessageFromPeer" => Event::NewMessageFromPeer { - conversation_id: cwtch_event.data["ConversationID"].parse().unwrap_or(-2), - handle: cwtch_event.data["RemotePeer"].clone(), + profile: cwtch_event.data["ProfileOnion"].clone().into(), + conversation_id: cwtch_event.data["ConversationID"].parse().unwrap_or(-2).into(), + contact: cwtch_event.data["RemotePeer"].clone().into(), nick: cwtch_event.data["Nick"].clone(), timestamp_received: DateTime::parse_from_rfc3339(cwtch_event.data["TimestampReceived"].as_str()).unwrap_or( DateTime::from(Utc::now())), message: cwtch_event.data["Data"].clone(), @@ -367,8 +580,9 @@ impl From<&CwtchEvent> for Event { error: cwtch_event.data["Error"].clone(), }, "ContactCreated" => Event::ContactCreated { - conversation_id: cwtch_event.data["ConversationID"].clone().parse().unwrap_or(-2), - remote_peer: cwtch_event.data["RemotePeer"].clone(), + profile: cwtch_event.data["ProfileOnion"].clone().into(), + conversation_id: cwtch_event.data["ConversationID"].clone().parse().unwrap_or(-2).into(), + contact: cwtch_event.data["RemotePeer"].clone().into(), unread: cwtch_event.data["unread"].clone().parse().unwrap_or(0), picture: cwtch_event.data["picture"].clone(), default_picture: cwtch_event.data["defaultPicture"].clone(), @@ -382,49 +596,55 @@ impl From<&CwtchEvent> for Event { last_msg_time: DateTime::parse_from_rfc3339(cwtch_event.data["lastMsgTime"].as_str()).unwrap_or(DateTime::from(Utc::now())), }, "PeerStateChange" => Event::PeerStateChange { - remote_peer: cwtch_event.data["RemotePeer"].clone(), + profile: cwtch_event.data["ProfileOnion"].clone().into(), + contact: cwtch_event.data["RemotePeer"].clone().into(), connection_state: ConnectionState::from(cwtch_event.data["ConnectionState"].as_str()), }, "UpdateGlobalSettings" => Event::UpdateGlobalSettings { settings: serde_json::from_str(cwtch_event.data["Data"].as_str()).unwrap_or(HashMap::new()), }, - "PeerDeleted" => Event::PeerDeleted { identity: cwtch_event.data["Identity"].clone() }, + "PeerDeleted" => Event::PeerDeleted { profile: cwtch_event.data["Identity"].clone().into() }, "ACNStatus" => Event::ACNStatus { progress: cwtch_event.data["Progress"].parse().unwrap_or(0), status: cwtch_event.data["Status"].clone(), }, - "ACNVersion" => Event::ACNVersion { verstion: cwtch_event.data["Data"].clone()}, + "ACNVersion" => Event::ACNVersion { version: cwtch_event.data["Data"].clone()}, "NetworkError" => Event::NetworkStatus { - address: cwtch_event.data["Onion"].clone(), + profile: cwtch_event.data["Onion"].clone().into(), error: cwtch_event.data["Error"].clone(), status: NetworkCheckStatus::from(cwtch_event.data["Status"].clone()) }, "ACNInfo" => Event::ACNInfo { - handle: cwtch_event.data["Handle"].clone(), + profile: cwtch_event.data["ProfileOnion"].clone().into(), + contact: cwtch_event.data["Handle"].clone().into(), key: cwtch_event.data["Key"].clone(), data: cwtch_event.data["Data"].clone(), }, "UpdatedProfileAttribute" => Event::UpdatedProfileAttribute { + profile: cwtch_event.data["ProfileOnion"].clone().into(), key: cwtch_event.data["Key"].clone(), value: cwtch_event.data["Data"].clone(), }, "IndexedAcknowledgement" => Event::IndexedAcknowledgement { + profile: cwtch_event.data["ProfileOnion"].clone().into(), conversation_id: cwtch_event.data["ConversationID"].parse().unwrap_or(-1), index: cwtch_event.data["Index"].parse().unwrap_or(-1), }, "IndexedAcknowledgement" => Event::IndexedFailure { - conversation_id: cwtch_event.data["ConversationID"].parse().unwrap_or(-1), + profile: cwtch_event.data["ProfileOnion"].clone().into(), + conversation_id: cwtch_event.data["ConversationID"].parse().unwrap_or(-1).into(), index: cwtch_event.data["Index"].parse().unwrap_or(-1), - handle: cwtch_event.data["Handle"].clone(), + contact: cwtch_event.data["Handle"].clone().into(), error: cwtch_event.data["Error"].clone(), }, "ShareManifest" => Event::ShareManifest { - filekey: cwtch_event.data["FileKey"].clone(), + profile: cwtch_event.data["ProfileOnion"].clone().into(), + filekey: cwtch_event.data["FileKey"].clone().into(), serializedManifest: cwtch_event.data["SerializedManifest"].clone(), }, "NewServer" => Event::NewServer { - onion: cwtch_event.data["Onion"].clone(), + server: cwtch_event.data["Onion"].clone().into(), server_bundle: cwtch_event.data["ServerBundle"].clone(), description: cwtch_event.data["Description"].clone(), storage_type: ServerStorageType::from(cwtch_event.data["StorageType"].clone()), @@ -432,11 +652,11 @@ impl From<&CwtchEvent> for Event { running: cwtch_event.data["Running"].parse().unwrap_or(false), }, "ServerIntentUpdate" => Event::ServerIntentUpdate { - identity: cwtch_event.data["Identity"].clone(), + server: cwtch_event.data["Identity"].clone().into(), intent: ServerIntent::from(cwtch_event.data["Intent"].clone()) }, "ServerDeleted" => Event::ServerDeleted { - identity: cwtch_event.data["Identity"].clone(), + server: cwtch_event.data["Identity"].clone().into(), success: cwtch_event.data["Status"].clone() == "success", error: match cwtch_event.data.get("Error") { Some(e) => Some(e.clone()), @@ -444,64 +664,73 @@ impl From<&CwtchEvent> for Event { } }, "ServerStatsUpdate" => Event::ServerStatsUpdate { - identity: cwtch_event.data["Identity"].clone(), + server: cwtch_event.data["Identity"].clone().into(), total_messages: cwtch_event.data["TotalMessages"].parse().unwrap_or(0), connections: cwtch_event.data["Connections"].parse().unwrap_or(0), }, "ManifestSizeReceived" => Event::ManifestSizeReceived { - filekey: cwtch_event.data["FileKey"].clone(), + profile: cwtch_event.data["ProfileOnion"].clone().into(), + filekey: cwtch_event.data["FileKey"].clone().into(), manifest_size: cwtch_event.data["ManifestSize"].parse().unwrap_or(0), - handle: cwtch_event.data["Handle"].clone(), + contact: cwtch_event.data["Handle"].clone().into(), }, "ManifestError" => Event::ManifestError { - handle: cwtch_event.data["Handle"].clone(), - filekey: cwtch_event.data["FileKey"].clone(), + profile: cwtch_event.data["ProfileOnion"].clone().into(), + contact: cwtch_event.data["Handle"].clone().into(), + filekey: cwtch_event.data["FileKey"].clone().into(), }, "ManifestReceived" => Event::ManifestReceived { - handle: cwtch_event.data["Handle"].clone(), - filekey: cwtch_event.data["FileKey"].clone(), + profile: cwtch_event.data["ProfileOnion"].clone().into(), + contact: cwtch_event.data["Handle"].clone().into(), + filekey: cwtch_event.data["FileKey"].clone().into(), serialized_manifest: cwtch_event.data["SerializedManifest"].clone(), }, "ManifestSaved" => Event::ManifestSaved { - handle: cwtch_event.data["Handle"].clone(), - filekey: cwtch_event.data["FileKey"].clone(), + profile: cwtch_event.data["ProfileOnion"].clone().into(), + contact: cwtch_event.data["Handle"].clone().into(), + filekey: cwtch_event.data["FileKey"].clone().into(), serialized_manifest: cwtch_event.data["SerializedManifest"].clone(), temp_file: cwtch_event.data["TempFile"].clone(), name_suggestion: cwtch_event.data["NameSuggestion"].clone(), }, "FileDownloadProgressUpdate" => Event::FileDownloadProgressUpdate { - filekey: cwtch_event.data["FileKey"].clone(), + profile: cwtch_event.data["ProfileOnion"].clone().into(), + filekey: cwtch_event.data["FileKey"].clone().into(), progress: cwtch_event.data["Progress"].parse().unwrap_or(0), filesize_in_chunks: cwtch_event.data["FileSizeInChunks"].parse().unwrap_or(0), name_suggestion: cwtch_event.data["NameSuggestion"].clone(), }, "PeerAcknowledgement" => Event::PeerAcknowledgement { + profile: cwtch_event.data["ProfileOnion"].clone().into(), event_id: cwtch_event.data["EventId"].clone(), - remote_peer: cwtch_event.data["RemotePeer"].clone(), + contact: cwtch_event.data["RemotePeer"].clone().into(), conversation_id: cwtch_event.data["ConversationID"].parse().unwrap_or(0) }, "NewMessageFromGroup" => Event::NewMessageFromGroup { + profile: cwtch_event.data["ProfileOnion"].clone().into(), timestamp_sent: DateTime::parse_from_rfc3339(cwtch_event.data["TimestampSent"].as_str()).unwrap_or( DateTime::from(Utc::now())), index: cwtch_event.data["RemotePeer"].parse().unwrap_or(-1), content_hash: cwtch_event.data["ContentHash"].clone(), - conversation_id: cwtch_event.data["ConversationID"].parse().unwrap_or(-2), - remote_peer: cwtch_event.data["RemotePeer"].clone(), + conversation_id: cwtch_event.data["ConversationID"].parse().unwrap_or(-2).into(), + contact: cwtch_event.data["RemotePeer"].clone().into(), nick: cwtch_event.data["Nick"].clone(), message: cwtch_event.data["Data"].clone(), notification: MessageNotification::from(cwtch_event.data["notification"].clone()), picture: cwtch_event.data["Picture"].clone(), }, "GroupCreated" => Event::GroupCreated { - conversation_id: cwtch_event.data["ConversationID"].parse().unwrap_or(-2), - group_id: cwtch_event.data["GroupID"].clone(), + profile: cwtch_event.data["ProfileOnion"].clone().into(), + conversation_id: cwtch_event.data["ConversationID"].parse().unwrap_or(-2).into(), + group_id: cwtch_event.data["GroupID"].clone().into(), group_server: cwtch_event.data["GroupServer"].clone(), group_name: cwtch_event.data["GroupName"].clone(), picture: cwtch_event.data["picture"].clone(), access_control_list: serde_json::from_str(cwtch_event.data["accessControlList"].as_str()).unwrap_or(ACL::new()), }, "NewGroup" => Event::NewGroup { - conversation_id: cwtch_event.data["ConversationID"].parse().unwrap_or(-2), - group_id: cwtch_event.data["GroupID"].clone(), + profile: cwtch_event.data["ProfileOnion"].clone().into(), + conversation_id: cwtch_event.data["ConversationID"].parse().unwrap_or(-2).into(), + group_id: cwtch_event.data["GroupID"].clone().into(), group_server: cwtch_event.data["GroupServer"].clone(), group_invite: cwtch_event.data["GroupInvite"].clone(), group_name: cwtch_event.data["GroupName"].clone(), @@ -509,11 +738,13 @@ impl From<&CwtchEvent> for Event { access_control_list: serde_json::from_str(cwtch_event.data["accessControlList"].as_str()).unwrap_or(ACL::new()), }, "ServerStateChange" => Event::ServerStateChange { + profile: cwtch_event.data["ProfileOnion"].clone().into(), group_server: cwtch_event.data["GroupServer"].clone(), state: ConnectionState::from(cwtch_event.data["ConnectionState"].as_str()), }, "NewRetValMessageFromPeer" => Event::NewRetValMessageFromPeer { - remote_peer: cwtch_event.data["RemotePeer"].clone(), + profile: cwtch_event.data["ProfileOnion"].clone().into(), + contact: cwtch_event.data["RemotePeer"].clone().into(), scope: cwtch_event.data["Scope"].clone(), path: cwtch_event.data["Path"].clone(), exists: cwtch_event.data["Exists"].parse().unwrap_or(false), diff --git a/src/lib.rs b/src/lib.rs index 7c1e53c..b44de4d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ mod cwtchlib_go; /// Basic structs using data from Cwtch and for deserializing JSON and serializing to JSON to communicate with Cwtch pub mod structs; +/// Additional structs for advnaced event handling and converstion helpers pub mod event; /// Error type for Cwtch lib related errors, intended for use with Result