gnucash future: Multiple changes pushed

John Ralls jralls at code.gnucash.org
Tue Jul 14 18:54:55 EDT 2026


Updated	 via  https://github.com/Gnucash/gnucash/commit/458b2ea4 (commit)
	 via  https://github.com/Gnucash/gnucash/commit/e417a6ea (commit)
	 via  https://github.com/Gnucash/gnucash/commit/ce4099d5 (commit)
	from  https://github.com/Gnucash/gnucash/commit/8a9abe81 (commit)



commit 458b2ea4c482b62f9c3c22eb5287154ff0a7d549
Merge: 8a9abe818d e417a6ea27
Author: John Ralls <jralls at ceridwen.us>
Date:   Tue Jul 14 15:51:55 2026 -0700

    Merge Brent McBride's 'gnc-state-cpp' into future.


commit e417a6ea27f1441662cf43a580794035bfa5db6b
Author: Brent McBride <mcbridebt at hotmail.com>
Date:   Wed Jul 8 14:44:14 2026 -0700

    Modernize gnc-state.cpp to C++ and add unit tests
    
    Convert C idioms in gnc-state.cpp to modern C++ now that the file is
    compiled as C++:
    
    - Replace the static char* state-file-name globals with
      std::optional<std::string>, eliminating the manual g_free/NULL
      ownership dance.
    - Use std::string and std::format for filename construction.
    - Use std::filesystem::path::filename() for the file-URI basename
      instead of g_path_get_basename.
    - Compare book GUIDs with the C++ gnc::GUID value type
      (gnc::GUID::from_string + operator==, catching gnc::guid_syntax_exception
      on a malformed id) instead of the C GncGUID/string_to_guid/guid_equal
      API; drop the scratch encode buffer.
    - Replace g_strstr_len with std::string_view::find.
    - Wrap GLib-owned resources in std::unique_ptr with the matching deleter
      (g_free for gchar* buffers, g_key_file_free for the scratch GKeyFile,
      g_strfreev for the groups array), replacing shared_ptr and removing the
      manual free/double-free cleanup dance.
    - Iterate the g_key_file_get_groups() result via std::span for a
      range-based loop instead of an indexed char** walk.
    - Parse session URIs through the GncUri C++ class instead of the C
      gnc-uri-utils helpers, preserving the g_strjoin NULL-truncation
      behavior for database URIs.
    - Guard qof_session_get_url() against NULL before dereferencing (was
      strlen(uri), UB on null) in gnc_state_set_base/gnc_state_save.
    - Sweep remaining GLib tokens: gchar/gint/gsize -> char/int/size_t,
      TRUE/FALSE -> true/false, const gchar* -> const char* on
      gnc_state_drop_sections_for (and its declaration in gnc-state.h).
    - Apply the project CodingStandard: return type on its own line for
      definitions, s_ prefix on free statics, and lines under 80 columns.
    
    Boundary GLib retained deliberately: GKeyFile (.gcm on-disk format),
    GError, g_key_file_*/gnc_key_file_* wrappers, gnc_build_book_path, and
    the long-lived s_state_file singleton (never-free-until-exit contract).
    
    Add gtest-gnc-state.cpp (10 tests, 2 suites) covering the public API and
    both the file- and database-URI base-name paths, and register it in the
    app-utils test CMakeLists.

diff --git a/libgnucash/app-utils/gnc-state.cpp b/libgnucash/app-utils/gnc-state.cpp
index 616aea5062..bc75859dd4 100644
--- a/libgnucash/app-utils/gnc-state.cpp
+++ b/libgnucash/app-utils/gnc-state.cpp
@@ -1,8 +1,9 @@
 /********************************************************************\
- * file-utils.c -- simple file utilities                            *
+ * gnc-state.cpp -- functions to manage gui state                   *
  * Copyright (C) 1997 Robin D. Clark <rclark at cs.hmc.edu>            *
  * Copyright (C) 1998 Rob Browning                                  *
  * Copyright (C) 1998-2000 Linas Vepstas <linas at linas.org>          *
+ * 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   *
@@ -22,14 +23,19 @@
 #include <config.h>
 
 #include <glib.h>
-//#include <glib/gstdio.h>
-//#include <errno.h>
+#include <filesystem>
+#include <format>
+#include <memory>
+#include <optional>
+#include <span>
+#include <string>
+#include <string_view>
 
 #include "gnc-state.h"
-//#include "gnc-engine.h"
 #include "gnc-filepath-utils.h"
 #include "gnc-gkeyfile-utils.h"
-#include "gnc-uri-utils.h"
+#include "gnc-uri.hpp"
+#include <guid.hpp>
 #include "qof.h"
 
 /* This static indicates the debugging module that this .o belongs to.  */
