1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package planning
  6
  7import (
  8	"fmt"
  9	"log/slog"
 10	"sort"
 11	"strings"
 12	"sync"
 13	"time"
 14
 15	"git.sr.ht/~amolith/planning-mcp-server/internal/config"
 16)
 17
 18// Manager handles planning operations
 19type Manager struct {
 20	config *config.Config
 21	logger *slog.Logger
 22
 23	// Thread-safe storage
 24	mu    sync.RWMutex
 25	goal  *Goal
 26	tasks map[string]*Task
 27}
 28
 29// New creates a new planning manager
 30func New(cfg *config.Config, logger *slog.Logger) *Manager {
 31	return &Manager{
 32		config: cfg,
 33		logger: logger,
 34		tasks:  make(map[string]*Task),
 35	}
 36}
 37
 38// SetGoal sets the initial goal (returns error if already set)
 39func (m *Manager) SetGoal(title, description string) error {
 40	if len(title) > m.config.Planning.MaxGoalLength {
 41		return fmt.Errorf("goal title too long (max %d characters)", m.config.Planning.MaxGoalLength)
 42	}
 43	if len(description) > m.config.Planning.MaxGoalLength {
 44		return fmt.Errorf("goal description too long (max %d characters)", m.config.Planning.MaxGoalLength)
 45	}
 46
 47	m.mu.Lock()
 48	defer m.mu.Unlock()
 49
 50	if m.goal != nil && m.goal.Text != "" {
 51		return fmt.Errorf("goal already set; use change_goal to update it")
 52	}
 53
 54	goalText := title
 55	if description != "" {
 56		goalText = fmt.Sprintf("%s: %s", title, description)
 57	}
 58
 59	m.goal = &Goal{
 60		Text:      strings.TrimSpace(goalText),
 61		UpdatedAt: time.Now(),
 62	}
 63
 64	m.logger.Info("Goal set", "title", title, "description", description)
 65	return nil
 66}
 67
 68// ChangeGoal changes an existing goal (requires a reason)
 69func (m *Manager) ChangeGoal(title, description, reason string) error {
 70	if len(title) > m.config.Planning.MaxGoalLength {
 71		return fmt.Errorf("goal title too long (max %d characters)", m.config.Planning.MaxGoalLength)
 72	}
 73	if len(description) > m.config.Planning.MaxGoalLength {
 74		return fmt.Errorf("goal description too long (max %d characters)", m.config.Planning.MaxGoalLength)
 75	}
 76	if reason == "" {
 77		return fmt.Errorf("reason is required when changing the goal")
 78	}
 79
 80	m.mu.Lock()
 81	defer m.mu.Unlock()
 82
 83	if m.goal == nil || m.goal.Text == "" {
 84		return fmt.Errorf("no goal set; use set_goal to create an initial goal")
 85	}
 86
 87	goalText := title
 88	if description != "" {
 89		goalText = fmt.Sprintf("%s: %s", title, description)
 90	}
 91
 92	oldGoal := m.goal.Text
 93	m.goal = &Goal{
 94		Text:      strings.TrimSpace(goalText),
 95		UpdatedAt: time.Now(),
 96	}
 97
 98	m.logger.Info("Goal changed", "old", oldGoal, "new", goalText, "reason", reason)
 99	return nil
100}
101
102// AddTasksResult contains the result of adding tasks
103type AddTasksResult struct {
104	AddedTasks       []*Task
105	HadExistingTasks bool
106}
107
108// AddTasks adds one or more tasks
109func (m *Manager) AddTasks(tasks []TaskInput) (*AddTasksResult, error) {
110	m.mu.Lock()
111	defer m.mu.Unlock()
112
113	// Check if there were existing tasks before adding new ones
114	hadExistingTasks := len(m.tasks) > 0
115
116	// Check task limits
117	if len(m.tasks)+len(tasks) > m.config.Planning.MaxTasks {
118		return nil, fmt.Errorf("too many tasks (max %d)", m.config.Planning.MaxTasks)
119	}
120
121	addedTasks := make([]*Task, 0, len(tasks))
122
123	for _, taskInput := range tasks {
124		// Validate task input
125		if taskInput.Title == "" {
126			return nil, fmt.Errorf("task title cannot be empty")
127		}
128
129		if len(taskInput.Title) > m.config.Planning.MaxTaskLength {
130			return nil, fmt.Errorf("task title too long (max %d characters)", m.config.Planning.MaxTaskLength)
131		}
132
133		if len(taskInput.Description) > m.config.Planning.MaxTaskLength {
134			return nil, fmt.Errorf("task description too long (max %d characters)", m.config.Planning.MaxTaskLength)
135		}
136
137		// Create task
138		task := NewTask(taskInput.Title, taskInput.Description)
139
140		// Check if task already exists (by ID)
141		if _, exists := m.tasks[task.ID]; exists {
142			m.logger.Warn("Task already exists, skipping", "id", task.ID, "title", task.Title)
143			continue
144		}
145
146		m.tasks[task.ID] = task
147		addedTasks = append(addedTasks, task)
148	}
149
150	m.logger.Info("Tasks added", "count", len(addedTasks))
151	return &AddTasksResult{
152		AddedTasks:       addedTasks,
153		HadExistingTasks: hadExistingTasks,
154	}, nil
155}
156
157// formatTaskList returns a markdown-formatted list of all tasks
158// This method assumes the mutex is already locked
159func (m *Manager) formatTaskList() string {
160	return m.formatTaskListWithFilter(nil)
161}
162
163// GetTasks returns a markdown-formatted list of all tasks
164func (m *Manager) GetTasks() string {
165	m.mu.RLock()
166	defer m.mu.RUnlock()
167	return m.formatTaskList()
168}
169
170// GetTasksByStatus returns a markdown-formatted list of tasks filtered by status
171func (m *Manager) GetTasksByStatus(statusFilter string) string {
172	m.mu.RLock()
173	defer m.mu.RUnlock()
174
175	// Parse the filter
176	var filterStatus *TaskStatus
177	if statusFilter != "" && statusFilter != "all" {
178		status := ParseStatus(statusFilter)
179		filterStatus = &status
180	}
181
182	return m.formatTaskListWithFilter(filterStatus)
183}
184
185// formatTaskListWithFilter returns a markdown-formatted list of tasks with optional status filter
186func (m *Manager) formatTaskListWithFilter(filterStatus *TaskStatus) string {
187	var lines []string
188
189	// Add goal if it exists
190	if m.goal != nil && m.goal.Text != "" {
191		lines = append(lines, fmt.Sprintf("**Goal:** %s", m.goal.Text))
192		lines = append(lines, "")
193	}
194
195	// Filter tasks if needed
196	filteredTasks := make(map[string]*Task)
197	for id, task := range m.tasks {
198		if filterStatus == nil || task.Status == *filterStatus {
199			filteredTasks[id] = task
200		}
201	}
202
203	if len(filteredTasks) == 0 {
204		if filterStatus != nil {
205			statusName := "pending"
206			switch *filterStatus {
207			case StatusInProgress:
208				statusName = "in progress"
209			case StatusCompleted:
210				statusName = "completed"
211			case StatusFailed:
212				statusName = "failed"
213			case StatusCancelled:
214				statusName = "cancelled"
215			}
216			lines = append(lines, fmt.Sprintf("No %s tasks.", statusName))
217		} else if len(lines) > 0 {
218			// We have a goal but no tasks
219			lines = append(lines, "No tasks defined yet.")
220		} else {
221			lines = append(lines, "No tasks defined yet.")
222		}
223		return strings.Join(lines, "\n")
224	}
225
226	// Sort tasks by creation time for consistent output
227	taskList := make([]*Task, 0, len(filteredTasks))
228	for _, task := range filteredTasks {
229		taskList = append(taskList, task)
230	}
231
232	// Check if there are any failed or cancelled tasks
233	hasFailed := false
234	hasCancelled := false
235	for _, task := range taskList {
236		if task.Status == StatusFailed {
237			hasFailed = true
238		}
239		if task.Status == StatusCancelled {
240			hasCancelled = true
241		}
242	}
243
244	// Add legend
245	legend := "Legend: ☐ pending  ⟳ in progress  ☑ completed"
246	if hasCancelled {
247		legend += "  ⊗ cancelled"
248	}
249	if hasFailed {
250		legend += "  ☒ failed"
251	}
252	lines = append(lines, legend)
253
254	// Sort tasks by creation time (oldest first for consistent ordering)
255	sort.Slice(taskList, func(i, j int) bool {
256		return taskList[i].CreatedAt.Before(taskList[j].CreatedAt)
257	})
258
259	for _, task := range taskList {
260		line := fmt.Sprintf("%s %s [%s]", task.Status.String(), task.Title, task.ID)
261		lines = append(lines, line)
262		if task.Description != "" {
263			lines = append(lines, fmt.Sprintf("  %s", task.Description))
264		}
265	}
266
267	return strings.Join(lines, "\n")
268}
269
270// UpdateTaskStatus updates the status of a specific task
271func (m *Manager) UpdateTaskStatus(taskID string, status TaskStatus) error {
272	m.mu.Lock()
273	defer m.mu.Unlock()
274
275	task, exists := m.tasks[taskID]
276	if !exists {
277		return fmt.Errorf("task not found: %s", taskID)
278	}
279
280	task.UpdateStatus(status)
281	m.logger.Info("Task status updated", "id", taskID, "status", status.String())
282	return nil
283}
284
285// UpdateTasks updates the status of multiple tasks in a single operation
286func (m *Manager) UpdateTasks(updates []TaskUpdate) error {
287	m.mu.Lock()
288	defer m.mu.Unlock()
289
290	// First validate all task IDs exist
291	for _, update := range updates {
292		if _, exists := m.tasks[update.TaskID]; !exists {
293			return fmt.Errorf("task not found: %s", update.TaskID)
294		}
295	}
296
297	// If all validations pass, apply all updates
298	for _, update := range updates {
299		task := m.tasks[update.TaskID]
300		task.UpdateStatus(update.Status)
301		m.logger.Info("Task status updated", "id", update.TaskID, "status", update.Status.String())
302	}
303
304	return nil
305}
306
307// GetGoal returns the current goal
308func (m *Manager) GetGoal() *Goal {
309	m.mu.RLock()
310	defer m.mu.RUnlock()
311	return m.goal
312}
313
314// GetTaskByID returns a task by its ID
315func (m *Manager) GetTaskByID(taskID string) (*Task, bool) {
316	m.mu.RLock()
317	defer m.mu.RUnlock()
318	task, exists := m.tasks[taskID]
319	return task, exists
320}
321
322// TaskInput represents input for creating a task
323type TaskInput struct {
324	Title       string `json:"title"`
325	Description string `json:"description"`
326}
327
328// TaskUpdate represents a task status update
329type TaskUpdate struct {
330	TaskID string     `json:"task_id"`
331	Status TaskStatus `json:"status"`
332}
333
334// DeleteTasks deletes one or more tasks by their IDs
335func (m *Manager) DeleteTasks(taskIDs []string) error {
336	m.mu.Lock()
337	defer m.mu.Unlock()
338
339	if len(taskIDs) == 0 {
340		return fmt.Errorf("at least one task ID is required")
341	}
342
343	// First validate all task IDs exist
344	notFound := make([]string, 0)
345	for _, taskID := range taskIDs {
346		if _, exists := m.tasks[taskID]; !exists {
347			notFound = append(notFound, taskID)
348		}
349	}
350
351	if len(notFound) > 0 {
352		return fmt.Errorf("task(s) not found: %s", strings.Join(notFound, ", "))
353	}
354
355	// If all validations pass, delete all tasks
356	for _, taskID := range taskIDs {
357		delete(m.tasks, taskID)
358		m.logger.Info("Task deleted", "id", taskID)
359	}
360
361	m.logger.Info("Tasks deleted", "count", len(taskIDs))
362	return nil
363}