package ticket import ( "encoding/json" "fmt" "os" "path/filepath" "sync" "time" "github.com/google/uuid" ) const ( ticketsFileName = "tickets.json" legacyFileName = "tasks.json" ) // JSONStore persists tickets as JSON arrays under DataDir/projects//tickets.json. type JSONStore struct { dataDir string mu sync.Mutex } // NewJSONStore creates a store rooted at dataDir. func NewJSONStore(dataDir string) *JSONStore { return &JSONStore{dataDir: dataDir} } func (s *JSONStore) projectPath(projectID string) string { return filepath.Join(s.dataDir, "projects", projectID) } func (s *JSONStore) ticketsPath(projectID string) string { return filepath.Join(s.projectPath(projectID), ticketsFileName) } func (s *JSONStore) legacyPath(projectID string) string { return filepath.Join(s.projectPath(projectID), legacyFileName) } // List returns all tickets for a project. func (s *JSONStore) List(projectID string) ([]Ticket, error) { s.mu.Lock() defer s.mu.Unlock() return s.readUnlocked(projectID) } // Get finds a ticket by ID across all project directories. func (s *JSONStore) Get(id string) (Ticket, error) { s.mu.Lock() defer s.mu.Unlock() projects, err := os.ReadDir(filepath.Join(s.dataDir, "projects")) if err != nil { if os.IsNotExist(err) { return Ticket{}, fmt.Errorf("ticket %s: not found", id) } return Ticket{}, fmt.Errorf("list projects: %w", err) } for _, p := range projects { if !p.IsDir() { continue } tickets, err := s.readUnlocked(p.Name()) if err != nil { return Ticket{}, err } for _, t := range tickets { if t.ID == id { return t, nil } } } return Ticket{}, fmt.Errorf("ticket %s: not found", id) } // Create adds a new ticket, assigning ID and timestamps if missing. func (s *JSONStore) Create(t Ticket) (Ticket, error) { s.mu.Lock() defer s.mu.Unlock() if t.ProjectID == "" { return Ticket{}, fmt.Errorf("projectId is required") } if t.Title == "" { return Ticket{}, fmt.Errorf("title is required") } if t.ID == "" { t.ID = uuid.NewString() } if t.Status == "" { t.Status = StatusTodo } now := time.Now().UTC() if t.CreatedAt.IsZero() { t.CreatedAt = now } if t.UpdatedAt.IsZero() { t.UpdatedAt = now } if err := os.MkdirAll(s.projectPath(t.ProjectID), 0o755); err != nil { return Ticket{}, fmt.Errorf("create project dir: %w", err) } tickets, err := s.readUnlocked(t.ProjectID) if err != nil { return Ticket{}, err } tickets = append(tickets, t) if err := s.writeUnlocked(t.ProjectID, tickets); err != nil { return Ticket{}, err } return t, nil } // Update replaces an existing ticket (matched by ID within its project). func (s *JSONStore) Update(t Ticket) error { s.mu.Lock() defer s.mu.Unlock() if t.ID == "" || t.ProjectID == "" { return fmt.Errorf("id and projectId are required") } tickets, err := s.readUnlocked(t.ProjectID) if err != nil { return err } found := false for i := range tickets { if tickets[i].ID == t.ID { t.CreatedAt = tickets[i].CreatedAt t.UpdatedAt = time.Now().UTC() tickets[i] = t found = true break } } if !found { return fmt.Errorf("ticket %s: not found", t.ID) } return s.writeUnlocked(t.ProjectID, tickets) } // Move relocates a ticket to another project directory. func (s *JSONStore) Move(id, toProjectID string) (Ticket, error) { s.mu.Lock() defer s.mu.Unlock() if id == "" { return Ticket{}, fmt.Errorf("ticket id is required") } if toProjectID == "" { return Ticket{}, fmt.Errorf("projectId is required") } projects, err := os.ReadDir(filepath.Join(s.dataDir, "projects")) if err != nil { if os.IsNotExist(err) { return Ticket{}, fmt.Errorf("ticket %s: not found", id) } return Ticket{}, fmt.Errorf("list projects: %w", err) } var found Ticket fromProject := "" for _, p := range projects { if !p.IsDir() { continue } tickets, err := s.readUnlocked(p.Name()) if err != nil { return Ticket{}, err } for i, t := range tickets { if t.ID != id { continue } found = t fromProject = p.Name() tickets = append(tickets[:i], tickets[i+1:]...) if err := s.writeUnlocked(fromProject, tickets); err != nil { return Ticket{}, err } break } if fromProject != "" { break } } if fromProject == "" { return Ticket{}, fmt.Errorf("ticket %s: not found", id) } found.ProjectID = toProjectID found.UpdatedAt = time.Now().UTC() if err := os.MkdirAll(s.projectPath(toProjectID), 0o755); err != nil { return Ticket{}, fmt.Errorf("create project dir: %w", err) } dest, err := s.readUnlocked(toProjectID) if err != nil { return Ticket{}, err } dest = append(dest, found) if err := s.writeUnlocked(toProjectID, dest); err != nil { return Ticket{}, err } return found, nil } // Delete removes a ticket by ID from its project's file. func (s *JSONStore) Delete(id string) error { s.mu.Lock() defer s.mu.Unlock() projects, err := os.ReadDir(filepath.Join(s.dataDir, "projects")) if err != nil { if os.IsNotExist(err) { return fmt.Errorf("ticket %s: not found", id) } return fmt.Errorf("list projects: %w", err) } for _, p := range projects { if !p.IsDir() { continue } tickets, err := s.readUnlocked(p.Name()) if err != nil { return err } for i, t := range tickets { if t.ID == id { tickets = append(tickets[:i], tickets[i+1:]...) return s.writeUnlocked(p.Name(), tickets) } } } return fmt.Errorf("ticket %s: not found", id) } func (s *JSONStore) readUnlocked(projectID string) ([]Ticket, error) { path := s.ticketsPath(projectID) data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { // Migrate from legacy tasks.json if present. legacy := s.legacyPath(projectID) data, err = os.ReadFile(legacy) if err != nil { if os.IsNotExist(err) { return []Ticket{}, nil } return nil, fmt.Errorf("read %s: %w", legacy, err) } path = legacy } else { return nil, fmt.Errorf("read %s: %w", path, err) } } if len(data) == 0 { return []Ticket{}, nil } var tickets []Ticket if err := json.Unmarshal(data, &tickets); err != nil { return nil, fmt.Errorf("parse %s: %w", path, err) } if tickets == nil { tickets = []Ticket{} } return tickets, nil } func (s *JSONStore) writeUnlocked(projectID string, tickets []Ticket) error { path := s.ticketsPath(projectID) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return fmt.Errorf("create project dir: %w", err) } data, err := json.MarshalIndent(tickets, "", " ") if err != nil { return fmt.Errorf("marshal tickets: %w", err) } data = append(data, '\n') tmp, err := os.CreateTemp(filepath.Dir(path), "tickets-*.tmp") if err != nil { return fmt.Errorf("create temp file: %w", err) } tmpName := tmp.Name() defer func() { _ = os.Remove(tmpName) }() if _, err := tmp.Write(data); err != nil { _ = tmp.Close() return fmt.Errorf("write temp file: %w", err) } if err := tmp.Close(); err != nil { return fmt.Errorf("close temp file: %w", err) } if err := os.Rename(tmpName, path); err != nil { return fmt.Errorf("rename temp to %s: %w", path, err) } // Clean up legacy file after successful write to tickets.json. _ = os.Remove(s.legacyPath(projectID)) return nil }