@@ -42,10 +48,10 @@ static QofLogModule log_module = G_LOG_DOMAIN;
  * name here as well. The old style state file will then be
  * converted into a new style one the next time state is saved.
  */
-static gchar* state_file_name = NULL;
-static gchar* state_file_name_pre_241 = NULL;
+static std::optional<std::string> s_state_file_name;
+static std::optional<std::string> s_state_file_name_pre_241;
 /* State file data for current book */
-static GKeyFile *state_file = NULL;
+static GKeyFile *s_state_file = nullptr;
 
 /* Determine which file name to use for the state file. This name is based
  * the current book's uri and guid.
@@ -69,162 +75,161 @@ static GKeyFile *state_file = NULL;
 static void
 gnc_state_set_base (const QofSession *session)
 {
-    gchar *basename, *original = NULL, *filename, *file_guid;
-    gchar *sf_extension = NULL;
-    const gchar *uri;
-    gchar guid_string[GUID_ENCODING_LENGTH+1];
-    QofBook *book;
-    const GncGUID *guid;
-    GKeyFile *key_file = NULL;
-    gint i;
-
     /* Reset filenames possibly found in a previous run */
-    g_free (state_file_name);
-    g_free (state_file_name_pre_241);
-    state_file_name = NULL;
-    state_file_name_pre_241 = NULL;
+    s_state_file_name.reset ();
+    s_state_file_name_pre_241.reset ();
 
-    uri = qof_session_get_url (session);
+    const char *uri = qof_session_get_url (session);
     ENTER("session %p (%s)", session, uri ? uri : "(null)");
-    if (!strlen (uri))
+    if (!uri || !*uri)
     {
         LEAVE("no uri, nothing to do");
         return;
     }
 
     /* Get the book GncGUID */
-    book = qof_session_get_book(session);
-    guid = qof_entity_get_guid(QOF_INSTANCE(book));
-    guid_to_string_buff(guid, guid_string);
+    QofBook *book = qof_session_get_book (session);
+    const GncGUID *guid = qof_entity_get_guid (QOF_INSTANCE (book));
 
-    if (gnc_uri_targets_local_fs (uri))
+    std::string basename;
+    GncUri parsed_uri {uri};
+    if (parsed_uri.targets_local_fs ())
     {
         /* The book_uri is a true file, use its basename. */
-        gchar *path = gnc_uri_get_path (uri);
-        basename = g_path_get_basename (path);
-        g_free (path);
+        basename = std::filesystem::path {*parsed_uri.path ()}
+                       .filename ().string ();
     }
     else
     {
-        /* The book_uri is composed of database connection parameters. */
-        gchar* scheme = NULL;
-        gchar* host = NULL;
-        gchar* dbname = NULL;
-        gchar* username = NULL;
-        gchar* password = NULL;
-        gint portnum = 0;
-        gnc_uri_get_components (uri, &scheme, &host, &portnum,
-                                &username, &password, &dbname);
-
-        basename = g_strjoin ("_", scheme, host, username, dbname, NULL);
-        g_free (scheme);
-        g_free (host);
-        g_free (username);
-        g_free (password);
-        g_free (dbname);
+        /* The book_uri is composed of database connection parameters.
+         * Join the scheme, host, username and dbname with underscores. As
+         * with the historical g_strjoin, an absent component terminates the
+         * name, so anything following a missing component is dropped. */
+        for (const auto& component : {parsed_uri.scheme (),
+                                      parsed_uri.hostname (),
+                                      parsed_uri.username (),
+                                      parsed_uri.path ()})
+        {
+            if (!component)
+                break;
+            if (!basename.empty ())
+                basename += '_';
+            basename += *component;
+        }
     }
 
