From 144d79f331a4ff66ee56ed938848696934103d18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Helleu?= Date: Tue, 26 May 2026 18:57:56 +0200 Subject: [PATCH] core: implement /theme save and /theme delete Add two complementary subcommands: /theme save [-full]: writes a user theme file at ${weechat_config_dir}/themes/.theme containing the current themable options. By default only options whose value differs from their default (config_file_option_has_changed) are written, which keeps the file small and focused. Pass "-full" to write every themable option (matches the format used by automatic backups). Name validation: refuses any name matching a built-in theme (those are reserved for in-memory registrations) and any name starting with "backup-" (reserved for /theme apply backups). Both checks print an error and abort without writing. /theme delete : removes ${weechat_config_dir}/themes/.theme via unlink. Refuses to delete a name registered as a built-in theme (a built-in has no file on disk to delete, even if the user has a shadowing file of the same name they cannot remove it this way; they can rename or delete it manually). The full-snapshot writer used by /theme apply backups is refactored into theme_write_file (name, description, diff_only). It is reused by theme_make_backup (diff_only=0) and theme_save (diff_only inverted from the user's -full flag). Bug fix while at it: the writer was previously calling config_file_option_value_to_string (ptr_option, 0, 1, 0); the third and fourth arguments are "use_colors" and "use_delimiters", so the call inserted GUI color escape codes into the file output and skipped quoting strings. Corrected to (ptr_option, 0, 0, 1) so plain text with proper string quoting is written; the change also fixes the content of files produced by theme_make_backup in the previous commit. --- src/core/core-command.c | 29 +++++++ src/core/core-theme.c | 118 +++++++++++++++++++++++++-- src/core/core-theme.h | 2 + tests/unit/core/test-core-theme.cpp | 120 +++++++++++++++++++++++++--- 4 files changed, 252 insertions(+), 17 deletions(-) diff --git a/src/core/core-command.c b/src/core/core-command.c index f98e8efe9..fac20d565 100644 --- a/src/core/core-command.c +++ b/src/core/core-command.c @@ -7333,6 +7333,23 @@ COMMAND_CALLBACK(theme) return theme_apply (argv[2]); } + /* "/theme save [-full]": write a user theme file */ + if (string_strcmp (argv[1], "save") == 0) + { + COMMAND_MIN_ARGS(3, "save"); + return theme_save (argv[2], + ((argc >= 4) + && (string_strcmp (argv[3], "-full") == 0)) + ? 1 : 0); + } + + /* "/theme delete ": remove a user theme file */ + if (string_strcmp (argv[1], "delete") == 0) + { + COMMAND_MIN_ARGS(3, "delete"); + return theme_delete (argv[2]); + } + /* "/theme info ": show details about a theme */ if (string_strcmp (argv[1], "info") == 0) { @@ -10057,6 +10074,8 @@ command_init (void) /* TRANSLATORS: only text between angle brackets (eg: "") may be translated */ N_("[list [-backups]]" " || apply " + " || save [-full]" + " || delete " " || info "), CMD_ARGS_DESC( N_("raw[list]: list registered themes and any *.theme files in " @@ -10068,6 +10087,14 @@ command_init (void) "value from the theme); if a file named .theme " "exists in directory \"themes\" it shadows any built-in " "theme of the same name"), + N_("raw[save]: save current themable options to a file " + ".theme in directory \"themes\"; by default only " + "options whose value differs from their default are " + "written, use \"-full\" to write every themable option; " + "the name must not match a built-in theme or start with " + "\"backup-\""), + N_("raw[delete]: delete a user theme file (refuses to delete " + "built-in themes, which have no file)"), N_("raw[info]: display details on a theme (name, description, " "creation date, WeeChat version, number of option overrides)"), N_("name: name of a theme"), @@ -10086,6 +10113,8 @@ command_init (void) "weechat.look.theme_backup.")), "list -backups" " || apply" + " || save -full" + " || delete" " || info", &command_theme, NULL, NULL); hook_command ( diff --git a/src/core/core-theme.c b/src/core/core-theme.c index 983b376f2..c7e5e69ba 100644 --- a/src/core/core-theme.c +++ b/src/core/core-theme.c @@ -308,18 +308,22 @@ theme_make_backup_name (void) } /* - * Writes a full snapshot of every themable option to a .theme file at + * Writes a snapshot of themable options to a .theme file at * "/themes/.theme". * * The themes directory is created if missing. The file contains an * [info] section (name, description, date, weechat version) followed by - * an [options] section listing every themable option's current value. + * an [options] section. + * + * If "diff_only" is non-zero, only options whose value differs from + * their default (config_file_option_has_changed) are written. If zero, + * every themable option is written (full snapshot). * * Returns 1 on success, 0 on error. */ int -theme_write_file_full (const char *name, const char *description) +theme_write_file (const char *name, const char *description, int diff_only) { char *path, *dir, *value, *now; FILE *file; @@ -368,8 +372,10 @@ theme_write_file_full (const char *name, const char *description) { if (!ptr_option->themable) continue; + if (diff_only && !config_file_option_has_changed (ptr_option)) + continue; value = config_file_option_value_to_string ( - ptr_option, 0, 1, 0); + ptr_option, 0, 0, 1); fprintf (file, "%s.%s.%s = %s\n", ptr_config->name, ptr_section->name, ptr_option->name, @@ -397,9 +403,10 @@ theme_make_backup (void) name = theme_make_backup_name (); if (!name) return NULL; - if (!theme_write_file_full ( + if (!theme_write_file ( name, - _("Automatic backup written before /theme apply"))) + _("Automatic backup written before /theme apply"), + 0)) /* full snapshot: backups must round-trip exactly */ { free (name); return NULL; @@ -773,6 +780,105 @@ theme_apply (const char *name) return WEECHAT_RC_OK; } +/* + * Saves the current themable options to a user theme file. + * + * Refuses names that match a built-in theme (registered via API) or + * that start with "backup-" (reserved for automatic backups). If + * "full" is non-zero, every themable option is written; otherwise + * only options whose value differs from their default are written. + * + * Returns WEECHAT_RC_OK on success, WEECHAT_RC_ERROR on validation or + * I/O failure. + */ + +int +theme_save (const char *name, int full) +{ + if (!name || !name[0]) + return WEECHAT_RC_ERROR; + + if (strncmp (name, "backup-", 7) == 0) + { + gui_chat_printf ( + NULL, + _("%sName \"%s\" is reserved for automatic backups"), + gui_chat_prefix[GUI_CHAT_PREFIX_ERROR], + name); + return WEECHAT_RC_ERROR; + } + + if (theme_search (name)) + { + gui_chat_printf ( + NULL, + _("%sName \"%s\" is reserved for a built-in theme"), + gui_chat_prefix[GUI_CHAT_PREFIX_ERROR], + name); + return WEECHAT_RC_ERROR; + } + + if (!theme_write_file (name, NULL, (full) ? 0 : 1)) + { + gui_chat_printf (NULL, + _("%sFailed to save theme \"%s\""), + gui_chat_prefix[GUI_CHAT_PREFIX_ERROR], + name); + return WEECHAT_RC_ERROR; + } + + gui_chat_printf (NULL, + _("Theme saved: %s"), + name); + return WEECHAT_RC_OK; +} + +/* + * Deletes a user theme file. + * + * Refuses names registered as built-in themes (they have no file). + * Returns WEECHAT_RC_OK on success, WEECHAT_RC_ERROR otherwise. + */ + +int +theme_delete (const char *name) +{ + char *path; + + if (!name || !name[0]) + return WEECHAT_RC_ERROR; + + if (theme_search (name)) + { + gui_chat_printf ( + NULL, + _("%sCannot delete built-in theme \"%s\""), + gui_chat_prefix[GUI_CHAT_PREFIX_ERROR], + name); + return WEECHAT_RC_ERROR; + } + + path = theme_user_file_path (name); + if (!path) + return WEECHAT_RC_ERROR; + + if (unlink (path) != 0) + { + gui_chat_printf (NULL, + _("%sFailed to delete theme \"%s\""), + gui_chat_prefix[GUI_CHAT_PREFIX_ERROR], + name); + free (path); + return WEECHAT_RC_ERROR; + } + + gui_chat_printf (NULL, + _("Theme deleted: %s"), + name); + free (path); + return WEECHAT_RC_OK; +} + /* * Initializes the theme subsystem. * diff --git a/src/core/core-theme.h b/src/core/core-theme.h index 93a3d4dbd..b666062f2 100644 --- a/src/core/core-theme.h +++ b/src/core/core-theme.h @@ -45,6 +45,8 @@ extern struct t_theme *theme_register (const char *name, struct t_hashtable *overrides); extern struct t_arraylist *theme_list (void); extern int theme_apply (const char *name); +extern int theme_save (const char *name, int full); +extern int theme_delete (const char *name); extern char *theme_make_backup (void); extern char *theme_user_file_path (const char *name); extern struct t_theme *theme_file_parse (const char *path); diff --git a/tests/unit/core/test-core-theme.cpp b/tests/unit/core/test-core-theme.cpp index 65be980c4..c7ca5c696 100644 --- a/tests/unit/core/test-core-theme.cpp +++ b/tests/unit/core/test-core-theme.cpp @@ -47,7 +47,8 @@ extern struct t_theme *theme_alloc (const char *name); extern void theme_free (struct t_theme *theme); extern char *theme_user_file_path (const char *name); extern char *theme_make_backup_name (void); -extern int theme_write_file_full (const char *name, const char *description); +extern int theme_write_file (const char *name, const char *description, + int diff_only); extern char *theme_file_strip_quotes (char *value); extern struct t_theme *theme_file_parse (const char *path); } @@ -330,22 +331,22 @@ TEST(CoreTheme, MakeBackupName) /* * Test functions: - * theme_write_file_full + * theme_write_file */ -TEST(CoreTheme, WriteFileFull) +TEST(CoreTheme, WriteFile) { char *path, line[8192]; FILE *file; int saw_info, saw_name, saw_description, saw_date, saw_weechat; - int saw_options_section, saw_an_option; + int saw_options_section, full_options, diff_options; /* refuse empty/NULL */ - LONGS_EQUAL(0, theme_write_file_full (NULL, NULL)); - LONGS_EQUAL(0, theme_write_file_full ("", NULL)); + LONGS_EQUAL(0, theme_write_file (NULL, NULL, 0)); + LONGS_EQUAL(0, theme_write_file ("", NULL, 0)); - /* write a valid file */ - LONGS_EQUAL(1, theme_write_file_full ("test_wrt", "a description")); + /* full snapshot: every themable option is written */ + LONGS_EQUAL(1, theme_write_file ("test_wrt", "a description", 0)); path = theme_user_file_path ("test_wrt"); CHECK(path != NULL); @@ -354,7 +355,7 @@ TEST(CoreTheme, WriteFileFull) CHECK(file != NULL); saw_info = saw_name = saw_description = saw_date = saw_weechat = 0; - saw_options_section = saw_an_option = 0; + saw_options_section = full_options = 0; while (fgets (line, sizeof (line) - 1, file)) { if (strncmp (line, "[info]", 6) == 0) @@ -372,7 +373,7 @@ TEST(CoreTheme, WriteFileFull) else if (saw_options_section && (strchr (line, '=') != NULL) && (strchr (line, '.') != NULL)) - saw_an_option = 1; + full_options++; } fclose (file); @@ -382,7 +383,30 @@ TEST(CoreTheme, WriteFileFull) LONGS_EQUAL(1, saw_date); LONGS_EQUAL(1, saw_weechat); LONGS_EQUAL(1, saw_options_section); - LONGS_EQUAL(1, saw_an_option); + CHECK(full_options > 10); /* core has many themable options */ + + unlink (path); + + /* diff-only snapshot in a freshly initialized config writes very + few (typically zero) [options] entries — never more than the + full snapshot */ + LONGS_EQUAL(1, theme_write_file ("test_wrt", NULL, 1)); + + file = fopen (path, "r"); + CHECK(file != NULL); + diff_options = 0; + saw_options_section = 0; + while (fgets (line, sizeof (line) - 1, file)) + { + if (strncmp (line, "[options]", 9) == 0) + saw_options_section = 1; + else if (saw_options_section + && (strchr (line, '=') != NULL) + && (strchr (line, '.') != NULL)) + diff_options++; + } + fclose (file); + CHECK(diff_options < full_options); unlink (path); free (path); @@ -619,6 +643,80 @@ TEST(CoreTheme, FileParse) unlink (path); } +/* + * Test functions: + * theme_save + */ + +TEST(CoreTheme, Save) +{ + char *path; + struct stat st; + + /* NULL / empty => error, no file */ + LONGS_EQUAL(WEECHAT_RC_ERROR, theme_save (NULL, 0)); + LONGS_EQUAL(WEECHAT_RC_ERROR, theme_save ("", 0)); + + /* reserved "backup-" prefix => error */ + LONGS_EQUAL(WEECHAT_RC_ERROR, theme_save ("backup-anything", 0)); + LONGS_EQUAL(WEECHAT_RC_ERROR, theme_save ("backup-anything", 1)); + + /* name colliding with a built-in is refused */ + theme_register ("dark", NULL); + LONGS_EQUAL(WEECHAT_RC_ERROR, theme_save ("dark", 0)); + LONGS_EQUAL(WEECHAT_RC_ERROR, theme_save ("dark", 1)); + + /* happy path: sparse save => file exists */ + LONGS_EQUAL(WEECHAT_RC_OK, theme_save ("save_test", 0)); + path = theme_user_file_path ("save_test"); + CHECK(path != NULL); + LONGS_EQUAL(0, stat (path, &st)); + unlink (path); + free (path); + + /* happy path: full snapshot => file exists, bigger than sparse */ + LONGS_EQUAL(WEECHAT_RC_OK, theme_save ("save_test", 1)); + path = theme_user_file_path ("save_test"); + CHECK(path != NULL); + LONGS_EQUAL(0, stat (path, &st)); + CHECK(st.st_size > 0); + unlink (path); + free (path); +} + +/* + * Test functions: + * theme_delete + */ + +TEST(CoreTheme, Delete) +{ + char *path; + struct stat st; + + /* NULL / empty => error */ + LONGS_EQUAL(WEECHAT_RC_ERROR, theme_delete (NULL)); + LONGS_EQUAL(WEECHAT_RC_ERROR, theme_delete ("")); + + /* refuses to delete a built-in (no file to delete) */ + theme_register ("dark", NULL); + LONGS_EQUAL(WEECHAT_RC_ERROR, theme_delete ("dark")); + + /* missing file => error */ + LONGS_EQUAL(WEECHAT_RC_ERROR, theme_delete ("does_not_exist")); + + /* happy path: write a file via theme_save (also ensures the themes + directory exists), delete it, confirm it is gone */ + LONGS_EQUAL(WEECHAT_RC_OK, theme_save ("del_test", 0)); + path = theme_user_file_path ("del_test"); + CHECK(path != NULL); + LONGS_EQUAL(0, stat (path, &st)); + + LONGS_EQUAL(WEECHAT_RC_OK, theme_delete ("del_test")); + LONGS_EQUAL(-1, stat (path, &st)); + free (path); +} + /* * Test functions: * theme_init