diff --git a/ibis/ibismessage.h b/ibis/ibismessage.h
--- a/ibis/ibismessage.h
+++ b/ibis/ibismessage.h
@@ -30,6 +30,7 @@
 #include <glib.h>
 #include <glib-object.h>
 
+#include "ibistags.h"
 #include "ibisversion.h"
 
 G_BEGIN_DECLS
@@ -153,25 +154,24 @@
  *
  * Gets the tags from @message.
  *
- * Returns: (transfer none) (element-type utf8 utf8) (nullable): The tags from
- *          @message.
+ * Returns: (transfer none) (nullable): The tags from @message.
  *
  * Since: 0.1
  */
 IBIS_AVAILABLE_IN_0_1
-GHashTable *ibis_message_get_tags(IbisMessage *message);
+IbisTags *ibis_message_get_tags(IbisMessage *message);
 
 /**
  * ibis_message_set_tags:
  * @message: The instance.
- * @tags: (transfer none) (element-type utf8 utf8) (nullable): The new tags.
+ * @tags: (transfer none) (nullable): The new tags.
  *
  * Sets the tags of @message to @tags.
  *
  * Since: 0.1
  */
 IBIS_AVAILABLE_IN_0_1
-void ibis_message_set_tags(IbisMessage *message, GHashTable *tags);
+void ibis_message_set_tags(IbisMessage *message, IbisTags *tags);
 
 G_END_DECLS
 
# HG changeset patch
# Node ID 5a6bbdc3dcde
# Parent  972b025becba
diff --git a/ibis/ibismessage.c b/ibis/ibismessage.c
--- a/ibis/ibismessage.c
+++ b/ibis/ibismessage.c
@@ -42,7 +42,7 @@
 	char *command;
 	GStrv params;
 	char *source;
-	GHashTable *tags;
+	IbisTags *tags;
 };
 
 /******************************************************************************
@@ -57,7 +57,7 @@
 	g_clear_pointer(&message->command, g_free);
 	g_clear_pointer(&message->params, g_strfreev);
 	g_clear_pointer(&message->source, g_free);
-	g_clear_pointer(&message->tags, g_hash_table_unref);
+	g_clear_object(&message->tags);
 
 	G_OBJECT_CLASS(ibis_message_parent_class)->finalize(obj);
 }
@@ -79,7 +79,7 @@
 		g_value_set_string(value, ibis_message_get_source(message));
 		break;
 	case PROP_TAGS:
-		g_value_set_boxed(value, ibis_message_get_tags(message));
+		g_value_set_object(value, ibis_message_get_tags(message));
 		break;
 	default:
 		G_OBJECT_WARN_INVALID_PROPERTY_ID(obj, param_id, pspec);
@@ -104,7 +104,7 @@
 		ibis_message_set_source(message, g_value_get_string(value));
 		break;
 	case PROP_TAGS:
-		ibis_message_set_tags(message, g_value_get_boxed(value));
+		ibis_message_set_tags(message, g_value_get_object(value));
 		break;
 	default:
 		G_OBJECT_WARN_INVALID_PROPERTY_ID(obj, param_id, pspec);
@@ -178,10 +178,10 @@
 	 *
 	 * Since: 0.1
 	 */
-	properties[PROP_TAGS] = g_param_spec_boxed(
+	properties[PROP_TAGS] = g_param_spec_object(
 		"tags", "tags",
 		"The ircv3 message tags from the message.",
-		G_TYPE_HASH_TABLE,
+		IBIS_TYPE_TAGS,
 		G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS);
 
 	g_object_class_install_properties(obj_class, N_PROPERTIES, properties);
@@ -239,7 +239,7 @@
 	}
 }
 
-GHashTable *
+IbisTags *
 ibis_message_get_tags(IbisMessage *message) {
 	g_return_val_if_fail(IBIS_IS_MESSAGE(message), NULL);
 
@@ -247,16 +247,10 @@
 }
 
 void