-    DEBUG ("Basename %s", basename);
-    original = gnc_build_book_path (basename);
-    g_free (basename);
-    DEBUG ("Original %s", original);
-
-    sf_extension = g_strdup (STATE_FILE_EXT);
-    i = 1;
-    while (1)
+    DEBUG ("Basename %s", basename.c_str ());
+    std::unique_ptr<char, decltype (&g_free)> raw_original {
+        gnc_build_book_path (basename.c_str ()), g_free};
+    std::string original {raw_original.get ()};
+    DEBUG ("Original %s", original.c_str ());
+
+    std::string sf_extension {STATE_FILE_EXT};
+    std::unique_ptr<GKeyFile, decltype (&g_key_file_free)> key_file {
+        nullptr, g_key_file_free};
+    int i = 1;
+    while (true)
     {
+        std::string filename;
         if (i == 1)
-            filename = g_strconcat (original, sf_extension, NULL);
+            filename = original + sf_extension;
         else
-            filename = g_strdup_printf ("%s_%d%s", original, i, sf_extension);
-        DEBUG ("Trying %s", filename);
-        key_file = gnc_key_file_load_from_file (filename, TRUE, FALSE, NULL);
-        DEBUG ("Result %p", key_file);
+            filename = std::format ("{}_{}{}", original, i, sf_extension);
+        DEBUG ("Trying %s", filename.c_str ());
+        key_file.reset (gnc_key_file_load_from_file (filename.c_str (), true,
+                                                     false, nullptr));
+        DEBUG ("Result %p", key_file.get ());
 
         if (!key_file)
         {
             DEBUG ("No key file by that name");
-            if (g_strcmp0 (sf_extension, STATE_FILE_EXT) == 0)
+            if (sf_extension == STATE_FILE_EXT)
             {
                 DEBUG ("Trying old state file names for compatibility");
                 i = 1;
-                g_free (sf_extension);
-                sf_extension = g_strdup ("");
+                sf_extension.clear ();
 
                 /* Regardless of whether or not an old state file is found,
                  * the currently tested name should be used for the future
                  * state file.
                  */
-                state_file_name = filename;
+                s_state_file_name = filename;
                 continue;
             }
 
             /* No old style file found. We'll return with the new file name
              * we set earlier, and no existing key file. */
-            g_free (filename);
             break;
         }
 
-        file_guid = g_key_file_get_string (key_file,
-                                           STATE_FILE_TOP, STATE_FILE_BOOK_GUID,
-                                           NULL);
-        DEBUG ("File GncGUID is %s", file_guid ? file_guid : "<not found>");
-        if (g_strcmp0 (guid_string, file_guid) == 0)
+        std::unique_ptr<char, decltype (&g_free)> raw_file_guid {
+            g_key_file_get_string (key_file.get (), STATE_FILE_TOP,
+                                   STATE_FILE_BOOK_GUID, nullptr),
+            g_free};
+        DEBUG ("File GncGUID is %s",
+               raw_file_guid ? raw_file_guid.get () : "<not found>");
+        bool matched = false;
+        if (raw_file_guid)
+        {
+            try
+            {
+                gnc::GUID file_guid =
+                    gnc::GUID::from_string (raw_file_guid.get ());
+                matched = file_guid == *guid;
+            }
+            catch (const gnc::guid_syntax_exception&)
+            {
+                /* Malformed guid in the state file; treat as no match. */
+            }
+        }
+        if (matched)
         {
             DEBUG ("Matched !!!");
             /* Save the found file for later use. Which name to save to
              * depends on whether it was an old or new style file name
              */
-            if (g_strcmp0 (sf_extension, STATE_FILE_EXT) == 0)
-                state_file_name = filename;
+            if (sf_extension == STATE_FILE_EXT)
+                s_state_file_name = filename;
             else
-                state_file_name_pre_241 = filename;
+                s_state_file_name_pre_241 = filename;
 
-            g_free (file_guid);
             break;
         }
-        DEBUG ("Clean up this pass");
-        g_free (file_guid);
-        g_key_file_free (key_file);
-        g_free (filename);
         i++;
     }
 
-    DEBUG("Clean up");
-    g_free(sf_extension);
-    g_free(original);
-    if (key_file != NULL)
-        g_key_file_free (key_file);
-
     LEAVE ();
 }
 
-GKeyFile *gnc_state_load (const QofSession *session)
+GKeyFile *
+gnc_state_load (const QofSession *session)
 {
     /* Drop possible previous state_file first */
-    if (state_file)
+    if (s_state_file)
     {
-        g_key_file_free (state_file);
-        state_file = NULL;
+        g_key_file_free (s_state_file);
+        s_state_file = nullptr;
     }
 
     gnc_state_set_base (session);
 
-    if (state_file_name_pre_241)
-        state_file = gnc_key_file_load_from_file (state_file_name_pre_241,
-                     TRUE, TRUE, NULL);
-    else if (state_file_name)
-        state_file = gnc_key_file_load_from_file (state_file_name,
-                     TRUE, TRUE, NULL);
+    if (s_state_file_name_pre_241)
+        s_state_file = gnc_key_file_load_from_file (
+            s_state_file_name_pre_241->c_str (), true, true, nullptr);
+    else if (s_state_file_name)
+        s_state_file = gnc_key_file_load_from_file (
+            s_state_file_name->c_str (), true, true, nullptr);
 
     return gnc_state_get_current ();
 }
 
