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