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
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// AddTasksResult contains the result of adding tasks
56type AddTasksResult struct {
57 AddedTasks []*Task
58 HadExistingTasks bool
59}
60
61// AddTasks adds one or more tasks
62func (m *Manager) AddTasks(tasks []TaskInput) (*AddTasksResult, error) {
63 m.mu.Lock()
64 defer m.mu.Unlock()
65
66 // Check if there were existing tasks before adding new ones
67 hadExistingTasks := len(m.tasks) > 0
68
69 // Check task limits
70 if len(m.tasks)+len(tasks) > m.config.Planning.MaxTasks {
71 return nil, fmt.Errorf("too many tasks (max %d)", m.config.Planning.MaxTasks)
72 }
73
74 addedTasks := make([]*Task, 0, len(tasks))
75
76 for _, taskInput := range tasks {
77 // Validate task input
78 if taskInput.Title == "" {
79 return nil, fmt.Errorf("task title cannot be empty")
80 }
81
82 if len(taskInput.Title) > m.config.Planning.MaxTaskLength {
83 return nil, fmt.Errorf("task title too long (max %d characters)", m.config.Planning.MaxTaskLength)
84 }
85
86 if len(taskInput.Description) > m.config.Planning.MaxTaskLength {
87 return nil, fmt.Errorf("task description too long (max %d characters)", m.config.Planning.MaxTaskLength)
88 }
89
90 // Create task
91 task := NewTask(taskInput.Title, taskInput.Description)
92
93 // Check if task already exists (by ID)
94 if _, exists := m.tasks[task.ID]; exists {
95 m.logger.Warn("Task already exists, skipping", "id", task.ID, "title", task.Title)
96 continue
97 }
98
99 m.tasks[task.ID] = task
100 addedTasks = append(addedTasks, task)
101 }
102
103 m.logger.Info("Tasks added", "count", len(addedTasks))
104 return &AddTasksResult{
105 AddedTasks: addedTasks,
106 HadExistingTasks: hadExistingTasks,
107 }, nil
108}
109
110// formatTaskList returns a markdown-formatted list of tasks
111// This method assumes the mutex is already locked
112func (m *Manager) formatTaskList() string {
113 var lines []string
114
115 // Add goal if it exists
116 if m.goal != nil && m.goal.Text != "" {
117 lines = append(lines, fmt.Sprintf("**Goal:** %s", m.goal.Text))
118 lines = append(lines, "")
119 }
120
121 if len(m.tasks) == 0 {
122 if len(lines) > 0 {
123 // We have a goal but no tasks
124 lines = append(lines, "No tasks defined yet.")
125 return strings.Join(lines, "\n")
126 }
127 return "No tasks defined yet."
128 }
129
130 // Sort tasks by creation time for consistent output
131 taskList := make([]*Task, 0, len(m.tasks))
132 for _, task := range m.tasks {
133 taskList = append(taskList, task)
134 }
135
136 // Check if there are any failed tasks
137 hasFailed := false
138 for _, task := range taskList {
139 if task.Status == StatusFailed {
140 hasFailed = true
141 break
142 }
143 }
144
145 // Add legend
146 legend := "Legend: ☐ pending ⟳ in progress ☑ completed"
147 if hasFailed {
148 legend += " ☒ failed"
149 }
150 lines = append(lines, legend)
151
152 // Simple sort by creation time (newest first could be changed if needed)
153 for i := range len(taskList) {
154 for j := i + 1; j < len(taskList); j++ {
155 if taskList[i].CreatedAt.After(taskList[j].CreatedAt) {
156 taskList[i], taskList[j] = taskList[j], taskList[i]
157 }
158 }
159 }
160
161 for _, task := range taskList {
162 line := fmt.Sprintf("%s %s [%s]", task.Status.String(), task.Title, task.ID)
163 lines = append(lines, line)
164 if task.Description != "" {
165 lines = append(lines, fmt.Sprintf(" %s", task.Description))
166 }
167 }
168
169 return strings.Join(lines, "\n")
170}
171
172// GetTasks returns a markdown-formatted list of tasks
173func (m *Manager) GetTasks() string {
174 m.mu.RLock()
175 defer m.mu.RUnlock()
176 return m.formatTaskList()
177}
178
179// UpdateTaskStatus updates the status of a specific task
180func (m *Manager) UpdateTaskStatus(taskID string, status TaskStatus) error {
181 m.mu.Lock()
182 defer m.mu.Unlock()
183
184 task, exists := m.tasks[taskID]
185 if !exists {
186 return fmt.Errorf("task not found: %s", taskID)
187 }
188
189 task.UpdateStatus(status)
190 m.logger.Info("Task status updated", "id", taskID, "status", status.String())
191 return nil
192}
193
194// GetGoal returns the current goal
195func (m *Manager) GetGoal() *Goal {
196 m.mu.RLock()
197 defer m.mu.RUnlock()
198 return m.goal
199}
200
201// GetTaskByID returns a task by its ID
202func (m *Manager) GetTaskByID(taskID string) (*Task, bool) {
203 m.mu.RLock()
204 defer m.mu.RUnlock()
205 task, exists := m.tasks[taskID]
206 return task, exists
207}
208
209// TaskInput represents input for creating a task
210type TaskInput struct {
211 Title string `json:"title"`
212 Description string `json:"description"`
213}