-ibis_message_set_tags(IbisMessage *message, GHashTable *tags) {
+ibis_message_set_tags(IbisMessage *message, IbisTags *tags) {
 	g_return_if_fail(IBIS_IS_MESSAGE(message));
 
-	if(message->tags != tags) {
-		g_clear_pointer(&message->tags, g_hash_table_unref);
-
-		if(tags != NULL) {
-			message->tags = g_hash_table_ref(tags);
-		}
-
+	if(g_set_object(&message->tags, tags)) {
 		g_object_notify_by_pspec(G_OBJECT(message), properties[PROP_TAGS]);
 	}
 }
diff --git a/ibis/ibisparser.c b/ibis/ibisparser.c
--- a/ibis/ibisparser.c
+++ b/ibis/ibisparser.c
@@ -179,8 +179,8 @@
 {
 	IbisMessage *message = NULL;
 	IbisMessageHandler handler = NULL;
+	IbisTags *tags = NULL;
 	GError *local_error = NULL;
-	GHashTable *tags = NULL;
 	GMatchInfo *info = NULL;
 	GStrv params = NULL;
 	gpointer func = NULL;
@@ -241,7 +241,7 @@
 			g_free(tags_string);
 			g_free(command);
 			g_clear_object(&message);
-			g_hash_table_destroy(tags);
+			g_clear_object(&tags);
 			g_match_info_unref(info);
 
 			return FALSE;
@@ -251,7 +251,7 @@
 
 	if(tags != NULL) {
 		ibis_message_set_tags(message, tags);
-		g_hash_table_unref(tags);
+		g_clear_object(&tags);
 	}
 
 	source = g_match_info_fetch_named(info, "source");
diff --git a/ibis/ibistags.h b/ibis/ibistags.h
--- a/ibis/ibistags.h
+++ b/ibis/ibistags.h
@@ -24,6 +24,7 @@
 #define IBIS_TAGS_H
 
 #include <glib.h>
+#include <glib-object.h>
 
 #include "ibisversion.h"
 
@@ -41,17 +42,112 @@
 G_BEGIN_DECLS
 
 /**
- * ibis_tag_unescape_value:
- * @escaped: The escaped value.
+ * IbisTags:
+ *
+ * An object for managing message tags.
+ *
+ * Since: 0.1
+ */
+#define IBIS_TYPE_TAGS (ibis_tags_get_type())
+
+IBIS_AVAILABLE_IN_0_1
+G_DECLARE_FINAL_TYPE(IbisTags, ibis_tags, IBIS, TAGS, GObject)
+
+/**
+ * ibis_tags_add:
+ * @tags: The instance.
+ * @key: The key of the new tag.
+ * @value: The unescaped value to add.
+ *
+ * Adds the new @key and @value to @tags.
+ *
+ * If @key already existed, it will be replaced.
  *
- * Unescapes @escaped.
+ * Since: 0.1
+ */
+IBIS_AVAILABLE_IN_0_1
+void ibis_tags_add(IbisTags *tags, const char *key, const char *value);
+
+/**
+ * ibis_tags_contains:
+ * @tags1: The first instance.
+ * @tags2: The second instance.
+ *
+ * Checks if all of the keys and values in @tags2 exist in @tags1.
+ *
+ * Returns: %TRUE if all keys and values from @tags2 can be found in @tags1.
  *
- * Returns: (transfer full): The unescaped value.
+ * Since: 0.1
+ */
+IBIS_AVAILABLE_IN_0_1
+gboolean ibis_tags_contains(IbisTags *tags1, IbisTags *tags2);
+
+/**
+ * ibis_tags_equal:
+ * @tags1: The first instance.
+ * @tags2: The second instance.
+ *
+ * Checks if both @tags1 and @tags2 contain the exact same keys and values.
+ *
+ * Return: %TRUE if the same keys and values are found in both tags.
  *
  * Since: 0.1
  */
 IBIS_AVAILABLE_IN_0_1
-char *ibis_tag_unescape_value(const char *escaped);
+gboolean ibis_tags_equal(IbisTags *tags1, IbisTags *tags2);
+
+/**
+ * ibis_tags_exists:
+ * @tags: The instance.
+ * @key: The key to look for.
+ *
+ * Checks whether or not @key exists in @tags.
+ *
+ * Returns: %TRUE if @key was found, otherwise %FALSE.
+ *
+ * Since: 0.1
+ */
+IBIS_AVAILABLE_IN_0_1
+gboolean ibis_tags_exists(IbisTags *tags, const char *key);
+
+/**
+ * ibis_tags_get_size:
+ * @tags: The instance.
+ *
+ * Gets the number of tags in @tags.
+ *
+ * Returns: The number of tags in @tags.
+ *
+ * Since: 0.1
+ */
+IBIS_AVAILABLE_IN_0_1
+guint ibis_tags_get_size(IbisTags *tags);
+
+/**
+ * ibis_tags_lookup:
+ * @tags: The instance.
+ * @key: The key to lookup.
+ *
+ * Looks up @key in @tags and returns the value.
+ *
+ * Returns: The value of @key if found, otherwise %NULL.
+ *
+ * Since: 0.1
+ */
+IBIS_AVAILABLE_IN_0_1
+const char *ibis_tags_lookup(IbisTags *tags, const char *key);
+
+/**
+ * ibis_tags_new:
+ *
+ * Creates a new tags instance.
+ *
+ * Returns: (transfer full): The new instance.
+ *
+ * Since: 0.1
+ */
+IBIS_AVAILABLE_IN_0_1
+IbisTags *ibis_tags_new(void);
 
 /**
  * ibis_tags_parse:
@@ -67,7 +163,34 @@
  * Since: 0.1
  */
 IBIS_AVAILABLE_IN_0_1
-GHashTable *ibis_tags_parse(const char *tags_string, GError **error);
+IbisTags *ibis_tags_parse(const char *tags_string, GError **error);
+
+/**
+ * ibis_tags_remove:
+ * @tags: The instance.
+ * @key: The key to remove.
+ *
+ * Removes @key from @tags.
+ *
+ * Returns: %TRUE if @key was found and removed otherwise %FALSE.
+ *
+ * Since: 0.1
+ */
+IBIS_AVAILABLE_IN_0_1
+gboolean ibis_tags_remove(IbisTags *tags, const char *key);
+
+/**
+ * ibis_tags_serialize:
+ * @tags: The instance.
+ *
+ * Serializes @tags to a string.
+ *
+ * Returns: (transfer full): The serialized string.
+ *
+ * Since: 0.1
+ */
+IBIS_AVAILABLE_IN_0_1
+char *ibis_tags_serialize(IbisTags *tags);
 
 G_END_DECLS
 
diff --git a/ibis/ibistags.c b/ibis/ibistags.c
--- a/ibis/ibistags.c
+++ b/ibis/ibistags.c
@@ -20,10 +20,17 @@
 
 #include "ibisstring.h"
 
+struct _IbisTags {
+	GObject parent;
+
+	GHashTable *tags;
+	GQueue *keys;
+};
+
 /******************************************************************************
- * Public API
+ * Helpers
  *****************************************************************************/
-char *
+static char *
 ibis_tag_unescape_value(const char *escaped) {
 	GString *unescaped = NULL;
 	gboolean escaping = FALSE;
@@ -38,7 +45,7 @@
 	for(int i = 0; escaped[i] != '\0'; i++) {
 		if(escaping) {
 			/* Set the replacement to the current character which will fall
-			 * through for everything, including '\\'
+			 * through for everything, including '\\'.
 			 */
 			char replacement = escaped[i];
 
@@ -63,20 +70,186 @@
 		}
 	}
 
-	return g_string_free(unescaped, FALSE);
+	return g_string_free_and_steal(unescaped);
+}
+
+static char *
+ibis_tag_escape_value(const char *value) {
+	GString *escaped = NULL;
+
+	g_return_val_if_fail(value != NULL, NULL);
+
+	escaped = g_string_new("");
+
+	for(int i = 0; value[i] != '\0'; i++) {
+		if(value[i] == ';') {
+			g_string_append(escaped, "\\:");
+		} else if(value[i] == ' ') {
+			g_string_append(escaped, "\\s");
+		} else if(value[i] == '\\') {
+			g_string_append(escaped, "\\\\");
+		} else if(value[i] == '\r') {
+			g_string_append(escaped, "\\r");
+		} else if(value[i] == '\n') {
+			g_string_append(escaped, "\\n");
+		} else {
+			g_string_append_c(escaped, value[i]);
+		}
+	}
+
+	return g_string_free_and_steal(escaped);
+}
+
+/******************************************************************************
+ * GObject Implementation
+ *****************************************************************************/
+G_DEFINE_FINAL_TYPE(IbisTags, ibis_tags, G_TYPE_OBJECT)
+
+static void
+ibis_tags_finalize(GObject *obj) {
+	IbisTags *tags = IBIS_TAGS(obj);
+
+	g_clear_pointer(&tags->tags, g_hash_table_unref);
+	g_clear_pointer(&tags->keys, g_queue_free);
+
+	G_OBJECT_CLASS(ibis_tags_parent_class)->finalize(obj);
+}
+
+static void
+ibis_tags_init(IbisTags *tags) {
+	tags->tags = g_hash_table_new_full(g_str_hash, g_str_equal, g_free,
+	                                   g_free);
+	tags->keys = g_queue_new();
+}
+
+static void
+ibis_tags_class_init(IbisTagsClass *klass) {
+	GObjectClass *obj_class = G_OBJECT_CLASS(klass);
+
+	obj_class->finalize = ibis_tags_finalize;
+}
+
+/******************************************************************************
+ * Public API
+ *****************************************************************************/
+void
+ibis_tags_add(IbisTags *tags, const char *key, const char *value) {
+	char *new_key = NULL;
+	char *normalized_value = NULL;
+	gboolean inserted = FALSE;
+
+	g_return_if_fail(IBIS_IS_TAGS(tags));
+	g_return_if_fail(!ibis_str_is_empty(key));
+
+	/* Per the spec, empty tag values (`foo=`) must be treated like missing
+	 * tag values (`foo`). So if a tag is not missing or empty, duplicate it,
+	 * otherwise use the NULL value it was initialized with to make it have a
+	 * missing value.
+	 */
+	if(!ibis_str_is_empty(value)) {
+		normalized_value = g_strdup(value);
+	}
+
+	new_key = g_strdup(key);
+	inserted = g_hash_table_insert(tags->tags, new_key, normalized_value);
+	if(inserted) {
+		g_queue_push_tail(tags->keys, new_key);
+	}
 }
 
-GHashTable *
+gboolean
+ibis_tags_contains(IbisTags *tags1, IbisTags *tags2) {
+	GHashTableIter iter;
+	gpointer expected_key = NULL;
+	gpointer expected_value = NULL;
+
+	g_return_val_if_fail(IBIS_IS_TAGS(tags1), FALSE);
+	g_return_val_if_fail(IBIS_IS_TAGS(tags2), FALSE);
+
+	/* We walk tags2 as that's our needle, and check if the keys and values
+	 * exist in tags1.
+	 */
+	g_hash_table_iter_init(&iter, tags2->tags);
+	while(g_hash_table_iter_next(&iter, &expected_key, &expected_value)) {
+		gpointer actual_value = NULL;
+		gboolean found = FALSE;
+
+		found = g_hash_table_lookup_extended(tags1->tags, expected_key, NULL,
+		                                     &actual_value);
+		if(!found) {
+			return FALSE;
+		}
+
+		if(!ibis_str_equal(expected_value, actual_value)) {
+			return FALSE;
+		}
+	}
+
+	return TRUE;
+}
+
+gboolean
+ibis_tags_equal(IbisTags *tags1, IbisTags *tags2) {
+	guint size1 = 0;
+	guint size2 = 0;
+
+	if(tags1 == NULL && tags2 == NULL) {
+		return TRUE;
+	}
+
+	if(tags1 == NULL || tags2 == NULL) {
+		return FALSE;
+	}
+
+	size1 = g_hash_table_size(tags1->tags);
+	size2 = g_hash_table_size(tags2->tags);
+
+	if(size1 != size2) {
+		return FALSE;
+	}
+
+	return ibis_tags_contains(tags1, tags2);
+}
+
+gboolean
+ibis_tags_exists(IbisTags *tags, const char *key) {
+	g_return_val_if_fail(IBIS_IS_TAGS(tags), FALSE);
+	g_return_val_if_fail(!ibis_str_is_empty(key), FALSE);
+
+	return g_hash_table_contains(tags->tags, key);
+}
+
+guint
+ibis_tags_get_size(IbisTags *tags) {
+	g_return_val_if_fail(IBIS_IS_TAGS(tags), 0);
+
+	return g_hash_table_size(tags->tags);
+}
+
+const char *
+ibis_tags_lookup(IbisTags *tags, const char *key) {
+	g_return_val_if_fail(IBIS_IS_TAGS(tags), FALSE);
+	g_return_val_if_fail(!ibis_str_is_empty(key), FALSE);
+
+	return g_hash_table_lookup(tags->tags, key);
+}
+
+IbisTags *
+ibis_tags_new(void) {
+	return g_object_new(IBIS_TYPE_TAGS, NULL);
+}
+
+IbisTags *
 ibis_tags_parse(const char *tags_string, GError **error) {
 	GError *local_error = NULL;
-	GHashTable *tags = NULL;
+	IbisTags *tags = NULL;
 	GMatchInfo *info = NULL;
 	GRegex *regex = NULL;
 	gboolean matches = FALSE;
 
 	g_return_val_if_fail(tags_string != NULL, NULL);
 
-	tags = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
+	tags = ibis_tags_new();
 
 	/* tags_string can never be NULL, because g_match_info_fetch_named always
 	 * returns a string. So if we were passed an empty string, just return the
@@ -127,10 +300,11 @@
 		unescaped = ibis_tag_unescape_value(value);
 		g_free(value);
 
-		/* the hash table is created with destroy notifies for both key and
-		 * value, so there's no need to free the allocated memory right now.
-		 */
-		g_hash_table_insert(tags, key, unescaped);
+		ibis_tags_add(tags, key, unescaped);
+
+		g_free(key);
+		g_free(unescaped);
+
 		g_match_info_next(info, &local_error);
 
 		if(local_error != NULL) {
@@ -144,3 +318,51 @@
 
 	return tags;
 }
+
+gboolean
+ibis_tags_remove(IbisTags *tags, const char *key) {
+	gpointer original_key = NULL;
+
+	g_return_val_if_fail(IBIS_IS_TAGS(tags), FALSE);
+	g_return_val_if_fail(!ibis_str_is_empty(key), FALSE);
+
+	if(g_hash_table_lookup_extended(tags->tags, key, &original_key, NULL)) {
+		g_queue_remove(tags->keys, original_key);
+	}
+
+	return g_hash_table_remove(tags->tags, key);
+}
+
+char *
+ibis_tags_serialize(IbisTags *tags) {
+	GString *str = NULL;
+	gboolean first = TRUE;
+
+	g_return_val_if_fail(IBIS_IS_TAGS(tags), NULL);
+
+	str = g_string_new("");
+
+	for(GList *l = tags->keys->head; l != NULL; l = l->next) {
+		char *key = l->data;
+		char *value = NULL;
+
+		value = g_hash_table_lookup(tags->tags, key);
+
+		if(!first) {
+			g_string_append_printf(str, ";%s", (char *)key);
+		} else {
+			g_string_append_printf(str, "@%s", (char *)key);
+			first = FALSE;
+		}
+
+		/* Normalize missing and empty values to missing. */
+		if(!ibis_str_is_empty(value)) {
+			char *escaped = ibis_tag_escape_value(value);
+
+			g_string_append_printf(str, "=%s", escaped);
+			g_free(escaped);
+		}
+	}
+
+	return g_string_free_and_steal(str);
+}
diff --git a/ibis/tests/test_message.c b/ibis/tests/test_message.c
--- a/ibis/tests/test_message.c
+++ b/ibis/tests/test_message.c
@@ -57,19 +57,17 @@
 static void
 test_ibis_message_properties(void) {
 	IbisMessage *message = NULL;
-	GHashTable *tags1 = NULL;
-	GHashTable *tags2 = NULL;
+	IbisTags *tags1 = NULL;
+	IbisTags *tags2 = NULL;
 	GStrv params1 = NULL;
 	GStrv params2 = NULL;
 	GStrvBuilder *builder = NULL;
 	char *source = NULL;
 	char *command = NULL;
-	gpointer value = NULL;
-	gboolean found = FALSE;
 
-	tags1 = g_hash_table_new(g_str_hash, g_str_equal);
-	g_hash_table_insert(tags1, "foo", "bar");
-	g_hash_table_insert(tags1, "baz", NULL);
+	tags1 = ibis_tags_new();
+	ibis_tags_add(tags1, "foo", "bar");
+	ibis_tags_add(tags1, "baz", NULL);
 
 	builder = g_strv_builder_new();
 	g_strv_builder_add_many(builder, "foo", "bar", "baz", NULL);
@@ -95,14 +93,8 @@
 		NULL);
 
 	g_assert_nonnull(tags2);
-	found = g_hash_table_lookup_extended(tags2, "foo", NULL, &value);
-	g_assert_true(found);
-	g_assert_cmpstr((char *)value, ==, "bar");
-
-	found = g_hash_table_lookup_extended(tags2, "baz", NULL, &value);
-	g_assert_true(found);
-	g_assert_null(value);
-	g_clear_pointer(&tags2, g_hash_table_unref);
+	g_assert_true(ibis_tags_equal(tags1, tags2));
+	g_clear_object(&tags2);
 
 	g_assert_cmpstr(source, ==, "source");
 	g_clear_pointer(&source, g_free);
@@ -113,7 +105,7 @@
 	g_assert_cmpstrv(params2, params1);
 	g_clear_pointer(&params2, g_strfreev);
 
-	g_clear_pointer(&tags1, g_hash_table_unref);
+	g_clear_object(&tags1);
 	g_clear_pointer(&params1, g_strfreev);
 	g_assert_finalize_object(message);
 }
diff --git a/ibis/tests/test_parser.c b/ibis/tests/test_parser.c
--- a/ibis/tests/test_parser.c
+++ b/ibis/tests/test_parser.c
@@ -23,7 +23,7 @@
 #define TEST_IBIS_PARSER_DOMAIN (g_quark_from_static_string("test-ibis-parser"))
 
 typedef struct {
-	GHashTable *tags;
+	IbisTags *tags;
 	char *source;
 	char *command;
 	guint n_params;
@@ -39,8 +39,6 @@
                        gpointer data)
 {
 	TestIbisParserData *d = data;
-	GHashTable *tags = NULL;
-	GHashTableIter iter;
 	GStrv params = NULL;
 	const char *command = NULL;
 	const char *source = NULL;
@@ -49,7 +47,6 @@
 	command = ibis_message_get_command(message);
 	params = ibis_message_get_params(message);
 	source = ibis_message_get_source(message);
-	tags = ibis_message_get_tags(message);
 
 	if(params != NULL) {
 		n_params = g_strv_length(params);
@@ -57,29 +54,11 @@
 
 	/* Make sure we have an expected tags hash table before checking them. */
 	if(d->tags != NULL) {
-		gpointer expected_key;
-		gpointer expected_value;
-		guint actual_size;
-		guint expected_size;
-
-		/* Make sure the tag hash tables have the same size. */
-		expected_size = g_hash_table_size(d->tags);
-		actual_size = g_hash_table_size(tags);
-		g_assert_cmpuint(actual_size, ==, expected_size);
+		IbisTags *tags = NULL;
 
-		/* Since the tables have the same size, we can walk through the expected
-		 * table and use it to verify the actual table.
-		 */
-		g_hash_table_iter_init(&iter, d->tags);
-		while(g_hash_table_iter_next(&iter, &expected_key, &expected_value)) {
-			gpointer actual_value = NULL;
-			gboolean found = FALSE;
+		tags = ibis_message_get_tags(message);
 
-			found = g_hash_table_lookup_extended(tags, expected_key, NULL,
-			                                     &actual_value);
-			g_assert_true(found);
-			g_assert_cmpstr(actual_value, ==, expected_value);
-		}
+		g_assert_true(ibis_tags_equal(d->tags, tags));
 	}
 
 	/* Walk through the params checking against the expected values. */
@@ -96,7 +75,7 @@
 	g_assert_cmpstr(command, ==, d->command);
 
 	/* Cleanup everything the caller allocated. */
-	g_clear_pointer(&d->tags, g_hash_table_destroy);
+	g_clear_object(&d->tags);
 
 	/* Return the return value the caller asked for. */
 	return TRUE;
@@ -121,8 +100,7 @@
 	GError *error = NULL;
 	gboolean result = FALSE;
 
-	ibis_parser_set_fallback_handler(parser,
-	                                         test_ibis_test_handler);
+	ibis_parser_set_fallback_handler(parser, test_ibis_test_handler);
 
 	result = ibis_parser_parse(parser, source, &error, d);
 
@@ -274,11 +252,12 @@
 		.command = "foo",
 	};
 
-	data.tags = g_hash_table_new(g_str_hash, g_str_equal);
-	g_hash_table_insert(data.tags, "a", "b");
-	g_hash_table_insert(data.tags, "c", "32");
-	g_hash_table_insert(data.tags, "k", "");
-	g_hash_table_insert(data.tags, "rt", "ql7");
+	data.tags = ibis_tags_new();
+	ibis_tags_add(data.tags, "a", "b");
+	ibis_tags_add(data.tags, "a", "b");
+	ibis_tags_add(data.tags, "c", "32");
+	ibis_tags_add(data.tags, "k", "");
+	ibis_tags_add(data.tags, "rt", "ql7");
 
 	test_ibis_parser("@a=b;c=32;k;rt=ql7 foo", &data);
 }
@@ -289,10 +268,10 @@
 		.command = "foo",
 	};
 
-	data.tags = g_hash_table_new(g_str_hash, g_str_equal);
-	g_hash_table_insert(data.tags, "a", "b\\and\nk");
-	g_hash_table_insert(data.tags, "c", "72 45");
-	g_hash_table_insert(data.tags, "d", "gh;764");
+	data.tags = ibis_tags_new();
+	ibis_tags_add(data.tags, "a", "b\\and\nk");
+	ibis_tags_add(data.tags, "c", "72 45");
+	ibis_tags_add(data.tags, "d", "gh;764");
 
 	test_ibis_parser("@a=b\\\\and\\nk;c=72\\s45;d=gh\\:764 foo",
 	                         &data);
@@ -307,10 +286,10 @@
 		.params = {"cd"},
 	};
 
-	data.tags = g_hash_table_new(g_str_hash, g_str_equal);
-	g_hash_table_insert(data.tags, "c", "");
-	g_hash_table_insert(data.tags, "h", "");
-	g_hash_table_insert(data.tags, "a", "b");
+	data.tags = ibis_tags_new();
+	ibis_tags_add(data.tags, "c", "");
+	ibis_tags_add(data.tags, "h", "");
+	ibis_tags_add(data.tags, "a", "b");
 
 	test_ibis_parser("@c;h=;a=b :quux ab cd", &data);
 }
