This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knot2 / crates / knot-messages / src / lib.rs
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}