1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain
4
5import (
6 "errors"
7 "fmt"
8 "unicode"
9 "unicode/utf8"
10)
11
12// Limits of the plain text of a merge request (ADR 0017), in characters.
13const (
14 // MaxTitleLen is the longest merge request title.
15 MaxTitleLen = 120
16 // MaxTextLen is the longest description or comment.
17 MaxTextLen = 8000
18)
19
20// ErrInvalidText means a text breaks the rules of ParseMRTitle or
21// ParsePlainText.
22var ErrInvalidText = errors.New("invalid text")
23
24// TextError says why a text is invalid. It matches ErrInvalidText.
25type TextError struct {
26 // Reason completes the sentence "the text ...", for example "is not valid
27 // UTF-8".
28 Reason string
29}
30
31// Error implements error.
32func (e *TextError) Error() string { return "text " + e.Reason }
33
34// Unwrap makes errors.Is(err, ErrInvalidText) true.
35func (e *TextError) Unwrap() error { return ErrInvalidText }
36
37// MRTitle is a validated merge request title: one line of plain text, at most
38// MaxTitleLen characters. Only ParseMRTitle builds a non-zero value.
39type MRTitle struct{ s string }
40
41// ParseMRTitle validates s: not blank, valid UTF-8, one line, at most
42// MaxTitleLen characters, no control character and no bidirectional override
43// or isolate character.
44func ParseMRTitle(s string) (MRTitle, error) {
45 if reason := textProblem(s, false, MaxTitleLen); reason != "" {
46 return MRTitle{}, &TextError{Reason: reason}
47 }
48 if isBlank(s) {
49 return MRTitle{}, &TextError{Reason: "is empty"}
50 }
51 return MRTitle{s: s}, nil
52}
53
54// String returns the title.
55func (t MRTitle) String() string { return t.s }
56
57// IsZero reports whether t is the zero value.
58func (t MRTitle) IsZero() bool { return t.s == "" }
59
60// PlainText is a validated multi-line plain text, such as a description or a
61// comment: valid UTF-8, at most MaxTextLen characters, line breaks allowed,
62// no other control character, no U+2028 or U+2029, no bidirectional mark,
63// override or isolate character.
64// The zero value is the empty text.
65type PlainText struct{ s string }
66
67// ParsePlainText validates s. The empty text is valid: a caller that needs
68// text checks IsBlank.
69func ParsePlainText(s string) (PlainText, error) {
70 if reason := textProblem(s, true, MaxTextLen); reason != "" {
71 return PlainText{}, &TextError{Reason: reason}
72 }
73 return PlainText{s: s}, nil
74}
75
76// String returns the text.
77func (t PlainText) String() string { return t.s }
78
79// IsBlank reports whether the text is empty or only white space.
80func (t PlainText) IsBlank() bool { return isBlank(t.s) }
81
82func isBlank(s string) bool {
83 for _, r := range s {
84 if !unicode.IsSpace(r) {
85 return false
86 }
87 }
88 return true
89}
90
91// textProblem returns why s breaks the rules, or "".
92func textProblem(s string, multiline bool, maxLen int) string {
93 if !utf8.ValidString(s) {
94 return "is not valid UTF-8"
95 }
96 if n := utf8.RuneCountInString(s); n > maxLen {
97 return fmt.Sprintf("is longer than %d characters (%d)", maxLen, n)
98 }
99 for _, r := range s {
100 switch {
101 case r == '\n':
102 if !multiline {
103 return "must be one line"
104 }
105 case unicode.IsControl(r):
106 return fmt.Sprintf("contains the control character U+%04X", r)
107 case r == '\u2028' || r == '\u2029':
108 return fmt.Sprintf("contains the line or paragraph separator U+%04X", r)
109 case isBidiControl(r):
110 return fmt.Sprintf("contains the bidirectional override or isolate character U+%04X", r)
111 }
112 }
113 return ""
114}
115
116// isBidiControl reports whether r is a bidirectional mark, embedding, override
117// or isolate character (U+200E, U+200F, U+061C, U+202A to U+202E, U+2066 to
118// U+2069), which can make text read differently from how it is stored.
119func isBidiControl(r rune) bool {
120 return r == 0x200e || r == 0x200f || r == 0x061c ||
121 (r >= 0x202a && r <= 0x202e) || (r >= 0x2066 && r <= 0x2069)
122}
123
124// FieldViolation names a request field that breaks a rule.
125type FieldViolation struct {
126 // Field is the name of the field in the API contract, for example
127 // "title".
128 Field string
129 // Reason says what is wrong with it.
130 Reason string
131}
132
133// InvalidFieldsError is a request rejected whole because of its fields. It
134// matches ErrInvalid.
135type InvalidFieldsError struct {
136 Fields []FieldViolation
137}
138
139// Error implements error.
140func (e *InvalidFieldsError) Error() string {
141 msg := "invalid argument"
142 for i, f := range e.Fields {
143 sep := "; "
144 if i == 0 {
145 sep = ": "
146 }
147 msg += sep + f.Field + " " + f.Reason
148 }
149 return msg
150}
151
152// Unwrap makes errors.Is(err, ErrInvalid) true.
153func (e *InvalidFieldsError) Unwrap() error { return ErrInvalid }
154
155// Violations collects the violations of a request.
156type Violations struct{ list []FieldViolation }
157
158// Add records that field breaks a rule.
159func (v *Violations) Add(field, reason string) {
160 v.list = append(v.list, FieldViolation{Field: field, Reason: reason})
161}
162
163// AddErr records an error of a parser for field: the reason of a *TextError,
164// else the text of the error.
165func (v *Violations) AddErr(field string, err error) {
166 var te *TextError
167 if errors.As(err, &te) {
168 v.Add(field, te.Reason)
169 return
170 }
171 v.Add(field, err.Error())
172}
173
174// Err returns nil without violations, else an *InvalidFieldsError.
175func (v *Violations) Err() error {
176 if len(v.list) == 0 {
177 return nil
178 }
179 return &InvalidFieldsError{Fields: append([]FieldViolation(nil), v.list...)}
180}