Merge changes I0f8252bc,Ibecb71a5,I035ac505

* changes:
  Fix leaf_data completion
  Make cli behave more like bash
  Change how word splitting works when completing
diff --git a/src/datastore_access.hpp b/src/datastore_access.hpp
index 72e3553..ae0d12a 100644
--- a/src/datastore_access.hpp
+++ b/src/datastore_access.hpp
@@ -38,13 +38,15 @@
 
 class DatastoreAccess {
 public:
+    using Tree = std::map<std::string, leaf_data_>;
     virtual ~DatastoreAccess() = 0;
-    virtual std::map<std::string, leaf_data_> getItems(const std::string& path) = 0;
+    virtual Tree getItems(const std::string& path) = 0;
     virtual void setLeaf(const std::string& path, leaf_data_ value) = 0;
     virtual void createPresenceContainer(const std::string& path) = 0;
     virtual void deletePresenceContainer(const std::string& path) = 0;
     virtual void createListInstance(const std::string& path) = 0;
     virtual void deleteListInstance(const std::string& path) = 0;
+    virtual Tree executeRpc(const std::string& path, const Tree& input) = 0;
 
     virtual std::shared_ptr<Schema> schema() = 0;
 
diff --git a/src/libyang_utils.cpp b/src/libyang_utils.cpp
index 0885c64..e6b825d 100644
--- a/src/libyang_utils.cpp
+++ b/src/libyang_utils.cpp
@@ -26,6 +26,8 @@
         return std::string(value->string());
     case LY_TYPE_ENUM:
         return enum_{std::string(value->enm()->name())};
+    case LY_TYPE_BINARY:
+        return std::string{value->binary()};
     default: // TODO: implement all types
         return "(can't print)"s;
     }
diff --git a/src/netconf-client.cpp b/src/netconf-client.cpp
index f1cdfe2..8f0e2ec 100644
--- a/src/netconf-client.cpp
+++ b/src/netconf-client.cpp
@@ -162,7 +162,7 @@
     return client::ReportedError{ss.str()};
 }
 
-unique_ptr_for<struct nc_reply_data> do_rpc_data(client::Session* session, unique_ptr_for<struct nc_rpc>&& rpc)
+std::optional<unique_ptr_for<struct nc_reply_data>> do_rpc_data_or_ok(client::Session* session, unique_ptr_for<struct nc_rpc>&& rpc)
 {
     auto x = do_rpc(session, std::move(rpc));
 
@@ -170,27 +170,29 @@
     case NC_RPL_DATA:
         return guarded(reinterpret_cast<struct nc_reply_data*>(x.release()));
     case NC_RPL_OK:
-        throw std::runtime_error{"Received OK instead of a data reply"};
+        return std::nullopt;
     case NC_RPL_ERROR:
         throw make_error(std::move(x));
     default:
         throw std::runtime_error{"Unhandled reply type"};
     }
+
+}
+
+unique_ptr_for<struct nc_reply_data> do_rpc_data(client::Session* session, unique_ptr_for<struct nc_rpc>&& rpc)
+{
+    auto x = do_rpc_data_or_ok(session, std::move(rpc));
+    if (!x) {
+        throw std::runtime_error{"Received OK instead of a data reply"};
+    }
+    return std::move(*x);
 }
 
 void do_rpc_ok(client::Session* session, unique_ptr_for<struct nc_rpc>&& rpc)
 {
-    auto x = do_rpc(session, std::move(rpc));
-
-    switch (x->type) {
-    case NC_RPL_DATA:
+    auto x = do_rpc_data_or_ok(session, std::move(rpc));
+    if (x) {
         throw std::runtime_error{"Unexpected DATA reply"};
-    case NC_RPL_OK:
-        return;
-    case NC_RPL_ERROR:
-        throw make_error(std::move(x));
-    default:
-        throw std::runtime_error{"Unhandled reply type"};
     }
 }
 }
@@ -363,6 +365,21 @@
     impl::do_rpc_ok(this, std::move(rpc));
 }
 
+std::shared_ptr<libyang::Data_Node> Session::rpc(const std::string& xmlData)
+{
+    auto rpc = impl::guarded(nc_rpc_act_generic_xml(xmlData.c_str(), NC_PARAMTYPE_CONST));
+    if (!rpc) {
+        throw std::runtime_error("Cannot create generic RPC");
+    }
+    auto reply = impl::do_rpc_data_or_ok(this, std::move(rpc));
+    if (reply) {
+        auto dataNode = libyang::create_new_Data_Node((*reply)->data);
+        return dataNode->dup_withsiblings(1);
+    } else {
+        return nullptr;
+    }
+}
+
 ReportedError::ReportedError(const std::string& what)
     : std::runtime_error(what)
 {
diff --git a/src/netconf-client.h b/src/netconf-client.h
index 1923e71..b9a1925 100644
--- a/src/netconf-client.h
+++ b/src/netconf-client.h
@@ -43,6 +43,7 @@
                     const NC_RPC_EDIT_ERROPT errorOption,
                     const std::string& data);
     void copyConfigFromString(const NC_DATASTORE target, const std::string& data);
