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