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}