@@ -420,11 +399,11 @@
 	};
 	const gchar *msg = NULL;
 
-	data.tags = g_hash_table_new(g_str_hash, g_str_equal);
-	g_hash_table_insert(data.tags, "tag1", "value1");
-	g_hash_table_insert(data.tags, "tag2", "");
-	g_hash_table_insert(data.tags, "vendor1/tag3", "value2");
-	g_hash_table_insert(data.tags, "vendor2/tag4", "");
+	data.tags = ibis_tags_new();
+	ibis_tags_add(data.tags, "tag1", "value1");
+	ibis_tags_add(data.tags, "tag2", "");
+	ibis_tags_add(data.tags, "vendor1/tag3", "value2");
+	ibis_tags_add(data.tags, "vendor2/tag4", "");
 
 	msg = "@tag1=value1;tag2;vendor1/tag3=value2;vendor2/tag4= "
 	      ":irc.example.com COMMAND param1 param2 :param3 param3";
@@ -456,11 +435,11 @@
 	};
 	const gchar *msg = NULL;
 
-	data.tags = g_hash_table_new(g_str_hash, g_str_equal);
-	g_hash_table_insert(data.tags, "tag1", "value1");
-	g_hash_table_insert(data.tags, "tag2", "");
-	g_hash_table_insert(data.tags, "vendor1/tag3", "value2");
-	g_hash_table_insert(data.tags, "vendor2/tag4", "");
+	data.tags = ibis_tags_new();
+	ibis_tags_add(data.tags, "tag1", "value1");
+	ibis_tags_add(data.tags, "tag2", "");
+	ibis_tags_add(data.tags, "vendor1/tag3", "value2");
+	ibis_tags_add(data.tags, "vendor2/tag4", "");
 
 	msg = "@tag1=value1;tag2;vendor1/tag3=value2;vendor2/tag4 "
 	      "COMMAND param1 param2 :param3 param3";
