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
s.observe(&record)
91
if err := s.store.Save(&record); err != nil {
92
return Mutation{}, err
93
}
94
return Mutation{Record: record, Path: recordPath(id), Warnings: warnings}, nil
95
}
97
// Reanchor changes only an annotation's target.
98
func (s *Service) Reanchor(input ReanchorInput) (Mutation, error) {
99
record, err := s.store.FindByID(input.ID)
100
if err != nil {
101
return Mutation{}, err
102
}
103
file := record.Spec.Target.File
104
if input.File != "" {
105
if file, err = s.store.FromRoot(input.File); err != nil {
106
return Mutation{}, err
107
}
108
}
109
excerpt := input.Excerpt
110
if excerpt == "" && record.Spec.Anchor.Scope == store.ScopeExcerpt {
111
excerpt = record.Spec.Anchor.Excerpt
112
}
113
moved := *record
114
if err := s.anchor(&moved, file, excerpt); err != nil {
115
return Mutation{}, err
116
}
117
moved.Spec.Target.File = file
118
s.observe(&moved)
119
if err := s.store.Save(&moved); err != nil {
120
return Mutation{}, err
121
}
122
return Mutation{Record: moved, Path: recordPath(moved.Metadata.ID)}, nil
123
}
125
func (s *Service) anchor(record *store.Annotation, file, excerpt string) error {
126
content, err := s.store.ReadSource(file)
127
if err != nil {
128
return fmt.Errorf("reading %s: %w", file, err)
129
}
130
if excerpt == "" {
131
record.Spec.Anchor = store.Anchor{Scope: store.ScopeFile}
132
record.Status.LastSeenLine = 0
133
return nil
134
}
135
captured, line, err := anchor.Capture(content, excerpt)
136
if err != nil {
137
lines := anchor.ExcerptLines(content, excerpt)
138
switch len(lines) {
139
case 0:
140
return fmt.Errorf("excerpt not found in %s; it must match the file verbatim", file)
141
default:
142
return fmt.Errorf("excerpt matches %d places in %s (lines %v); extend it until it is unique", len(lines), file, lines)
143
}
144
}
145
record.Spec.Anchor = captured
146
record.Status.LastSeenLine = line
147
return nil
148
}
150
func (s *Service) observe(record *store.Annotation) {
151
commit, err := provenance.Head(s.repository.Root)
152
if err != nil {
153
commit = ""
154
}
155
record.Status.Observe(store.AnchorOK, commit, store.Now())
156
}
158
func (s *Service) captureGit(record *store.Annotation) []string {
159
file := record.Spec.Target.File
160
context, err := provenance.Capture(s.repository.Root, file, record.Status.LastSeenLine, record.Status.LastSeenLine)
161
if err == nil {
162
record.Spec.Git = context
163
if provenance.WorktreeIsDirty(s.repository.Root, file) {
164
return []string{fmt.Sprintf("%s has uncommitted changes, so commit %s does not describe what was annotated", file, context.Commit[:7])}
165
}
166
return nil
167
}
168
if errors.Is(err, provenance.ErrNoGit) {
169
return []string{fmt.Sprintf("no git context recorded for %s", file)}
170
}
171
return []string{fmt.Sprintf("git context failed for %s: %v", file, err)}
172
}
174
func recordPath(id string) string {
175
return path.Join(store.DirName, "annotations", id+".yaml")
176
}