diff --git a/quick-add/Services/DBusClient.vala b/quick-add/Services/DBusClient.vala index 216006c9e..6eaf70d4b 100644 --- a/quick-add/Services/DBusClient.vala +++ b/quick-add/Services/DBusClient.vala @@ -22,6 +22,7 @@ [DBus (name = "io.github.alainm23.planify")] public interface DBusClientInterface : Object { public abstract void add_item (string id) throws Error; + public abstract void update_item (string id) throws Error; } public class DBusClient : Object { diff --git a/quick-add/cli/ArgumentParser.vala b/quick-add/cli/ArgumentParser.vala new file mode 100644 index 000000000..04780abb6 --- /dev/null +++ b/quick-add/cli/ArgumentParser.vala @@ -0,0 +1,319 @@ +/* + * Copyright © 2026 Alain M. (https://github.com/alainm23/planify) + * + * 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 3 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, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA + */ + +namespace PlanifyCLI { + public enum CommandType { + NONE, + ADD, + LIST_PROJECTS, + LIST, + UPDATE + } + + public class TaskArguments : Object { + public string? content { get; set; default = null; } + public string? description { get; set; default = null; } + public string? project_name { get; set; default = null; } + public string? project_id { get; set; default = null; } + public string? section_name { get; set; default = null; } + public string? section_id { get; set; default = null; } + public string? parent_id { get; set; default = null; } + public int priority { get; set; default = 4; } + public string? due_date { get; set; default = null; } + public string? labels { get; set; default = null; } + public int pinned { get; set; default = -1; } // -1 = not set, 0 = unpinned, 1 = pinned + } + + public class ListArguments : Object { + public string? project_name { get; set; default = null; } + public string? project_id { get; set; default = null; } + } + + public class UpdateArguments : Object { + public string? task_id { get; set; default = null; } + public string? content { get; set; default = null; } + public string? description { get; set; default = null; } + public string? project_name { get; set; default = null; } + public string? project_id { get; set; default = null; } + public string? section_name { get; set; default = null; } + public string? section_id { get; set; default = null; } + public string? parent_id { get; set; default = null; } + public int priority { get; set; default = -1; } + public string? due_date { get; set; default = null; } + public string? labels { get; set; default = null; } + public int checked { get; set; default = -1; } // -1 = not set, 0 = uncomplete, 1 = complete + public int pinned { get; set; default = -1; } // -1 = not set, 0 = unpinned, 1 = pinned + } + + public class ParsedCommand : Object { + public CommandType command_type { get; set; default = CommandType.NONE; } + public TaskArguments? task_args { get; set; default = null; } + public ListArguments? list_args { get; set; default = null; } + public UpdateArguments? update_args { get; set; default = null; } + } + + public class ArgumentParser : Object { + public static ParsedCommand? parse (string[] args, out int exit_code) { + exit_code = 0; + + // Check for top-level help + if (args.length >= 2 && (args[1] == "-h" || args[1] == "--help")) { + print_general_help (args[0]); + exit_code = 0; + return null; + } + + // Check for command + if (args.length < 2) { + stderr.printf ("Error: No command specified\n"); + stderr.printf ("Usage: %s [OPTIONS]\n", args[0]); + stderr.printf ("Commands: add, list, update, list-projects\n"); + stderr.printf ("Run '%s --help' for more information\n", args[0]); + exit_code = 1; + return null; + } + + string command = args[1]; + var parsed = new ParsedCommand (); + + string[] command_args = new string[args.length - 1]; + command_args[0] = args[0] + " " + command; + for (int i = 2; i < args.length; i++) { + command_args[i - 1] = args[i]; + } + + try { + switch (command) { + case "list-projects": + parsed.command_type = CommandType.LIST_PROJECTS; + return parsed; + + case "list": + parsed.command_type = CommandType.LIST; + parsed.list_args = parse_list_command (command_args); + return parsed; + + case "add": + parsed.command_type = CommandType.ADD; + parsed.task_args = parse_add_command (command_args); + return parsed; + + case "update": + parsed.command_type = CommandType.UPDATE; + parsed.update_args = parse_update_command (command_args); + return parsed; + + default: + stderr.printf ("Error: Unknown command '%s'\n", command); + stderr.printf ("Available commands: add, list, update, list-projects\n"); + exit_code = 1; + return null; + } + } catch (OptionError e) { + stderr.printf ("Error: %s\n", e.message); + exit_code = 1; + return null; + } + } + + private static ListArguments parse_list_command (string[] args) throws OptionError { + string? project_name = null; + string? project_id = null; + + var options = new OptionEntry[3]; + options[0] = { "project", 'p', 0, OptionArg.STRING, ref project_name, + "Project name (defaults to inbox)", "PROJECT" }; + options[1] = { "project-id", 'i', 0, OptionArg.STRING, ref project_id, + "Project ID (preferred over name)", "ID" }; + options[2] = { null }; + + var context = new OptionContext ("- List tasks from a project"); + context.add_main_entries (options, null); + context.set_help_enabled (true); + + unowned string[] tmp = args; + context.parse (ref tmp); + + var list_args = new ListArguments (); + list_args.project_name = project_name; + list_args.project_id = project_id; + + return list_args; + } + + private static TaskArguments parse_add_command (string[] args) throws OptionError { + string? content = null; + string? description = null; + string? project_name = null; + string? project_id = null; + string? section_name = null; + string? section_id = null; + string? parent_id = null; + int priority = 4; + string? due_date = null; + string? labels = null; + string? pin_str = null; + + var options = new OptionEntry[12]; + options[0] = { "content", 'c', 0, OptionArg.STRING, ref content, + "Task content (required)", "CONTENT" }; + options[1] = { "description", 'd', 0, OptionArg.STRING, ref description, + "Task description", "DESC" }; + options[2] = { "project", 'p', 0, OptionArg.STRING, ref project_name, + "Project name (defaults to inbox)", "PROJECT" }; + options[3] = { "project-id", 'i', 0, OptionArg.STRING, ref project_id, + "Project ID (preferred over name)", "ID" }; + options[4] = { "section", 's', 0, OptionArg.STRING, ref section_name, + "Section name", "SECTION" }; + options[5] = { "section-id", 'S', 0, OptionArg.STRING, ref section_id, + "Section ID (preferred over name)", "ID" }; + options[6] = { "parent-id", 'a', 0, OptionArg.STRING, ref parent_id, + "Parent task ID (creates a subtask)", "ID" }; + options[7] = { "priority", 'P', 0, OptionArg.INT, ref priority, + "Priority: 1=high, 2=medium, 3=low, 4=none (default: 4)", "1-4" }; + options[8] = { "due", 'D', 0, OptionArg.STRING, ref due_date, + "Due date in YYYY-MM-DD format", "DATE" }; + options[9] = { "labels", 'l', 0, OptionArg.STRING, ref labels, + "Comma-separated list of label names", "LABELS" }; + options[10] = { "pin", 0, 0, OptionArg.STRING, ref pin_str, + "Pin or unpin the task", "true|false" }; + options[11] = { null }; + + var context = new OptionContext ("- Add a new task to Planify"); + context.add_main_entries (options, null); + context.set_help_enabled (true); + + unowned string[] tmp = args; + context.parse (ref tmp); + + var task_args = new TaskArguments (); + task_args.content = content; + task_args.description = description; + task_args.project_name = project_name; + task_args.project_id = project_id; + task_args.section_name = section_name; + task_args.section_id = section_id; + task_args.parent_id = parent_id; + task_args.priority = priority; + task_args.due_date = due_date; + task_args.labels = labels; + task_args.pinned = parse_boolean_option (pin_str); + + return task_args; + } + + private static UpdateArguments parse_update_command (string[] args) throws OptionError { + string? task_id = null; + string? content = null; + string? description = null; + string? project_name = null; + string? project_id = null; + string? section_name = null; + string? section_id = null; + string? parent_id = null; + int priority = -1; + string? due_date = null; + string? labels = null; + string? complete_str = null; + string? pin_str = null; + + var options = new OptionEntry[14]; + options[0] = { "task-id", 't', 0, OptionArg.STRING, ref task_id, + "Task ID to update (required)", "ID" }; + options[1] = { "content", 'c', 0, OptionArg.STRING, ref content, + "New task content", "CONTENT" }; + options[2] = { "description", 'd', 0, OptionArg.STRING, ref description, + "New task description", "DESC" }; + options[3] = { "project", 'p', 0, OptionArg.STRING, ref project_name, + "Move to project by name", "PROJECT" }; + options[4] = { "project-id", 'i', 0, OptionArg.STRING, ref project_id, + "Move to project by ID (preferred over name)", "ID" }; + options[5] = { "section", 's', 0, OptionArg.STRING, ref section_name, + "Section name", "SECTION" }; + options[6] = { "section-id", 'S', 0, OptionArg.STRING, ref section_id, + "Section ID (preferred over name)", "ID" }; + options[7] = { "parent-id", 'a', 0, OptionArg.STRING, ref parent_id, + "New parent task ID", "ID" }; + options[8] = { "priority", 'P', 0, OptionArg.INT, ref priority, + "Priority: 1=high, 2=medium, 3=low, 4=none", "1-4" }; + options[9] = { "due", 'D', 0, OptionArg.STRING, ref due_date, + "Due date in YYYY-MM-DD format", "DATE" }; + options[10] = { "labels", 'l', 0, OptionArg.STRING, ref labels, + "Comma-separated list of label names", "LABELS" }; + options[11] = { "complete", 0, 0, OptionArg.STRING, ref complete_str, + "Mark task as complete or incomplete", "true|false" }; + options[12] = { "pin", 0, 0, OptionArg.STRING, ref pin_str, + "Pin or unpin the task", "true|false" }; + options[13] = { null }; + + var context = new OptionContext ("- Update an existing task. Only provided fields will be changed."); + context.add_main_entries (options, null); + context.set_help_enabled (true); + + unowned string[] tmp = args; + context.parse (ref tmp); + + var update_args = new UpdateArguments (); + update_args.task_id = task_id; + update_args.content = content; + update_args.description = description; + update_args.project_name = project_name; + update_args.project_id = project_id; + update_args.section_name = section_name; + update_args.section_id = section_id; + update_args.parent_id = parent_id; + update_args.priority = priority; + update_args.due_date = due_date; + update_args.labels = labels; + update_args.checked = parse_boolean_option (complete_str); + update_args.pinned = parse_boolean_option (pin_str); + + return update_args; + } + + private static int parse_boolean_option (string? value) throws OptionError { + if (value == null) { + return -1; + } + + string lower_value = value.down (); + if (lower_value == "true") { + return 1; + } else if (lower_value == "false") { + return 0; + } else { + throw new OptionError.BAD_VALUE ("Boolean option requires 'true' or 'false'"); + } + } + + private static void print_general_help (string program_name) { + stdout.printf ("Usage: %s [OPTIONS]\n\n", program_name); + stdout.printf ("Commands:\n"); + stdout.printf (" add Add a new task\n"); + stdout.printf (" list List tasks from a project\n"); + stdout.printf (" update Update an existing task\n"); + stdout.printf (" list-projects List all projects\n\n"); + stdout.printf ("Run '%s --help' for command-specific options\n\n", program_name); + stdout.printf ("Examples:\n"); + stdout.printf (" %s add --help\n", program_name); + stdout.printf (" %s list --help\n", program_name); + stdout.printf (" %s update --help\n", program_name); + } + } +} \ No newline at end of file diff --git a/quick-add/cli/Main.vala b/quick-add/cli/Main.vala new file mode 100644 index 000000000..391854a7b --- /dev/null +++ b/quick-add/cli/Main.vala @@ -0,0 +1,393 @@ +/* + * Copyright © 2026 Alain M. (https://github.com/alainm23/planify) + * + * 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 3 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, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA + */ + +namespace PlanifyCLI { + private static int list_projects () { + // Initialize database + Services.Database.get_default ().init_database (); + + // Get all projects from store + var projects = Services.Store.instance ().projects; + + // Output as JSON + OutputFormatter.print_projects_list (projects); + + return 0; + } + + private static int list_tasks (ListArguments args) { + // Initialize database + Services.Database.get_default ().init_database (); + + // Find target project + string? error_message; + Objects.Project? target_project = TaskCreator.find_project ( + args.project_id, + args.project_name, + out error_message + ); + + if (target_project == null) { + stderr.printf ("%s\n", error_message); + return 1; + } + + // Get all items for the project + var items = Services.Store.instance ().get_items_by_project (target_project); + + // Output result + OutputFormatter.print_tasks_list (items); + + return 0; + } + + private static int update_task (UpdateArguments args) { + // Validate task_id + if (args.task_id == null || args.task_id.strip () == "") { + stderr.printf ("Error: --task-id is required\n"); + return 1; + } + + // Initialize database + Services.Database.get_default ().init_database (); + + // Get existing item + Objects.Item? item = Services.Store.instance ().get_item (args.task_id.strip ()); + if (item == null) { + stderr.printf ("Error: Task ID '%s' not found\n", args.task_id); + return 1; + } + + string? error_message; + + // Update content if provided + if (args.content != null && args.content.strip () != "") { + item.content = args.content.strip (); + } + + // Update description if provided + if (args.description != null) { + item.description = args.description.strip (); + } + + // Update priority if provided + if (args.priority != -1) { + if (!TaskValidator.validate_priority (args.priority, out error_message)) { + stderr.printf ("%s\n", error_message); + return 1; + } + // Convert user-friendly priority (1=high, 4=none) to internal format (4=high, 1=none) + item.priority = 5 - args.priority; + } + + // Update due date if provided + if (args.due_date != null) { + GLib.DateTime? due_datetime; + if (!TaskValidator.validate_and_parse_date (args.due_date, out due_datetime, out error_message)) { + stderr.printf ("%s\n", error_message); + return 1; + } + if (due_datetime != null) { + item.due.date = Utils.Datetime.get_todoist_datetime_format (due_datetime); + } + } + + // Handle project change if provided + bool project_changed = false; + string old_project_id = item.project_id; + string old_section_id = item.section_id; + string old_parent_id = item.parent_id; + + if (args.project_id != null || args.project_name != null) { + Objects.Project? target_project = TaskCreator.find_project ( + args.project_id, + args.project_name, + out error_message + ); + + if (target_project == null) { + stderr.printf ("%s\n", error_message); + return 1; + } + + if (item.project_id != target_project.id) { + item.project_id = target_project.id; + item.section_id = ""; // Reset section when moving to new project + project_changed = true; + } + } + + // Handle section change if provided + if (args.section_id != null || args.section_name != null) { + Objects.Project? current_project = Services.Store.instance ().get_project (item.project_id); + if (current_project != null) { + Objects.Section? target_section = TaskCreator.find_section ( + args.section_id, + args.section_name, + current_project, + out error_message + ); + + if (target_section == null) { + stderr.printf ("%s\n", error_message); + return 1; + } + + if (item.section_id != target_section.id) { + item.section_id = target_section.id; + } + } + } + + // Update parent_id if provided + if (args.parent_id != null) { + if (!TaskValidator.validate_parent_id (args.parent_id, out error_message)) { + stderr.printf ("%s\n", error_message); + return 1; + } + item.parent_id = args.parent_id.strip (); + } + + // Update labels if provided + if (args.labels != null) { + var new_labels = new Gee.HashMap (); + string[] label_names = args.labels.split (","); + foreach (string label_name in label_names) { + string trimmed = label_name.strip (); + if (trimmed != "") { + Objects.Label? label = Services.Store.instance ().get_label_by_name (trimmed, true, item.project.source_id); + if (label == null) { + // Create new label if it doesn't exist + label = new Objects.Label (); + label.id = Util.get_default ().generate_id (label); + label.name = trimmed; + label.color = Util.get_default ().get_random_color (); + label.source_id = item.project.source_id; + Services.Store.instance ().insert_label (label); + } + new_labels[label.id] = label; + } + } + item.check_labels (new_labels); + } + + // Handle completion status change if provided + if (args.checked != -1) { + bool old_checked = item.checked; + bool new_checked = args.checked == 1; + + if (old_checked != new_checked) { + item.checked = new_checked; + if (new_checked) { + item.completed_at = new GLib.DateTime.now_local ().to_string (); + } else { + item.completed_at = ""; + } + + // Use async completion handler + var loop = new MainLoop (); + item.complete_item.begin (old_checked, (obj, res) => { + var response = item.complete_item.end (res); + if (!response.status) { + stderr.printf ("Error: Failed to update task completion status\n"); + } + loop.quit (); + }); + loop.run (); + } + } + + // Update pinned if provided + if (args.pinned != -1) { + item.pinned = args.pinned == 1; + } + + // Save changes + if (project_changed) { + Services.Store.instance ().move_item (item, old_project_id, old_section_id, old_parent_id); + } else { + Services.Store.instance ().update_item (item); + } + + // Notify main app via DBus + bool dbus_notified = false; + try { + DBusClient.get_default ().interface.update_item (item.id); + dbus_notified = true; + } catch (Error e) { + // Not a critical error - main app might not be running + debug ("DBus notification failed: %s", e.message); + } + + // Ensure DBus message is flushed before exit + if (dbus_notified) { + var main_context = MainContext.default (); + while (main_context.pending ()) { + main_context.iteration (false); + } + } + + // Get the current project for output + Objects.Project? current_project = Services.Store.instance ().get_project (item.project_id); + if (current_project == null) { + current_project = Services.Store.instance ().get_inbox_project (); + } + + // Output result + OutputFormatter.print_task_result (item, current_project); + + return 0; + } + + private static int add_task (TaskArguments args) { + // Validate content + string? error_message; + if (!TaskValidator.validate_content (args.content, out error_message)) { + stderr.printf ("%s\n", error_message); + return 1; + } + + // Validate priority + if (!TaskValidator.validate_priority (args.priority, out error_message)) { + stderr.printf ("%s\n", error_message); + return 1; + } + + // Validate and parse due date + GLib.DateTime? due_datetime; + if (!TaskValidator.validate_and_parse_date (args.due_date, out due_datetime, out error_message)) { + stderr.printf ("%s\n", error_message); + return 1; + } + + // Initialize database + Services.Database.get_default ().init_database (); + + // Validate parent_id if provided + if (!TaskValidator.validate_parent_id (args.parent_id, out error_message)) { + stderr.printf ("%s\n", error_message); + return 1; + } + + // Find target project + Objects.Project? target_project = TaskCreator.find_project ( + args.project_id, + args.project_name, + out error_message + ); + + if (target_project == null) { + stderr.printf ("%s\n", error_message); + return 1; + } + + // Create item + var item = TaskCreator.create_item ( + args.content, + args.description, + args.priority, + due_datetime, + target_project, + args.parent_id + ); + + // Find and set section if provided + if (args.section_id != null || args.section_name != null) { + Objects.Section? target_section = TaskCreator.find_section ( + args.section_id, + args.section_name, + target_project, + out error_message + ); + + if (target_section == null) { + stderr.printf ("%s\n", error_message); + return 1; + } + + item.section_id = target_section.id; + } + + // Add labels if provided + if (args.labels != null && args.labels.strip () != "") { + string[] label_names = args.labels.split (","); + foreach (string label_name in label_names) { + string trimmed = label_name.strip (); + if (trimmed != "") { + Objects.Label? label = Services.Store.instance ().get_label_by_name (trimmed, true, target_project.source_id); + if (label == null) { + // Create new label if it doesn't exist + label = new Objects.Label (); + label.id = Util.get_default ().generate_id (label); + label.name = trimmed; + label.color = Util.get_default ().get_random_color (); + label.source_id = target_project.source_id; + Services.Store.instance ().insert_label (label); + } + item.labels.add (label); + } + } + } + + // Set pinned if provided + if (args.pinned != -1) { + item.pinned = args.pinned == 1; + } + + // Save and notify + TaskCreator.save_and_notify (item); + + // Output result + OutputFormatter.print_task_result (item, target_project); + + return 0; + } + + public static int main (string[] args) { + // Initialize localization + Intl.setlocale (LocaleCategory.ALL, ""); + string langpack_dir = Path.build_filename (Build.INSTALL_PREFIX, "share", "locale"); + Intl.bindtextdomain (Build.GETTEXT_PACKAGE, langpack_dir); + Intl.bind_textdomain_codeset (Build.GETTEXT_PACKAGE, "UTF-8"); + Intl.textdomain (Build.GETTEXT_PACKAGE); + + // Parse arguments + int exit_code; + ParsedCommand? parsed = ArgumentParser.parse (args, out exit_code); + + if (parsed == null) { + return exit_code; + } + + // Route to appropriate command handler + switch (parsed.command_type) { + case CommandType.LIST_PROJECTS: + return list_projects (); + case CommandType.LIST: + return list_tasks (parsed.list_args); + case CommandType.UPDATE: + return update_task (parsed.update_args); + case CommandType.ADD: + return add_task (parsed.task_args); + default: + stderr.printf ("Error: Unknown command\n"); + return 1; + } + } +} diff --git a/quick-add/cli/OutputFormatter.vala b/quick-add/cli/OutputFormatter.vala new file mode 100644 index 000000000..53065f28a --- /dev/null +++ b/quick-add/cli/OutputFormatter.vala @@ -0,0 +1,124 @@ +/* + * Copyright © 2026 Alain M. (https://github.com/alainm23/planify) + * + * 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 3 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, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA + */ + +namespace PlanifyCLI { + public class OutputFormatter : Object { + private delegate void FieldValueAdder (Json.Builder builder); + + private struct FieldDef { + string name; + FieldValueAdder add_value; + } + + // Define fields to include in output + private static FieldDef[] get_project_fields (Objects.Project project) { + return { + { "id", (builder) => builder.add_string_value (project.id) }, + { "name", (builder) => builder.add_string_value (project.name) }, + { "item-count", (builder) => builder.add_int_value (project.item_count) }, + { "description", (builder) => builder.add_string_value (project.description) } + }; + } + + private static FieldDef[] get_item_fields (Objects.Item item) { + return { + { "id", (builder) => builder.add_string_value (item.id) }, + { "content", (builder) => builder.add_string_value (item.content) }, + { "description", (builder) => builder.add_string_value (item.description) }, + { "checked", (builder) => builder.add_boolean_value (item.checked) }, + { "pinned", (builder) => builder.add_boolean_value (item.pinned) }, + { "priority", (builder) => builder.add_int_value (5 - item.priority) }, + { "project-id", (builder) => builder.add_string_value (item.project_id) }, + { "section-id", (builder) => builder.add_string_value (item.section_id) }, + { "parent-id", (builder) => builder.add_string_value (item.parent_id) }, + { "added-at", (builder) => builder.add_string_value (item.added_at) }, + { "completed-at", (builder) => builder.add_string_value (item.completed_at) }, + { "updated-at", (builder) => builder.add_string_value (item.updated_at) }, + { "labels", (builder) => { + builder.begin_array (); + foreach (var label in item.labels) { + builder.add_string_value (label.name); + } + builder.end_array (); + }} + }; + } + + public static void print_task_result (Objects.Item item, Objects.Project project) { + var builder = new Json.Builder (); + builder.begin_object (); + + builder.set_member_name ("task"); + add_object (builder, get_item_fields (item)); + + builder.set_member_name ("project"); + add_object (builder, get_project_fields (project)); + + builder.end_object (); + + var generator = new Json.Generator (); + generator.set_root (builder.get_root ()); + generator.pretty = true; + stdout.printf ("%s\n", generator.to_data (null)); + } + + public static void print_projects_list (Gee.ArrayList projects) { + var builder = new Json.Builder (); + builder.begin_array (); + + foreach (var project in projects) { + add_object (builder, get_project_fields (project)); + } + + builder.end_array (); + + var generator = new Json.Generator (); + generator.set_root (builder.get_root ()); + generator.pretty = true; + stdout.printf ("%s\n", generator.to_data (null)); + } + + public static void print_tasks_list (Gee.ArrayList items) { + var builder = new Json.Builder (); + builder.begin_array (); + + foreach (var item in items) { + add_object (builder, get_item_fields (item)); + } + + builder.end_array (); + + var generator = new Json.Generator (); + generator.set_root (builder.get_root ()); + generator.pretty = true; + stdout.printf ("%s\n", generator.to_data (null)); + } + + private static void add_object (Json.Builder builder, FieldDef[] fields) { + builder.begin_object (); + + foreach (var field in fields) { + builder.set_member_name (field.name); + field.add_value (builder); + } + + builder.end_object (); + } + } +} diff --git a/quick-add/cli/TaskCreator.vala b/quick-add/cli/TaskCreator.vala new file mode 100644 index 000000000..f2a268785 --- /dev/null +++ b/quick-add/cli/TaskCreator.vala @@ -0,0 +1,162 @@ +/* + * Copyright © 2026 Alain M. (https://github.com/alainm23/planify) + * + * 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 3 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, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA + */ + +namespace PlanifyCLI { + public class TaskCreator : Object { + public static Objects.Section? find_section (string? section_id, string? section_name, Objects.Project project, out string? error_message) { + error_message = null; + Objects.Section? target_section = null; + + // Prefer section ID over name + if (section_id != null && section_id.strip () != "") { + // Search by section ID + foreach (var section in Services.Store.instance ().get_sections_by_project (project)) { + if (section.id == section_id.strip ()) { + target_section = section; + break; + } + } + + if (target_section == null) { + error_message = "Error: Section ID '%s' not found in project '%s'".printf (section_id, project.name); + return null; + } + } else if (section_name != null && section_name.strip () != "") { + // Search for section by name (case-insensitive) + string search_name = section_name.strip ().down (); + foreach (var section in Services.Store.instance ().get_sections_by_project (project)) { + if (section.name.down () == search_name) { + target_section = section; + break; + } + } + + if (target_section == null) { + error_message = "Error: Section '%s' not found in project '%s'".printf (section_name, project.name); + return null; + } + } + + return target_section; + } + + public static Objects.Project? find_project (string? project_id, string? project_name, out string? error_message) { + error_message = null; + Objects.Project? target_project = null; + + // Prefer project ID over name + if (project_id != null && project_id.strip () != "") { + // Search by project ID + foreach (var project in Services.Store.instance ().projects) { + if (project.id == project_id.strip () && !project.is_archived && !project.is_deleted) { + target_project = project; + break; + } + } + + if (target_project == null) { + error_message = "Error: Project ID '%s' not found".printf (project_id); + return null; + } + } else if (project_name != null && project_name.strip () != "") { + // Search for project by name (case-insensitive) + string search_name = project_name.strip ().down (); + foreach (var project in Services.Store.instance ().projects) { + if (project.name.down () == search_name && !project.is_archived && !project.is_deleted) { + target_project = project; + break; + } + } + + if (target_project == null) { + error_message = "Error: Project '%s' not found".printf (project_name); + return null; + } + } else { + // Default to inbox + target_project = Services.Store.instance ().get_inbox_project (); + if (target_project == null) { + error_message = "Error: No inbox project found"; + return null; + } + } + + return target_project; + } + + public static Objects.Item create_item ( + string content, + string? description, + int priority, + GLib.DateTime? due_datetime, + Objects.Project project, + string? parent_id + ) { + var item = new Objects.Item (); + item.id = Util.get_default ().generate_id (item); + item.content = content.strip (); + item.project_id = project.id; + // Convert user-friendly priority (1=high, 4=none) to internal format (4=high, 1=none) + item.priority = 5 - priority; + item.added_at = new GLib.DateTime.now_local ().to_string (); + + // Set parent_id if provided + if (parent_id != null && parent_id.strip () != "") { + item.parent_id = parent_id.strip (); + } + + // Set description if provided + if (description != null && description.strip () != "") { + item.description = description.strip (); + } + + // Set due date if provided + if (due_datetime != null) { + item.due.date = Utils.Datetime.get_todoist_datetime_format (due_datetime); + } + + return item; + } + + public static bool save_and_notify (Objects.Item item) { + // Insert item into database + Services.Store.instance ().insert_item (item); + + // Notify main app via DBus + bool dbus_notified = false; + try { + DBusClient.get_default ().interface.add_item (item.id); + dbus_notified = true; + } catch (Error e) { + // Not a critical error - main app might not be running + debug ("DBus notification failed: %s", e.message); + } + + // Ensure DBus message is flushed before exit + if (dbus_notified) { + var main_context = MainContext.default (); + while (main_context.pending ()) { + main_context.iteration (false); + } + } + + return true; + } + } +} \ No newline at end of file diff --git a/quick-add/cli/TaskValidator.vala b/quick-add/cli/TaskValidator.vala new file mode 100644 index 000000000..de55ebf6c --- /dev/null +++ b/quick-add/cli/TaskValidator.vala @@ -0,0 +1,89 @@ +/* + * Copyright © 2026 Alain M. (https://github.com/alainm23/planify) + * + * 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 3 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, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA + */ + +namespace PlanifyCLI { + public class TaskValidator : Object { + public static bool validate_content (string? content, out string? error_message) { + error_message = null; + + if (content == null || content.strip () == "") { + error_message = "Error: --content is required"; + return false; + } + + return true; + } + + public static bool validate_priority (int priority, out string? error_message) { + error_message = null; + + if (priority < 1 || priority > 4) { + error_message = "Error: --priority must be between 1 and 4"; + return false; + } + + return true; + } + + public static bool validate_and_parse_date (string? due_date, out GLib.DateTime? datetime, out string? error_message) { + datetime = null; + error_message = null; + + if (due_date == null || due_date.strip () == "") { + return true; // No date provided is valid + } + + // Parse YYYY-MM-DD format + string[] parts = due_date.strip ().split ("-"); + if (parts.length != 3) { + error_message = "Error: Invalid date format. Use YYYY-MM-DD"; + return false; + } + + int year = int.parse (parts[0]); + int month = int.parse (parts[1]); + int day = int.parse (parts[2]); + + if (year < 1900 || year > 3000 || month < 1 || month > 12 || day < 1 || day > 31) { + error_message = "Error: Invalid date values"; + return false; + } + + datetime = new GLib.DateTime.local (year, month, day, 0, 0, 0); + return true; + } + + public static bool validate_parent_id (string? parent_id, out string? error_message) { + error_message = null; + + if (parent_id == null || parent_id.strip () == "") { + return true; // No parent_id provided is valid + } + + // Check if parent task exists + Objects.Item? parent_item = Services.Store.instance ().get_item (parent_id.strip ()); + if (parent_item == null) { + error_message = "Error: Parent task ID '%s' not found".printf (parent_id); + return false; + } + + return true; + } + } +} \ No newline at end of file diff --git a/quick-add/meson.build b/quick-add/meson.build index f1f76bb23..3c37f7b05 100644 --- a/quick-add/meson.build +++ b/quick-add/meson.build @@ -11,4 +11,22 @@ executable( config_file, dependencies: deps, install: true, -) \ No newline at end of file +) + +# CLI executable +cli_sources = files( + 'Services/DBusClient.vala', + 'cli/ArgumentParser.vala', + 'cli/TaskValidator.vala', + 'cli/TaskCreator.vala', + 'cli/OutputFormatter.vala', + 'cli/Main.vala', +) + +executable( + 'io.github.alainm23.planify.cli', + cli_sources, + config_file, + dependencies: deps, + install: true, +) diff --git a/test/cli/test-argument-parser.vala b/test/cli/test-argument-parser.vala new file mode 100644 index 000000000..7a895099e --- /dev/null +++ b/test/cli/test-argument-parser.vala @@ -0,0 +1,246 @@ +/* + * Copyright © 2026 Alain M. (https://github.com/alainm23/planify) + * + * 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 3 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, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA + */ + +/** + * ArgumentParser Tests + * + * Tests for command-line argument parsing including: + * - Command recognition (add, list, update, list-projects) + * - Option parsing (short and long forms) + * - Error handling (missing args, invalid values) + */ + +namespace PlanifyCLI.Tests.ArgumentParser { + void test_no_command () { + print ("Testing: No command provided\n"); + int exit_code; + string[] args = {"planify-cli"}; + + var parsed = PlanifyCLI.ArgumentParser.parse (args, out exit_code); + + assert (parsed == null); + assert (exit_code == 1); + print (" ✓ Returns null with exit code 1\n---\n"); + } + + void test_unknown_command () { + print ("Testing: Unknown command\n"); + int exit_code; + string[] args = {"planify-cli", "invalid-command"}; + + var parsed = PlanifyCLI.ArgumentParser.parse (args, out exit_code); + + assert (parsed == null); + assert (exit_code == 1); + print (" ✓ Returns null with exit code 1\n---\n"); + } + + void test_list_projects () { + print ("Testing: 'list-projects' command\n"); + int exit_code; + string[] args = {"planify-cli", "list-projects"}; + + var parsed = PlanifyCLI.ArgumentParser.parse (args, out exit_code); + + assert (parsed != null); + assert (exit_code == 0); + assert (parsed.command_type == PlanifyCLI.CommandType.LIST_PROJECTS); + print (" ✓ Parses list-projects command\n---\n"); + } + + void test_add_minimal () { + print ("Testing: 'add' command with minimal args\n"); + int exit_code; + string[] args = {"planify-cli", "add", "-c", "Test task"}; + + var parsed = PlanifyCLI.ArgumentParser.parse (args, out exit_code); + + assert (parsed != null); + assert (exit_code == 0); + assert (parsed.command_type == PlanifyCLI.CommandType.ADD); + assert (parsed.task_args != null); + assert (parsed.task_args.content == "Test task"); + print (" ✓ Parses add command with content\n---\n"); + } + + void test_add_full () { + print ("Testing: 'add' command with all options\n"); + int exit_code; + string[] args = { + "planify-cli", "add", + "-c", "Complete task", + "-d", "Task description", + "-p", "Work", + "-s", "In Progress", + "-P", "1", + "-D", "2024-12-31", + "-l", "urgent,important", + "--pin", "true" + }; + + var parsed = PlanifyCLI.ArgumentParser.parse (args, out exit_code); + + assert (parsed != null); + assert (exit_code == 0); + assert (parsed.command_type == PlanifyCLI.CommandType.ADD); + assert (parsed.task_args != null); + assert (parsed.task_args.content == "Complete task"); + assert (parsed.task_args.description == "Task description"); + assert (parsed.task_args.project_name == "Work"); + assert (parsed.task_args.section_name == "In Progress"); + assert (parsed.task_args.priority == 1); + assert (parsed.task_args.due_date == "2024-12-31"); + assert (parsed.task_args.labels == "urgent,important"); + assert (parsed.task_args.pinned == 1); + print (" ✓ Parses all add options correctly\n---\n"); + } + + void test_list_with_project () { + print ("Testing: 'list' command with project\n"); + int exit_code; + string[] args = {"planify-cli", "list", "-p", "Work"}; + + var parsed = PlanifyCLI.ArgumentParser.parse (args, out exit_code); + + assert (parsed != null); + assert (exit_code == 0); + assert (parsed.command_type == PlanifyCLI.CommandType.LIST); + assert (parsed.list_args != null); + assert (parsed.list_args.project_name == "Work"); + print (" ✓ Parses list command with project name\n---\n"); + } + + void test_list_with_project_id () { + print ("Testing: 'list' command with project ID\n"); + int exit_code; + string[] args = {"planify-cli", "list", "-i", "project-123"}; + + var parsed = PlanifyCLI.ArgumentParser.parse (args, out exit_code); + + assert (parsed != null); + assert (exit_code == 0); + assert (parsed.command_type == PlanifyCLI.CommandType.LIST); + assert (parsed.list_args != null); + assert (parsed.list_args.project_id == "project-123"); + print (" ✓ Parses list command with project ID\n---\n"); + } + + void test_update_minimal () { + print ("Testing: 'update' command with minimal args\n"); + int exit_code; + string[] args = {"planify-cli", "update", "-t", "task-456", "-c", "Updated content"}; + + var parsed = PlanifyCLI.ArgumentParser.parse (args, out exit_code); + + assert (parsed != null); + assert (exit_code == 0); + assert (parsed.command_type == PlanifyCLI.CommandType.UPDATE); + assert (parsed.update_args != null); + assert (parsed.update_args.task_id == "task-456"); + assert (parsed.update_args.content == "Updated content"); + print (" ✓ Parses update command with task ID and content\n---\n"); + } + + void test_update_completion () { + print ("Testing: 'update' command with completion flags\n"); + int exit_code; + + // Test --complete=true + string[] args_complete = {"planify-cli", "update", "-t", "task-123", "--complete", "true"}; + var parsed = PlanifyCLI.ArgumentParser.parse (args_complete, out exit_code); + + assert (parsed != null); + assert (exit_code == 0); + assert (parsed.update_args.checked == 1); + print (" ✓ Parses --complete=true\n"); + + // Test --complete=false + string[] args_uncomplete = {"planify-cli", "update", "-t", "task-123", "--complete", "false"}; + parsed = PlanifyCLI.ArgumentParser.parse (args_uncomplete, out exit_code); + + assert (parsed != null); + assert (exit_code == 0); + assert (parsed.update_args.checked == 0); + print (" ✓ Parses --complete=false\n---\n"); + } + + void test_missing_required_arg () { + print ("Testing: Missing required argument value\n"); + int exit_code; + string[] args = {"planify-cli", "add", "-c"}; // -c without value + + var parsed = PlanifyCLI.ArgumentParser.parse (args, out exit_code); + + assert (parsed == null); + assert (exit_code == 1); + print (" ✓ Returns error for missing argument value\n---\n"); + } + + void test_invalid_pin_value () { + print ("Testing: Invalid --pin value\n"); + int exit_code; + string[] args = {"planify-cli", "add", "-c", "Task", "--pin", "invalid"}; + + var parsed = PlanifyCLI.ArgumentParser.parse (args, out exit_code); + + assert (parsed == null); + assert (exit_code == 1); + print (" ✓ Returns error for invalid pin value\n---\n"); + } + + void test_invalid_complete_value () { + print ("Testing: Invalid --complete value\n"); + int exit_code; + string[] args = {"planify-cli", "update", "-t", "task-123", "--complete", "invalid"}; + + var parsed = PlanifyCLI.ArgumentParser.parse (args, out exit_code); + + assert (parsed == null); + assert (exit_code == 1); + print (" ✓ Returns error for invalid complete value\n---\n"); + } + + void test_unknown_option () { + print ("Testing: Unknown option\n"); + int exit_code; + string[] args = {"planify-cli", "add", "-c", "Task", "--unknown-option"}; + + var parsed = PlanifyCLI.ArgumentParser.parse (args, out exit_code); + + assert (parsed == null); + assert (exit_code == 1); + print (" ✓ Returns error for unknown option\n---\n"); + } + + public void register_tests () { + Test.add_func ("/cli/argument_parser/no_command", test_no_command); + Test.add_func ("/cli/argument_parser/unknown_command", test_unknown_command); + Test.add_func ("/cli/argument_parser/list_projects", test_list_projects); + Test.add_func ("/cli/argument_parser/add_minimal", test_add_minimal); + Test.add_func ("/cli/argument_parser/add_full", test_add_full); + Test.add_func ("/cli/argument_parser/list_with_project", test_list_with_project); + Test.add_func ("/cli/argument_parser/list_with_project_id", test_list_with_project_id); + Test.add_func ("/cli/argument_parser/update_minimal", test_update_minimal); + Test.add_func ("/cli/argument_parser/update_completion", test_update_completion); + Test.add_func ("/cli/argument_parser/missing_required_arg", test_missing_required_arg); + Test.add_func ("/cli/argument_parser/invalid_pin_value", test_invalid_pin_value); + Test.add_func ("/cli/argument_parser/invalid_complete_value", test_invalid_complete_value); + Test.add_func ("/cli/argument_parser/unknown_option", test_unknown_option); + } +} \ No newline at end of file diff --git a/test/cli/test-priority-conversion.vala b/test/cli/test-priority-conversion.vala new file mode 100644 index 000000000..7396402cc --- /dev/null +++ b/test/cli/test-priority-conversion.vala @@ -0,0 +1,50 @@ +/* + * Copyright © 2026 Alain M. (https://github.com/alainm23/planify) + * + * 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 3 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, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA + */ + +/** + * Priority Conversion Tests + * + * Tests the conversion between user-friendly priority values + * and internal priority representation. + */ + +namespace PlanifyCLI.Tests.PriorityConversion { + void test_priority_conversion () { + print ("Testing: Priority conversion (user-friendly to internal)\n"); + + // User-friendly: 1=high, 2=medium, 3=low, 4=none + // Internal: 4=high, 3=medium, 2=low, 1=none + // Conversion: internal = 5 - user_friendly + + assert (5 - 1 == 4); // high + assert (5 - 2 == 3); // medium + assert (5 - 3 == 2); // low + assert (5 - 4 == 1); // none + + print (" ✓ Priority conversion logic verified\n"); + print (" 1 (high) -> 4 (internal)\n"); + print (" 2 (medium) -> 3 (internal)\n"); + print (" 3 (low) -> 2 (internal)\n"); + print (" 4 (none) -> 1 (internal)\n---\n"); + } + + public void register_tests () { + Test.add_func ("/cli/priority_conversion", test_priority_conversion); + } +} \ No newline at end of file diff --git a/test/cli/test-task-validator.vala b/test/cli/test-task-validator.vala new file mode 100644 index 000000000..cd3521a63 --- /dev/null +++ b/test/cli/test-task-validator.vala @@ -0,0 +1,149 @@ +/* + * Copyright © 2026 Alain M. (https://github.com/alainm23/planify) + * + * 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 3 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, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA + */ + +/** + * TaskValidator Tests + * + * Tests for input validation logic including: + * - Content validation (required field) + * - Priority validation (1-4 range) + * - Date format validation (YYYY-MM-DD) + */ + +namespace PlanifyCLI.Tests.TaskValidator { + void test_content_required () { + print ("Testing: Content validation - null\n"); + string? error_message; + + bool result = PlanifyCLI.TaskValidator.validate_content (null, out error_message); + + assert (result == false); + assert (error_message != null); + print (" ✓ Rejects null content\n---\n"); + } + + void test_content_empty () { + print ("Testing: Content validation - empty string\n"); + string? error_message; + + bool result = PlanifyCLI.TaskValidator.validate_content (" ", out error_message); + + assert (result == false); + assert (error_message != null); + print (" ✓ Rejects empty/whitespace content\n---\n"); + } + + void test_content_valid () { + print ("Testing: Content validation - valid\n"); + string? error_message; + + bool result = PlanifyCLI.TaskValidator.validate_content ("Valid task content", out error_message); + + assert (result == true); + assert (error_message == null); + print (" ✓ Accepts valid content\n---\n"); + } + + void test_priority_range () { + print ("Testing: Priority validation - range checks\n"); + string? error_message; + + // Test invalid priorities + assert (PlanifyCLI.TaskValidator.validate_priority (0, out error_message) == false); + print (" ✓ Rejects priority 0\n"); + + assert (PlanifyCLI.TaskValidator.validate_priority (5, out error_message) == false); + print (" ✓ Rejects priority 5\n"); + + assert (PlanifyCLI.TaskValidator.validate_priority (-1, out error_message) == false); + print (" ✓ Rejects priority -1\n"); + + // Test valid priorities + assert (PlanifyCLI.TaskValidator.validate_priority (1, out error_message) == true); + assert (PlanifyCLI.TaskValidator.validate_priority (2, out error_message) == true); + assert (PlanifyCLI.TaskValidator.validate_priority (3, out error_message) == true); + assert (PlanifyCLI.TaskValidator.validate_priority (4, out error_message) == true); + print (" ✓ Accepts priorities 1-4\n---\n"); + } + + void test_date_format_valid () { + print ("Testing: Date validation - valid formats\n"); + string? error_message; + GLib.DateTime? datetime; + + bool result = PlanifyCLI.TaskValidator.validate_and_parse_date ("2024-12-31", out datetime, out error_message); + + assert (result == true); + assert (datetime != null); + assert (datetime.get_year () == 2024); + assert (datetime.get_month () == 12); + assert (datetime.get_day_of_month () == 31); + assert (error_message == null); + print (" ✓ Parses valid YYYY-MM-DD format\n---\n"); + } + + void test_date_format_invalid () { + print ("Testing: Date validation - invalid formats\n"); + string? error_message; + GLib.DateTime? datetime; + + // Invalid format + bool result = PlanifyCLI.TaskValidator.validate_and_parse_date ("12/31/2024", out datetime, out error_message); + assert (result == false); + assert (error_message != null); + print (" ✓ Rejects invalid date format\n"); + + // Invalid values + result = PlanifyCLI.TaskValidator.validate_and_parse_date ("2024-13-01", out datetime, out error_message); + assert (result == false); + print (" ✓ Rejects invalid month\n"); + + result = PlanifyCLI.TaskValidator.validate_and_parse_date ("2024-01-32", out datetime, out error_message); + assert (result == false); + print (" ✓ Rejects invalid day\n---\n"); + } + + void test_date_null_or_empty () { + print ("Testing: Date validation - null/empty (optional)\n"); + string? error_message; + GLib.DateTime? datetime; + + // Null is valid (optional field) + bool result = PlanifyCLI.TaskValidator.validate_and_parse_date (null, out datetime, out error_message); + assert (result == true); + assert (datetime == null); + print (" ✓ Accepts null date (optional)\n"); + + // Empty string is valid (optional field) + result = PlanifyCLI.TaskValidator.validate_and_parse_date ("", out datetime, out error_message); + assert (result == true); + assert (datetime == null); + print (" ✓ Accepts empty date (optional)\n---\n"); + } + + public void register_tests () { + Test.add_func ("/cli/task_validator/content_required", test_content_required); + Test.add_func ("/cli/task_validator/content_empty", test_content_empty); + Test.add_func ("/cli/task_validator/content_valid", test_content_valid); + Test.add_func ("/cli/task_validator/priority_range", test_priority_range); + Test.add_func ("/cli/task_validator/date_format_valid", test_date_format_valid); + Test.add_func ("/cli/task_validator/date_format_invalid", test_date_format_invalid); + Test.add_func ("/cli/task_validator/date_null_or_empty", test_date_null_or_empty); + } +} \ No newline at end of file diff --git a/test/meson.build b/test/meson.build index d71dc9f6d..e1dee0920 100644 --- a/test/meson.build +++ b/test/meson.build @@ -16,3 +16,21 @@ test_chrono = executable( test('chrono', test_chrono, timeout: 30, suite: ['chrono']) +test_cli_sources = [ + 'test-cli-runner.vala', + 'cli/test-argument-parser.vala', + 'cli/test-task-validator.vala', + 'cli/test-priority-conversion.vala', + join_paths(meson.project_source_root(), 'quick-add', 'cli', 'ArgumentParser.vala'), + join_paths(meson.project_source_root(), 'quick-add', 'cli', 'TaskValidator.vala') +] + +test_cli = executable( + 'test-cli', + test_cli_sources, + dependencies: [ core_dep, glib_dep ], + install: false +) + +test('cli', test_cli, timeout: 30, suite: ['cli']) + diff --git a/test/test-cli-runner.vala b/test/test-cli-runner.vala new file mode 100644 index 000000000..f04b5ced9 --- /dev/null +++ b/test/test-cli-runner.vala @@ -0,0 +1,35 @@ +/* + * Copyright © 2026 Alain M. (https://github.com/alainm23/planify) + * + * 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 3 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, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA + */ + +/** + * CLI Test Runner + * + * Main test runner that orchestrates all CLI component tests. + * Each component has its own test file in test/cli/ directory. + */ + +void main (string[] args) { + Test.init (ref args); + + PlanifyCLI.Tests.ArgumentParser.register_tests (); + PlanifyCLI.Tests.TaskValidator.register_tests (); + PlanifyCLI.Tests.PriorityConversion.register_tests (); + + Test.run (); +} \ No newline at end of file