@@ -483,8 +462,8 @@
 		.command = "COMMAND",
 	};
 
-	data.tags = g_hash_table_new(g_str_hash, g_str_equal);
-	g_hash_table_insert(data.tags, "foo", "\\\\;\\s \r\n");
+	data.tags = ibis_tags_new();
+	ibis_tags_add(data.tags, "foo", "\\\\;\\s \r\n");
 
 	test_ibis_parser("@foo=\\\\\\\\\\:\\\\s\\s\\r\\n COMMAND", &data);
 }
@@ -536,8 +515,8 @@
 		.command = "COMMAND",
 	};
 
-	data.tags = g_hash_table_new(g_str_hash, g_str_equal);
-	g_hash_table_insert(data.tags, "tag1", "value\\ntest");
+	data.tags = ibis_tags_new();
+	ibis_tags_add(data.tags, "tag1", "value\\ntest");
 
 	test_ibis_parser("@tag1=value\\\\ntest COMMAND", &data);
 }
@@ -548,8 +527,8 @@
 		.command = "COMMAND",
 	};
 
-	data.tags = g_hash_table_new(g_str_hash, g_str_equal);
-	g_hash_table_insert(data.tags, "tag1", "value1");
+	data.tags = ibis_tags_new();
+	ibis_tags_add(data.tags, "tag1", "value1");
 
 	test_ibis_parser("@tag1=value\\1 COMMAND", &data);
 }
@@ -560,8 +539,8 @@
 		.command = "COMMAND",
 	};
 
-	data.tags = g_hash_table_new(g_str_hash, g_str_equal);
-	g_hash_table_insert(data.tags, "tag1", "value1");
+	data.tags = ibis_tags_new();
+	ibis_tags_add(data.tags, "tag1", "value1");
 
 	test_ibis_parser("@tag1=value1\\ COMMAND", &data);
 }
