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// UpdateTasks updates the status of multiple tasks in a single operation
195func (m *Manager) UpdateTasks(updates []TaskUpdate) error {
196	m.mu.Lock()
197	defer m.mu.Unlock()
198
199	// First validate all task IDs exist
200	for _, update := range updates {
201		if _, exists := m.tasks[update.TaskID]; !exists {
202			return fmt.Errorf("task not found: %s", update.TaskID)
203		}
204	}
205
206	// If all validations pass, apply all updates
207	for _, update := range updates {
208		task := m.tasks[update.TaskID]
209		task.UpdateStatus(update.Status)
210		m.logger.Info("Task status updated", "id", update.TaskID, "status", update.Status.String())
211	}
212
213	return nil
214}
215
216// GetGoal returns the current goal
217func (m *Manager) GetGoal() *Goal {
218	m.mu.RLock()
219	defer m.mu.RUnlock()
220	return m.goal
221}
222
223// GetTaskByID returns a task by its ID
224func (m *Manager) GetTaskByID(taskID string) (*Task, bool) {
225	m.mu.RLock()
226	defer m.mu.RUnlock()
227	task, exists := m.tasks[taskID]
228	return task, exists
229}
230
231// TaskInput represents input for creating a task
232type TaskInput struct {
233	Title       string `json:"title"`
234	Description string `json:"description"`
235}
236
237// TaskUpdate represents a task status update
238type TaskUpdate struct {
239	TaskID string     `json:"task_id"`
240	Status TaskStatus `json:"status"`
241}