+    std::shared_ptr<libyang::Data_Node> rpc(const std::string& xmlData);
     void commit();
     void discard();
     struct nc_session* session_internal(); // FIXME: remove me
diff --git a/src/netconf_access.cpp b/src/netconf_access.cpp
index 5b1ad78..7387231 100644
--- a/src/netconf_access.cpp
+++ b/src/netconf_access.cpp
@@ -13,35 +13,50 @@
 #include "utils.hpp"
 #include "yang_schema.hpp"
 
-NetconfAccess::~NetconfAccess() = default;
+namespace {
 
-std::map<std::string, leaf_data_> NetconfAccess::getItems(const std::string& path)
+// This is very similar to the fillMap lambda in SysrepoAccess, however,
+// Sysrepo returns a weird array-like structure, while libnetconf
+// returns libyang::Data_Node
+void fillMap(DatastoreAccess::Tree& res, const std::vector<std::shared_ptr<libyang::Data_Node>> items, std::optional<std::string> ignoredXPathPrefix = std::nullopt)
 {
-    using namespace std::string_literals;
-    std::map<std::string, leaf_data_> res;
-
-    // This is very similar to the fillMap lambda in SysrepoAccess, however,
-    // Sysrepo returns a weird array-like structure, while libnetconf
-    // returns libyang::Data_Node
-    auto fillMap = [&res](auto items) {
-        for (const auto& it : items) {
-            if (!it)
-                continue;
-            if (it->schema()->nodetype() == LYS_LIST) {
-                res.emplace(it->path(), special_{SpecialValue::List});
-            }
-            if (it->schema()->nodetype() == LYS_LEAF) {
-                libyang::Data_Node_Leaf_List leaf(it);
-                res.emplace(leaf.path(), leafValueFromValue(leaf.value(), leaf.leaf_type()->base()));
-            }
-        }
+    auto stripXPathPrefix = [&ignoredXPathPrefix] (auto path) {
+        return ignoredXPathPrefix ? path.substr(ignoredXPathPrefix->size()) : path;
     };
 
+    for (const auto& it : items) {
+        if (!it)
+            continue;
+        if (it->schema()->nodetype() == LYS_CONTAINER) {
+            if (libyang::Schema_Node_Container{it->schema()}.presence()) {
+                // The fact that the container is included in the data tree
+                // means that it is present and I don't need to check any
+                // value.
+                res.emplace(stripXPathPrefix(it->path()), special_{SpecialValue::PresenceContainer});
+            }
+        }
+        if (it->schema()->nodetype() == LYS_LIST) {
+            res.emplace(stripXPathPrefix(it->path()), special_{SpecialValue::List});
+        }
+        if (it->schema()->nodetype() == LYS_LEAF) {
+            libyang::Data_Node_Leaf_List leaf(it);
+            res.emplace(stripXPathPrefix(it->path()), leafValueFromValue(leaf.value(), leaf.leaf_type()->base()));
+        }
+    }
+}
+}
+
+
+NetconfAccess::~NetconfAccess() = default;
+
+DatastoreAccess::Tree NetconfAccess::getItems(const std::string& path)
+{
+    Tree res;
     auto config = m_session->getConfig(NC_DATASTORE_RUNNING, (path != "/") ? std::optional{path} : std::nullopt);
 
     if (config) {
         for (auto it : config->tree_for()) {
-            fillMap(it->tree_dfs());
+            fillMap(res, it->tree_dfs());
         }
     }
     return res;
@@ -130,6 +145,25 @@
     m_session->discard();
 }
 
+DatastoreAccess::Tree NetconfAccess::executeRpc(const std::string& path, const Tree& input)
+{
+    auto root = m_schema->dataNodeFromPath(path);
+    for (const auto& [k, v] : input) {
+        auto node = m_schema->dataNodeFromPath(joinPaths(path, k), leafDataToString(v));
+        root->merge(node, 0);
+    }
+    auto data = root->print_mem(LYD_XML, 0);
+
+    Tree res;
+    auto output = m_session->rpc(data);
+    if (output) {
+        for (auto it : output->tree_for()) {
+            fillMap(res, it->tree_dfs(), joinPaths(path, "/"));
+        }
+    }
+    return res;
+}
+
 std::string NetconfAccess::fetchSchema(const std::string_view module, const
         std::optional<std::string_view> revision, const
         std::optional<std::string_view> submodule, const
diff --git a/src/netconf_access.hpp b/src/netconf_access.hpp
index 29101ed..f43976a 100644
--- a/src/netconf_access.hpp
+++ b/src/netconf_access.hpp
@@ -33,7 +33,7 @@
     NetconfAccess(const std::string& socketPath);
     NetconfAccess(std::unique_ptr<libnetconf::client::Session>&& session);
     ~NetconfAccess() override;
-    std::map<std::string, leaf_data_> getItems(const std::string& path) override;
+    Tree getItems(const std::string& path) override;
     void setLeaf(const std::string& path, leaf_data_ value) override;
     void createPresenceContainer(const std::string& path) override;
     void deletePresenceContainer(const std::string& path) override;
@@ -41,6 +41,7 @@
     void deleteListInstance(const std::string& path) override;
     void commitChanges() override;
     void discardChanges() override;
+    Tree executeRpc(const std::string& path, const Tree& input) override;
 
     std::shared_ptr<Schema> schema() override;
 
diff --git a/src/python_netconf.cpp b/src/python_netconf.cpp
index eaa4349..5806afa 100644
--- a/src/python_netconf.cpp
+++ b/src/python_netconf.cpp
@@ -69,5 +69,6 @@
             .def("getItems", &NetconfAccess::getItems, "xpath"_a)
             .def("setLeaf", &NetconfAccess::setLeaf, "xpath"_a, "value"_a)
             .def("commitChanges", &NetconfAccess::commitChanges)
+            .def("executeRpc", &NetconfAccess::executeRpc, "rpc"_a, "input"_a=DatastoreAccess::Tree{})
             ;
 }
diff --git a/src/sysrepo_access.cpp b/src/sysrepo_access.cpp
index 221f482..cf19407 100644
--- a/src/sysrepo_access.cpp
+++ b/src/sysrepo_access.cpp
@@ -8,6 +8,7 @@
 
 #include <sysrepo-cpp/Session.hpp>
 #include "sysrepo_access.hpp"
+#include "utils.hpp"
 #include "yang_schema.hpp"
 
 leaf_data_ leafValueFromVal(const sysrepo::S_Val& value)
@@ -83,6 +84,47 @@
     }
 };
 