@@ -572,10 +551,10 @@
 		.command = "COMMAND",
 	};
 
-	data.tags = g_hash_table_new(g_str_hash, g_str_equal);
-	g_hash_table_insert(data.tags, "tag1", "5");
-	g_hash_table_insert(data.tags, "tag2", "3");
-	g_hash_table_insert(data.tags, "tag3", "4");
+	data.tags = ibis_tags_new();
+	ibis_tags_add(data.tags, "tag1", "5");
+	ibis_tags_add(data.tags, "tag2", "3");
+	ibis_tags_add(data.tags, "tag3", "4");
 
 	test_ibis_parser("@tag1=1;tag2=3;tag3=4;tag1=5 COMMAND", &data);
 }
@@ -587,11 +566,11 @@
 	};
 	const gchar *msg = NULL;
 
-	data.tags = g_hash_table_new(g_str_hash, g_str_equal);
-	g_hash_table_insert(data.tags, "tag1", "5");
-	g_hash_table_insert(data.tags, "tag2", "3");
-	g_hash_table_insert(data.tags, "tag3", "4");
-	g_hash_table_insert(data.tags, "vendor/tag2", "8");
+	data.tags = ibis_tags_new();
+	ibis_tags_add(data.tags, "tag1", "5");
+	ibis_tags_add(data.tags, "tag2", "3");
+	ibis_tags_add(data.tags, "tag3", "4");
+	ibis_tags_add(data.tags, "vendor/tag2", "8");
 
 	msg = "@tag1=1;tag2=3;tag3=4;tag1=5;vendor/tag2=8 COMMAND";
 
@@ -651,10 +630,10 @@
 	};
 	const char *msg = NULL;
 
-	data.tags = g_hash_table_new(g_str_hash, g_str_equal);
-	g_hash_table_insert(data.tags, "aaa", "bbb");
-	g_hash_table_insert(data.tags, "ccc", "");
-	g_hash_table_insert(data.tags, "example.com/ddd", "eee");
+	data.tags = ibis_tags_new();
+	ibis_tags_add(data.tags, "aaa", "bbb");
+	ibis_tags_add(data.tags, "ccc", "");
+	ibis_tags_add(data.tags, "example.com/ddd", "eee");
 
 	msg = "@aaa=bbb;ccc;example.com/ddd=eee :nick!ident@host.com PRIVMSG me "
 	      ":Hello";
@@ -672,8 +651,8 @@
 	};
 	const char *msg = NULL;
 
-	data.tags = g_hash_table_new(g_str_hash, g_str_equal);
-	g_hash_table_insert(data.tags, "+icon", "https://example.com/favicon.png");
+	data.tags = ibis_tags_new();
+	ibis_tags_add(data.tags, "+icon", "https://example.com/favicon.png");
 
 	msg = "@+icon=https://example.com/favicon.png :url_bot!bot@example.com "
 	      "PRIVMSG #channel :Example.com: A News Story";
@@ -691,10 +670,10 @@
 	};
 	const char *msg = NULL;
 
-	data.tags = g_hash_table_new(g_str_hash, g_str_equal);
-	g_hash_table_insert(data.tags, "label", "123");
-	g_hash_table_insert(data.tags, "msgid", "abc");
-	g_hash_table_insert(data.tags, "+example-client-tag", "example-value");
+	data.tags = ibis_tags_new();
+	ibis_tags_add(data.tags, "label", "123");
+	ibis_tags_add(data.tags, "msgid", "abc");
+	ibis_tags_add(data.tags, "+example-client-tag", "example-value");
 
 	msg = "@label=123;msgid=abc;+example-client-tag=example-value "
 	      ":nick!user@example.com TAGMSG #channel";
diff --git a/ibis/tests/test_tags.c b/ibis/tests/test_tags.c
--- a/ibis/tests/test_tags.c
+++ b/ibis/tests/test_tags.c
@@ -24,69 +24,596 @@
  * Tests
  *****************************************************************************/
 static void
