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