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 var lines []string
104
105 // Add goal if it exists
106 if m.goal != nil && m.goal.Text != "" {
107 lines = append(lines, fmt.Sprintf("**Goal:** %s", m.goal.Text))
108 lines = append(lines, "")
109 }
110
111 if len(m.tasks) == 0 {
112 if len(lines) > 0 {
113 // We have a goal but no tasks
114 lines = append(lines, "No tasks defined yet.")
115 return strings.Join(lines, "\n")
116 }
117 return "No tasks defined yet."
118 }
119
120 // Sort tasks by creation time for consistent output
121 taskList := make([]*Task, 0, len(m.tasks))
122 for _, task := range m.tasks {
123 taskList = append(taskList, task)
124 }
125
126 // Check if there are any failed tasks
127 hasFailed := false
128 for _, task := range taskList {
129 if task.Status == StatusFailed {
130 hasFailed = true
131 break
132 }
133 }
134
135 // Add legend
136 legend := "Legend: ☐ pending ⟳ in progress ☑ completed"
137 if hasFailed {
138 legend += " ☒ failed"
139 }
140 lines = append(lines, legend)
141
142 // Simple sort by creation time (newest first could be changed if needed)
143 for i := range len(taskList) {
144 for j := i + 1; j < len(taskList); j++ {
145 if taskList[i].CreatedAt.After(taskList[j].CreatedAt) {
146 taskList[i], taskList[j] = taskList[j], taskList[i]
147 }
148 }
149 }
150
151 for _, task := range taskList {
152 line := fmt.Sprintf("%s %s [%s]", task.Status.String(), task.Title, task.ID)
153 lines = append(lines, line)
154 if task.Description != "" {
155 lines = append(lines, fmt.Sprintf(" %s", task.Description))
156 }
157 }
158
159 return strings.Join(lines, "\n")
160}
161
162// UpdateTaskStatus updates the status of a specific task
163func (m *Manager) UpdateTaskStatus(taskID string, status TaskStatus) error {
164 m.mu.Lock()
165 defer m.mu.Unlock()
166
167 task, exists := m.tasks[taskID]
168 if !exists {
169 return fmt.Errorf("task not found: %s", taskID)
170 }
171
172 task.UpdateStatus(status)
173 m.logger.Info("Task status updated", "id", taskID, "status", status.String())
174 return nil
175}
176
177// GetGoal returns the current goal
178func (m *Manager) GetGoal() *Goal {
179 m.mu.RLock()
180 defer m.mu.RUnlock()
181 return m.goal
182}
183
184// GetTaskByID returns a task by its ID
185func (m *Manager) GetTaskByID(taskID string) (*Task, bool) {
186 m.mu.RLock()
187 defer m.mu.RUnlock()
188 task, exists := m.tasks[taskID]
189 return task, exists
190}
191
192// TaskInput represents input for creating a task
193type TaskInput struct {
194 Title string `json:"title"`
195 Description string `json:"description"`
196}