-test_ibis_tag_unescape_simple(void) {
-	const char *input = "foo";
-	char *escaped = NULL;
+test_ibis_tags_new(void) {
+	IbisTags *tags = NULL;
+
+	tags = ibis_tags_new();
+	g_assert_true(IBIS_IS_TAGS(tags));
+
+	g_assert_cmpuint(ibis_tags_get_size(tags), ==, 0);
+
+	g_assert_finalize_object(tags);
+}
+
+/******************************************************************************
+ * Serialize Tests
+ *****************************************************************************/
+static void
+test_ibis_tags_serialize_empty(void) {
+	IbisTags *tags = NULL;
+	char *serialized = NULL;
+
+	tags = ibis_tags_new();
+
+	serialized = ibis_tags_serialize(tags);
+	g_assert_cmpstr(serialized, ==, "");
+	g_clear_pointer(&serialized, g_free);
+
+	g_assert_finalize_object(tags);
+}
+
+static void
+test_ibis_tags_serialize_single(void) {
+	IbisTags *tags = NULL;
+	char *serialized = NULL;
+
+	tags = ibis_tags_new();
+	ibis_tags_add(tags, "foo", "bar");
+
+	serialized = ibis_tags_serialize(tags);
+	g_assert_cmpstr(serialized, ==, "@foo=bar");
+	g_clear_pointer(&serialized, g_free);
+
+	g_assert_finalize_object(tags);
+}
+
+static void
+test_ibis_tags_serialize_single_missing_value(void) {
+	IbisTags *tags = NULL;
+	char *serialized = NULL;
+
+	tags = ibis_tags_new();
+	ibis_tags_add(tags, "foo", NULL);
+
+	serialized = ibis_tags_serialize(tags);
+	g_assert_cmpstr(serialized, ==, "@foo");
+	g_clear_pointer(&serialized, g_free);
+
+	g_assert_finalize_object(tags);
+}
+
+static void
+test_ibis_tags_serialize_single_empty_value(void) {
+	IbisTags *tags = NULL;
+	char *serialized = NULL;
+
+	tags = ibis_tags_new();
+	ibis_tags_add(tags, "foo", "");
 
-	escaped = ibis_tag_unescape_value(input);
-	g_assert_cmpstr(escaped, ==, "foo");
+	serialized = ibis_tags_serialize(tags);
+	g_assert_cmpstr(serialized, ==, "@foo");
+	g_clear_pointer(&serialized, g_free);
+
+	g_assert_finalize_object(tags);
+}
+
+static void
+test_ibis_tags_serialize_mixed_values(void) {
+	IbisTags *tags = NULL;
+	char *serialized = NULL;
+
+	tags = ibis_tags_new();
+	ibis_tags_add(tags, "foo", "bar");
+	ibis_tags_add(tags, "baz", "");
+	ibis_tags_add(tags, "quux", NULL);
+	ibis_tags_add(tags, "corge", "");
+	ibis_tags_add(tags, "grault", "garply");
+	ibis_tags_add(tags, "waldo", NULL);
+
+	serialized = ibis_tags_serialize(tags);
+	g_assert_cmpstr(serialized, ==,
+	                "@foo=bar;baz;quux;corge;grault=garply;waldo");
+	g_clear_pointer(&serialized, g_free);
+
+	g_assert_finalize_object(tags);
+}
+
+static void
+test_ibis_tags_serialize_messed_with(void) {
+	IbisTags *tags = NULL;
+	char *serialized = NULL;
+	gboolean removed = FALSE;
 
-	g_clear_pointer(&escaped, g_free);
+	tags = ibis_tags_new();
+	ibis_tags_add(tags, "foo", "bar");
+	ibis_tags_add(tags, "baz", "");
+	ibis_tags_add(tags, "quux", NULL);
+	removed = ibis_tags_remove(tags, "baz");
+	g_assert_true(removed);
+	ibis_tags_add(tags, "corge", "");
+	ibis_tags_add(tags, "grault", "garply");
+	removed = ibis_tags_remove(tags, "grault");
+	g_assert_true(removed);
+	ibis_tags_add(tags, "waldo", NULL);
+
+	serialized = ibis_tags_serialize(tags);
+	g_assert_cmpstr(serialized, ==,
+	                "@foo=bar;quux;corge;waldo");
+	g_clear_pointer(&serialized, g_free);
+
+	g_assert_finalize_object(tags);
+}
+
+static void
+test_ibis_tags_serialize_escapes(void) {
+	IbisTags *tags = NULL;
+	char *serialized = NULL;
+
+	tags = ibis_tags_new();
+	ibis_tags_add(tags, "foo", ";");
+	ibis_tags_add(tags, "bar", " ");
+	ibis_tags_add(tags, "baz", "\\");
+	ibis_tags_add(tags, "quux", "\r");
+	ibis_tags_add(tags, "qux", "\n");
+
+	serialized = ibis_tags_serialize(tags);
+	g_assert_cmpstr(serialized, ==,
+	                "@foo=\\:;bar=\\s;baz=\\\\;quux=\\r;qux=\\n");
+	g_clear_pointer(&serialized, g_free);
+
+	g_assert_finalize_object(tags);
 }
 
 static void
-test_ibis_tag_unescape_semi_colon(void) {
-	const char *input = "foo\\:bar";
-	char *escaped = NULL;
+test_ibis_tags_serialize_mixed_values_with_escapes(void) {
+	IbisTags *tags = NULL;
+	char *serialized = NULL;
+
+	tags = ibis_tags_new();
+	ibis_tags_add(tags, "foo", "a;b;c");
+	ibis_tags_add(tags, "bar", "one two three");
+	ibis_tags_add(tags, "baz", "c:\\Users");
+	ibis_tags_add(tags, "quux", "hello world!\r\n");
+
+	serialized = ibis_tags_serialize(tags);
+	g_assert_cmpstr(serialized, ==,
+	                "@foo=a\\:b\\:c;bar=one\\stwo\\sthree;baz=c:\\\\Users;"
+	                "quux=hello\\sworld!\\r\\n");
+	g_clear_pointer(&serialized, g_free);
+
+	g_assert_finalize_object(tags);
+}
+
+static void
+test_ibis_tags_serialize_duplicates(void) {
+	IbisTags *tags = NULL;
+	char *serialized = NULL;
+
+	tags = ibis_tags_new();
+	ibis_tags_add(tags, "foo", "a;b;c");
+	ibis_tags_add(tags, "foo", "dog");
+	ibis_tags_add(tags, "bar", NULL);
+	ibis_tags_add(tags, "bar", "c:\\Users");
+	ibis_tags_add(tags, "baz", NULL);
+	ibis_tags_add(tags, "foo", "cat");
+
+	serialized = ibis_tags_serialize(tags);
+	g_assert_cmpstr(serialized, ==, "@foo=cat;bar=c:\\\\Users;baz");
+	g_clear_pointer(&serialized, g_free);
+
+	g_assert_finalize_object(tags);
+}
+
+/******************************************************************************
+ * Parse tests
+ *****************************************************************************/
+static void
+test_ibis_tags_parse_empty(void) {
+	IbisTags *tags = NULL;
+	GError *error = NULL;
+
+	tags = ibis_tags_parse("", &error);
+	g_assert_no_error(error);
+
+	g_assert_cmpuint(ibis_tags_get_size(tags), ==, 0);
+
+	g_assert_finalize_object(tags);
+}
+
+static void
+test_ibis_tags_parse_single(void) {
+	IbisTags *tags = NULL;
+	GError *error = NULL;
+	const char *value = NULL;
+
+	tags = ibis_tags_parse("@foo=bar", &error);
+	g_assert_no_error(error);
+
+	g_assert_cmpuint(ibis_tags_get_size(tags), ==, 1);
+
+	g_assert_true(ibis_tags_exists(tags, "foo"));
+	value = ibis_tags_lookup(tags, "foo");
+	g_assert_cmpstr(value, ==, "bar");
+
+	g_assert_finalize_object(tags);
+}
 
-	escaped = ibis_tag_unescape_value(input);
-	g_assert_cmpstr(escaped, ==, "foo;bar");
+static void
+test_ibis_tags_parse_single_missing_value(void) {
+	IbisTags *tags = NULL;
+	GError *error = NULL;
+	const char *value = NULL;
+
+	tags = ibis_tags_parse("@foo", &error);
+	g_assert_no_error(error);
+
+	g_assert_cmpuint(ibis_tags_get_size(tags), ==, 1);
+
+	g_assert_true(ibis_tags_exists(tags, "foo"));
+	value = ibis_tags_lookup(tags, "foo");
+	g_assert_null(value);
+
+	g_assert_finalize_object(tags);
+}
+
+static void
+test_ibis_tags_parse_single_empty_value(void) {
+	IbisTags *tags = NULL;
+	GError *error = NULL;
+	const char *value = NULL;
+
+	tags = ibis_tags_parse("@foo=", &error);
+	g_assert_no_error(error);
+
+	g_assert_cmpuint(ibis_tags_get_size(tags), ==, 1);
+
+	g_assert_true(ibis_tags_exists(tags, "foo"));
+	value = ibis_tags_lookup(tags, "foo");
+	g_assert_null(value);
+
+	g_assert_finalize_object(tags);
+}
 
-	g_clear_pointer(&escaped, g_free);
+static void
+test_ibis_tags_parse_mixed_values(void) {
+	IbisTags *tags = NULL;
+	GError *error = NULL;
+	const char *value = NULL;
+
+	tags = ibis_tags_parse("@foo=bar;baz;quux;corge;grault=garply;waldo",
+	                       &error);
+	g_assert_no_error(error);
+
+	g_assert_cmpuint(ibis_tags_get_size(tags), ==, 6);
+
+	g_assert_true(ibis_tags_exists(tags, "foo"));
+	value = ibis_tags_lookup(tags, "foo");
+	g_assert_cmpstr(value, ==, "bar");
+
+	g_assert_true(ibis_tags_exists(tags, "baz"));
+	value = ibis_tags_lookup(tags, "baz");
+	g_assert_null(value);
+
+	g_assert_true(ibis_tags_exists(tags, "quux"));
+	value = ibis_tags_lookup(tags, "quux");
+	g_assert_null(value);
+
+	g_assert_true(ibis_tags_exists(tags, "corge"));
+	value = ibis_tags_lookup(tags, "corge");
+	g_assert_null(value);
+
+	g_assert_true(ibis_tags_exists(tags, "grault"));
+	value = ibis_tags_lookup(tags, "grault");
+	g_assert_cmpstr(value, ==, "garply");
+
+	g_assert_true(ibis_tags_exists(tags, "waldo"));
+	value = ibis_tags_lookup(tags, "waldo");
+	g_assert_null(value);
+
+	g_assert_finalize_object(tags);
 }
 
 static void
-test_ibis_tag_unescape_space(void) {
-	const char *input = "foo\\sbar";
-	char *escaped = NULL;
+test_ibis_tags_parse_escapes(void) {
+	IbisTags *tags = NULL;
+	GError *error = NULL;
+	const char *value = NULL;
+
+	tags = ibis_tags_parse("@foo=\\:;bar=\\s;baz=\\\\;quux=\\r;qux=\\n",
+	                       &error);
+	g_assert_no_error(error);
+
+	g_assert_cmpuint(ibis_tags_get_size(tags), ==, 5);
+
+	g_assert_true(ibis_tags_exists(tags, "foo"));
+	value = ibis_tags_lookup(tags, "foo");
+	g_assert_cmpstr(value, ==, ";");
+
+	g_assert_true(ibis_tags_exists(tags, "bar"));
+	value = ibis_tags_lookup(tags, "bar");
+	g_assert_cmpstr(value, ==, " ");
+
+	g_assert_true(ibis_tags_exists(tags, "baz"));
+	value = ibis_tags_lookup(tags, "baz");
+	g_assert_cmpstr(value, ==, "\\");
+
+	g_assert_true(ibis_tags_exists(tags, "quux"));
+	value = ibis_tags_lookup(tags, "quux");
+	g_assert_cmpstr(value, ==, "\r");
+
+	g_assert_true(ibis_tags_exists(tags, "qux"));
+	value = ibis_tags_lookup(tags, "qux");
+	g_assert_cmpstr(value, ==, "\n");
+
+	g_assert_finalize_object(tags);
+}
+
+static void
+test_ibis_tags_parse_mixed_values_with_escapes(void) {
+	IbisTags *tags = NULL;
+	GError *error = NULL;
+	const char *value = NULL;
+
+	tags = ibis_tags_parse("@foo=a\\:b\\:c;bar=one\\stwo\\sthree;"
+	                       "baz=c:\\\\Users;quux=hello\\sworld!\\r\\n",
+	                       &error);
+	g_assert_no_error(error);
+
+	g_assert_cmpuint(ibis_tags_get_size(tags), ==, 4);
+
+	g_assert_true(ibis_tags_exists(tags, "foo"));
+	value = ibis_tags_lookup(tags, "foo");
+	g_assert_cmpstr(value, ==, "a;b;c");
+
+	g_assert_true(ibis_tags_exists(tags, "bar"));
+	value = ibis_tags_lookup(tags, "bar");
+	g_assert_cmpstr(value, ==, "one two three");
+
+	g_assert_true(ibis_tags_exists(tags, "baz"));
+	value = ibis_tags_lookup(tags, "baz");
+	g_assert_cmpstr(value, ==, "c:\\Users");
+
+	g_assert_true(ibis_tags_exists(tags, "quux"));
+	value = ibis_tags_lookup(tags, "quux");
+	g_assert_cmpstr(value, ==, "hello world!\r\n");
+
+	g_assert_finalize_object(tags);
+}
+
+static void
+test_ibis_tags_parse_duplicates(void) {
+	IbisTags *tags = NULL;
+	GError *error = NULL;
+	const char *value = NULL;
 
-	escaped = ibis_tag_unescape_value(input);
-	g_assert_cmpstr(escaped, ==, "foo bar");
+	tags = ibis_tags_parse("@foo=seven\\seight\\snine;bar=one\\stwo\\sthree;"
+	                       "foo=7,8,9;bar=1,2,3",
+	                       &error);
+	g_assert_no_error(error);
+
+	g_assert_cmpuint(ibis_tags_get_size(tags), ==, 2);
+
+	g_assert_true(ibis_tags_exists(tags, "foo"));
+	value = ibis_tags_lookup(tags, "foo");
+	g_assert_cmpstr(value, ==, "7,8,9");
+
+	g_assert_true(ibis_tags_exists(tags, "bar"));
+	value = ibis_tags_lookup(tags, "bar");
+	g_assert_cmpstr(value, ==, "1,2,3");
+
+	g_assert_finalize_object(tags);
+}
+
+/******************************************************************************
+ * Spec tests
+ *****************************************************************************/
+static void
+test_ibis_tags_spec_trailing_slash(void) {
+	IbisTags *tags = NULL;
+	GError *error = NULL;
+	const char *value = NULL;
+
+	tags = ibis_tags_parse("@foo=test\\", &error);
+	g_assert_no_error(error);
+
+	g_assert_true(ibis_tags_exists(tags, "foo"));
+
+	value = ibis_tags_lookup(tags, "foo");
+	g_assert_cmpstr(value, ==, "test");
+
+	g_assert_finalize_object(tags);
+}
 
-	g_clear_pointer(&escaped, g_free);
+static void
+test_ibis_tags_spec_invalid_escape(void) {
+	IbisTags *tags = NULL;
+	GError *error = NULL;
+	const char *value = NULL;
+
+	tags = ibis_tags_parse("@foo=\\b", &error);
+	g_assert_no_error(error);
+
+	g_assert_true(ibis_tags_exists(tags, "foo"));
+
+	value = ibis_tags_lookup(tags, "foo");
+	g_assert_cmpstr(value, ==, "b");
+
+	g_assert_finalize_object(tags);
+}
+
+/******************************************************************************
+ * Equal Tests
+ *****************************************************************************/
+static void
+ibis_tags_equal_null_null(void) {
+	g_assert_true(ibis_tags_equal(NULL, NULL));
+}
+
+static void
+ibis_tags_equal_empty_empty(void) {
+	IbisTags *tags1 = NULL;
+	IbisTags *tags2 = NULL;
+
+	tags1 = ibis_tags_new();
+	tags2 = ibis_tags_new();
+
+	g_assert_true(ibis_tags_equal(tags1, tags2));
+
+	g_assert_finalize_object(tags1);
+	g_assert_finalize_object(tags2);
 }
 
 static void
-test_ibis_tag_unescape_backslash(void) {
-	const char *input = "foo\\\\bar";
-	char *escaped = NULL;
+ibis_tags_equal_null_empty(void) {
+	IbisTags *tags = NULL;
+
+	tags = ibis_tags_new();
+
+	g_assert_false(ibis_tags_equal(NULL, tags));
+
+	g_assert_finalize_object(tags);
+}
+
+static void
+ibis_tags_equal_empty_null(void) {
+	IbisTags *tags = NULL;
+
+	tags = ibis_tags_new();
+
+	g_assert_false(ibis_tags_equal(tags, NULL));
+
+	g_assert_finalize_object(tags);
+}
+
+static void
+ibis_tags_equal_single(void) {
+	IbisTags *tags1 = NULL;
+	IbisTags *tags2 = NULL;
 
-	escaped = ibis_tag_unescape_value(input);
-	g_assert_cmpstr(escaped, ==, "foo\\bar");
+	tags1 = ibis_tags_new();
+	ibis_tags_add(tags1, "foo", "bar");
+
+	tags2 = ibis_tags_new();
+	ibis_tags_add(tags2, "foo", "bar");
+
+	g_assert_true(ibis_tags_equal(tags1, tags2));
+
+	g_assert_finalize_object(tags1);
+	g_assert_finalize_object(tags2);
+}
 
-	g_clear_pointer(&escaped, g_free);
+static void
+ibis_tags_equal_multiple(void) {
+	IbisTags *tags1 = NULL;
+	IbisTags *tags2 = NULL;
+
+	tags1 = ibis_tags_new();
+	ibis_tags_add(tags1, "foo", "bar");
+	ibis_tags_add(tags1, "baz", "quux");
+
+	tags2 = ibis_tags_new();
+	ibis_tags_add(tags2, "foo", "bar");
+	ibis_tags_add(tags2, "baz", "quux");
+
+	g_assert_true(ibis_tags_equal(tags1, tags2));
+
+	g_assert_finalize_object(tags1);
+	g_assert_finalize_object(tags2);
 }
 
 static void
-test_ibis_tag_unescape_carriage_return(void) {
-	const char *input = "foo\\rbar";
-	char *escaped = NULL;
+ibis_tags_equal_mixed(void) {
+	IbisTags *tags1 = NULL;
+	IbisTags *tags2 = NULL;
+
+	tags1 = ibis_tags_new();
+	ibis_tags_add(tags1, "foo", "bar");
+
+	tags2 = ibis_tags_new();
+	ibis_tags_add(tags2, "foo", "bar");
+	ibis_tags_add(tags2, "baz", "quux");
+
+	g_assert_false(ibis_tags_equal(tags1, tags2));
+
+	g_assert_finalize_object(tags1);
+	g_assert_finalize_object(tags2);
+}
 
-	escaped = ibis_tag_unescape_value(input);
-	g_assert_cmpstr(escaped, ==, "foo\rbar");
+/******************************************************************************
+ * Contains Tests
+ *****************************************************************************/
+static void
+ibis_tags_contains_empty(void) {
+	IbisTags *tags1 = NULL;
+	IbisTags *tags2 = NULL;
 
-	g_clear_pointer(&escaped, g_free);
+	tags1 = ibis_tags_new();
+	ibis_tags_add(tags1, "foo", "bar");
+
+	tags2 = ibis_tags_new();
+
+	g_assert_true(ibis_tags_contains(tags1, tags2));
+
+	g_assert_finalize_object(tags1);
+	g_assert_finalize_object(tags2);
 }
 
 static void
-test_ibis_tag_unescape_line_feed(void) {
-	const char *input = "foo\\nbar";
-	char *escaped = NULL;
+ibis_tags_contains_single(void) {
+	IbisTags *tags1 = NULL;
+	IbisTags *tags2 = NULL;
+
+	tags1 = ibis_tags_new();
+	ibis_tags_add(tags1, "foo", "bar");
+	ibis_tags_add(tags1, "baz", "quux");
+
+	tags2 = ibis_tags_new();
+	ibis_tags_add(tags1, "foo", "bar");
+
+	g_assert_true(ibis_tags_contains(tags1, tags2));
+
+	g_assert_finalize_object(tags1);
+	g_assert_finalize_object(tags2);
+}
+
+static void
+ibis_tags_contains_multiple(void) {
+	IbisTags *tags1 = NULL;
+	IbisTags *tags2 = NULL;
+
+	tags1 = ibis_tags_new();
+	ibis_tags_add(tags1, "foo", "bar");
+	ibis_tags_add(tags1, "baz", "quux");
+	ibis_tags_add(tags1, "qux", "corge");
 
-	escaped = ibis_tag_unescape_value(input);
-	g_assert_cmpstr(escaped, ==, "foo\nbar");
+	tags2 = ibis_tags_new();
+	ibis_tags_add(tags2, "foo", "bar");
+	ibis_tags_add(tags2, "qux", "corge");
+
+	g_assert_true(ibis_tags_contains(tags1, tags2));
+
+	g_assert_finalize_object(tags1);
+	g_assert_finalize_object(tags2);
+}
 
-	g_clear_pointer(&escaped, g_free);
+static void
+ibis_tags_contains_too_many(void) {
+	IbisTags *tags1 = NULL;
+	IbisTags *tags2 = NULL;
+
+	tags1 = ibis_tags_new();
+	ibis_tags_add(tags1, "foo", "bar");
+	ibis_tags_add(tags1, "baz", "quux");
+
+	tags2 = ibis_tags_new();
+	ibis_tags_add(tags2, "foo", "bar");
+	ibis_tags_add(tags1, "baz", "quux");
+	ibis_tags_add(tags2, "qux", "corge");
+
+	g_assert_false(ibis_tags_contains(tags1, tags2));
+
+	g_assert_finalize_object(tags1);
+	g_assert_finalize_object(tags2);
 }
 
 /******************************************************************************
@@ -96,17 +623,66 @@
 main(int argc, char *argv[]) {
 	g_test_init(&argc, &argv, NULL);
 
-	g_test_add_func("/ibis/tag/unescape/simple",
-	                test_ibis_tag_unescape_simple);
-	g_test_add_func("/ibis/tag/unescape/semi-colon",
-	                test_ibis_tag_unescape_semi_colon);
-	g_test_add_func("/ibis/tag/unescape/space", test_ibis_tag_unescape_space);
-	g_test_add_func("/ibis/tag/unescape/backslash",
-	                test_ibis_tag_unescape_backslash);
-	g_test_add_func("/ibis/tag/unescape/carriage-return",
-	                test_ibis_tag_unescape_carriage_return);
-	g_test_add_func("/ibis/tag/unescape/line-feed",
-	                test_ibis_tag_unescape_line_feed);
+	g_test_add_func("/ibis/tags/new", test_ibis_tags_new);
+
+	/* Serialize Tests */
+	g_test_add_func("/ibis/tags/serialize/empty",
+	                test_ibis_tags_serialize_empty);
+	g_test_add_func("/ibis/tags/serialize/single",
+	                test_ibis_tags_serialize_single);
+	g_test_add_func("/ibis/tags/serialize/single-missing-value",
+	                test_ibis_tags_serialize_single_missing_value);
+	g_test_add_func("/ibis/tags/serialize/single-empty-value",
+	                test_ibis_tags_serialize_single_empty_value);
+	g_test_add_func("/ibis/tags/serialize/mixed-values",
+	                test_ibis_tags_serialize_mixed_values);
+	g_test_add_func("/ibis/tags/serialize/messed-with",
+	                test_ibis_tags_serialize_messed_with);
+	g_test_add_func("/ibis/tags/serialize/escapes",
+	                test_ibis_tags_serialize_escapes);
+	g_test_add_func("/ibis/tags/serialize/mixed-values-with-escapes",
+	                test_ibis_tags_serialize_mixed_values_with_escapes);
+	g_test_add_func("/ibis/tags/serialize/duplicates",
+	                test_ibis_tags_serialize_duplicates);
+
+	/* Parser Tests */
+	g_test_add_func("/ibis/tags/parse/empty", test_ibis_tags_parse_empty);
+	g_test_add_func("/ibis/tags/parse/single", test_ibis_tags_parse_single);
+	g_test_add_func("/ibis/tags/parse/single-missing-value",
+	                test_ibis_tags_parse_single_missing_value);
+	g_test_add_func("/ibis/tags/parse/single-empty-value",
+	                test_ibis_tags_parse_single_empty_value);
+	g_test_add_func("/ibis/tags/parse/mixed-values",
+	                test_ibis_tags_parse_mixed_values);
+	g_test_add_func("/ibis/tags/parse/escapes", test_ibis_tags_parse_escapes);
+	g_test_add_func("/ibis/tags/parse/mixed-values-with-escape",
+	                test_ibis_tags_parse_mixed_values_with_escapes);
+	g_test_add_func("/ibis/tags/parse/duplicates",
+	                test_ibis_tags_parse_duplicates);
+
+	/* Spec Tests */
+	g_test_add_func("/ibis/tags/spec/trailing-slash",
+	                test_ibis_tags_spec_trailing_slash);
+	g_test_add_func("/ibis/tags/spec/invalid-escape",
+	                test_ibis_tags_spec_invalid_escape);
+
+	/* Equal Tests */
+	g_test_add_func("/ibis/tags/equal/null-null", ibis_tags_equal_null_null);
+	g_test_add_func("/ibis/tags/equal/empty-empty",
+	                ibis_tags_equal_empty_empty);
+	g_test_add_func("/ibis/tags/equal/null-empty", ibis_tags_equal_null_empty);
+	g_test_add_func("/ibis/tags/equal/empty-null", ibis_tags_equal_empty_null);
+	g_test_add_func("/ibis/tags/equal/single", ibis_tags_equal_single);
+	g_test_add_func("/ibis/tags/equal/multiple", ibis_tags_equal_multiple);
+	g_test_add_func("/ibis/tags/equal/mixed", ibis_tags_equal_mixed);
+
+	/* Contains Tests */
+	g_test_add_func("/ibis/tags/contains/empty", ibis_tags_contains_empty);
+	g_test_add_func("/ibis/tags/contains/single", ibis_tags_contains_single);
+	g_test_add_func("/ibis/tags/contains/multiple",
+	                ibis_tags_contains_multiple);
+	g_test_add_func("/ibis/tags/contains/too-many",
+	                ibis_tags_contains_too_many);
 
 	return g_test_run();
 }
