internal/application/service.go
1
package application
3
import (
4
"errors"
5
"fmt"
6
"path"
7
"time"
9
"github.com/janpuc/koment/internal/anchor"
10
"github.com/janpuc/koment/internal/provenance"
11
"github.com/janpuc/koment/internal/repository"
12
"github.com/janpuc/koment/internal/store"
13
)
15
// Service owns local repository reads and mutations.
16
type Service struct {
17
repository repository.Repository
18
store *store.Store
19
}
21
// AddInput is the complete intent needed to create an annotation.
22
type AddInput struct {
23
File string
24
Excerpt string
25
Kind store.Kind
26
Body string
27
Author store.Author
28
Policy *store.Policy
29
}
31
// ReanchorInput moves an existing annotation without changing its identity.
32
type ReanchorInput struct {
33
ID string
34
File string
35
Excerpt string
36
}
38
// Mutation is the durable record and its repository-relative path.
39
type Mutation struct {
40
Record store.Annotation
41
Path string
42
Warnings []string
43
}
45
// NewService constructs the application service for one repository.
46
func NewService(entry repository.Repository) *Service {
47
return &Service{repository: entry, store: entry.Store()}
48
}
50
// Snapshot reads the repository through the shared snapshot contract.
51
func (s *Service) Snapshot() (*RepositorySnapshot, error) {
52
return BuildSnapshot(s.repository)
53
}
55
// Add creates one fully validated annotation record.
56
func (s *Service) Add(input AddInput) (Mutation, error) {
57
file, err := s.store.FromRoot(input.File)
58
if err != nil {
59
return Mutation{}, err
60
}
61
if err := input.Author.Validate(); err != nil {
62
return Mutation{}, err
63
}
64
if _, err := store.ParseKind(string(input.Kind)); err != nil {
65
return Mutation{}, err
66
}
67
id, err := store.NewID(time.Now())
68
if err != nil {
69
return Mutation{}, err
70
}
71
record := store.Annotation{
72
Version: store.RecordVersion, ID: id, File: file, Kind: input.Kind,
73
Body: store.WrapProse(input.Body), Created: store.Today(), Author: input.Author,
74
Policy: input.Policy,
75
}
76
if err := s.anchor(&record, file, input.Excerpt); err != nil {
77
return Mutation{}, err
78
}
79
warnings := s.captureGit(&record)
80
if err := s.store.Save(&record); err != nil {
81
return Mutation{}, err
82
}
83
return Mutation{Record: record, Path: recordPath(record.ID), Warnings: warnings}, nil
84
}
86
// Reanchor changes only an annotation's target.
87
func (s *Service) Reanchor(input ReanchorInput) (Mutation, error) {
88
record, err := s.store.FindByID(input.ID)
89
if err != nil {
90
return Mutation{}, err
91
}
92
file := record.File
93
if input.File != "" {
94
if file, err = s.store.FromRoot(input.File); err != nil {
95
return Mutation{}, err
96
}
97
}
98
excerpt := input.Excerpt
99
if excerpt == "" && record.Anchor.Scope == store.ScopeExcerpt {
100
excerpt = record.Anchor.Excerpt
101
}
102
moved := *record
103
if err := s.anchor(&moved, file, excerpt); err != nil {
104
return Mutation{}, err
105
}
106
moved.File = file
107
if err := s.store.Save(&moved); err != nil {
108
return Mutation{}, err
109
}
110
return Mutation{Record: moved, Path: recordPath(moved.ID)}, nil
111
}
113
func (s *Service) anchor(record *store.Annotation, file, excerpt string) error {
114
content, err := s.store.ReadSource(file)
115
if err != nil {
116
return fmt.Errorf("reading %s: %w", file, err)
117
}
118
if excerpt == "" {
119
record.Anchor = store.Anchor{Scope: store.ScopeFile}
120
return nil
121
}
122
captured, err := anchor.Capture(content, excerpt)
123
if err != nil {
124
lines := anchor.ExcerptLines(content, excerpt)
125
switch len(lines) {
126
case 0:
127
return fmt.Errorf("excerpt not found in %s; it must match the file verbatim", file)
128
default:
129
return fmt.Errorf("excerpt matches %d places in %s (lines %v); extend it until it is unique", len(lines), file, lines)
130
}
131
}
132
record.Anchor = captured
133
return nil
134
}
136
func (s *Service) captureGit(record *store.Annotation) []string {
137
context, err := provenance.Capture(s.repository.Root, record.File, record.Anchor.LastSeenLine, record.Anchor.LastSeenLine)
138
if err == nil {
139
record.Git = context
140
if provenance.WorktreeIsDirty(s.repository.Root, record.File) {
141
return []string{fmt.Sprintf("%s has uncommitted changes, so commit %s does not describe what was annotated", record.File, context.Commit[:7])}
142
}
143
return nil
144
}
145
if errors.Is(err, provenance.ErrNoGit) {
146
return []string{fmt.Sprintf("no git context recorded for %s", record.File)}
147
}
148
return []string{fmt.Sprintf("git context failed for %s: %v", record.File, err)}
149
}
151
func recordPath(id string) string {
152
return path.Join(store.DirName, "annotations", id+".yaml")
153
}