-void gnc_state_save (const QofSession *session)
+void
+gnc_state_save (const QofSession *session)
 {
-    GError *error = NULL;
+    GError *error = nullptr;
 
-    if (!strlen (qof_session_get_url(session)))
+    const char *uri = qof_session_get_url (session);
+    if (!uri || !*uri)
     {
         DEBUG("No file associated with session - skip state saving");
         return;
@@ -233,8 +238,9 @@ void gnc_state_save (const QofSession *session)
     gnc_state_set_base (session);
 
     /* Write it all out to disk */
-    if (state_file_name)
-        gnc_key_file_save_to_file(state_file_name, state_file, &error);
+    if (s_state_file_name)
+        gnc_key_file_save_to_file (s_state_file_name->c_str (), s_state_file,
+                                   &error);
     else
         PWARN ("No state file name set, can't save state");
 
@@ -245,26 +251,23 @@ void gnc_state_save (const QofSession *session)
     }
 }
 
-GKeyFile *gnc_state_get_current (void)
+GKeyFile *
+gnc_state_get_current (void)
 {
-    if (!state_file)
+    if (!s_state_file)
     {
         PINFO ("No pre-existing state found, creating new one");
-        state_file = g_key_file_new ();
+        s_state_file = g_key_file_new ();
     }
 
-    return state_file;
+    return s_state_file;
 
 }
 
