gnucash future: Multiple changes pushed

John Ralls jralls at code.gnucash.org
Tue Jun 23 19:04:20 EDT 2026


Updated	 via  https://github.com/Gnucash/gnucash/commit/287c802d (commit)
	 via  https://github.com/Gnucash/gnucash/commit/590d76cc (commit)
	 via  https://github.com/Gnucash/gnucash/commit/5ee139b5 (commit)
	 via  https://github.com/Gnucash/gnucash/commit/12e2922f (commit)
	from  https://github.com/Gnucash/gnucash/commit/2b93466c (commit)



commit 287c802d8c22b5dfe3dbcb0d0c6ed74ae0d8f0d4
Merge: 590d76ccc4 5ee139b556
Author: John Ralls <jralls at ceridwen.us>
Date:   Tue Jun 23 16:02:03 2026 -0700

    Merge Brent McBride's 'uri-utils-cpp' into future.


commit 590d76ccc4a4ce370266d809fe246d03d83c44b6
Author: John Ralls <jralls at ceridwen.us>
Date:   Tue Jun 23 15:35:45 2026 -0700

    Clang: -Wno-character-conversion was introduced in clang 21.
    
    Gcc doesn't have it at all.

diff --git a/CMakeLists.txt b/CMakeLists.txt
index 67867ba2c1..76385829a8 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -625,8 +625,12 @@ set(CMAKE_C_EXTENSIONS ON)
 
 # -Wno-deprecated-volatile, -Wno-volatile: Guile heavily uses
 # -volatile. Guile is C but we include its headers in C++ swig files.
+# -Wno-character-conversions: Googletest casts char8_t* to char32_t
 if (CMAKE_CXX_COMPILER_ID MATCHES "AppleClang|Clang")
   set(volatile_warning "-Wno-deprecated-volatile")
