This repository has no description
14 kB
376 lines
1mod template;
2
3use confique::Config;
4
5pub use template::{Key, Line, Lines, NoKeys, Segment, Shape, Template, TemplateError};
6
7// Each msg field defines the enum of placeholders it accepts,
8// so a typo'ed `{handel}` in a given config gets caught at startup,
9// rather than printed at some poor pusher mid-push.
10macro_rules! keys {
11 ( $( $name:ident { $( $variant:ident = $placeholder:literal ),+ $(,)? } )+ ) => {
12 $(
13 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
14 pub enum $name {
15 $( $variant, )+
16 }
17
18 impl Key for $name {
19 const PLACEHOLDERS: &'static [(&'static str, Self)] =
20 &[ $( ($placeholder, Self::$variant), )+ ];
21 }
22 )+
23 };
24}
25
26macro_rules! config_type {
27 (Lines) => { Vec<String> };
28 (Line) => { String };
29}
30
31macro_rules! parse_field {
32 (Lines, $field:expr, $value:expr) => {
33 Template::parse_lines($field, $value)
34 };
35 (Line, $field:expr, $value:expr) => {
36 Template::parse($field, $value)
37 };
38}
39
40macro_rules! message_group {
41 (
42 $config:ident => $catalog:ident @ $prefix:literal {
43 $( $field:ident : $shape:ident<$keys:ty> = $default:tt ),+ $(,)?
44 }
45 ) => {
46 #[derive(Debug, ::confique::Config)]
47 pub struct $config {
48 $(
49 #[config(default = $default)]
50 pub $field: config_type!($shape),
51 )+
52 }
53
54 #[derive(Debug)]
55 pub struct $catalog {
56 $( pub $field: Template<$keys, $shape>, )+
57 }
58
59 impl $catalog {
60 pub fn parse(config: &$config) -> Result<Self, TemplateError> {
61 Ok(Self {
62 $(
63 $field: parse_field!(
64 $shape,
65 concat!($prefix, ".", stringify!($field)),
66 &config.$field
67 )?,
68 )+
69 })
70 }
71 }
72 };
73}
74
75keys! {
76 KnotKey { Knot = "knot" }
77 PushAckKey { Knot = "knot", Refs = "refs" }
78 UrlKey { Url = "url" }
79 CiLogsKey { Host = "host", Port = "port", Repo = "repo", Sha = "sha" }
80 GreetingKey { User = "user", Knot = "knot" }
81 CountKey { Count = "count" }
82 RefKey { Ref = "ref" }
83 ErrorKey { Error = "error" }
84 CommandKey { Command = "command" }
85 VersionKey { Version = "version" }
86 AlgorithmKey { Algorithm = "algorithm" }
87 ValueKey { Value = "value" }
88 OidKey { Oid = "oid" }
89 DetailKey { Detail = "detail" }
90 DeclaredComputedKey { Declared = "declared", Computed = "computed" }
91 DeclaredReceivedKey { Declared = "declared", Received = "received" }
92 DeclaredLimitKey { Declared = "declared", Limit = "limit" }
93 FreeFloorKey { Free = "free", Floor = "floor" }
94 WhatLimitKey { What = "what", Limit = "limit" }
95}
96
97message_group! {
98 PushConfig => PushMessages @ "messages.push" {
99 ack: Lines<PushAckKey> = ["{knot} received {refs}."],
100 pull_request: Lines<UrlKey> = [
101 "",
102 "-> Open stinky pull request for this branch:",
103 " {url}",
104 ""
105 ],
106 pipeline_clean: Lines<NoKeys> = ["pipeline compiled with no diagnostics"],
107 pipeline_none: Lines<NoKeys> = ["no pipelines to compile"],
108 ci_logs: Lines<CiLogsKey> = [
109 "-> Browse CI logs in your terminal:",
110 " ssh -t -p {port} {host} {repo} {sha}"
111 ],
112 }
113}
114
115message_group! {
116 FetchConfig => FetchMessages @ "messages.fetch" {
117 motd: Lines<KnotKey> = ["Thanks for using {knot}!"],
118 enumerating: Lines<CountKey> = ["Enumerating objects: {count}, done."],
119 total: Lines<CountKey> = ["Total {count}, done."],
120 fatal: Line<ErrorKey> = "knot: {error}",
121 }
122}
123
124message_group! {
125 RejectConfig => RejectMessages @ "messages.reject" {
126 reserved_refs: Line<NoKeys> = "refs/cobs/* and refs/hidden/* are reserved and cannot be pushed",
127 cob_create_only: Line<NoKeys> = "existing refs/cobs/* object cannot be modified or deleted over the wire",
128 cob_delete: Line<NoKeys> = "refs/cobs/* stores append-only collaborative objects and cannot be deleted",
129 hidden_reserved: Line<NoKeys> = "refs/hidden/* is reserved for server-side fork staging and cannot be pushed",
130 cob_verification: Line<ErrorKey> = "collaborative-object verification failed: {error}",
131 ref_exists: Line<NoKeys> = "reference already exists",
132 stale_old_value: Line<NoKeys> = "stale info: old value doesn't match",
133 missing_objects: Line<NoKeys> = "missing necessary objects",
134 missing_objects_for: Line<RefKey> = "missing necessary objects for {ref}",
135 atomic_failed: Line<NoKeys> = "atomic transaction failed",
136 atomic_aborted: Line<NoKeys> = "atomic push aborted",
137 authorization_unavailable: Line<NoKeys> = "authorization unavailable",
138 unpacker_error: Line<NoKeys> = "unpacker error",
139 ref_snapshot_unavailable: Line<NoKeys> = "ref snapshot unavailable",
140 object_migration_failed: Line<NoKeys> = "object migration failed",
141 }
142}
143
144message_group! {
145 SshConfig => SshMessages @ "messages.ssh" {
146 greeting: Lines<GreetingKey> = [
147 "Hi {user}! You're authenticated to {knot} knot.",
148 "This knot serves git over ssh, so there's no shell here. :P",
149 "Clone repo with: git clone {knot}:<repoDID>"
150 ],
151 unsupported_command: Line<NoKeys> = "knot: unsupported command",
152 too_many_operations: Line<NoKeys> = "knot: too many concurrent operations from your address, try again shortly",
153 repo_not_found: Line<NoKeys> = "knot: repository not found",
154 index_warming: Line<NoKeys> = "knot: repository index is warming, retry shortly",
155 lfs_disabled: Line<NoKeys> = "knot: LFS isn't enabled on this knot",
156 key_not_registered: Line<NoKeys> = "knot: your ssh key isn't registered to a user authorized to push here. If you offer several keys, make sure the registered one is offered first.",
157 push_denied: Line<NoKeys> = "knot: you aren't authorized to push to this repository.",
158 shutting_down: Line<NoKeys> = "knot: server is shutting down",
159 archive_malformed: Line<NoKeys> = "knot: malformed upload-archive request",
160 archive_timeout: Line<NoKeys> = "knot: upload-archive request timed out",
161 archive_failed: Line<NoKeys> = "knot: upload-archive failed",
162 advertise_failed: Line<NoKeys> = "knot: cannot advertise refs",
163 push_too_large: Line<NoKeys> = "knot: push exceeds configured size limit",
164 receive_deadline: Line<NoKeys> = "knot: receive exceeded its time budget",
165 malformed_pack: Line<NoKeys> = "knot: malformed pack stream",
166 receive_read_error: Line<NoKeys> = "knot: receive read error",
167 receive_ended_early: Line<NoKeys> = "knot: receive stream ended early",
168 receive_failed: Line<NoKeys> = "knot: receive-pack failed",
169 }
170}
171
172message_group! {
173 HttpConfig => HttpMessages @ "messages.http" {
174 push_denied: Line<NoKeys> = "you aren't authorized to push to this repository",
175 repo_not_found: Line<NoKeys> = "repository not found",
176 push_too_large: Line<NoKeys> = "push exceeds the configured size limit",
177 malformed_pack: Line<ErrorKey> = "malformed pack stream: {error}",
178 receive_ended_early: Line<NoKeys> = "receive stream ended early",
179 }
180}
181
182message_group! {
183 LfsConfig => LfsMessages @ "messages.lfs" {
184 invalid_oid: Line<ValueKey> = "invalid LFS oid {value}",
185 hash_mismatch: Line<DeclaredComputedKey> = "oid mismatch, declared {declared}, computed {computed}",
186 size_mismatch: Line<DeclaredReceivedKey> = "size mismatch, declared {declared}, received {received}",
187 size_limit_exceeded: Line<DeclaredLimitKey> = "object size {declared} exceeds limit {limit}",
188 free_space_denied: Line<FreeFloorKey> = "free space {free} below floor {floor}",
189 not_found: Line<OidKey> = "object {oid} not found",
190 framing: Line<DetailKey> = "protocol framing fault: {detail}",
191 too_many: Line<WhatLimitKey> = "too many {what} in one message, limit {limit}",
192 unknown_command: Line<CommandKey> = "unknown command {command}",
193 unsupported_version: Line<VersionKey> = "unsupported version {version}",
194 unsupported_hash: Line<AlgorithmKey> = "unsupported hash algorithm {algorithm}",
195 put_on_download: Line<NoKeys> = "put-object isn't allowed on a download channel",
196 verify_on_download: Line<NoKeys> = "verify-object isn't allowed on a download channel",
197 get_on_upload: Line<NoKeys> = "get-object isn't allowed on an upload channel",
198 put_no_body: Line<NoKeys> = "put-object is missing its object body",
199 }
200}
201
202#[derive(Debug, Config)]
203pub struct MessagesConfig {
204 #[config(nested)]
205 pub push: PushConfig,
206 #[config(nested)]
207 pub fetch: FetchConfig,
208 #[config(nested)]
209 pub reject: RejectConfig,
210 #[config(nested)]
211 pub ssh: SshConfig,
212 #[config(nested)]
213 pub http: HttpConfig,
214 #[config(nested)]
215 pub lfs: LfsConfig,
216}
217
218impl MessagesConfig {
219 pub fn defaults() -> Self {
220 Self::builder()
221 .load()
222 .expect("message defaults satisfy every field")
223 }
224}
225
226#[derive(Debug)]
227pub struct Catalog {
228 pub push: PushMessages,
229 pub fetch: FetchMessages,
230 pub reject: RejectMessages,
231 pub ssh: SshMessages,
232 pub http: HttpMessages,
233 pub lfs: LfsMessages,
234}
235
236impl Catalog {
237 pub fn parse(config: &MessagesConfig) -> Result<Self, TemplateError> {
238 Ok(Self {
239 push: PushMessages::parse(&config.push)?,
240 fetch: FetchMessages::parse(&config.fetch)?,
241 reject: RejectMessages::parse(&config.reject)?,
242 ssh: SshMessages::parse(&config.ssh)?,
243 http: HttpMessages::parse(&config.http)?,
244 lfs: LfsMessages::parse(&config.lfs)?,
245 })
246 }
247
248 pub fn defaults() -> Self {
249 Self::parse(&MessagesConfig::defaults()).expect("built-in message templates parse")
250 }
251}
252
253pub fn default_catalog() -> &'static Catalog {
254 static DEFAULTS: std::sync::LazyLock<Catalog> = std::sync::LazyLock::new(Catalog::defaults);
255 &DEFAULTS
256}
257
258pub fn count_refs(applied: usize) -> String {
259 match applied {
260 1 => "1 ref".to_string(),
261 n => format!("{n} refs"),
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
270 fn the_defaults_parse_into_a_full_catalog() {
271 let catalog = Catalog::defaults();
272 assert_eq!(catalog.reject.ref_exists.text(), "reference already exists");
273 assert_eq!(
274 catalog.ssh.repo_not_found.text(),
275 "knot: repository not found"
276 );
277 }
278
279 #[test]
280 fn the_pull_request_block_matches_the_shipped_shape() {
281 let catalog = Catalog::defaults();
282 let url = "https://oyster.cafe/nel.pet/anemone/pulls/new";
283 let block = catalog
284 .push
285 .pull_request
286 .lines(|UrlKey::Url| url.to_string());
287 assert_eq!(
288 block,
289 vec![
290 "\u{200b}".to_string(),
291 "-> Open stinky pull request for this branch:".to_string(),
292 format!(" {url}"),
293 "\u{200b}".to_string(),
294 ]
295 );
296 }
297
298 #[test]
299 fn the_greeting_names_the_user_and_the_knot() {
300 let catalog = Catalog::defaults();
301 let lines = catalog.ssh.greeting.lines(|key| match key {
302 GreetingKey::User => "@nel.pet".to_string(),
303 GreetingKey::Knot => "oyster.cafe".to_string(),
304 });
305 assert!(lines[0].contains("@nel.pet"));
306 assert!(lines.iter().any(|line| line.contains("oyster.cafe")));
307 }
308
309 #[test]
310 fn an_empty_lines_template_mutes_the_message() {
311 let template: Template<NoKeys, Lines> =
312 Template::parse_lines("messages.test", &[]).unwrap();
313 assert!(template.text_lines().is_empty());
314 }
315
316 #[test]
317 fn an_unknown_placeholder_is_a_parse_error() {
318 let error = Template::<KnotKey, Lines>::parse_lines(
319 "messages.fetch.motd",
320 &["hi {handle}".to_string()],
321 )
322 .unwrap_err();
323 assert_eq!(
324 error,
325 TemplateError::UnknownPlaceholder {
326 field: "messages.fetch.motd",
327 name: "handle".to_string(),
328 }
329 );
330 }
331
332 #[test]
333 fn doubled_braces_render_as_literal_braces() {
334 let template: Template<NoKeys, Line> =
335 Template::parse("messages.test", "a {{literal}} brace").unwrap();
336 assert_eq!(template.text(), "a {literal} brace");
337 }
338
339 #[test]
340 fn line_templates_reject_empty_and_multiline_text() {
341 assert_eq!(
342 Template::<NoKeys, Line>::parse("messages.test", "").unwrap_err(),
343 TemplateError::Empty {
344 field: "messages.test"
345 }
346 );
347 assert_eq!(
348 Template::<NoKeys, Line>::parse("messages.test", "a\nb").unwrap_err(),
349 TemplateError::Multiline {
350 field: "messages.test"
351 }
352 );
353 }
354
355 #[test]
356 fn unbalanced_braces_are_parse_errors() {
357 assert_eq!(
358 Template::<NoKeys, Line>::parse("messages.test", "open {").unwrap_err(),
359 TemplateError::UnclosedBrace {
360 field: "messages.test"
361 }
362 );
363 assert_eq!(
364 Template::<NoKeys, Line>::parse("messages.test", "close }").unwrap_err(),
365 TemplateError::StrayBrace {
366 field: "messages.test"
367 }
368 );
369 }
370
371 #[test]
372 fn ref_counts_pluralize() {
373 assert_eq!(count_refs(1), "1 ref");
374 assert_eq!(count_refs(3), "3 refs");
375 }
376}