internal/application/service.go
1
package application
3
import (
4
"errors"
5
"fmt"
6
"path"
7
"strings"
8
"time"
10
"github.com/koment-dev/koment/internal/anchor"
11
"github.com/koment-dev/koment/internal/provenance"
12
"github.com/koment-dev/koment/internal/repository"
13
"github.com/koment-dev/koment/internal/store"
14
)
16
// Service owns local repository reads and mutations.
17
type Service struct {
18
repository repository.Repository
19
store *store.Store
20
}
22
// AddInput is the complete intent needed to create an annotation.
23
type AddInput struct {
24
File string
25
Excerpt string
26
Kind store.Type
27
Title string
28
Body string
29
Author store.Author
30
Policy *store.Policy
31
}
33
// ReanchorInput moves an existing annotation without changing its identity.
34
type ReanchorInput struct {
35
ID string
36
File string
37
Excerpt string
38
}
40
// Mutation is the durable record and its repository-relative path.
41
type Mutation struct {
42
Record store.Annotation
43
Path string
44
Warnings []string
45
}
47
// NewService constructs the application service for one repository.
48
func NewService(entry repository.Repository) *Service {
49
return &Service{repository: entry, store: entry.Store()}
50
}
52
// Snapshot reads the repository through the shared snapshot contract.
53
func (s *Service) Snapshot() (*RepositorySnapshot, error) {
54
return BuildSnapshot(s.repository)
55
}
57
// Add creates one fully validated annotation record.
58
func (s *Service) Add(input AddInput) (Mutation, error) {
59
file, err := s.store.FromRoot(input.File)
60
if err != nil {
61
return Mutation{}, err
62
}
63
if err := input.Author.Validate(); err != nil {
64
return Mutation{}, err
65
}
66
if _, err := store.ParseType(string(input.Kind)); err != nil {
67
return Mutation{}, err
68
}
69
id, err := store.NewID(time.Now())
70
if err != nil {
71
return Mutation{}, err
72
}
73
record := store.Annotation{
74
APIVersion: store.APIVersion,
75
Kind: store.KindAnnotation,
76
Metadata: store.Metadata{ID: id, Created: store.Now()},
77
Spec: store.Spec{
78
Target: store.Target{File: file},
79
Type: input.Kind,
80
Title: strings.TrimSpace(input.Title),
81
Body: store.WrapProse(input.Body),
82
Author: input.Author,
83
Policy: input.Policy,
84
},
85
}
86
if err := s.anchor(&record, file, input.Excerpt); err != nil {
87
return Mutation{}, err
88
}
89
warnings := s.captureGit(&record)
90
if strings.TrimSpace(record.Spec.Title) == "" {
91
warnings = append(warnings, "no title provided; the first sentence of the body will be shown as the headline (ADR 0115)")
92
}
93
s.observe(&record)
94
if err := s.store.Save(&record); err != nil {
95
return Mutation{}, err
96
}
97
return Mutation{Record: record, Path: recordPath(id), Warnings: warnings}, nil
98
}
100
// Reanchor changes only an annotation's target.
101
func (s *Service) Reanchor(input ReanchorInput) (Mutation, error) {
102
record, err := s.store.FindByID(input.ID)
103
if err != nil {
104
return Mutation{}, err
105
}
106
file := record.Spec.Target.File
107
if input.File != "" {
108
if file, err = s.store.FromRoot(input.File); err != nil {
109
return Mutation{}, err
110
}
111
}
112
excerpt := input.Excerpt
113
if excerpt == "" && record.Spec.Anchor.Scope == store.ScopeExcerpt {
114
excerpt = record.Spec.Anchor.Excerpt
115
}
116
moved := *record
117
if err := s.anchor(&moved, file, excerpt); err != nil {
118
return Mutation{}, err
119
}
120
moved.Spec.Target.File = file
121
s.observe(&moved)
122
if err := s.store.Save(&moved); err != nil {
123
return Mutation{}, err
124
}
125
return Mutation{Record: moved, Path: recordPath(moved.Metadata.ID)}, nil
126
}
128
func (s *Service) anchor(record *store.Annotation, file, excerpt string) error {
129
content, err := s.store.ReadSource(file)
130
if err != nil {
131
return fmt.Errorf("reading %s: %w", file, err)
132
}
133
if excerpt == "" {
134
record.Spec.Anchor = store.Anchor{Scope: store.ScopeFile}
135
record.Status.LastSeenLine = 0
136
return nil
137
}
138
captured, line, err := anchor.Capture(content, excerpt)
139
if err != nil {
140
lines := anchor.ExcerptLines(content, excerpt)
141
switch len(lines) {
142
case 0:
143
return fmt.Errorf("excerpt not found in %s; it must match the file verbatim%s", file, nearMissHint(content, excerpt))
144
default:
145
return fmt.Errorf("excerpt matches %d places in %s (lines %v); extend it until it is unique", len(lines), file, lines)
146
}
147
}
148
record.Spec.Anchor = captured
149
record.Status.LastSeenLine = line
150
return nil
151
}
153
func (s *Service) observe(record *store.Annotation) {
154
commit, err := provenance.Head(s.repository.Root)
155
if err != nil {
156
commit = ""
157
}
158
record.Status.Observe(store.AnchorOK, commit, store.Now())
159
}
161
func (s *Service) captureGit(record *store.Annotation) []string {
162
file := record.Spec.Target.File
163
context, err := provenance.Capture(s.repository.Root, file, record.Status.LastSeenLine, record.Status.LastSeenLine)
164
if err == nil {
165
record.Spec.Git = context
166
if provenance.WorktreeIsDirty(s.repository.Root, file) {
167
return []string{fmt.Sprintf("%s has uncommitted changes, so commit %s does not describe what was annotated", file, context.Commit[:7])}
168
}
169
return nil
170
}
171
if errors.Is(err, provenance.ErrNoGit) {
172
return []string{fmt.Sprintf("no git context recorded for %s", file)}
173
}
174
return []string{fmt.Sprintf("git context failed for %s: %v", file, err)}
175
}
177
func recordPath(id string) string {
178
return path.Join(store.DirName, "annotations", id+".yaml")
179
}
181
func nearMissHint(content []byte, excerpt string) string {
182
wanted := collapseWhitespace(excerpt)
183
if wanted == "" || !strings.Contains(collapseWhitespace(string(content)), wanted) {
184
return ""
185
}
186
if strings.Contains(string(content), "\r\n") {
187
return ". The text is there once whitespace is ignored, and the file has CRLF line endings"
188
}
189
return ". The text is there once whitespace is ignored, so check indentation and trailing spaces"
190
}
192
func collapseWhitespace(text string) string {
193
return strings.Join(strings.Fields(text), " ")
194
}
196
// EditInput changes only the prose a person can improve later. Identity,
197
// authorship, creation time and anchor are not editable here.
198
type EditInput struct {
199
ID string
200
Title *string
201
Body *string
202
}
204
// Edit rewrites an annotation's headline or rationale in place.
205
func (s *Service) Edit(input EditInput) (Mutation, error) {
206
record, err := s.store.FindByID(input.ID)
207
if err != nil {
208
return Mutation{}, err
209
}
210
if input.Title == nil && input.Body == nil {
211
return Mutation{}, fmt.Errorf("edit needs --title, --body, or both")
212
}
213
edited := *record
214
if input.Title != nil {
215
edited.Spec.Title = strings.TrimSpace(*input.Title)
216
}
217
if input.Body != nil {
218
edited.Spec.Body = store.WrapProse(*input.Body)
219
}
220
if err := edited.Validate(); err != nil {
221
return Mutation{}, err
222
}
223
if err := s.store.Save(&edited); err != nil {
224
return Mutation{}, err
225
}
226
return Mutation{Record: edited, Path: recordPath(edited.Metadata.ID)}, nil
227
}
229
// Forget deletes one annotation record. Git holds who removed it and why.
230
func (s *Service) Forget(id string) (store.Annotation, error) {
231
record, err := s.store.FindByID(id)
232
if err != nil {
233
return store.Annotation{}, err
234
}
235
if err := s.store.Remove(id); err != nil {
236
return store.Annotation{}, err
237
}
238
return *record, nil
239
}