+struct updateSrValFromValue : boost::static_visitor<void> {
+    std::string xpath;
+    sysrepo::S_Val v;
+    updateSrValFromValue(const std::string& xpath, sysrepo::S_Val v)
+        : xpath(xpath)
+        , v(v)
+    {
+    }
+
+    void operator()(const enum_& value) const
+    {
+        v->set(xpath.c_str(), value.m_value.c_str(), SR_ENUM_T);
+    }
+
+    void operator()(const binary_& value) const
+    {
+        v->set(xpath.c_str(), value.m_value.c_str(), SR_BINARY_T);
+    }
+
+    void operator()(const identityRef_& value) const
+    {
+        v->set(xpath.c_str(), (value.m_prefix.value().m_name + ":" + value.m_value).c_str(), SR_IDENTITYREF_T);
+    }
+
+    void operator()(const special_& value) const
+    {
+        throw std::runtime_error("Tried constructing S_Val from a " + specialValueToString(value));
+    }
+
+    void operator()(const std::string& value) const
+    {
+        v->set(xpath.c_str(), value.c_str(), SR_STRING_T);
+    }
+
+    template <typename T>
+    void operator()(const T value) const
+    {
+        v->set(xpath.c_str(), value);
+    }
+};
+
 SysrepoAccess::~SysrepoAccess() = default;
 
 SysrepoAccess::SysrepoAccess(const std::string& appname)
@@ -111,10 +153,10 @@
     }
 }
 
-std::map<std::string, leaf_data_> SysrepoAccess::getItems(const std::string& path)
+DatastoreAccess::Tree SysrepoAccess::getItems(const std::string& path)
 {
     using namespace std::string_literals;
-    std::map<std::string, leaf_data_> res;
+    Tree res;
 
     auto fillMap = [&res](auto items) {
         if (!items)
@@ -203,6 +245,25 @@
     }
 }
 