+  if (CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL "21")
+    set (character_conversions "-Wno-character-conversion")
+  endif()
 elseif(CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
   set(volatile_warning "-Wno-volatile")
 else()
@@ -634,13 +638,12 @@ else()
 endif()
 if (UNIX)
   set( CMAKE_C_FLAGS "-Werror -Wall -Wmissing-prototypes -Wmissing-declarations ${CMAKE_C_FLAGS}")
-  # -Wno-character-conversions: Googletest casts char8_t* to char32_t
-  set( CMAKE_CXX_FLAGS "-Werror -Wall -Wmissing-declarations -Wno-character-conversion  ${volatile_warning} ${CMAKE_CXX_FLAGS}")
+  set( CMAKE_CXX_FLAGS "-Werror -Wall -Wmissing-declarations ${character_conversions} ${volatile_warning} ${CMAKE_CXX_FLAGS}")
   set( CMAKE_C_FLAGS_RELEASE "-O3 -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=2 ${CMAKE_C_FLAGS}")
 endif()
 if (MINGW)
   set( CMAKE_C_FLAGS "-Werror -Wall -Wmissing-prototypes -Wmissing-declarations ${CMAKE_C_FLAGS}")
-  set( CMAKE_CXX_FLAGS "-Werror -Wall -Wmissing-declarations -Wno-character-conversion ${volatile_warning} -DWINVER=0x0A00 ${CMAKE_CXX_FLAGS}") # Workaround for bug in gtest on mingw, see https://github.com/google/googletest/issues/893 and https://github.com/google/googletest/issues/920
+  set( CMAKE_CXX_FLAGS "-Werror -Wall -Wmissing-declarations ${character_conversions} ${volatile_warning} -DWINVER=0x0A00 ${CMAKE_CXX_FLAGS}") # Workaround for bug in gtest on mingw, see https://github.com/google/googletest/issues/893 and https://github.com/google/googletest/issues/920
 endif()
 
 if (APPLE)

commit 5ee139b5564944d7eb3f03fe5ae7e16211681f30
Author: Brent McBride <mcbridebt at hotmail.com>
Date:   Sun Jun 21 21:19:50 2026 -0700

    Modernize gnc-uri utilities into a GncUri C++ class

diff --git a/gnucash/gnome-utils/gnc-main-window.cpp b/gnucash/gnome-utils/gnc-main-window.cpp
index 84d87a4505..41eeed391f 100644
--- a/gnucash/gnome-utils/gnc-main-window.cpp
+++ b/gnucash/gnome-utils/gnc-main-window.cpp
@@ -70,7 +70,7 @@
 #include "gnc-ui.h"
 #include "gnc-ui-util.h"
 #include <gnc-glib-utils.h>
-#include "gnc-uri-utils.h"
+#include "gnc-uri.hpp"
 #include "gnc-version.h"
 #include "gnc-warnings.h"
 #include "gnc-window.h"
@@ -1567,19 +1567,19 @@ gnc_main_window_generate_title (GncMainWindow *window)
         filename = g_strdup(_("Unsaved Book"));
     else
     {
-        if (gnc_uri_targets_local_fs (uri))
+        GncUri parsed { uri };
+        if (parsed.targets_local_fs ())
         {
             /* The filename is a true file.
                The Gnome HIG 2.0 recommends only the file name (no path) be used. (p15) */
-            gchar *path = gnc_uri_get_path ( uri );
-            filename = g_path_get_basename ( path );
-            g_free ( path );
+            filename = g_path_get_basename ( parsed.path ()->c_str () );
         }
         else
         {
             /* The filename is composed of database connection parameters.
                For this we will show access_method://username@database[:port] */
-            filename = gnc_uri_normalize_uri (uri, FALSE);
+            auto normalized = parsed.try_str (false);
+            filename = normalized ? g_strdup (normalized->c_str ()) : nullptr;
         }
     }
 
@@ -1733,11 +1733,12 @@ static gchar *generate_statusbar_lastmodified_message()
         return nullptr;
     else
     {
-        if (gnc_uri_targets_local_fs (uri))
+        GncUri parsed { uri };
+        if (parsed.targets_local_fs ())
         {
             /* The filename is a true file. */
-            gchar *filepath = gnc_uri_get_path ( uri );
-            gchar *filename = g_path_get_basename ( filepath );
+            std::string filepath = parsed.path ().value_or ("");
+            gchar *filename = g_path_get_basename ( filepath.c_str () );
             GFile *file = g_file_new_for_uri (uri);
             GFileInfo *info = g_file_query_info (file,
                                                  G_FILE_ATTRIBUTE_TIME_MODIFIED,
@@ -1748,7 +1749,7 @@ static gchar *generate_statusbar_lastmodified_message()
             {
                 // Access the mtime information through stat(2)
                 struct stat statbuf;
-                int r = stat(filepath, &statbuf);
+                int r = stat(filepath.c_str(), &statbuf);
                 if (r == 0)
                 {
                     /* Translators: This is the date and time that is shown in
@@ -1766,12 +1767,11 @@ static gchar *generate_statusbar_lastmodified_message()
                 }
                 else
                 {
-                    g_warning("Unable to read mtime for file %s\n", filepath);
+                    g_warning("Unable to read mtime for file %s\n", filepath.c_str());
                     // message is still nullptr
                 }
             }
             g_free(filename);
-            g_free(filepath);
             g_object_unref (info);
             g_object_unref (file);
         }
@@ -5480,7 +5480,12 @@ add_about_paths (GtkDialog *dialog)
     for (const auto& ep : ep_vec)
     {
         gchar *env_name = g_strconcat (ep.env_name, ":", nullptr);
-        const gchar *uri = gnc_uri_create_uri ("file", nullptr, 0, nullptr, nullptr, ep.env_path);
+        GncUri file_uri { std::string {"file"}, std::nullopt, 0, std::nullopt,
+                          std::nullopt,
+                          ep.env_path ? std::optional<std::string> { ep.env_path }
+                                      : std::nullopt };
+        auto uri_str = file_uri.try_str ();
+        const gchar *uri = uri_str ? uri_str->c_str () : nullptr;
         gchar *display_uri = gnc_doclink_get_unescaped_just_uri (uri);
 
         gchar *url_tag = g_strdup_printf ("%s%d", "url_tag", row);
diff --git a/gnucash/gnome/gnc-plugin-page-invoice.cpp b/gnucash/gnome/gnc-plugin-page-invoice.cpp
index 17c6676e2e..df3b7d045d 100644
--- a/gnucash/gnome/gnc-plugin-page-invoice.cpp
+++ b/gnucash/gnome/gnc-plugin-page-invoice.cpp
@@ -40,7 +40,6 @@
 #include "gnucash-register.h"
 #include "gnc-prefs.h"
 #include "gnc-ui-util.h"
-#include "gnc-uri-utils.h"
 #include "gnc-window.h"
 #include "dialog-utils.h"
 #include "dialog-doclink.h"
diff --git a/gnucash/import-export/csv-imp/assistant-csv-price-import.cpp b/gnucash/import-export/csv-imp/assistant-csv-price-import.cpp
index 3543b51747..c4ae82a765 100644
--- a/gnucash/import-export/csv-imp/assistant-csv-price-import.cpp
+++ b/gnucash/import-export/csv-imp/assistant-csv-price-import.cpp
@@ -37,7 +37,7 @@
 #include <cstdint>
 
 #include "gnc-ui.h"
-#include "gnc-uri-utils.h"
+#include "gnc-uri.hpp"
 #include "gnc-ui-util.h"
 #include "dialog-utils.h"
 
@@ -738,8 +738,8 @@ CsvImpPriceAssist::check_for_valid_filename ()
         return false;
     }
 
-    auto filepath = gnc_uri_get_path (file_name);
-    auto starting_dir = g_path_get_dirname (filepath);
+    auto filepath = GncUri{file_name}.path().value_or ("");
+    auto starting_dir = g_path_get_dirname (filepath.c_str());
 
     m_fc_file_name = file_name;
     gnc_set_default_directory (GNC_PREFS_GROUP, starting_dir);
@@ -747,7 +747,6 @@ CsvImpPriceAssist::check_for_valid_filename ()
     DEBUG("file_name selected is %s", m_fc_file_name.c_str());
     DEBUG("starting directory is %s", starting_dir);
 
-    g_free (filepath);
     g_free (file_name);
     g_free (starting_dir);
 
diff --git a/gnucash/import-export/csv-imp/assistant-csv-trans-import.cpp b/gnucash/import-export/csv-imp/assistant-csv-trans-import.cpp
index c62ed927ae..9b652a5867 100644
--- a/gnucash/import-export/csv-imp/assistant-csv-trans-import.cpp
+++ b/gnucash/import-export/csv-imp/assistant-csv-trans-import.cpp
@@ -40,7 +40,7 @@
 
 #include "gnc-path.h"
 #include "gnc-ui.h"
-#include "gnc-uri-utils.h"
+#include "gnc-uri.hpp"
 #include "gnc-ui-util.h"
 #include "dialog-utils.h"
 
@@ -714,8 +714,8 @@ CsvImpTransAssist::check_for_valid_filename ()
         return false;
     }
 
-    auto filepath = gnc_uri_get_path (file_name);
-    auto starting_dir = g_path_get_dirname (filepath);
+    auto filepath = GncUri{file_name}.path().value_or ("");
+    auto starting_dir = g_path_get_dirname (filepath.c_str());
 
     m_fc_file_name = file_name;
     gnc_set_default_directory (GNC_PREFS_GROUP, starting_dir);
@@ -723,7 +723,6 @@ CsvImpTransAssist::check_for_valid_filename ()
     DEBUG("file_name selected is %s", m_fc_file_name.c_str());
     DEBUG("starting directory is %s", starting_dir);
 
-    g_free (filepath);
     g_free (file_name);
     g_free (starting_dir);
 
diff --git a/libgnucash/backend/dbi/gnc-backend-dbi.cpp b/libgnucash/backend/dbi/gnc-backend-dbi.cpp
index 08c0baa805..0944aedc96 100644
--- a/libgnucash/backend/dbi/gnc-backend-dbi.cpp
+++ b/libgnucash/backend/dbi/gnc-backend-dbi.cpp
@@ -47,7 +47,7 @@
 #include "SX-book.h"
 #include "Recurrence.h"
 #include <gnc-features.h>
-#include "gnc-uri-utils.h"
+#include "gnc-uri.hpp"
 #include "gnc-filepath-utils.h"
 #include <gnc-path.h>
 #include "gnc-locale-utils.h"
@@ -132,24 +132,13 @@ struct UriStrings
 
 UriStrings::UriStrings(const std::string& uri)
 {
-    gchar *scheme, *host, *username, *password, *dbname;
-    int portnum;
-    gnc_uri_get_components(uri.c_str(), &scheme, &host, &portnum, &username,
-                           &password, &dbname);
-    m_protocol = std::string{scheme};
-    m_host = std::string{host};
-    if (dbname)
-	m_dbname = std::string{dbname};
-    if (username)
-        m_username = std::string{username};
-    if (password)
-        m_password = std::string{password};
-    m_portnum = portnum;
-    g_free(scheme);
-    g_free(host);
-    g_free(username);
-    g_free(password);
-    g_free(dbname);
+    GncUri parsed { uri };
+    m_protocol = parsed.scheme().value_or("");
+    m_host     = parsed.hostname().value_or("");
+    m_dbname   = parsed.path().value_or("");
+    m_username = parsed.username().value_or("");
+    m_password = parsed.password().value_or("");
+    m_portnum  = parsed.port();
 }
 
 std::string
@@ -366,9 +355,7 @@ GncDbiBackend<DbType::DBI_SQLITE>::session_begin(QofSession* session,
     ENTER (" ");
 
     /* Remove uri type if present */
-    auto path = gnc_uri_get_path (new_uri);
-    std::string filepath{path};
-    g_free(path);
+    std::string filepath = GncUri{new_uri}.path().value_or("");
     GFileTest ftest = static_cast<decltype (ftest)> (
         G_FILE_TEST_IS_REGULAR | G_FILE_TEST_EXISTS) ;
     file_exists = g_file_test (filepath.c_str(), ftest);
@@ -1033,14 +1020,12 @@ QofDbiBackendProvider<DbType::DBI_SQLITE>::type_check(const char *uri)
     gchar buf[51]{};
     G_GNUC_UNUSED size_t chars_read;
     gint status;
-    gchar* filename;
 
     // BAD if the path is null
     g_return_val_if_fail (uri != nullptr, FALSE);
 
-    filename = gnc_uri_get_path (uri);
-    f = g_fopen (filename, "r");
-    g_free (filename);
+    std::string filename = GncUri{uri}.path().value_or("");
+    f = g_fopen (filename.c_str(), "r");
 
     // OK if the file doesn't exist - new file
     if (f == nullptr)
diff --git a/libgnucash/backend/dbi/test/test-backend-dbi-basic.cpp b/libgnucash/backend/dbi/test/test-backend-dbi-basic.cpp
index 8ba7ce591e..2a1d0c9129 100644
--- a/libgnucash/backend/dbi/test/test-backend-dbi-basic.cpp
+++ b/libgnucash/backend/dbi/test/test-backend-dbi-basic.cpp
@@ -36,7 +36,7 @@
 #include <qof.h>
 /* For cleaning up the database */
 #include <dbi/dbi.h>
-#include <gnc-uri-utils.h>
+#include <gnc-uri.hpp>
     /* For setup_business */
 #include "Account.h"
 #include <TransLog.h>
@@ -88,9 +88,9 @@ static char*
 normalize_path(char* path)
 {
     g_return_val_if_fail(path, nullptr);
-    auto rv = gnc_uri_normalize_uri (path, FALSE);
+    auto rv = GncUri { path }.try_str (false);
     g_free (path);
-    return rv;
+    return rv ? g_strdup (rv->c_str ()) : nullptr;
 }
 
 
@@ -276,8 +276,16 @@ destroy_database (gchar* url)
     dbi_result tables;
     StrVec tblnames;
 
-    gnc_uri_get_components (url, &scheme, &host, &portnum,
-                            &username, &password, &dbname);
+    if (url && *url)
+    {
+        GncUri parsed { url };
+        scheme   = parsed.scheme ()   ? g_strdup (parsed.scheme ()->c_str ())   : nullptr;
+        host     = parsed.hostname () ? g_strdup (parsed.hostname ()->c_str ()) : nullptr;
+        username = parsed.username () ? g_strdup (parsed.username ()->c_str ()) : nullptr;
+        password = parsed.password () ? g_strdup (parsed.password ()->c_str ()) : nullptr;
+        dbname   = parsed.path ()     ? g_strdup (parsed.path ()->c_str ())     : nullptr;
+        portnum  = parsed.port ();
+    }
     if (g_strcmp0 (scheme, "postgres") == 0)
 #if HAVE_LIBDBI_R
         conn = dbi_conn_new_r (pgsql, dbi_instance);
diff --git a/libgnucash/backend/xml/gnc-backend-xml.cpp b/libgnucash/backend/xml/gnc-backend-xml.cpp
index 8a941b28b1..1a0e7f0138 100644
--- a/libgnucash/backend/xml/gnc-backend-xml.cpp
+++ b/libgnucash/backend/xml/gnc-backend-xml.cpp
@@ -67,7 +67,7 @@
 
 #include "qof.h"
 #include "gnc-engine.h"
-#include <gnc-uri-utils.h>
+#include <gnc-uri.hpp>
 #include "gnc-prefs.h"
 
 #ifndef HAVE_STRPTIME
@@ -117,7 +117,6 @@ QofXmlBackendProvider::type_check (const char *uri)
     GStatBuf sbuf;
     int rc;
     FILE* t;
-    gchar* filename;
     QofBookFileType xml_type;
     gboolean result;
 
@@ -126,8 +125,8 @@ QofXmlBackendProvider::type_check (const char *uri)
         return FALSE;
     }
 
-    filename = gnc_uri_get_path (uri);
-    t = g_fopen (filename, "r");
+    std::string filename = GncUri{uri}.path().value_or ("");
+    t = g_fopen (filename.c_str(), "r");
     if (!t)
     {
         PINFO (" new file");
@@ -135,7 +134,7 @@ QofXmlBackendProvider::type_check (const char *uri)
         goto det_exit;
     }
     fclose (t);
-    rc = g_stat (filename, &sbuf);
+    rc = g_stat (filename.c_str(), &sbuf);
     if (rc < 0)
     {
         result = FALSE;
@@ -147,7 +146,7 @@ QofXmlBackendProvider::type_check (const char *uri)
         result = TRUE;
         goto det_exit;
     }
-    xml_type = gnc_is_xml_data_file_v2 (filename, NULL);
+    xml_type = gnc_is_xml_data_file_v2 (filename.c_str(), NULL);
     if ((xml_type == GNC_BOOK_XML2_FILE) ||
         (xml_type == GNC_BOOK_XML1_FILE) ||
         (xml_type == GNC_BOOK_POST_XML2_0_0_FILE))
@@ -155,11 +154,10 @@ QofXmlBackendProvider::type_check (const char *uri)
         result = TRUE;
         goto det_exit;
     }
-    PINFO (" %s is not a gnc XML file", filename);
+    PINFO (" %s is not a gnc XML file", filename.c_str());
     result = FALSE;
 
 det_exit:
-    g_free (filename);
     return result;
 }
 
diff --git a/libgnucash/backend/xml/gnc-xml-backend.cpp b/libgnucash/backend/xml/gnc-xml-backend.cpp
index 6f48e574f1..f8beafd13b 100644
--- a/libgnucash/backend/xml/gnc-xml-backend.cpp
+++ b/libgnucash/backend/xml/gnc-xml-backend.cpp
@@ -33,7 +33,8 @@
 #endif
 
 #include <gnc-engine.h> //for GNC_MOD_BACKEND
-#include <gnc-uri-utils.h>
+#include <gnc-filepath-utils.h>
+#include <gnc-uri.hpp>
 #include <TransLog.h>
 #include <gnc-prefs.h>
 
@@ -115,9 +116,7 @@ GncXmlBackend::session_begin(QofSession* session, const char* new_uri,
                       SessionOpenMode mode)
 {
     /* Make sure the directory is there */
-    auto path_str = gnc_uri_get_path (new_uri);
-    m_fullpath = path_str;
-    g_free (path_str);
+    m_fullpath = GncUri { new_uri }.path().value_or ("");
 
     if (m_fullpath.empty())
     {
diff --git a/libgnucash/backend/xml/test/gtest-load-save-files.cpp b/libgnucash/backend/xml/test/gtest-load-save-files.cpp
index b8ee039650..f5c76427e0 100644
--- a/libgnucash/backend/xml/test/gtest-load-save-files.cpp
+++ b/libgnucash/backend/xml/test/gtest-load-save-files.cpp
@@ -40,7 +40,7 @@
 #include <TransLog.h>
 #include <gnc-engine.h>
 #include <gnc-prefs.h>
-#include <gnc-uri-utils.h>
+#include <gnc-uri.hpp>
 
 #pragma GCC diagnostic push
 #pragma GCC diagnostic ignored "-Wcpp"
@@ -209,7 +209,7 @@ public:
 TEST_P(LoadSaveFiles, test_file)
 {
     auto filename = GetParam();
-    auto base_url = gnc_uri_normalize_uri (filename.c_str (), FALSE);
+    auto base_url = GncUri { filename }.try_str (false);
     /* Verify that we can write a compressed version of the original file that
      * has the original content when uncompressed.
      */
@@ -217,9 +217,9 @@ TEST_P(LoadSaveFiles, test_file)
     /* Verify that we can read a compressed file and write an uncompressed file
      * that has the original content.
      */
-    auto compressed_url = gnc_uri_normalize_uri (new_compressed_file.c_str (), FALSE);
+    auto compressed_url = GncUri { new_compressed_file }.try_str (false);
     auto new_uncompressed_file = filename + "-test-uncompressed~";
-    auto uncompressed_url = gnc_uri_normalize_uri (new_uncompressed_file.c_str (), FALSE);
+    auto uncompressed_url = GncUri { new_uncompressed_file }.try_str (false);
     const char *logdomain = "backend.xml";
     GLogLevelFlags loglevel = static_cast<decltype (loglevel)>
                               (G_LOG_LEVEL_WARNING);
@@ -230,7 +230,7 @@ TEST_P(LoadSaveFiles, test_file)
     {
         auto load_uncompressed_session = std::shared_ptr<QofSession>{qof_session_new (qof_book_new ()), qof_session_destroy};
 
-        QOF_SESSION_CHECKED_CALL(qof_session_begin, load_uncompressed_session, base_url, SESSION_READ_ONLY);
+        QOF_SESSION_CHECKED_CALL(qof_session_begin, load_uncompressed_session, base_url ? base_url->c_str () : nullptr, SESSION_READ_ONLY);
         QOF_SESSION_CHECKED_CALL(qof_session_load, load_uncompressed_session, nullptr);
 
         auto save_compressed_session = std::shared_ptr<QofSession>{qof_session_new (nullptr), qof_session_destroy};
@@ -238,7 +238,7 @@ TEST_P(LoadSaveFiles, test_file)
         g_unlink (new_compressed_file.c_str ());
         g_unlink ((new_compressed_file + ".LCK").c_str ());
 
-        QOF_SESSION_CHECKED_CALL(qof_session_begin, save_compressed_session, compressed_url, SESSION_NEW_OVERWRITE);
+        QOF_SESSION_CHECKED_CALL(qof_session_begin, save_compressed_session, compressed_url ? compressed_url->c_str () : nullptr, SESSION_NEW_OVERWRITE);
 
         qof_event_suspend ();
         qof_session_swap_data (load_uncompressed_session.get (), save_compressed_session.get ());
@@ -259,14 +259,14 @@ TEST_P(LoadSaveFiles, test_file)
     {
         auto load_compressed_session = std::shared_ptr<QofSession>{qof_session_new (qof_book_new ()), qof_session_destroy};
 
-        QOF_SESSION_CHECKED_CALL(qof_session_begin, load_compressed_session, compressed_url, SESSION_READ_ONLY);
+        QOF_SESSION_CHECKED_CALL(qof_session_begin, load_compressed_session, compressed_url ? compressed_url->c_str () : nullptr, SESSION_READ_ONLY);
         QOF_SESSION_CHECKED_CALL(qof_session_load, load_compressed_session, nullptr);
 
         auto save_uncompressed_session = std::shared_ptr<QofSession>{qof_session_new (nullptr), qof_session_destroy};
 
         g_unlink (new_uncompressed_file.c_str ());
         g_unlink ((new_uncompressed_file + ".LCK").c_str ());
-        QOF_SESSION_CHECKED_CALL(qof_session_begin, save_uncompressed_session, uncompressed_url, SESSION_NEW_OVERWRITE);
+        QOF_SESSION_CHECKED_CALL(qof_session_begin, save_uncompressed_session, uncompressed_url ? uncompressed_url->c_str () : nullptr, SESSION_NEW_OVERWRITE);
 
         qof_event_suspend ();
         qof_session_swap_data (load_compressed_session.get (), save_uncompressed_session.get ());
@@ -281,10 +281,6 @@ TEST_P(LoadSaveFiles, test_file)
         qof_session_end (save_uncompressed_session.get ());
     }
 
-    g_free (base_url);
-    g_free (compressed_url);
-    g_free (uncompressed_url);
-
     if (!compare_files (filename, new_uncompressed_file))
         return;
 }
diff --git a/libgnucash/backend/xml/test/gtest-xml-contents.cpp b/libgnucash/backend/xml/test/gtest-xml-contents.cpp
index f01ba3e2e7..7c716c0c80 100644
--- a/libgnucash/backend/xml/test/gtest-xml-contents.cpp
+++ b/libgnucash/backend/xml/test/gtest-xml-contents.cpp
@@ -30,7 +30,7 @@
 #include <gnc-prefs.h>
 #include <Account.hpp>
 #include <gnc-datetime.hpp>
-#include <gnc-uri-utils.h>
+#include <gnc-uri.hpp>
 
 #pragma GCC diagnostic push
 #pragma GCC diagnostic ignored "-Wcpp"
@@ -63,10 +63,9 @@ static QofBook*
 session_load (QofSession* session, const char* filename)
 {
     if (!session || !filename) return nullptr;
-    auto url = gnc_uri_normalize_uri (filename, FALSE);
+    auto url = GncUri { filename }.try_str (false);
 
-    qof_session_begin (session, url, SESSION_READ_ONLY);
-    g_free (url);
+    qof_session_begin (session, url ? url->c_str () : nullptr, SESSION_READ_ONLY);
 
     if (qof_session_get_error(session) != 0)
     {
diff --git a/libgnucash/backend/xml/test/test-load-xml2.cpp b/libgnucash/backend/xml/test/test-load-xml2.cpp
index 4ed7384a7c..5cc111f3c3 100644
--- a/libgnucash/backend/xml/test/test-load-xml2.cpp
+++ b/libgnucash/backend/xml/test/test-load-xml2.cpp
@@ -43,7 +43,7 @@
 #include <TransLog.h>
 #include <gnc-engine.h>
 #include <gnc-prefs.h>
-#include <gnc-uri-utils.h>
+#include <gnc-uri.hpp>
 
 #include <unittest-support.h>
 #include <test-engine-stuff.h>
@@ -92,15 +92,14 @@ test_load_file (const char* filename)
 
     auto book = qof_book_new();
     auto session = qof_session_new (book);
-    auto url = gnc_uri_normalize_uri (filename, FALSE);
+    auto url = GncUri { filename }.try_str (false);
 
     remove_locks (filename);
 
     ignore_lock = (g_strcmp0 (g_getenv ("SRCDIR"), ".") != 0);
     /*    gnc_prefs_set_file_save_compressed(FALSE); */
-    qof_session_begin (session, url,
+    qof_session_begin (session, url ? url->c_str () : nullptr,
                        ignore_lock ? SESSION_READ_ONLY : SESSION_NORMAL_OPEN);
-    g_free (url);
 
     qof_session_load (session, NULL);
 
diff --git a/libgnucash/core-utils/gnc-filepath-utils.h b/libgnucash/core-utils/gnc-filepath-utils.h
index 38c5ca3afe..0d9747633d 100644
--- a/libgnucash/core-utils/gnc-filepath-utils.h
+++ b/libgnucash/core-utils/gnc-filepath-utils.h
@@ -29,6 +29,9 @@
 #ifndef GNC_FILEPATH_UTILS_H
 #define GNC_FILEPATH_UTILS_H
 
+#define GNC_DATAFILE_EXT ".gnucash"
+#define GNC_LOGFILE_EXT  ".log" /* GnuCash transaction-log file extension */
+
 #include <glib.h>
 
 #ifdef __cplusplus
diff --git a/libgnucash/engine/CMakeLists.txt b/libgnucash/engine/CMakeLists.txt
index 4797652a9d..71f384fe0a 100644
--- a/libgnucash/engine/CMakeLists.txt
+++ b/libgnucash/engine/CMakeLists.txt
@@ -74,6 +74,7 @@ set (engine_HEADERS
   gnc-session.h
   gnc-timezone.hpp
   gnc-uri-utils.h
+  gnc-uri.hpp
   gncAddress.h
   gncAddressP.h
   gncBillTerm.h
@@ -174,7 +175,7 @@ set (engine_SOURCES
   gnc-rational.cpp
   gnc-session.c
   gnc-timezone.cpp
-  gnc-uri-utils.c
+  gnc-uri.cpp
   engine-helpers.c
   guid.cpp
   policy.cpp
diff --git a/libgnucash/engine/gnc-uri-utils.h b/libgnucash/engine/gnc-uri-utils.h
index 68e4aca2b7..e743094376 100644
--- a/libgnucash/engine/gnc-uri-utils.h
+++ b/libgnucash/engine/gnc-uri-utils.h
@@ -58,27 +58,12 @@
 #ifndef GNCURIUTILS_H_
 #define GNCURIUTILS_H_
 
-#define GNC_DATAFILE_EXT ".gnucash"
-#define GNC_LOGFILE_EXT  ".log"
-
 #include "platform.h"
 
 #ifdef __cplusplus
 extern "C" {
 #endif
 
-/** Checks if the given uri is a valid uri
- *
- *  A valid uri is defined by having at least a scheme and a path.
- *  If the uri is not referring to a file on the local file system
- *  a hostname should be set as well.
- *
- *  @param uri The uri to check
- *
- *  @return TRUE if the input is a valid uri, FALSE otherwise
- */
-gboolean gnc_uri_is_uri (const gchar *uri);
-
 /** Converts a uri in separate components.
   *
  *  The function allocates memory for each of the components that it finds
@@ -193,28 +178,6 @@ gchar *gnc_uri_create_uri (const gchar *scheme,
 gchar *gnc_uri_normalize_uri (const gchar *uri, gboolean allow_password);
 
 
-/** Checks if the given uri is a valid uri
- *
- *  A valid uri is defined by having at least a scheme and a path.
- *  If the uri is not referring to a file on the local file system
- *  a hostname should be set as well.
- *
- *  @param uri The uri to check
- *
- *  @return TRUE if the input is a valid uri, FALSE otherwise
- */
-gboolean gnc_uri_is_uri (const gchar *uri);
-
-
-/** Checks if there is a backend that explicitly stated to handle the given scheme.
- *
- *  @param scheme The scheme to check
- *
- *  @return TRUE if at least one backend explicitly handles this scheme, otherwise FALSE
- */
-gboolean gnc_uri_is_known_scheme (const gchar *scheme);
-
-
 /** Checks if the given scheme is used to refer to a file
  *  (as opposed to a network service like a database or web url)
  *
diff --git a/libgnucash/engine/gnc-uri.cpp b/libgnucash/engine/gnc-uri.cpp
old mode 100644
new mode 100755
index fd962dc2bb..3f30857a83
--- a/libgnucash/engine/gnc-uri.cpp
+++ b/libgnucash/engine/gnc-uri.cpp
@@ -1,8 +1,9 @@
 /*
- * gnc-uri-utils.c -- utility functions to convert uri in separate
+ * gnc-uri.cpp -- utility functions to convert uri in separate
  *                    components and back.
  *
  * Copyright (C) 2010 Geert Janssens <janssens.geert at telenet.be>
+ * Copyright (C) 2026 Brent McBride <mcbridebt at hotmail.com>
  *
  * This program is free software; you can redistribute it and/or
  * modify it under the terms of the GNU General Public License as
@@ -23,53 +24,47 @@
  */
 
 #include <glib.h>
+#include <cstdint>
+#include <optional>
+#include <stdexcept>
+#include <string>
+#include <utility>
 #include "gnc-uri-utils.h"
+#include "gnc-uri.hpp"
 #include "gnc-filepath-utils.h"
 #include "qofsession.h"
 
-/* Checks if the given uri is a valid uri
- */
-gboolean gnc_uri_is_uri (const gchar *uri)
+/* Duplicates an optional component into a freshly allocated C string, or
+ * returns nullptr when the component is absent. This preserves the public C
+ * API's contract that returned strings (or NULL) are released with g_free(). */
+static gchar *
+dup_or_null (const std::optional<std::string>& component)
 {
+    return component ? g_strdup (component->c_str()) : nullptr;
+}
 
-    gchar *scheme = NULL, *hostname = NULL;
-    gchar *username = NULL, *password = NULL;
-    gchar *path     = NULL;
-    gint   port = 0;
-    gboolean is_uri = FALSE;
-
-    gnc_uri_get_components ( uri, &scheme, &hostname, &port,
-                             &username, &password, &path );
-
-    /* For gnucash to consider a uri valid the following must be true:
-     * - scheme and path must not be NULL
-     * - for anything but local filesystem uris, hostname must be valid as well */
-    is_uri = (scheme && path && (gnc_uri_is_file_scheme(scheme) || hostname));
-
-    g_free (scheme);
-    g_free (hostname);
-    g_free (username);
-    g_free (password);
-    g_free (path);
-
-    return is_uri;
+/* Wraps a possibly-null C string as an optional, mapping NULL to nullopt so
+ * the historical "absent vs empty" distinction is preserved when crossing
+ * from the C veneers into the GncUri class. */
+static std::optional<std::string>
+to_opt (const gchar *s)
+{
+    return s ? std::optional<std::string> { s } : std::nullopt;
 }
 
-/* Checks if the given scheme is used to refer to a file
- * (as opposed to a network service)
- */
-gboolean gnc_uri_is_known_scheme (const gchar *scheme)
+/* True when the scheme is one of the registered backend access methods. */
+static bool
+scheme_is_known (const gchar *scheme)
 {
-    gboolean is_known_scheme = FALSE;
-    GList *node;
+    bool is_known_scheme = false;
     GList *known_scheme_list = qof_backend_get_registered_access_method_list();
 
-    for ( node = known_scheme_list; node != NULL; node = node->next )
+    for ( GList *node = known_scheme_list; node != nullptr; node = node->next )
     {
-        gchar *known_scheme = node->data;
+        gchar *known_scheme = static_cast<gchar*>(node->data);
         if ( !g_ascii_strcasecmp (scheme, known_scheme) )
         {
-            is_known_scheme = TRUE;
+            is_known_scheme = true;
             break;
         }
     }
@@ -78,252 +73,158 @@ gboolean gnc_uri_is_known_scheme (const gchar *scheme)
     return is_known_scheme;
 }
 
-/* Checks if the given scheme is used to refer to a file
- * (as opposed to a network service)
- * Note unknown schemes are always considered network schemes.
+/* ---------------------------------------------------------------------------
+ * GncUri - C++ interface
  *
- * *Compatibility note:*
- * This used to be the other way around before gnucash 3.4. Before
- * that unknown schemes were always considered local file system
- * uri schemes.
- */
-gboolean gnc_uri_is_file_scheme (const gchar *scheme)
+ * The class below carries all of the parsing and composition logic. The
+ * extern "C" functions further down are thin veneers over it, preserving the
+ * historical gchar* / g_free contract for C (and not-yet-migrated C++)
+ * callers.
+ * ------------------------------------------------------------------------- */
+
+GncUri::GncUri (std::optional<std::string> scheme,
+                std::optional<std::string> hostname,
+                int32_t port,
+                std::optional<std::string> username,
+                std::optional<std::string> password,
+                std::optional<std::string> path)
+    : m_scheme   (std::move (scheme))
+    , m_hostname (std::move (hostname))
+    , m_username (std::move (username))
+    , m_password (std::move (password))
+    , m_path     (std::move (path))
+    , m_port     (port)
 {
-    return (scheme &&
-            (!g_ascii_strcasecmp (scheme, "file") ||
-             !g_ascii_strcasecmp (scheme, "xml") ||
-             !g_ascii_strcasecmp (scheme, "sqlite3")));
+    /* A GncUri built from components must be able to form a valid uri: it
+     * needs a path, and a non-file scheme also needs a hostname. Failing here
+     * keeps every constructed GncUri valid, so str()/try_str() never have to
+     * re-check. (The parsing ctor below is deliberately permissive instead.) */
+    if (!m_path)
+        throw std::invalid_argument ("GncUri: a path is required");
+    if (m_scheme && !scheme_is_file (*m_scheme) && !m_hostname)
+        throw std::invalid_argument (
+            "GncUri: a hostname is required for a non-file scheme");
 }
 
-/* Checks if the given uri defines a file
- * (as opposed to a network service)
- */
-gboolean gnc_uri_is_file_uri (const gchar *uri)
+GncUri::GncUri (const std::string& uri)
 {
-    gchar *scheme = gnc_uri_get_scheme ( uri );
-    gboolean result = gnc_uri_is_file_scheme ( scheme );
-
-    g_free ( scheme );
-
-    return result;
-}
-
-/* Checks if the given uri is a valid uri
- */
-gboolean gnc_uri_targets_local_fs (const gchar *uri)
-{
-
-    gchar *scheme = NULL, *hostname = NULL;
-    gchar *username = NULL, *password = NULL;
-    gchar *path     = NULL;
-    gint   port = 0;
-    gboolean is_local_fs = FALSE;
-
-    gnc_uri_get_components ( uri, &scheme, &hostname, &port,
-                             &username, &password, &path );
-
-    /* For gnucash to consider a uri to target the local fs:
-     * path must not be NULL
-     * AND
-     *   scheme should be NULL
-     *   OR
-     *   scheme must be file type scheme (file, xml, sqlite) */
-    is_local_fs = (path && (!scheme || gnc_uri_is_file_scheme(scheme)));
-
-    g_free (scheme);
-    g_free (hostname);
-    g_free (username);
-    g_free (password);
-    g_free (path);
-
-    return is_local_fs;
-}
-
-/* Splits a uri into its separate components */
-void gnc_uri_get_components (const gchar *uri,
-                             gchar **scheme,
-                             gchar **hostname,
-                             gint32 *port,
-                             gchar **username,
-                             gchar **password,
-                             gchar **path)
-{
-    gchar **splituri;
-    gchar *url = NULL, *tmpusername = NULL, *tmphostname = NULL;
-    gchar *delimiter = NULL;
-
-    *scheme   = NULL;
-    *hostname = NULL;
-    *port     = 0;
-    *username = NULL;
-    *password = NULL;
-    *path     = NULL;
-
-    g_return_if_fail( uri != NULL && strlen (uri) > 0);
+    if (uri.empty())
+        return;
 
-    splituri = g_strsplit ( uri, "://", 2 );
-    if ( splituri[1] == NULL )
+    auto sep = uri.find ("://");
+    if (sep == std::string::npos)
     {
-        /* No scheme means simple file path.
-           Set path to copy of the input. */
-        *path     = g_strdup ( uri );
-        g_strfreev ( splituri );
+        /* No scheme means a simple file path; the path is a copy of the input. */
+        m_path = uri;
         return;
     }
 
-    /* At least a scheme was found, set it here */
-    *scheme = g_strdup ( splituri[0] );
+    std::string scheme = uri.substr (0, sep);
+    std::string rest   = uri.substr (sep + 3);
+    m_scheme = scheme;
 
-    if ( gnc_uri_is_file_scheme ( *scheme ) )
+    if (scheme_is_file (scheme))
     {
-        /* a true file uri on windows can start file:///N:/
-           so we come here with /N:/, it could also be /N:\
-        */
-        if (g_str_has_prefix (splituri[1], "/") &&
-           ((g_strstr_len (splituri[1], -1,  ":/") != NULL) || (g_strstr_len (splituri[1], -1,  ":\\") != NULL)))
-        {
-            gchar *ptr = splituri[1];
-            *path = gnc_resolve_file_path ( ptr + 1 );
-        }
-        else
-            *path = gnc_resolve_file_path ( splituri[1] );
-        g_strfreev ( splituri );
+        /* A true file uri on windows can start with file:///N:/, so we arrive
+         * here with /N:/ (it could also be /N:\). Strip the leading slash in
+         * that case before resolving. */
+        const gchar *file_path = rest.c_str();
+        if (!rest.empty() && rest.front() == '/' &&
+            (rest.find (":/") != std::string::npos ||
+             rest.find (":\\") != std::string::npos))
+            file_path = rest.c_str() + 1;
+
+        gchar *resolved = gnc_resolve_file_path (file_path);
+        m_path = std::string { resolved };
+        g_free (resolved);
         return;
     }
 
-    /* Protocol indicates full network style uri, let's see if it
-     * has a username and/or password
-     */
-    url = g_strdup (splituri[1]);
-    g_strfreev ( splituri );
-
-    /* Check for "@" sign, but start from the end - the password may contain
-     * this sign as well
-     */
-    delimiter = g_strrstr ( url, "@" );
-    if ( delimiter != NULL )
+    /* Network style uri: [user[:password]@]hostname[:port][/path].
+     * Look for the '@' from the end, as the password may contain one too. */
+    std::string hostpart;
+    auto at = rest.rfind ('@');
+    if (at != std::string::npos)
     {
-        /* There is at least a username in the url */
-        delimiter[0] = '\0';
-        tmpusername = url;
-        tmphostname = delimiter + 1;
-
-        /* Check if there's a password too by looking for a :
-         * Start from the beginning this time to avoid possible :
-         * in the password */
-        delimiter = g_strstr_len ( tmpusername, -1, ":" );
-        if ( delimiter != NULL )
+        std::string userinfo = rest.substr (0, at);
+        hostpart = rest.substr (at + 1);
+
+        /* Look for a password, searching from the start so a ':' in the
+         * password doesn't confuse the split. */
+        auto colon = userinfo.find (':');
+        if (colon != std::string::npos)
         {
-            /* There is password in the url */
-            delimiter[0] = '\0';
-            *password = g_strdup ( (const gchar*)(delimiter + 1) );
+            m_username = userinfo.substr (0, colon);
+            m_password = userinfo.substr (colon + 1);
         }
-        *username = g_strdup ( (const gchar*)tmpusername );
+        else
+            m_username = userinfo;
     }
     else
-    {
-        /* No username and password were given */
-        tmphostname = url;
-    }
+        hostpart = rest;
 
-    /* Find the path part */
-    delimiter = g_strstr_len ( tmphostname, -1, "/" );
-    if ( delimiter != NULL )
+    /* Split off the path part. */
+    auto slash = hostpart.find ('/');
+    if (slash != std::string::npos)
     {
-        delimiter[0] = '\0';
-        if ( gnc_uri_is_file_scheme ( *scheme ) ) /* always return absolute file paths */
-            *path = gnc_resolve_file_path ( (const gchar*)(delimiter + 1) );
-        else /* path is no file path, so copy it as is */
-            *path = g_strdup ( (const gchar*)(delimiter + 1) );
+        m_path = hostpart.substr (slash + 1);
+        hostpart.erase (slash);
     }
 
-    /* Check for a port specifier */
-    delimiter = g_strstr_len ( tmphostname, -1, ":" );
-    if ( delimiter != NULL )
+    /* Split off an optional port specifier. */
+    auto port_colon = hostpart.find (':');
+    if (port_colon != std::string::npos)
     {
-        delimiter[0] = '\0';
-        *port = g_ascii_strtoll ( delimiter + 1, NULL, 0 );
+        m_port = static_cast<int32_t> (
+            g_ascii_strtoll (hostpart.c_str() + port_colon + 1, nullptr, 0));
+        hostpart.erase (port_colon);
     }
 
-    *hostname = g_strdup ( (const gchar*)tmphostname );
-
-    g_free ( url );
-
-    return;
+    m_hostname = hostpart;
+}
 
+bool
+GncUri::scheme_is_file (const std::string& scheme) noexcept
+{
+    return (!g_ascii_strcasecmp (scheme.c_str(), "file") ||
+            !g_ascii_strcasecmp (scheme.c_str(), "xml") ||
+            !g_ascii_strcasecmp (scheme.c_str(), "sqlite3"));
 }
 
-gchar *gnc_uri_get_scheme (const gchar *uri)
+bool
+GncUri::is_file_uri () const noexcept
 {
-    gchar *scheme   = NULL;
-    gchar *hostname = NULL;
-    gint32 port     = 0;
-    gchar *username = NULL;
-    gchar *password = NULL;
-    gchar *path     = NULL;
-
-    gnc_uri_get_components ( uri, &scheme, &hostname, &port,
-                             &username, &password, &path );
-
-    g_free (hostname);
-    g_free (username);
-    g_free (password);
-    g_free (path);
-
-    return scheme;
+    return m_scheme && scheme_is_file (*m_scheme);
 }
 
-gchar *gnc_uri_get_path (const gchar *uri)
+bool
+GncUri::targets_local_fs () const noexcept
 {
-    gchar *scheme   = NULL;
-    gchar *hostname = NULL;
-    gint32 port = 0;
-    gchar *username = NULL;
-    gchar *password = NULL;
-    gchar *path     = NULL;
-
-    gnc_uri_get_components ( uri, &scheme, &hostname, &port,
-                             &username, &password, &path );
-
-    g_free (scheme);
-    g_free (hostname);
-    g_free (username);
-    g_free (password);
-
-    return path;
+    /* Targets the local fs when it has a path and either no scheme or a
+     * file-type scheme (file, xml, sqlite). */
+    return m_path && (!m_scheme || scheme_is_file (*m_scheme));
 }
 
-/* Generates a normalized uri from the separate components */
-gchar *gnc_uri_create_uri (const gchar *scheme,
-                           const gchar *hostname,
-                           gint32 port,
-                           const gchar *username,
-                           const gchar *password,
-                           const gchar *path)
+std::optional<std::string>
+GncUri::try_str (bool allow_password) const
 {
-    gchar *userpass = NULL, *portstr = NULL, *uri = NULL;
+    if (!m_path)
+        return std::nullopt;
 
-    g_return_val_if_fail( path != 0, NULL );
+    const std::string& path = *m_path;
 
-    if (!scheme || gnc_uri_is_file_scheme (scheme))
+    if (!m_scheme || scheme_is_file (*m_scheme))
     {
-        /* Compose a file based uri, which means ignore everything but
-         * the scheme and the path
-         * We return an absolute pathname if the scheme is known or
-         * no scheme was given. For an unknown scheme, we return the
-         * path info as is.
-         */
-        gchar *abs_path;
-        gchar *uri_scheme;
-        if (scheme && (!gnc_uri_is_known_scheme (scheme)) )
-            abs_path = g_strdup ( path );
-        else
-            abs_path = gnc_resolve_file_path ( path );
+        /* File based uri: ignore everything but the scheme and the path. The
+         * path is resolved to an absolute name for a known (or absent) scheme;
+         * for an unknown scheme it is used as is. */
+        gchar *resolved = (m_scheme && !scheme_is_known (m_scheme->c_str()))
+                          ? g_strdup (path.c_str())
+                          : gnc_resolve_file_path (path.c_str());
+        std::string abs_path { resolved };
+        g_free (resolved);
 
-        if (!scheme)
-            uri_scheme = g_strdup ("file");
-        else
-            uri_scheme = g_strdup (scheme);
+        std::string scheme = m_scheme ? *m_scheme : std::string { "file" };
 
         /* Arrive here with...
          *
@@ -335,84 +236,162 @@ gchar *gnc_uri_create_uri (const gchar *scheme,
          *
          * \\myserver\share\path\to\file with space.txt
          * becomes file://\\myserver\share\path\to\file with space.txt
-         *
-         * probably they should all be forward slashes and spaces escaped
-         * also with UNC it could be file://myserver/share/path/to/file with space.txt
          */
+        bool absolute = !abs_path.empty() &&
+                        (abs_path.front() == '/' || abs_path.front() == '\\');
+        return absolute ? scheme + "://" + abs_path
+                        : scheme + ":///" + abs_path; // extra "/" for windows
+    }
 
-        if (g_str_has_prefix (abs_path, "/") || g_str_has_prefix (abs_path, "\\"))
-            uri = g_strdup_printf ( "%s://%s", uri_scheme, abs_path );
-        else // for windows add an extra "/"
-            uri = g_strdup_printf ( "%s:///%s", uri_scheme, abs_path );
+    std::string userpass;
+    if (m_username && !m_username->empty())
+    {
+        userpass = *m_username;
+        if (allow_password && m_password && !m_password->empty())
+        {
+            userpass += ':';
+            userpass += *m_password;
+        }
+        userpass += '@';
+    }
 
-        g_free (uri_scheme);
-        g_free (abs_path);
+    std::string portstr;
+    if (m_port != 0)
+        portstr = ":" + std::to_string (m_port);
 
-        return uri;
-    }
+    return *m_scheme + "://" + userpass + *m_hostname + portstr + "/" + path;
+}
 
-    /* Not a file based uri, we need to setup all components that are not NULL
-     * For this scenario, hostname is mandatory.
-     */
-    g_return_val_if_fail( hostname != 0, NULL );
+std::string
+GncUri::str (bool allow_password) const
+{
+    if (std::optional<std::string> result = try_str (allow_password))
+        return std::move (*result);
 
-    if ( username != NULL && *username )
-    {
-        if ( password != NULL && *password )
-            userpass = g_strdup_printf ( "%s:%s@", username, password );
-        else
-            userpass = g_strdup_printf ( "%s@", username );
-    }
-    else
-        userpass = g_strdup ( "" );
+    /* try_str only fails when there is no path; a non-file scheme without a
+     * hostname is already rejected by the component constructor. */
+    throw std::invalid_argument ("GncUri::str: a path is required");
+}
 
-    if ( port != 0 )
-        portstr = g_strdup_printf ( ":%d", port );
-    else
-        portstr = g_strdup ( "" );
+/* Checks if the given scheme is used to refer to a file
+ * (as opposed to a network service)
+ * Note unknown schemes are always considered network schemes.
+ *
+ * *Compatibility note:*
+ * This used to be the other way around before gnucash 3.4. Before
+ * that unknown schemes were always considered local file system
+ * uri schemes.
+ */
+gboolean
+gnc_uri_is_file_scheme (const gchar *scheme)
+{
+    return scheme && GncUri::scheme_is_file (scheme);
+}
+
+/* Checks if the given uri defines a file
+ * (as opposed to a network service)
+ */
+gboolean
+gnc_uri_is_file_uri (const gchar *uri)
+{
+    g_return_val_if_fail (uri != nullptr, FALSE);
+    return GncUri { uri }.is_file_uri();
+}
+
+/* Checks if the given uri is a valid uri
+ */
+gboolean
+gnc_uri_targets_local_fs (const gchar *uri)
+{
+    g_return_val_if_fail (uri != nullptr, FALSE);
+    return GncUri { uri }.targets_local_fs();
+}
 
-    // XXX Do I have to add the slash always or are there situations
-    //     it is in the path already ?
-    uri = g_strconcat ( scheme, "://", userpass, hostname, portstr, "/", path, NULL );
+/* Splits a uri into its separate components. Thin C veneer over the GncUri
+ * class; each component is handed back as a freshly allocated string (or NULL
+ * when absent) that the caller frees with g_free(). */
+void
+gnc_uri_get_components (const gchar *uri,
+                        gchar **scheme,
+                        gchar **hostname,
+                        gint32 *port,
+                        gchar **username,
+                        gchar **password,
+                        gchar **path)
+{
+    *scheme   = nullptr;
+    *hostname = nullptr;
+    *port     = 0;
+    *username = nullptr;
+    *password = nullptr;
+    *path     = nullptr;
 
-    g_free ( userpass );
-    g_free ( portstr );
+    g_return_if_fail( uri != nullptr && strlen (uri) > 0);
 
-    return uri;
+    GncUri parsed { uri };
 
+    *scheme   = dup_or_null (parsed.scheme());
+    *hostname = dup_or_null (parsed.hostname());
+    *username = dup_or_null (parsed.username());
+    *password = dup_or_null (parsed.password());
+    *path     = dup_or_null (parsed.path());
+    *port     = parsed.port();
 }
 
-gchar *gnc_uri_normalize_uri (const gchar *uri, gboolean allow_password)
+gchar *
+gnc_uri_get_scheme (const gchar *uri)
 {
-    gchar *scheme   = NULL;
-    gchar *hostname = NULL;
-    gint32 port = 0;
-    gchar *username = NULL;
-    gchar *password = NULL;
-    gchar *path     = NULL;
-    gchar *newuri   = NULL;
-
-    gnc_uri_get_components ( uri, &scheme, &hostname, &port,
-                             &username, &password, &path );
-    if (allow_password)
-        newuri = gnc_uri_create_uri ( scheme, hostname, port,
-                                      username, password, path);
-    else
-        newuri = gnc_uri_create_uri ( scheme, hostname, port,
-                                      username, /* no password */ NULL, path);
+    g_return_val_if_fail (uri != nullptr, nullptr);
+    return dup_or_null (GncUri { uri }.scheme());
+}
 
-    g_free (scheme);
-    g_free (hostname);
-    g_free (username);
-    g_free (password);
-    g_free (path);
+gchar *
+gnc_uri_get_path (const gchar *uri)
+{
+    g_return_val_if_fail (uri != nullptr, nullptr);
+    return dup_or_null (GncUri { uri }.path());
+}
 
-    return newuri;
+/* Generates a normalized uri from the separate components */
+gchar *
+gnc_uri_create_uri (const gchar *scheme,
+                    const gchar *hostname,
+                    gint32 port,
+                    const gchar *username,
+                    const gchar *password,
+                    const gchar *path)
+{
+    g_return_val_if_fail( path != nullptr, nullptr );
+
+    /* For a non-file scheme a hostname is mandatory. (A missing scheme is
+     * treated as a file scheme, which needs no hostname.) */
+    if (scheme && !GncUri::scheme_is_file (scheme))
+        g_return_val_if_fail( hostname != nullptr, nullptr );
+
+    GncUri uri { to_opt (scheme), to_opt (hostname), port,
+                 to_opt (username), to_opt (password), to_opt (path) };
+    return g_strdup (uri.str().c_str());
+}
+
+gchar *
+gnc_uri_normalize_uri (const gchar *uri, gboolean allow_password)
+{
+    g_return_val_if_fail (uri != nullptr, nullptr);
+
+    GncUri parsed { uri };
+    return gnc_uri_create_uri (
+        parsed.scheme()   ? parsed.scheme()->c_str()   : nullptr,
+        parsed.hostname() ? parsed.hostname()->c_str() : nullptr,
+        parsed.port(),
+        parsed.username() ? parsed.username()->c_str() : nullptr,
+        (allow_password && parsed.password()) ? parsed.password()->c_str() : nullptr,
+        parsed.path()     ? parsed.path()->c_str()     : nullptr );
 }
 
-gchar *gnc_uri_add_extension ( const gchar *uri, const gchar *extension )
+gchar *
+gnc_uri_add_extension ( const gchar *uri, const gchar *extension )
 {
-    g_return_val_if_fail( uri != 0, NULL );
+    g_return_val_if_fail( uri != nullptr, nullptr );
 
     /* Only add extension if the user provided the extension and the uri is
      * file based.
@@ -425,5 +404,5 @@ gchar *gnc_uri_add_extension ( const gchar *uri, const gchar *extension )
         return g_strdup( uri );
 
     /* Ok, all tests passed, let's add the extension */
-    return g_strconcat( uri, extension, NULL );
+    return g_strconcat( uri, extension, nullptr );
 }
diff --git a/libgnucash/engine/gnc-uri.hpp b/libgnucash/engine/gnc-uri.hpp
new file mode 100644
index 0000000000..4f3a066a50
--- /dev/null
+++ b/libgnucash/engine/gnc-uri.hpp
@@ -0,0 +1,119 @@
+/********************************************************************\
+ * gnc-uri.hpp -- C++ interface to parse and compose uris.          *
+ *                                                                  *
+ * Copyright (C) 2026 Brent McBride <mcbridebt at hotmail.com>         *
+ *                                                                  *
+ * This program is free software; you can redistribute it and/or    *
+ * modify it under the terms of the GNU General Public License as   *
+ * published by the Free Software Foundation; either version 2 of   *
+ * the License, or (at your option) any later version.              *
+ *                                                                  *
+ * This program is distributed in the hope that it will be useful,  *
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of   *
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the    *
+ * GNU General Public License for more details.                     *
+ *                                                                  *
+ * You should have received a copy of the GNU General Public License*
+ * along with this program; if not, contact:                        *
+ *                                                                  *
+ * Free Software Foundation           Voice:  +1-617-542-5942       *
+ * 51 Franklin Street, Fifth Floor    Fax:    +1-617-542-2652       *
+ * Boston, MA  02110-1301,  USA       gnu at gnu.org                   *
+ *                                                                  *
+\********************************************************************/
+
+#ifndef GNC_URI_HPP
+#define GNC_URI_HPP
+
+#include <cstdint>
+#include <optional>
+#include <string>
+
+/** @addtogroup Engine
+    @{ */
+/** @file gnc-uri.hpp
+ *  @brief C++ interface to parse and compose GnuCash resource locators.
+ *  @author Copyright (C) 2026 Brent McBride <mcbridebt at hotmail.com>
+ *
+ *  GnuCash refers to the books it stores by a uri, which may be a network
+ *  service (such as a database) or a local filesystem path. This is the C++
+ *  face of that utility; the C functions in gnc-uri-utils.h are thin veneers
+ *  over the class below and remain available for not-yet-migrated callers.
+ */
+
+/** A parsed GnuCash resource locator.
+ *
+ *  Construct one from a uri (or a bare local filesystem path) to inspect its
+ *  components, or from individual components to compose a normalized uri with
+ *  str(). Components that are absent from a uri are reported as std::nullopt,
+ *  preserving the historical distinction between a missing component and one
+ *  that is present but empty.
+ */
+class GncUri
+{
+public:
+    /** Parse a uri, or a bare local filesystem path, into its components.
+     *  An empty string yields an empty GncUri (all components absent). */
+    explicit GncUri (const std::string& uri);
+
+    /** Construct directly from individual components, typically to compose a
+     *  uri with str(). Absent components are represented by std::nullopt. */
+    GncUri (std::optional<std::string> scheme,
+            std::optional<std::string> hostname,
+            int32_t port,
+            std::optional<std::string> username,
+            std::optional<std::string> password,
+            std::optional<std::string> path);
+
+    const std::optional<std::string>& scheme()   const noexcept { return m_scheme; }
+    const std::optional<std::string>& hostname() const noexcept { return m_hostname; }
+    const std::optional<std::string>& username() const noexcept { return m_username; }
+    const std::optional<std::string>& password() const noexcept { return m_password; }
+    const std::optional<std::string>& path()     const noexcept { return m_path; }
+    int32_t port() const noexcept { return m_port; }
+
+    /** True if this uri uses a file-type scheme (file, xml, sqlite3). A uri
+     *  without a scheme is not considered a file uri (matching the historical
+     *  gnc_uri_is_file_uri behaviour). */
+    bool is_file_uri() const noexcept;
+
+    /** True if the uri refers to the local filesystem: it has a path and
+     *  either no scheme or a file-type scheme. */
+    bool targets_local_fs() const noexcept;
+
+    /** Compose a normalized uri string from the components.
+     *
+     *  @param allow_password When false, any password is omitted from the
+     *         result.
+     *  @return The composed uri. For a file-type (or absent) scheme the path
+     *          is resolved to an absolute name.
+     *  @throws std::invalid_argument when no path is present, or when a
+     *          non-file scheme is missing its hostname.
+     */
+    std::string str (bool allow_password = true) const;
+
+    /** Like str(), but returns std::nullopt instead of throwing when the
+     *  components cannot form a valid uri (no path is present, or a non-file
+     *  scheme is missing its hostname). Intended for callers that historically
+     *  tolerated a NULL result from gnc_uri_normalize_uri / gnc_uri_create_uri.
+     *
+     *  @param allow_password When false, any password is omitted from the
+     *         result.
+     */
+    std::optional<std::string> try_str (bool allow_password = true) const;
+
+    /** True if @a scheme is a file-type scheme (file, xml, sqlite3). */
+    static bool scheme_is_file (const std::string& scheme) noexcept;
+
+private:
+    std::optional<std::string> m_scheme;
+    std::optional<std::string> m_hostname;
+    std::optional<std::string> m_username;
+    std::optional<std::string> m_password;
+    std::optional<std::string> m_path;
+    int32_t m_port = 0;
+};
+
+/** @} */
+
+#endif /* GNC_URI_HPP */
diff --git a/libgnucash/engine/qofsession.cpp b/libgnucash/engine/qofsession.cpp
index 81392db879..d2808dafaf 100644
--- a/libgnucash/engine/qofsession.cpp
+++ b/libgnucash/engine/qofsession.cpp
@@ -55,7 +55,7 @@ static QofLogModule log_module = QOF_MOD_SESSION;
 #include "qof-backend.hpp"
 #include "qofsession.hpp"
 #include "gnc-backend-prov.hpp"
-#include "gnc-uri-utils.h"
+#include "gnc-uri.hpp"
 
 #include <vector>
 #include <boost/algorithm/string.hpp>
@@ -275,7 +275,10 @@ QofSessionImpl::begin (const char* new_uri, SessionOpenMode mode) noexcept
     char * scheme {g_uri_parse_scheme (new_uri)};
     char * filename {nullptr};
     if (g_strcmp0 (scheme, "file") == 0)
-        filename = gnc_uri_get_path(new_uri);
+    {
+        if (auto path = GncUri{new_uri}.path())
+            filename = g_strdup (path->c_str());
+    }
     else if (!scheme)
         filename = g_strdup (new_uri);
 
diff --git a/libgnucash/engine/test/CMakeLists.txt b/libgnucash/engine/test/CMakeLists.txt
index 6d5b073694..aeab39a551 100644
--- a/libgnucash/engine/test/CMakeLists.txt
+++ b/libgnucash/engine/test/CMakeLists.txt
@@ -130,6 +130,9 @@ set(test_qofsession_SOURCES
 gnc_add_test(test-qofsession "${test_qofsession_SOURCES}"
   gtest_engine_INCLUDES gtest_old_engine_LIBS)
 
+gnc_add_test(test-gnc-uri  gtest-gnc-uri.cpp
+  gtest_engine_INCLUDES gtest_old_engine_LIBS)
+
 gnc_add_test(test-qofid  test-qofid.cpp
   gtest_engine_INCLUDES gtest_old_engine_LIBS)
 
@@ -211,6 +214,7 @@ gnc_add_test(test-gnc-option "${test_gnc_option_SOURCES}"
 
 set(test_engine_SOURCES_DIST
         gtest-gnc-euro.cpp
+        gtest-gnc-uri.cpp
         gtest-gnc-int128.cpp
         gtest-gnc-rational.cpp
         gtest-gnc-numeric.cpp
@@ -231,7 +235,7 @@ set(test_engine_SOURCES_DIST
         test-engine.c
         test-gnc-date.c
         test-gnc-guid.cpp
-	test-gnc-uri-utils.c
+        test-gnc-uri-utils.c
         test-group-vs-book.cpp
         test-guid.cpp
         test-job.c
diff --git a/libgnucash/engine/test/gtest-gnc-uri.cpp b/libgnucash/engine/test/gtest-gnc-uri.cpp
new file mode 100644
index 0000000000..b356c03c35
--- /dev/null
+++ b/libgnucash/engine/test/gtest-gnc-uri.cpp
@@ -0,0 +1,170 @@
+/********************************************************************\
+ * gtest-gnc-uri.cpp -- Unit tests for the GncUri C++ class.        *
+ *                                                                  *
+ * Copyright (C) 2026 Brent McBride <mcbridebt at hotmail.com>         *
+ *                                                                  *
+ * This program is free software; you can redistribute it and/or    *
+ * modify it under the terms of the GNU General Public License as   *
+ * published by the Free Software Foundation; either version 2 of   *
+ * the License, or (at your option) any later version.              *
+ *                                                                  *
+ * This program is distributed in the hope that it will be useful,  *
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of   *
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the    *
+ * GNU General Public License for more details.                     *
+ *                                                                  *
+ * You should have received a copy of the GNU General Public License*
+ * along with this program; if not, contact:                        *
+ *                                                                  *
+ * Free Software Foundation           Voice:  +1-617-542-5942       *
+ * 51 Franklin Street, Fifth Floor    Fax:    +1-617-542-2652       *
+ * Boston, MA  02110-1301,  USA       gnu at gnu.org                   *
+ *                                                                  *
+\********************************************************************/
+
+#include <config.h>
+#include <glib.h>
+#include <optional>
+#include <stdexcept>
+#include <string>
+#include "qof.h"
+#include "gnc-backend-prov.hpp"
+#include "gnc-uri-utils.h"
+#include "gnc-uri.hpp"
+#pragma GCC diagnostic push
+#pragma GCC diagnostic ignored "-Wcpp"
+#include <gtest/gtest.h>
+#pragma GCC diagnostic pop
+
+/* Parse a file uri into its components. */
+TEST(GncUri, ParseFileUri)
+{
+    GncUri f { "file:///test/path/file.gnucash" };
+    ASSERT_TRUE (f.scheme().has_value());
+    EXPECT_EQ (*f.scheme(), "file");
+    EXPECT_FALSE (f.hostname().has_value());
+    ASSERT_TRUE (f.path().has_value());
+    EXPECT_EQ (*f.path(), "/test/path/file.gnucash");
+    EXPECT_TRUE (f.is_file_uri());
+    EXPECT_TRUE (f.targets_local_fs());
+}
+
+/* Parse a database uri with userinfo and a port. */
+TEST(GncUri, ParseDatabaseUri)
+{
+    GncUri d { "postgres://dbuser:dbpass@www.gnucash.org:744/gnucash" };
+    ASSERT_TRUE (d.scheme().has_value());
+    EXPECT_EQ (*d.scheme(), "postgres");
+    ASSERT_TRUE (d.hostname().has_value());
+    EXPECT_EQ (*d.hostname(), "www.gnucash.org");
+    ASSERT_TRUE (d.username().has_value());
+    EXPECT_EQ (*d.username(), "dbuser");
+    ASSERT_TRUE (d.password().has_value());
+    EXPECT_EQ (*d.password(), "dbpass");
+    EXPECT_EQ (d.port(), 744);
+    EXPECT_FALSE (d.is_file_uri());
+    EXPECT_FALSE (d.targets_local_fs());
+}
+
+/* Compose a uri, with and without the password. */
+TEST(GncUri, ComposeWithAndWithoutPassword)
+{
+    GncUri d { "postgres://dbuser:dbpass@www.gnucash.org:744/gnucash" };
+    EXPECT_EQ (d.str (true),  "postgres://dbuser:dbpass@www.gnucash.org:744/gnucash");
+    EXPECT_EQ (d.str (false), "postgres://dbuser@www.gnucash.org:744/gnucash");
+    EXPECT_EQ (d.try_str (false).value_or (""),
+               "postgres://dbuser@www.gnucash.org:744/gnucash");
+}
+
+/* A bare path has no scheme but still targets the local fs. */
+TEST(GncUri, BarePath)
+{
+    GncUri p { "/test/path/file.gnucash" };
+    EXPECT_FALSE (p.scheme().has_value());
+    ASSERT_TRUE (p.path().has_value());
+    EXPECT_EQ (*p.path(), "/test/path/file.gnucash");
+    EXPECT_FALSE (p.is_file_uri());    /* no scheme -> not a file *uri* */
+    EXPECT_TRUE (p.targets_local_fs());
+}
+
+/* An empty uri yields an object with no components set. */
+TEST(GncUri, EmptyUri)
+{
+    GncUri e { std::string {} };
+    EXPECT_FALSE (e.scheme().has_value());
+    EXPECT_FALSE (e.hostname().has_value());
+    EXPECT_FALSE (e.path().has_value());
+    EXPECT_EQ (e.port(), 0);
+    EXPECT_FALSE (e.is_file_uri());
+}
+
+/* A file uri carrying a Windows-style drive letter (file:///N:/...) has its
+ * leading slash stripped before the path is resolved. The shape of the string
+ * is what triggers this, so it exercises the branch on any platform. */
+TEST(GncUri, WindowsStyleDriveLetterPath)
+{
+    GncUri w { "file:///N:/test/path/file.gnucash" };
+    ASSERT_TRUE (w.scheme().has_value());
+    EXPECT_EQ (*w.scheme(), "file");
+    EXPECT_TRUE (w.path().has_value());
+    EXPECT_TRUE (w.is_file_uri());
+}
+
+/* Compose from individual components (mirrors gnc_uri_create_uri). With no
+ * backend registered "xml" is an unknown scheme, so the relative path is used
+ * as is. */
+TEST(GncUri, ComposeFromComponents)
+{
+    GncUri c { std::string {"xml"}, std::nullopt, 0, std::nullopt, std::nullopt,
+               std::string {"relative/path/file.gnucash"} };
+    EXPECT_EQ (c.str(), "xml:///relative/path/file.gnucash");
+}
+
+/* The component constructor rejects parts that can't form a valid uri: no path
+ * at all, or a non-file scheme with a path but no hostname. */
+TEST(GncUri, ComponentCtorThrowsWhenIncomplete)
+{
+    EXPECT_THROW ((GncUri { std::nullopt, std::nullopt, 0, std::nullopt,
+                            std::nullopt, std::nullopt }),
+                  std::invalid_argument);
+    EXPECT_THROW ((GncUri { std::string {"postgres"}, std::nullopt, 0,
+                            std::nullopt, std::nullopt,
+                            std::string {"gnucash"} }),
+                  std::invalid_argument);
+}
+
+/* The parsing ctor stays permissive, so a parsed uri can still lack a path (a
+ * network scheme with a host but no path). Such an object can't be turned back
+ * into a locator: try_str() returns nullopt and str() throws. */
+TEST(GncUri, ParsedUriWithoutPathCannotStringify)
+{
+    GncUri incomplete { "postgres://www.gnucash.org" };
+    EXPECT_FALSE (incomplete.try_str().has_value());
+    EXPECT_THROW (incomplete.str(), std::invalid_argument);
+}
+
+/* A minimal backend provider used only to populate the list of registered
+ * access methods that the uri code consults. */
+struct UriTestProvider : public QofBackendProvider
+{
+    UriTestProvider (const char* name, const char* method)
+        : QofBackendProvider {name, method} {}
+    QofBackend* create_backend (void) override { return nullptr; }
+    bool type_check (const char*) override { return false; }
+};
+
+/* Registering a provider whose access method matches a file-type scheme
+ * ("xml") makes the known-scheme lookup find a match, so a uri composed for
+ * that scheme has its path resolved instead of being used as is. An absolute
+ * path resolves to itself, keeping the result predictable. */
+TEST(GncUri, KnownScheme)
+{
+    qof_backend_register_provider (
+        QofBackendProvider_ptr {new UriTestProvider {"Test Backend", "xml"}});
+
+    GncUri uri { std::string {"xml"}, std::nullopt, 0, std::nullopt,
+                 std::nullopt, std::string {"/test/path/file.gnucash"} };
+    EXPECT_EQ (uri.str(), "xml:///test/path/file.gnucash");
+
+    qof_backend_unregister_all_providers ();
+}
diff --git a/libgnucash/engine/test/test-gnc-uri-utils.c b/libgnucash/engine/test/test-gnc-uri-utils.c
index 171fbdf28f..4d827b076d 100644
--- a/libgnucash/engine/test/test-gnc-uri-utils.c
+++ b/libgnucash/engine/test/test-gnc-uri-utils.c
@@ -28,6 +28,7 @@
 #include <glib.h>
 #include "qof.h"
 #include <unittest-support.h>
+#include "gnc-filepath-utils.h"
 #include "gnc-uri-utils.h"
 
 static const gchar *suitename = "/engine/uri-utils";
@@ -272,6 +273,17 @@ test_gnc_uri_create_uri()
         g_assert_cmpstr ( turi, ==, strs[i].created_uri );
         g_free(turi);
     }
+
+    /* A file-type scheme with a non-absolute path exercises the "scheme:///path"
+     * branch that inserts an extra slash. In the test environment no backends
+     * are registered, so an (unknown) file-type scheme leaves the path
+     * unresolved, making the result predictable. */
+    {
+        gchar *turi = gnc_uri_create_uri( "xml", NULL, 0, NULL, NULL,
+                                          "relative/path/file.gnucash" );
+        g_assert_cmpstr ( turi, ==, "xml:///relative/path/file.gnucash" );
+        g_free(turi);
+    }
 }
 
 /* TEST: gnc_uri_normalize_uri */
@@ -319,6 +331,75 @@ test_gnc_uri_is_file_uri()
     }
 }
 
+/* TEST: gnc_uri_targets_local_fs */
+static void
+test_gnc_uri_targets_local_fs()
+{
+    struct test_local_fs_struct
+    {
+        const gchar *uri;
+        gboolean     targets_local_fs;
+    } cases[] =
+    {
+#ifndef G_OS_WIN32
+        { "/test/path/file.gnucash",          TRUE },  /* no scheme   -> local */
+        { "file:///test/path/file.gnucash",   TRUE },  /* file scheme -> local */
+        { "xml:///test/path/file.gnucash",    TRUE },
+        { "sqlite3:///test/path/file.gnucash", TRUE },
+#else
+        { "c:\\test\\path\\file.gnucash",          TRUE },  /* no scheme   -> local */
+        { "file://c:\\test\\path\\file.gnucash",   TRUE },  /* file scheme -> local */
+        { "xml://c:\\test\\path\\file.gnucash",    TRUE },
+        { "sqlite3://c:\\test\\path\\file.gnucash", TRUE },
+#endif
+        { "mysql://www.gnucash.org/gnucash",                  FALSE }, /* db scheme -> not local */
+        { "postgres://dbuser:dbpass@www.gnucash.org/gnucash", FALSE },
+        { NULL, FALSE },
+    };
+
+    int i;
+    for (i = 0; cases[i].uri != NULL; i++)
+        g_assert_true ( gnc_uri_targets_local_fs (cases[i].uri) == cases[i].targets_local_fs );
+}
+
+/* TEST: gnc_uri_add_extension */
+static void
+test_gnc_uri_add_extension()
+{
+    gchar *result;
+
+    /* A NULL uri returns NULL (and logs a critical from g_return_val_if_fail) */
+    if (g_test_undefined ())
+    {
+        g_test_expect_message ("gnc.engine", G_LOG_LEVEL_CRITICAL,
+                               "*assertion 'uri != nullptr' failed*");
+        g_assert_null ( gnc_uri_add_extension (NULL, GNC_DATAFILE_EXT) );
+        g_test_assert_expected_messages ();
+    }
+
+    /* A non-file uri is never modified, only duplicated */
+    result = gnc_uri_add_extension ( "mysql://www.gnucash.org/gnucash", GNC_DATAFILE_EXT );
+    g_assert_cmpstr ( result, ==, "mysql://www.gnucash.org/gnucash" );
+    g_free (result);
+
+#ifndef G_OS_WIN32
+    /* A file uri without the extension gets it appended */
+    result = gnc_uri_add_extension ( "file:///test/path/file", GNC_DATAFILE_EXT );
+    g_assert_cmpstr ( result, ==, "file:///test/path/file" GNC_DATAFILE_EXT );
+    g_free (result);
+
+    /* A file uri that already ends in the extension is left unchanged */
+    result = gnc_uri_add_extension ( "file:///test/path/file" GNC_DATAFILE_EXT, GNC_DATAFILE_EXT );
+    g_assert_cmpstr ( result, ==, "file:///test/path/file" GNC_DATAFILE_EXT );
+    g_free (result);
+
+    /* A NULL extension leaves the uri unchanged, only duplicated */
+    result = gnc_uri_add_extension ( "file:///test/path/file", NULL );
+    g_assert_cmpstr ( result, ==, "file:///test/path/file" );
+    g_free (result);
+#endif
+}
+
 void
 test_suite_gnc_uri_utils(void)
 {
@@ -329,5 +410,6 @@ test_suite_gnc_uri_utils(void)
     GNC_TEST_ADD_FUNC(suitename, "gnc_uri_normalize_uri()", test_gnc_uri_normalize_uri);
     GNC_TEST_ADD_FUNC(suitename, "gnc_uri_is_file_scheme()", test_gnc_uri_is_file_scheme);
     GNC_TEST_ADD_FUNC(suitename, "gnc_uri_is_file_uri()", test_gnc_uri_is_file_uri);
-
+    GNC_TEST_ADD_FUNC(suitename, "gnc_uri_targets_local_fs()", test_gnc_uri_targets_local_fs);
+    GNC_TEST_ADD_FUNC(suitename, "gnc_uri_add_extension()", test_gnc_uri_add_extension);
 }
diff --git a/po/POTFILES.in b/po/POTFILES.in
index 6aa4fe2df2..df15450bbe 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -664,7 +664,7 @@ libgnucash/engine/gnc-rational.cpp
 libgnucash/engine/gnc-session.c
 libgnucash/engine/gncTaxTable.c
 libgnucash/engine/gnc-timezone.cpp
-libgnucash/engine/gnc-uri-utils.c
+libgnucash/engine/gnc-uri.cpp
 libgnucash/engine/gncVendor.c
 libgnucash/engine/guid.cpp
 libgnucash/engine/kvp-frame.cpp

commit 12e2922fc5dd6f1ce836d88bbee33a3a4731e743
Author: Brent McBride <mcbridebt at hotmail.com>
Date:   Sun Jun 21 21:19:38 2026 -0700

    Rename gnc-uri-utils.c to gnc-uri.cpp

diff --git a/libgnucash/engine/gnc-uri-utils.c b/libgnucash/engine/gnc-uri.cpp
old mode 100755
new mode 100644
similarity index 100%
rename from libgnucash/engine/gnc-uri-utils.c
rename to libgnucash/engine/gnc-uri.cpp



Summary of changes:
 CMakeLists.txt                                     |   9 +-
 gnucash/gnome-utils/gnc-main-window.cpp            |  31 +-
 gnucash/gnome/gnc-plugin-page-invoice.cpp          |   1 -
 .../csv-imp/assistant-csv-price-import.cpp         |   7 +-
 .../csv-imp/assistant-csv-trans-import.cpp         |   7 +-
 libgnucash/backend/dbi/gnc-backend-dbi.cpp         |  37 +-
 .../backend/dbi/test/test-backend-dbi-basic.cpp    |  18 +-
 libgnucash/backend/xml/gnc-backend-xml.cpp         |  14 +-
 libgnucash/backend/xml/gnc-xml-backend.cpp         |   7 +-
 .../backend/xml/test/gtest-load-save-files.cpp     |  20 +-
 libgnucash/backend/xml/test/gtest-xml-contents.cpp |   7 +-
 libgnucash/backend/xml/test/test-load-xml2.cpp     |   7 +-
 libgnucash/core-utils/gnc-filepath-utils.h         |   3 +
 libgnucash/engine/CMakeLists.txt                   |   3 +-
 libgnucash/engine/gnc-uri-utils.c                  | 429 ---------------------
 libgnucash/engine/gnc-uri-utils.h                  |  37 --
 libgnucash/engine/gnc-uri.cpp                      | 408 ++++++++++++++++++++
 libgnucash/engine/gnc-uri.hpp                      | 119 ++++++
 libgnucash/engine/qofsession.cpp                   |   7 +-
 libgnucash/engine/test/CMakeLists.txt              |   6 +-
 libgnucash/engine/test/gtest-gnc-uri.cpp           | 170 ++++++++
 libgnucash/engine/test/test-gnc-uri-utils.c        |  84 +++-
 po/POTFILES.in                                     |   2 +-
 23 files changed, 873 insertions(+), 560 deletions(-)
 delete mode 100755 libgnucash/engine/gnc-uri-utils.c
 create mode 100755 libgnucash/engine/gnc-uri.cpp
 create mode 100644 libgnucash/engine/gnc-uri.hpp
 create mode 100644 libgnucash/engine/test/gtest-gnc-uri.cpp



More information about the gnucash-changes mailing list