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