+DatastoreAccess::Tree SysrepoAccess::executeRpc(const std::string &path, const Tree &input)
+{
+    auto srInput = std::make_shared<sysrepo::Vals>(input.size());
+    {
+        size_t i = 0;
+        for (const auto& [k, v] : input) {
+            boost::apply_visitor(updateSrValFromValue(joinPaths(path, k), srInput->val(i)), v);
+            ++i;
+        }
+    }
+    auto output = m_session->rpc_send(path.c_str(), srInput);
+    Tree res;
+    for (size_t i = 0; i < output->val_cnt(); ++i) {
+        const auto& v = output->val(i);
+        res.emplace(std::string(v->xpath()).substr(joinPaths(path, "/").size()), leafValueFromVal(v));
+    }
+    return res;
+}
+
 std::string SysrepoAccess::fetchSchema(const char* module, const char* revision, const char* submodule)
 {
     std::string schema;
diff --git a/src/sysrepo_access.hpp b/src/sysrepo_access.hpp
index 9861ab4..c2ce909 100644
--- a/src/sysrepo_access.hpp
+++ b/src/sysrepo_access.hpp
@@ -28,12 +28,13 @@
 public:
     ~SysrepoAccess() override;
     SysrepoAccess(const std::string& appname);
-    std::map<std::string, leaf_data_> getItems(const std::string& path) override;
+    Tree getItems(const std::string& path) override;
     void setLeaf(const std::string& path, leaf_data_ value) override;
     void createPresenceContainer(const std::string& path) override;
     void deletePresenceContainer(const std::string& path) override;
     void createListInstance(const std::string& path) override;
     void deleteListInstance(const std::string& path) override;
+    Tree executeRpc(const std::string& path, const Tree& input) override;
 
     std::shared_ptr<Schema> schema() override;
 
diff --git a/src/utils.cpp b/src/utils.cpp
index d0e8639..978e0a9 100644
--- a/src/utils.cpp
+++ b/src/utils.cpp
@@ -11,10 +11,26 @@
 
 std::string joinPaths(const std::string& prefix, const std::string& suffix)
 {
-    if (prefix.empty() || suffix.empty() || prefix == "/")
-        return prefix + suffix;
-    else
-        return prefix + '/' + suffix;
+    // These two if statements are essential for the algorithm:
+    // The first one solves joining nothing and a relative path - the algorithm
+    // down below adds a leading slash, turning it into an absolute path.
+    // The second one would always add a trailing slash to the path.
+    if (prefix.empty()) {
+        return suffix;
+    }
+
+    if (suffix.empty()) {
+        return prefix;
+    }
+
+    // Otherwise, strip slashes where the join is going to happen. This will
+    // also change "/" to "", but the return statement takes care of that and
+    // inserts the slash again.
+    auto prefixWithoutTrailingSlash = !prefix.empty() && prefix.back() == '/' ? prefix.substr(0, prefix.length() - 1) : prefix;
+    auto suffixWithoutLeadingSlash = !suffix.empty() && suffix.front() == '/' ? suffix.substr(1) : suffix;
+
+    // And join the result with a slash.
+    return prefixWithoutTrailingSlash + '/' + suffixWithoutLeadingSlash;
 }
 
 std::string stripLastNodeFromPath(const std::string& path)
diff --git a/tests/datastore_access.cpp b/tests/datastore_access.cpp
index 535abdc..112ace9 100644
--- a/tests/datastore_access.cpp
+++ b/tests/datastore_access.cpp
@@ -7,6 +7,7 @@
 */
 
 #include "trompeloeil_doctest.h"
+#include <sysrepo-cpp/Session.hpp>
 
 #ifdef sysrepo_BACKEND
 #include "sysrepo_access.hpp"
@@ -19,13 +20,21 @@
 #include "sysrepo_subscription.hpp"
 #include "utils.hpp"
 
-class MockRecorder : public Recorder {
+using namespace std::literals::string_literals;
+
+class MockRecorder : public trompeloeil::mock_interface<Recorder> {
 public:
-    MAKE_MOCK3(write, void(const std::string&, const std::string&, const std::string&), override);
+    IMPLEMENT_MOCK3(write);
 };
 
 namespace std {
-std::ostream& operator<<(std::ostream& s, const std::map<std::string, leaf_data_> map)
+std::ostream& operator<<(std::ostream& s, const std::optional<std::string>& opt)
+{
+    s << (opt ? *opt : "std::nullopt");
+    return s;
+}
+
+std::ostream& operator<<(std::ostream& s, const DatastoreAccess::Tree& map)
 {
     s << std::endl
       << "{";
@@ -51,87 +60,88 @@
 #error "Unknown backend"
 #endif
 
+
     SECTION("set leafInt8 to -128")
     {
-        REQUIRE_CALL(mock, write("/example-schema:leafInt8", "", "-128"));
+        REQUIRE_CALL(mock, write("/example-schema:leafInt8", std::nullopt, "-128"s));
         datastore.setLeaf("/example-schema:leafInt8", int8_t{-128});
         datastore.commitChanges();
     }
 
     SECTION("set leafInt16 to -32768")
     {
-        REQUIRE_CALL(mock, write("/example-schema:leafInt16", "", "-32768"));
+        REQUIRE_CALL(mock, write("/example-schema:leafInt16", std::nullopt, "-32768"s));
         datastore.setLeaf("/example-schema:leafInt16", int16_t{-32768});
         datastore.commitChanges();
     }
 
     SECTION("set leafInt32 to -2147483648")
     {
-        REQUIRE_CALL(mock, write("/example-schema:leafInt32", "", "-2147483648"));
+        REQUIRE_CALL(mock, write("/example-schema:leafInt32", std::nullopt, "-2147483648"s));
         datastore.setLeaf("/example-schema:leafInt32", int32_t{-2147483648});
         datastore.commitChanges();
     }
 
     SECTION("set leafInt64 to -50000000000")
     {
-        REQUIRE_CALL(mock, write("/example-schema:leafInt64", "", "-50000000000"));
+        REQUIRE_CALL(mock, write("/example-schema:leafInt64", std::nullopt, "-50000000000"s));
         datastore.setLeaf("/example-schema:leafInt64", int64_t{-50000000000});
         datastore.commitChanges();
     }
 
     SECTION("set leafUInt8 to 255")
     {
-        REQUIRE_CALL(mock, write("/example-schema:leafUInt8", "", "255"));
+        REQUIRE_CALL(mock, write("/example-schema:leafUInt8", std::nullopt, "255"s));
         datastore.setLeaf("/example-schema:leafUInt8", uint8_t{255});
         datastore.commitChanges();
     }
 
     SECTION("set leafUInt16 to 65535")
     {
-        REQUIRE_CALL(mock, write("/example-schema:leafUInt16", "", "65535"));
+        REQUIRE_CALL(mock, write("/example-schema:leafUInt16", std::nullopt, "65535"s));
         datastore.setLeaf("/example-schema:leafUInt16", uint16_t{65535});
         datastore.commitChanges();
     }
 
     SECTION("set leafUInt32 to 4294967295")
     {
-        REQUIRE_CALL(mock, write("/example-schema:leafUInt32", "", "4294967295"));
+        REQUIRE_CALL(mock, write("/example-schema:leafUInt32", std::nullopt, "4294967295"s));
         datastore.setLeaf("/example-schema:leafUInt32", uint32_t{4294967295});
         datastore.commitChanges();
     }
 
     SECTION("set leafUInt64 to 50000000000")
     {
-        REQUIRE_CALL(mock, write("/example-schema:leafUInt64", "", "50000000000"));
+        REQUIRE_CALL(mock, write("/example-schema:leafUInt64", std::nullopt, "50000000000"s));
         datastore.setLeaf("/example-schema:leafUInt64", uint64_t{50000000000});
         datastore.commitChanges();
     }
 
     SECTION("set leafEnum to coze")
     {
-        REQUIRE_CALL(mock, write("/example-schema:leafEnum", "", "coze"));
+        REQUIRE_CALL(mock, write("/example-schema:leafEnum", std::nullopt, "coze"s));
         datastore.setLeaf("/example-schema:leafEnum", enum_{"coze"});
         datastore.commitChanges();
     }
 
     SECTION("set leafDecimal to 123.544")
     {
-        REQUIRE_CALL(mock, write("/example-schema:leafDecimal", "", "123.544"));
+        REQUIRE_CALL(mock, write("/example-schema:leafDecimal", std::nullopt, "123.544"s));
         datastore.setLeaf("/example-schema:leafDecimal", 123.544);
         datastore.commitChanges();
     }
 
     SECTION("create presence container")
     {
-        REQUIRE_CALL(mock, write("/example-schema:pContainer", "", ""));
+        REQUIRE_CALL(mock, write("/example-schema:pContainer", std::nullopt, ""s));
         datastore.createPresenceContainer("/example-schema:pContainer");
         datastore.commitChanges();
     }
 
     SECTION("create a list instance")
     {
-        REQUIRE_CALL(mock, write("/example-schema:person[name='Nguyen']", "", ""));
-        REQUIRE_CALL(mock, write("/example-schema:person[name='Nguyen']/name", "", "Nguyen"));
+        REQUIRE_CALL(mock, write("/example-schema:person[name='Nguyen']", std::nullopt, ""s));
+        REQUIRE_CALL(mock, write("/example-schema:person[name='Nguyen']/name", std::nullopt, "Nguyen"s));
         datastore.createListInstance("/example-schema:person[name='Nguyen']");
         datastore.commitChanges();
     }
@@ -139,12 +149,12 @@
     SECTION("leafref pointing to a key of a list")
     {
         {
-            REQUIRE_CALL(mock, write("/example-schema:person[name='Dan']", "", ""));
-            REQUIRE_CALL(mock, write("/example-schema:person[name='Dan']/name", "", "Dan"));
-            REQUIRE_CALL(mock, write("/example-schema:person[name='Elfi']", "", ""));
-            REQUIRE_CALL(mock, write("/example-schema:person[name='Elfi']/name", "", "Elfi"));
-            REQUIRE_CALL(mock, write("/example-schema:person[name='Kolafa']", "", ""));
-            REQUIRE_CALL(mock, write("/example-schema:person[name='Kolafa']/name", "", "Kolafa"));
+            REQUIRE_CALL(mock, write("/example-schema:person[name='Dan']", std::nullopt, ""s));
+            REQUIRE_CALL(mock, write("/example-schema:person[name='Dan']/name", std::nullopt, "Dan"s));
+            REQUIRE_CALL(mock, write("/example-schema:person[name='Elfi']", std::nullopt, ""s));
+            REQUIRE_CALL(mock, write("/example-schema:person[name='Elfi']/name", std::nullopt, "Elfi"s));
+            REQUIRE_CALL(mock, write("/example-schema:person[name='Kolafa']", std::nullopt, ""s));
+            REQUIRE_CALL(mock, write("/example-schema:person[name='Kolafa']/name", std::nullopt, "Kolafa"s));
             datastore.createListInstance("/example-schema:person[name='Dan']");
             datastore.createListInstance("/example-schema:person[name='Elfi']");
             datastore.createListInstance("/example-schema:person[name='Kolafa']");
@@ -156,21 +166,21 @@
         // SECTION.
         SECTION("Dan")
         {
-            REQUIRE_CALL(mock, write("/example-schema:bossPerson", "", "Dan"));
+            REQUIRE_CALL(mock, write("/example-schema:bossPerson", std::nullopt, "Dan"s));
             datastore.setLeaf("/example-schema:bossPerson", std::string{"Dan"});
             datastore.commitChanges();
         }
 
         SECTION("Elfi")
         {
-            REQUIRE_CALL(mock, write("/example-schema:bossPerson", "", "Elfi"));
+            REQUIRE_CALL(mock, write("/example-schema:bossPerson", std::nullopt, "Elfi"s));
             datastore.setLeaf("/example-schema:bossPerson", std::string{"Elfi"});
             datastore.commitChanges();
         }
 
         SECTION("Kolafa")
         {
-            REQUIRE_CALL(mock, write("/example-schema:bossPerson", "", "Kolafa"));
+            REQUIRE_CALL(mock, write("/example-schema:bossPerson", std::nullopt, "Kolafa"s));
             datastore.setLeaf("/example-schema:bossPerson", std::string{"Kolafa"});
             datastore.commitChanges();
         }
@@ -178,26 +188,26 @@
     SECTION("bool values get correctly represented as bools")
     {
         {
-            REQUIRE_CALL(mock, write("/example-schema:down", "", "true"));
+            REQUIRE_CALL(mock, write("/example-schema:down", std::nullopt, "true"s));
             datastore.setLeaf("/example-schema:down", bool{true});
             datastore.commitChanges();
         }
 
-        std::map<std::string, leaf_data_> expected{{"/example-schema:down", bool{true}}};
+        DatastoreAccess::Tree expected{{"/example-schema:down", bool{true}}};
         REQUIRE(datastore.getItems("/example-schema:down") == expected);
     }
 
     SECTION("getting items from the whole module")
     {
         {
-            REQUIRE_CALL(mock, write("/example-schema:up", "", "true"));
-            REQUIRE_CALL(mock, write("/example-schema:down", "", "false"));
+            REQUIRE_CALL(mock, write("/example-schema:up", std::nullopt, "true"s));
+            REQUIRE_CALL(mock, write("/example-schema:down", std::nullopt, "false"s));
             datastore.setLeaf("/example-schema:up", bool{true});
             datastore.setLeaf("/example-schema:down", bool{false});
             datastore.commitChanges();
         }
 
-        std::map<std::string, leaf_data_> expected{{"/example-schema:down", bool{false}},
+        DatastoreAccess::Tree expected{{"/example-schema:down", bool{false}},
         // Sysrepo always returns containers when getting values, but
         // libnetconf does not. This is fine by the YANG standard:
         // https://tools.ietf.org/html/rfc7950#section-7.5.7 Furthermore,
@@ -216,11 +226,11 @@
     SECTION("getItems returns correct datatypes")
     {
         {
-            REQUIRE_CALL(mock, write("/example-schema:leafEnum", "", "lol"));
+            REQUIRE_CALL(mock, write("/example-schema:leafEnum", std::nullopt, "lol"s));
             datastore.setLeaf("/example-schema:leafEnum", enum_{"lol"});
             datastore.commitChanges();
         }
-        std::map<std::string, leaf_data_> expected{{"/example-schema:leafEnum", enum_{"lol"}}};
+        DatastoreAccess::Tree expected{{"/example-schema:leafEnum", enum_{"lol"}}};
 
         REQUIRE(datastore.getItems("/example-schema:leafEnum") == expected);
     }
@@ -228,18 +238,18 @@
     SECTION("getItems on a list")
     {
         {
-            REQUIRE_CALL(mock, write("/example-schema:person[name='Jan']", "", ""));
-            REQUIRE_CALL(mock, write("/example-schema:person[name='Jan']/name", "", "Jan"));
-            REQUIRE_CALL(mock, write("/example-schema:person[name='Michal']", "", ""));
-            REQUIRE_CALL(mock, write("/example-schema:person[name='Michal']/name", "", "Michal"));
-            REQUIRE_CALL(mock, write("/example-schema:person[name='Petr']", "", ""));
-            REQUIRE_CALL(mock, write("/example-schema:person[name='Petr']/name", "", "Petr"));
+            REQUIRE_CALL(mock, write("/example-schema:person[name='Jan']", std::nullopt, ""s));
+            REQUIRE_CALL(mock, write("/example-schema:person[name='Jan']/name", std::nullopt, "Jan"s));
+            REQUIRE_CALL(mock, write("/example-schema:person[name='Michal']", std::nullopt, ""s));
+            REQUIRE_CALL(mock, write("/example-schema:person[name='Michal']/name", std::nullopt, "Michal"s));
+            REQUIRE_CALL(mock, write("/example-schema:person[name='Petr']", std::nullopt, ""s));
+            REQUIRE_CALL(mock, write("/example-schema:person[name='Petr']/name", std::nullopt, "Petr"s));
             datastore.createListInstance("/example-schema:person[name='Jan']");
             datastore.createListInstance("/example-schema:person[name='Michal']");
             datastore.createListInstance("/example-schema:person[name='Petr']");
             datastore.commitChanges();
         }
-        std::map<std::string, leaf_data_> expected{
+        DatastoreAccess::Tree expected{
             {"/example-schema:person[name='Jan']", special_{SpecialValue::List}},
             {"/example-schema:person[name='Jan']/name", std::string{"Jan"}},
             {"/example-schema:person[name='Michal']", special_{SpecialValue::List}},
@@ -251,5 +261,142 @@
         REQUIRE(datastore.getItems("/example-schema:person") == expected);
     }
 
+    SECTION("presence containers")
+    {
+        DatastoreAccess::Tree expected;
+        // Make sure it's not there before we create it
+        REQUIRE(datastore.getItems("/example-schema:pContainer") == expected);
+
+        {
+            REQUIRE_CALL(mock, write("/example-schema:pContainer", std::nullopt, ""s));
+            datastore.createPresenceContainer("/example-schema:pContainer");
+            datastore.commitChanges();
+        }
+        expected = {
+            {"/example-schema:pContainer", special_{SpecialValue::PresenceContainer}}
+        };
+        REQUIRE(datastore.getItems("/example-schema:pContainer") == expected);
+
+        // Make sure it's not there after we delete it
+        {
+            REQUIRE_CALL(mock, write("/example-schema:pContainer", ""s, std::nullopt));
+            datastore.deletePresenceContainer("/example-schema:pContainer");
+            datastore.commitChanges();
+        }
+        expected = {};
+        REQUIRE(datastore.getItems("/example-schema:pContainer") == expected);
+
+    }
+
+    waitForCompletionAndBitMore(seq1);
+}
+
+class RpcCb: public sysrepo::Callback {
+    int rpc(const char *xpath, const ::sysrepo::S_Vals input, ::sysrepo::S_Vals_Holder output, void *) override
+    {
+        const auto nukes = "/example-schema:launch-nukes"s;
+        if (xpath == "/example-schema:noop"s) {
+            return SR_ERR_OK;
+        } else if (xpath == nukes) {
+            uint64_t kilotons = 0;
+            bool hasCities = false;
+            for (size_t i = 0; i < input->val_cnt(); ++i) {
+                const auto& val = input->val(i);
+                if (val->xpath() == nukes + "/payload/kilotons") {
+                    kilotons = val->data()->get_uint64();
+                } else if (val->xpath() == nukes + "/payload") {
+                    // ignore, container
+                } else if (val->xpath() == nukes + "/description") {
+                    // unused
+                } else if (std::string_view{val->xpath()}.find(nukes + "/cities") == 0) {
+                    hasCities = true;
+                } else {
+                    throw std::runtime_error("RPC launch-nukes: unexpected input "s + val->xpath());
+                }
+            }
+            if (kilotons == 333'666) {
+                // magic, just do not generate any output. This is important because the NETCONF RPC returns just <ok/>.
+                return SR_ERR_OK;
+            }
+            auto buf = output->allocate(2);
+            size_t i = 0;
+            buf->val(i++)->set((nukes + "/blast-radius").c_str(), uint32_t{33'666});
+            buf->val(i++)->set((nukes + "/actual-yield").c_str(), static_cast<uint64_t>(1.33 * kilotons));
+            if (hasCities) {
+                buf = output->reallocate(output->val_cnt() + 2);
+                buf->val(i++)->set((nukes + "/damaged-places/targets[city='London']/city").c_str(), "London");
+                buf->val(i++)->set((nukes + "/damaged-places/targets[city='Berlin']/city").c_str(), "Berlin");
+            }
+            return SR_ERR_OK;
+        }
+        throw std::runtime_error("unrecognized RPC");
+    }
+};
+
+TEST_CASE("rpc") {
+    trompeloeil::sequence seq1;
+    auto srConn = std::make_shared<sysrepo::Connection>("netconf-cli-test-rpc");
+    auto srSession = std::make_shared<sysrepo::Session>(srConn);
+    auto srSubscription = std::make_shared<sysrepo::Subscribe>(srSession);
+    auto cb = std::make_shared<RpcCb>();
+    sysrepo::Logs{}.set_stderr(SR_LL_INF);
+    srSubscription->rpc_subscribe("/example-schema:noop", cb, nullptr, SR_SUBSCR_CTX_REUSE);
+    srSubscription->rpc_subscribe("/example-schema:launch-nukes", cb, nullptr, SR_SUBSCR_CTX_REUSE);
+
+#ifdef sysrepo_BACKEND
+    SysrepoAccess datastore("netconf-cli-test");
+#elif defined(netconf_BACKEND)
+    NetconfAccess datastore(NETOPEER_SOCKET_PATH);
+#else
+#error "Unknown backend"
+#endif
+
+    std::string rpc;
+    DatastoreAccess::Tree input, output;
+
+    SECTION("noop") {
+        rpc = "/example-schema:noop";
+    }
+
+    SECTION("small nuke") {
+        rpc = "/example-schema:launch-nukes";
+        input = {
+            {"description", "dummy"s},
+            {"payload/kilotons", uint64_t{333'666}},
+        };
+        // no data are returned
+    }
+
+    SECTION("small nuke") {
+        rpc = "/example-schema:launch-nukes";
+        input = {
+            {"description", "dummy"s},
+            {"payload/kilotons", uint64_t{4}},
+        };
+        output = {
+            {"blast-radius", uint32_t{33'666}},
+            {"actual-yield", uint64_t{5}},
+        };
+    }
+
+    SECTION("with lists") {
+        rpc = "/example-schema:launch-nukes";
+        input = {
+            {"payload/kilotons", uint64_t{6}},
+            {"cities/targets[city='Prague']/city", "Prague"s},
+        };
+        output = {
+            {"blast-radius", uint32_t{33'666}},
+            {"actual-yield", uint64_t{7}},
+            {"damaged-places", special_{SpecialValue::PresenceContainer}},
+            {"damaged-places/targets[city='London']", special_{SpecialValue::List}},
+            {"damaged-places/targets[city='London']/city", "London"s},
+            {"damaged-places/targets[city='Berlin']", special_{SpecialValue::List}},
+            {"damaged-places/targets[city='Berlin']/city", "Berlin"s},
+        };
+    }
+
+    REQUIRE(datastore.executeRpc(rpc, input) == output);
+
     waitForCompletionAndBitMore(seq1);
 }
diff --git a/tests/example-schema.yang b/tests/example-schema.yang
index 498446e..d993781 100644
--- a/tests/example-schema.yang
+++ b/tests/example-schema.yang
@@ -83,4 +83,48 @@
     container lol {
         uses upAndDown;
     }
+
+    grouping targets_def {
+        list targets {
+            key 'city';
+            leaf city {
+                type string;
+            }
+        }
+    }
+
+    rpc launch-nukes {
+        input {
+            container payload {
+                leaf kilotons {
+                    type uint64;
+                    mandatory true;
+                    units "kilotons";
+                }
+            }
+            leaf description {
+                type string;
+            }
+            container cities {
+                presence true;
+                uses targets_def;
+            }
+        }
+        output {
+            leaf blast-radius {
+                type uint32;
+                units "m";
+            }
+            leaf actual-yield {
+                type uint64;
+                units "kilotons";
+            }
+            container damaged-places {
+                presence true;
+                uses targets_def;
+            }
+        }
+    }
+
+    rpc noop {}
 }
diff --git a/tests/mock/sysrepo_subscription.cpp b/tests/mock/sysrepo_subscription.cpp
index 801ec1c..7cd5cf3 100644
--- a/tests/mock/sysrepo_subscription.cpp
+++ b/tests/mock/sysrepo_subscription.cpp
@@ -28,9 +28,11 @@
             return SR_ERR_OK;
 
         while (auto change = sess->get_change_next(it)) {
-            m_recorder->write(change->new_val()->xpath(),
-                              change->old_val() ? change->old_val()->val_to_string() : "",
-                              change->new_val()->val_to_string());
+            auto xpath = (change->new_val() ? change->new_val() : change->old_val())->xpath();
+
+            auto oldValue = change->old_val() ? std::optional{change->old_val()->val_to_string()} : std::nullopt;
+            auto newValue = change->new_val() ? std::optional{change->new_val()->val_to_string()} : std::nullopt;
+            m_recorder->write(xpath, oldValue, newValue);
         }
 
         return SR_ERR_OK;
diff --git a/tests/mock/sysrepo_subscription.hpp b/tests/mock/sysrepo_subscription.hpp
index 36681a6..dce6506 100644
--- a/tests/mock/sysrepo_subscription.hpp
+++ b/tests/mock/sysrepo_subscription.hpp
@@ -8,6 +8,7 @@
 
 #pragma once
 
+#include <optional>
 #include <memory>
 
 namespace sysrepo {
@@ -21,7 +22,7 @@
 class Recorder {
 public:
     virtual ~Recorder();
-    virtual void write(const std::string& xpath, const std::string& oldValue, const std::string& newValue) = 0;
+    virtual void write(const std::string& xpath, const std::optional<std::string>& oldValue, const std::optional<std::string>& newValue) = 0;
 };
 
 class SysrepoSubscription {
diff --git a/tests/utils.cpp b/tests/utils.cpp
index a6a982b..7717480 100644
--- a/tests/utils.cpp
+++ b/tests/utils.cpp
@@ -22,4 +22,61 @@
         REQUIRE((filterByPrefix(set, "polivkax") == std::set<std::string>{}));
         REQUIRE((filterByPrefix(set, "co") == std::set<std::string>{"copak", "coze"}));
     }
+
+    SECTION("joinPaths") {
+        std::string prefix, suffix, result;
+
+        SECTION("regular") {
+            prefix = "/example:a";
+            suffix = "leaf";
+            result = "/example:a/leaf";
+        }
+
+        SECTION("no prefix - absolute path") {
+            suffix = "/example:a/leaf";
+            result = "/example:a/leaf";
+        }
+
+        SECTION("no prefix - relative path") {
+            suffix = "example:a/leaf";
+            result = "example:a/leaf";
+        }
+
+        SECTION("no suffix") {
+            prefix = "/example:a/leaf";
+            result = "/example:a/leaf";
+        }
+
+        SECTION("at root") {
+            prefix = "/";
+            suffix = "example:a";
+            result = "/example:a";
+        }
+
+        SECTION("trailing slash") {
+            prefix = "/example:a";
+            suffix = "/";
+            result = "/example:a/";
+        }
+
+        SECTION("prefix ends with slash") {
+            prefix = "/example:a/";
+            suffix = "leaf";
+            result = "/example:a/leaf";
+        }
+
+        SECTION("suffix starts with slash") {
+            prefix = "/example:a";
+            suffix = "/leaf";
+            result = "/example:a/leaf";
+        }
+
+        SECTION("slashes all the way to eleven") {
+            prefix = "/example:a/";
+            suffix = "/leaf";
+            result = "/example:a/leaf";
+        }
+
+        REQUIRE(joinPaths(prefix, suffix) == result);
+    }
 }