-gint gnc_state_drop_sections_for (const gchar *partial_name)
+int
+gnc_state_drop_sections_for (const char *partial_name)
 {
-    gchar **groups;
-    gint found_count = 0, dropped_count = 0;
-    gsize i, num_groups;
-    GError *error = NULL;
-
-    if (!state_file)
+    if (!s_state_file)
     {
         PWARN ("No pre-existing state found, ignoring drop request");
         return 0;
@@ -272,17 +275,23 @@ gint gnc_state_drop_sections_for (const gchar *partial_name)
 
     ENTER("");
 
-    groups = g_key_file_get_groups (state_file, &num_groups);
-    for (i = 0; i < num_groups; i++)
+    int found_count = 0, dropped_count = 0;
+    gsize num_groups;
+    std::unique_ptr<char *, decltype (&g_strfreev)> groups {
+        g_key_file_get_groups (s_state_file, &num_groups), g_strfreev};
+    for (char *group : std::span {groups.get (), num_groups})
     {
-        if (g_strstr_len (groups[i], -1, partial_name))
+        if (std::string_view {group}.find (partial_name)
+            != std::string_view::npos)
         {
-            DEBUG ("Section \"%s\" matches \"%s\", removing", groups[i], partial_name);
+            DEBUG ("Section \"%s\" matches \"%s\", removing", group,
+                   partial_name);
             found_count++;
-            if (!g_key_file_remove_group (state_file, groups[i], &error))
+            GError *error = nullptr;
+            if (!g_key_file_remove_group (s_state_file, group, &error))
             {
                 PWARN ("Warning: unable to remove section %s.\n  %s",
-                        groups[i],
+                        group,
                         error->message);
                 g_error_free (error);
             }
@@ -291,7 +300,6 @@ gint gnc_state_drop_sections_for (const gchar *partial_name)
 
         }
     }
-    g_strfreev (groups);
 
     LEAVE("Found %i sections matching \"%s\", successfully removed %i",
             found_count, partial_name, dropped_count);
diff --git a/libgnucash/app-utils/gnc-state.h b/libgnucash/app-utils/gnc-state.h
index 624813c39a..884f5c9bf4 100644
--- a/libgnucash/app-utils/gnc-state.h
+++ b/libgnucash/app-utils/gnc-state.h
@@ -104,7 +104,7 @@ GKeyFile *gnc_state_get_current (void);
  *
  * @return The number of successfully dropped sections.
  */
-gint gnc_state_drop_sections_for (const gchar *partial_name);
+int gnc_state_drop_sections_for (const char *partial_name);
 
 #ifdef __cplusplus
 }
diff --git a/libgnucash/app-utils/test/CMakeLists.txt b/libgnucash/app-utils/test/CMakeLists.txt
index 08c6a80a02..b47c56d865 100644
--- a/libgnucash/app-utils/test/CMakeLists.txt
+++ b/libgnucash/app-utils/test/CMakeLists.txt
@@ -75,6 +75,28 @@ gnc_add_test(test-autoclear "${test_autoclear_SOURCES}"
     test_autoclear_LIBS
 )
 
+set(test_gnc_state_SOURCES
+  gtest-gnc-state.cpp
+)
+
+set(test_gnc_state_INCLUDE_DIRS
+  ${CMAKE_BINARY_DIR}/common
+  ${CMAKE_SOURCE_DIR}/libgnucash/engine
+  ${CMAKE_SOURCE_DIR}/libgnucash/core-utils
+  ${CMAKE_SOURCE_DIR}/libgnucash/app-utils
+)
+
+set(test_gnc_state_LIBS
+  gnc-app-utils
+  gnc-engine
+  gtest
+)
+
+gnc_add_test(test-gnc-state "${test_gnc_state_SOURCES}"
+    test_gnc_state_INCLUDE_DIRS
+    test_gnc_state_LIBS
+)
+
 set(GUILE_DEPENDS
   scm-test-engine
   scm-app-utils
@@ -87,6 +109,7 @@ set(GUILE_DEPENDS
 set_dist_list(test_app_utils_DIST
   CMakeLists.txt
   gtest-gnc-quotes.cpp
+  gtest-gnc-state.cpp
   test-exp-parser.c
   test-print-parse-amount.cpp
   test-sx.cpp
diff --git a/libgnucash/app-utils/test/gtest-gnc-state.cpp b/libgnucash/app-utils/test/gtest-gnc-state.cpp
new file mode 100644
index 0000000000..8eaedb918c
--- /dev/null
+++ b/libgnucash/app-utils/test/gtest-gnc-state.cpp
@@ -0,0 +1,284 @@
+/********************************************************************
+ * gtest-gnc-state.cpp: test suite for gnc-state                    *
+ * 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, you can retrieve it from        *
+ * https://www.gnu.org/licenses/old-licenses/gpl-2.0.html           *
+ * or 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 "../gnc-state.h"
+#include <qof.h>
+#include "gnc-filepath-utils.h"
+#pragma GCC diagnostic push
+#pragma GCC diagnostic ignored "-Wcpp"
+#include <gtest/gtest.h>
+#pragma GCC diagnostic pop
+
+/* Test seam exported (without a public header) by qofsession.cpp: set a
+ * session's URI directly, without opening a backend. Used the same way as
+ * libgnucash/engine/test/test-qofsession-old.cpp. */
+extern void (*p_qof_session_set_uri) (QofSession *, char const *);
+void init_static_qofsession_pointers (void);
+
+/* gnc_state_get_current() lazily creates a process-wide singleton GKeyFile
+ * and always returns the same pointer thereafter. */
+TEST(GncState, GetCurrentCreatesSingleton)
+{
+    GKeyFile *kf = gnc_state_get_current();
+    ASSERT_NE(kf, nullptr);
+
+    GKeyFile *kf2 = gnc_state_get_current();
+    EXPECT_EQ(kf, kf2);
+}
+
+/* gnc_state_drop_sections_for() removes every group whose name contains the
+ * given substring and returns the number of groups removed. */
+TEST(GncState, DropSectionsForMatchesSubstring)
+{
+    GKeyFile *kf = gnc_state_get_current();
+
+    g_key_file_set_string(kf, "Account_abc", "key", "value");
+    g_key_file_set_string(kf, "Register_abc", "key", "value");
+    g_key_file_set_string(kf, "Window_def", "key", "value");
+
+    gint dropped = gnc_state_drop_sections_for("abc");
+
+    EXPECT_EQ(dropped, 2);
+    EXPECT_FALSE(g_key_file_has_group(kf, "Account_abc"));
+    EXPECT_FALSE(g_key_file_has_group(kf, "Register_abc"));
+    EXPECT_TRUE(g_key_file_has_group(kf, "Window_def"));
+
+    /* clean up the group we left behind so tests stay independent */
+    g_key_file_remove_group(kf, "Window_def", nullptr);
+}
+
+/* When nothing matches, no groups are removed and the count is zero. */
+TEST(GncState, DropSectionsForNoMatchReturnsZero)
+{
+    gint dropped = gnc_state_drop_sections_for("no-such-section-xyz");
+    EXPECT_EQ(dropped, 0);
+}
+
+/* Fixture that drives the on-disk save/load path. It redirects the state
+ * directory to a throwaway temp dir via GNC_DATA_HOME and gives the session a
+ * URI without opening a real backend. */
+class GncStateFile : public ::testing::Test
+{
+protected:
+    gchar      *m_data_home      = nullptr;
+    gchar      *m_saved_builddir = nullptr;
+    QofSession *m_session        = nullptr;
+
+    void SetUp() override
+    {
+        qof_init();
+        m_data_home = g_dir_make_tmp("gnc-state-XXXXXX", nullptr);
+        ASSERT_NE(m_data_home, nullptr);
+        /* Under ctest the harness sets GNC_UNINSTALLED=YES and GNC_BUILDDIR,
+         * and gnc-filepath roots user data at $GNC_BUILDDIR/gnc_data_home,
+         * ignoring GNC_DATA_HOME. Point both at our throwaway dir so each test
+         * gets an isolated books/ directory whichever way it is launched, and
+         * so book_path() (via gnc_build_book_path) matches where the code under
+         * test actually reads and writes. */
+        g_setenv("GNC_DATA_HOME", m_data_home, TRUE);
+        m_saved_builddir = g_strdup(g_getenv("GNC_BUILDDIR"));
+        g_setenv("GNC_BUILDDIR", m_data_home, TRUE);
+        gnc_filepath_init();
+
+        m_session = qof_session_new(qof_book_new());
+        init_static_qofsession_pointers();
+    }
+
+    void TearDown() override
+    {
+        qof_session_destroy(m_session);
+        g_unsetenv("GNC_DATA_HOME");
+        if (m_saved_builddir)
+        {
+            g_setenv("GNC_BUILDDIR", m_saved_builddir, TRUE);
+            g_free(m_saved_builddir);
+            m_saved_builddir = nullptr;
+        }
+        else
+            g_unsetenv("GNC_BUILDDIR");
+        gnc_filepath_init();
+        g_free(m_data_home);
+        qof_close();
+    }
+
+    /* Path of a state file as the code under test computes it. Use the
+     * production builder rather than duplicating the layout, which is rooted
+     * at a platform- and environment-dependent user-data directory, not
+     * directly under GNC_DATA_HOME. */
+    gchar *book_path(const char *basename)
+    {
+        return gnc_build_book_path(basename);
+    }
+
+    /* The session book's guid in the same encoding gnc-state uses. */
+    void book_guid_string(gchar *buf)
+    {
+        QofBook *book = qof_session_get_book(m_session);
+        const GncGUID *guid = qof_entity_get_guid(QOF_INSTANCE(book));
+        guid_to_string_buff(guid, buf);
+    }
+
+    /* Write a state file into the books/ dir with the given book guid and a
+     * recognisable seeded section, so set_base() finds it on disk. */
+    void write_state_file(const char *basename, const char *book_guid)
+    {
+        GKeyFile *kf = g_key_file_new();
+        g_key_file_set_string(kf, STATE_FILE_TOP, STATE_FILE_BOOK_GUID, book_guid);
+        g_key_file_set_string(kf, "Seeded", "seedkey", "seedval");
+
+        gchar *path = book_path(basename);
+        gchar *dir  = g_path_get_dirname(path);
+        g_mkdir_with_parents(dir, 0700);
+        g_free(dir);
+
+        gchar *data = g_key_file_to_data(kf, nullptr, nullptr);
+        g_file_set_contents(path, data, -1, nullptr);
+        g_free(data);
+        g_free(path);
+        g_key_file_free(kf);
+    }
+};
+
+/* A session with no URI must not attempt to write a state file. */
+TEST_F(GncStateFile, SaveWithEmptyUriDoesNothing)
+{
+    p_qof_session_set_uri(m_session, "");
+    gnc_state_save(m_session);
+    SUCCEED();
+}
+
+/* A file:// URI uses the file's basename; state survives a save/load round
+ * trip once the book guid has been stamped into the state (as the gui does). */
+TEST_F(GncStateFile, FileUriSaveLoadRoundTrip)
+{
+    p_qof_session_set_uri(m_session, "file:///MyTestBook.gnucash");
+
+    QofBook *book = qof_session_get_book(m_session);
+    const GncGUID *guid = qof_entity_get_guid(QOF_INSTANCE(book));
+    gchar guid_string[GUID_ENCODING_LENGTH + 1];
+    guid_to_string_buff(guid, guid_string);
+
+    GKeyFile *state = gnc_state_get_current();
+    g_key_file_set_string(state, STATE_FILE_TOP, STATE_FILE_BOOK_GUID, guid_string);
+    g_key_file_set_string(state, "TestSection", "testkey", "testval");
+
+    gnc_state_save(m_session);
+
+    gchar *path = book_path("MyTestBook.gnucash" STATE_FILE_EXT);
+    EXPECT_TRUE(g_file_test(path, G_FILE_TEST_EXISTS));
+    g_free(path);
+
+    GKeyFile *loaded = gnc_state_load(m_session);
+    gchar *val = g_key_file_get_string(loaded, "TestSection", "testkey", nullptr);
+    EXPECT_STREQ(val, "testval");
+    g_free(val);
+}
+
+/* For a database URI with no user name, g_strjoin() treats the missing
+ * component as a terminator, so the basename is truncated and the database
+ * name is dropped. This pins that long-standing contract. */
+TEST_F(GncStateFile, DbUriBasenameTruncatesAtMissingComponent)
+{
+    p_qof_session_set_uri(m_session, "postgres://www.gnucash.org/gnucash");
+    gnc_state_save(m_session);
+
+    gchar *truncated = book_path("postgres_www.gnucash.org" STATE_FILE_EXT);
+    EXPECT_TRUE(g_file_test(truncated, G_FILE_TEST_EXISTS));
+    g_free(truncated);
+
+    /* The database name must NOT have made it into the file name. */
+    gchar *with_dbname = book_path("postgres_www.gnucash.org_gnucash" STATE_FILE_EXT);
+    EXPECT_FALSE(g_file_test(with_dbname, G_FILE_TEST_EXISTS));
+    g_free(with_dbname);
+}
+
+/* When every component is present the basename joins scheme, host, user and
+ * database name with underscores. */
+TEST_F(GncStateFile, DbUriBasenameJoinsAllComponents)
+{
+    p_qof_session_set_uri(m_session, "postgres://dbuser@www.gnucash.org/gnucash");
+    gnc_state_save(m_session);
+
+    gchar *path = book_path("postgres_www.gnucash.org_dbuser_gnucash" STATE_FILE_EXT);
+    EXPECT_TRUE(g_file_test(path, G_FILE_TEST_EXISTS));
+    g_free(path);
+}
+
+/* Loading a session with no URI yields an empty (non-NULL) state. */
+TEST_F(GncStateFile, LoadWithEmptyUriReturnsEmptyState)
+{
+    p_qof_session_set_uri(m_session, "");
+    GKeyFile *loaded = gnc_state_load(m_session);
+    EXPECT_NE(loaded, nullptr);
+}
+
+/* A pre-existing .gcm file owned by a different book is skipped, and a new
+ * numbered file name is chosen instead. */
+TEST_F(GncStateFile, FileUriDisambiguatesOnGuidMismatch)
+{
+    p_qof_session_set_uri(m_session, "file:///MyBook.gnucash");
+
+    /* Seed a state file belonging to some OTHER book. */
+    write_state_file("MyBook.gnucash" STATE_FILE_EXT,
+                     "00000000000000000000000000000000");
+
+    gnc_state_save(m_session);
+
+    gchar *path = book_path("MyBook.gnucash_2" STATE_FILE_EXT);
+    EXPECT_TRUE(g_file_test(path, G_FILE_TEST_EXISTS));
+    g_free(path);
+}
+
+/* A pre-existing .gcm file whose stored guid can't be parsed is treated as a
+ * non-match rather than aborting, so a new numbered file name is chosen. */
+TEST_F(GncStateFile, FileUriDisambiguatesOnMalformedGuid)
+{
+    p_qof_session_set_uri(m_session, "file:///MyBook.gnucash");
+
+    /* Seed a state file whose guid is not a valid guid string. */
+    write_state_file("MyBook.gnucash" STATE_FILE_EXT, "not-a-valid-guid");
+
+    gnc_state_save(m_session);
+
+    gchar *path = book_path("MyBook.gnucash_2" STATE_FILE_EXT);
+    EXPECT_TRUE(g_file_test(path, G_FILE_TEST_EXISTS));
+    g_free(path);
+}
+
+/* When no new-style .gcm file exists, an old (pre-2.4.1) extension-less file
+ * with a matching guid is found and loaded. */
+TEST_F(GncStateFile, FileUriLoadsPre241StyleFile)
+{
+    p_qof_session_set_uri(m_session, "file:///MyBook.gnucash");
+
+    gchar guid_string[GUID_ENCODING_LENGTH + 1];
+    book_guid_string(guid_string);
+    write_state_file("MyBook.gnucash", guid_string);
+
+    GKeyFile *loaded = gnc_state_load(m_session);
+    gchar *val = g_key_file_get_string(loaded, "Seeded", "seedkey", nullptr);
+    EXPECT_STREQ(val, "seedval");
+    g_free(val);
+}

commit ce4099d57d3d35f908e133bc3f712b4375b871ad
Author: Brent McBride <mcbridebt at hotmail.com>
Date:   Tue Jun 23 21:22:13 2026 -0700

    Rename gnc-state.c to gnc-state.cpp
    
    Build the file as C++ in preparation for modernization. The body is
    unchanged; the only enabling change is adding extern "C" guards to
    gnc-gkeyfile-utils.h, whose gnc_key_file_* functions gnc-state is now
    the first C++ consumer of.

diff --git a/libgnucash/app-utils/CMakeLists.txt b/libgnucash/app-utils/CMakeLists.txt
index 26476c3c76..344d21e0b8 100644
--- a/libgnucash/app-utils/CMakeLists.txt
+++ b/libgnucash/app-utils/CMakeLists.txt
@@ -32,7 +32,7 @@ set (app_utils_SOURCES
   gnc-gsettings.cpp
   gnc-prefs-utils.c
   gnc-quotes.cpp
-  gnc-state.c
+  gnc-state.cpp
   gnc-ui-util.cpp
   gnc-ui-balances.cpp
   )
diff --git a/libgnucash/app-utils/gnc-state.c b/libgnucash/app-utils/gnc-state.cpp
similarity index 100%
rename from libgnucash/app-utils/gnc-state.c
rename to libgnucash/app-utils/gnc-state.cpp
diff --git a/libgnucash/core-utils/gnc-gkeyfile-utils.h b/libgnucash/core-utils/gnc-gkeyfile-utils.h
index 7c8ecb4712..c0ce588e50 100644
--- a/libgnucash/core-utils/gnc-gkeyfile-utils.h
+++ b/libgnucash/core-utils/gnc-gkeyfile-utils.h
@@ -37,6 +37,9 @@
 #ifndef GNC_GKEYFILE_UTILS_H
 #define GNC_GKEYFILE_UTILS_H
 
+#ifdef __cplusplus
+extern "C" {
+#endif
 
 /** Open and read a key/value file from disk into memory.
  *
@@ -74,6 +77,10 @@ gboolean gnc_key_file_save_to_file (const gchar *file,
                                     GKeyFile *key_file,
                                     GError **error);
 
+#ifdef __cplusplus
+}
+#endif
+
 #endif /* GNC_GKEYFILE_UTILS_H */
 /** @} */
 /** @} */
diff --git a/po/POTFILES.in b/po/POTFILES.in
index 4e8d17fea0..d2acbd170f 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -544,7 +544,7 @@ libgnucash/app-utils/gnc-gsettings.cpp
 libgnucash/app-utils/gnc-help-utils.c
 libgnucash/app-utils/gnc-prefs-utils.c
 libgnucash/app-utils/gnc-quotes.cpp
-libgnucash/app-utils/gnc-state.c
+libgnucash/app-utils/gnc-state.cpp
 libgnucash/app-utils/gnc-sx-instance-model.c
 libgnucash/app-utils/gnc-ui-balances.cpp
 libgnucash/app-utils/gnc-ui-util.cpp



Summary of changes:
 libgnucash/app-utils/CMakeLists.txt                |   2 +-
 .../app-utils/{gnc-state.c => gnc-state.cpp}       | 240 ++++++++---------
 libgnucash/app-utils/gnc-state.h                   |   2 +-
 libgnucash/app-utils/test/CMakeLists.txt           |  23 ++
 libgnucash/app-utils/test/gtest-gnc-state.cpp      | 284 +++++++++++++++++++++
 libgnucash/core-utils/gnc-gkeyfile-utils.h         |   7 +
 po/POTFILES.in                                     |   2 +-
 7 files changed, 441 insertions(+), 119 deletions(-)
 rename libgnucash/app-utils/{gnc-state.c => gnc-state.cpp} (52%)
 create mode 100644 libgnucash/app-utils/test/gtest-gnc-state.cpp



More information about the gnucash-changes mailing list