Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 44 additions & 4 deletions backend/controllers/edit_task.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"fmt"
"io"
"net/http"
"os"
"strconv"
)

// EditTaskHandler godoc
Expand Down Expand Up @@ -61,10 +63,48 @@ func EditTaskHandler(w http.ResponseWriter, r *http.Request) {
return
}

// Validate dependencies
if err := utils.ValidateDependencies(depends, uuid); err != nil {
http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest)
return
if len(depends) > 0 {
origin := os.Getenv("CONTAINER_ORIGIN")
tasks, err := tw.FetchTasksFromTaskwarrior(email, encryptionSecret, origin, uuid)
if err != nil {
http.Error(w, fmt.Sprintf("failed to fetch tasks for validation: %v", err), http.StatusInternalServerError)
return
}
taskIDInt, err := strconv.ParseInt(taskID, 10, 32)
if err != nil {
http.Error(w, fmt.Sprintf("invalid taskID format: %v", err), http.StatusBadRequest)
return
}

var taskUUID string
for _, task := range tasks {
if task.ID == int32(taskIDInt) {
taskUUID = task.UUID
break
}
}

if taskUUID == "" {
http.Error(w, fmt.Sprintf("task with ID %s not found", taskID), http.StatusBadRequest)
return
}

if err := utils.ValidateDependencies(depends, taskUUID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

taskDeps := make([]utils.TaskDependencyInfo, len(tasks))
for i, task := range tasks {
taskDeps[i] = utils.TaskDependencyInfo{
UUID: task.UUID,
Depends: task.Depends,
}
}
if err := utils.DetectCircularDependency(taskDeps, taskUUID, depends); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}

logStore := models.GetLogStore()
Expand Down
68 changes: 68 additions & 0 deletions backend/utils/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,71 @@ func Test_ValidateDependencies_EmptyList(t *testing.T) {
err := ValidateDependencies(depends, currentTaskUUID)
assert.NoError(t, err)
}

func Test_ValidateDependencies_SelfDependency(t *testing.T) {
depends := []string{"current-task-uuid"}
currentTaskUUID := "current-task-uuid"
err := ValidateDependencies(depends, currentTaskUUID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "cannot depend on itself")
}

func Test_DetectCircularDependency(t *testing.T) {
t.Run("no circular dependency - simple chain", func(t *testing.T) {
tasks := []TaskDependencyInfo{
{UUID: "A", Depends: []string{"B"}},
{UUID: "B", Depends: []string{}},
{UUID: "C", Depends: []string{"B"}},
}
err := DetectCircularDependency(tasks, "A", []string{"B"})
assert.NoError(t, err, "should not detect cycle in simple chain")
})

t.Run("circular dependency - A depends on B, B depends on C, C depends on A", func(t *testing.T) {
tasks := []TaskDependencyInfo{
{UUID: "A", Depends: []string{}},
{UUID: "B", Depends: []string{"C"}},
{UUID: "C", Depends: []string{"A"}},
}
err := DetectCircularDependency(tasks, "A", []string{"B"})
assert.Error(t, err, "should detect circular dependency")
assert.Contains(t, err.Error(), "circular dependency", "error should mention circular dependency")
})

t.Run("circular dependency - self reference", func(t *testing.T) {
tasks := []TaskDependencyInfo{
{UUID: "A", Depends: []string{}},
}
err := DetectCircularDependency(tasks, "A", []string{"A"})
assert.Error(t, err, "should detect self-referential circular dependency")
})

t.Run("no circular dependency - valid dependencies", func(t *testing.T) {
tasks := []TaskDependencyInfo{
{UUID: "A", Depends: []string{"B", "C"}},
{UUID: "B", Depends: []string{"D"}},
{UUID: "C", Depends: []string{"D"}},
{UUID: "D", Depends: []string{}},
}
err := DetectCircularDependency(tasks, "A", []string{"B", "C"})
assert.NoError(t, err, "should not detect cycle in valid dependency tree")
})

t.Run("circular dependency - existing cycle in graph", func(t *testing.T) {
tasks := []TaskDependencyInfo{
{UUID: "A", Depends: []string{"B"}},
{UUID: "B", Depends: []string{"A"}},
}
err := DetectCircularDependency(tasks, "A", []string{"B"})
assert.Error(t, err, "should detect existing circular dependency")
})

t.Run("no circular dependency - empty depends", func(t *testing.T) {
tasks := []TaskDependencyInfo{
{UUID: "A", Depends: []string{"B"}},
{UUID: "B", Depends: []string{}},
}
err := DetectCircularDependency(tasks, "A", []string{})
assert.NoError(t, err, "removing dependencies should not create cycle")
})
}
99 changes: 97 additions & 2 deletions backend/utils/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ import (
"fmt"
)

// ValidateDependencies validates dependencies
type TaskDependencyInfo struct {
UUID string
Depends []string
}

func ValidateDependencies(depends []string, currentTaskUUID string) error {
if len(depends) == 0 {
return nil
}

// check for self-dependency
for _, dep := range depends {
if dep == currentTaskUUID {
return fmt.Errorf("task cannot depend on itself: %s", dep)
Expand All @@ -19,3 +22,95 @@ func ValidateDependencies(depends []string, currentTaskUUID string) error {

return nil
}

func DetectCircularDependency(tasks []TaskDependencyInfo, taskUUID string, newDepends []string) error {
graph := make(map[string][]string)

for _, task := range tasks {
graph[task.UUID] = make([]string, 0)
for _, dep := range task.Depends {
if dep != "" {
graph[task.UUID] = append(graph[task.UUID], dep)
}
}
}

if _, exists := graph[taskUUID]; exists {
graph[taskUUID] = make([]string, 0)
for _, dep := range newDepends {
if dep != "" {
graph[taskUUID] = append(graph[taskUUID], dep)
}
}
} else {
graph[taskUUID] = newDepends
}

visited := make(map[string]bool)
recStack := make(map[string]bool)

var dfs func(uuid string, path []string) error
dfs = func(uuid string, path []string) error {
visited[uuid] = true
recStack[uuid] = true
currentPath := append(path, uuid)

for _, depUUID := range graph[uuid] {
if _, exists := graph[depUUID]; !exists {
continue
}

if !visited[depUUID] {
if err := dfs(depUUID, currentPath); err != nil {
return err
}
} else if recStack[depUUID] {
cyclePath := buildCyclePath(currentPath, depUUID)
return fmt.Errorf("circular dependency detected: %s", cyclePath)
}
}

recStack[uuid] = false
return nil
}

for uuid := range graph {
if !visited[uuid] {
if err := dfs(uuid, []string{}); err != nil {
return err
}
}
}

return nil
}

func buildCyclePath(path []string, cycleStartUUID string) string {
cycleStartIdx := -1
for i, uuid := range path {
if uuid == cycleStartUUID {
cycleStartIdx = i
break
}
}

if cycleStartIdx == -1 {
return fmt.Sprintf("cycle detected involving task %s", cycleStartUUID)
}

cyclePath := path[cycleStartIdx:]
cyclePath = append(cyclePath, cycleStartUUID)

result := ""
for i, uuid := range cyclePath {
if i > 0 {
result += " → "
}
if len(uuid) > 8 {
result += uuid[:8] + "..."
} else {
result += uuid
}
}
return result
}