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
15 kB 408 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 AuthorizedKey { Authorized = "authorized" } 82 CountKey { Count = "count" } 83 RefKey { Ref = "ref" } 84 ErrorKey { Error = "error" } 85 CommandKey { Command = "command" } 86 VersionKey { Version = "version" } 87 AlgorithmKey { Algorithm = "algorithm" } 88 ValueKey { Value = "value" } 89 OidKey { Oid = "oid" } 90 DetailKey { Detail = "detail" } 91 DeclaredComputedKey { Declared = "declared", Computed = "computed" } 92 DeclaredReceivedKey { Declared = "declared", Received = "received" } 93 DeclaredLimitKey { Declared = "declared", Limit = "limit" } 94 FreeFloorKey { Free = "free", Floor = "floor" } 95 WhatLimitKey { What = "what", Limit = "limit" } 96} 97 98message_group! { 99 PushConfig => PushMessages @ "messages.push" { 100 ack: Lines<PushAckKey> = ["{knot} received {refs}."], 101 pull_request: Lines<UrlKey> = [ 102 "", 103 "-> Open stinky pull request for this branch:", 104 " {url}", 105 "" 106 ], 107 pipeline_clean: Lines<NoKeys> = ["pipeline compiled with no diagnostics"], 108 pipeline_none: Lines<NoKeys> = ["no pipelines to compile"], 109 ci_logs: Lines<CiLogsKey> = [ 110 "-> Browse CI logs in your terminal:", 111 " ssh -t -p {port} {host} {repo} {sha}" 112 ], 113 } 114} 115 116message_group! { 117 FetchConfig => FetchMessages @ "messages.fetch" { 118 motd: Lines<KnotKey> = ["Thanks for using {knot}!"], 119 enumerating: Lines<CountKey> = ["Enumerating objects: {count}, done."], 120 total: Lines<CountKey> = ["Total {count}, done."], 121 fatal: Line<ErrorKey> = "knot: {error}", 122 } 123} 124 125message_group! { 126 RejectConfig => RejectMessages @ "messages.reject" { 127 reserved_refs: Line<NoKeys> = "refs/cobs/* and refs/hidden/* are reserved and cannot be pushed", 128 cob_create_only: Line<NoKeys> = "existing refs/cobs/* object cannot be modified or deleted over the wire", 129 cob_delete: Line<NoKeys> = "refs/cobs/* stores append-only collaborative objects and cannot be deleted", 130 hidden_reserved: Line<NoKeys> = "refs/hidden/* is reserved for server-side fork staging and cannot be pushed", 131 cob_verification: Line<ErrorKey> = "collaborative-object verification failed: {error}", 132 ref_exists: Line<NoKeys> = "reference already exists", 133 stale_old_value: Line<NoKeys> = "stale info: old value doesn't match", 134 missing_objects: Line<NoKeys> = "missing necessary objects", 135 missing_objects_for: Line<RefKey> = "missing necessary objects for {ref}", 136 atomic_failed: Line<NoKeys> = "atomic transaction failed", 137 atomic_aborted: Line<NoKeys> = "atomic push aborted", 138 authorization_unavailable: Line<NoKeys> = "authorization unavailable", 139 unpacker_error: Line<NoKeys> = "unpacker error", 140 ref_snapshot_unavailable: Line<NoKeys> = "ref snapshot unavailable", 141 object_migration_failed: Line<NoKeys> = "object migration failed", 142 } 143} 144 145message_group! { 146 SshConfig => SshMessages @ "messages.ssh" { 147 greeting: Lines<GreetingKey> = [ 148 "Hi {user}! You're authenticated to {knot} knot.", 149 "This knot serves git over ssh, so there's no shell here. :P", 150 "Clone repo with: git clone {knot}:<repoDID>" 151 ], 152 greeting_unknown: Lines<KnotKey> = [ 153 "Hi there! This is the {knot} knot.", 154 "This knot serves git over ssh, so there's no shell here. :P", 155 "Clone repo with: git clone {knot}:<repoDID>", 156 "Publish your ssh key to your atproto account so this knot can identify your pushes.", 157 "Put your handle in the url, as in yourhandle@{knot}:<repoDID>, so your ssh client can find your registered key on its own." 158 ], 159 unsupported_command: Line<NoKeys> = "knot: unsupported command", 160 too_many_operations: Line<NoKeys> = "knot: too many concurrent operations from your address, try again shortly", 161 repo_not_found: Line<NoKeys> = "knot: repository not found", 162 index_warming: Line<NoKeys> = "knot: repository index is warming, retry shortly", 163 lfs_disabled: Line<NoKeys> = "knot: LFS isn't enabled on this knot", 164 key_not_registered: Line<AuthorizedKey> = "knot: this ssh key doesn't match any key published by the accounts that may push here. Authorized: {authorized}. If your agent offers several keys, add -o IdentitiesOnly=yes so it offers your registered key.", 165 identity_unavailable: Line<NoKeys> = "knot: couldn't read the account records needed to check your ssh key, retry shortly", 166 push_denied: Line<NoKeys> = "knot: you aren't authorized to push to this repository.", 167 shutting_down: Line<NoKeys> = "knot: server is shutting down", 168 archive_malformed: Line<NoKeys> = "knot: malformed upload-archive request", 169 archive_timeout: Line<NoKeys> = "knot: upload-archive request timed out", 170 archive_failed: Line<NoKeys> = "knot: upload-archive failed", 171 advertise_failed: Line<NoKeys> = "knot: cannot advertise refs", 172 push_too_large: Line<NoKeys> = "knot: push exceeds configured size limit", 173 receive_deadline: Line<NoKeys> = "knot: receive exceeded its time budget", 174 malformed_pack: Line<NoKeys> = "knot: malformed pack stream", 175 receive_read_error: Line<NoKeys> = "knot: receive read error", 176 receive_ended_early: Line<NoKeys> = "knot: receive stream ended early", 177 receive_failed: Line<NoKeys> = "knot: receive-pack failed", 178 } 179} 180 181message_group! { 182 HttpConfig => HttpMessages @ "messages.http" { 183 push_denied: Line<NoKeys> = "you aren't authorized to push to this repository", 184 repo_not_found: Line<NoKeys> = "repository not found", 185 push_too_large: Line<NoKeys> = "push exceeds the configured size limit", 186 malformed_pack: Line<ErrorKey> = "malformed pack stream: {error}", 187 receive_ended_early: Line<NoKeys> = "receive stream ended early", 188 } 189} 190 191message_group! { 192 LfsConfig => LfsMessages @ "messages.lfs" { 193 invalid_oid: Line<ValueKey> = "invalid LFS oid {value}", 194 hash_mismatch: Line<DeclaredComputedKey> = "oid mismatch, declared {declared}, computed {computed}", 195 size_mismatch: Line<DeclaredReceivedKey> = "size mismatch, declared {declared}, received {received}", 196 size_limit_exceeded: Line<DeclaredLimitKey> = "object size {declared} exceeds limit {limit}", 197 free_space_denied: Line<FreeFloorKey> = "free space {free} below floor {floor}", 198 not_found: Line<OidKey> = "object {oid} not found", 199 framing: Line<DetailKey> = "protocol framing fault: {detail}", 200 too_many: Line<WhatLimitKey> = "too many {what} in one message, limit {limit}", 201 unknown_command: Line<CommandKey> = "unknown command {command}", 202 unsupported_version: Line<VersionKey> = "unsupported version {version}", 203 unsupported_hash: Line<AlgorithmKey> = "unsupported hash algorithm {algorithm}", 204 put_on_download: Line<NoKeys> = "put-object isn't allowed on a download channel", 205 verify_on_download: Line<NoKeys> = "verify-object isn't allowed on a download channel", 206 get_on_upload: Line<NoKeys> = "get-object isn't allowed on an upload channel", 207 put_no_body: Line<NoKeys> = "put-object is missing its object body", 208 } 209} 210 211#[derive(Debug, Config)] 212pub struct MessagesConfig { 213 #[config(nested)] 214 pub push: PushConfig, 215 #[config(nested)] 216 pub fetch: FetchConfig, 217 #[config(nested)] 218 pub reject: RejectConfig, 219 #[config(nested)] 220 pub ssh: SshConfig, 221 #[config(nested)] 222 pub http: HttpConfig, 223 #[config(nested)] 224 pub lfs: LfsConfig, 225} 226 227impl MessagesConfig { 228 pub fn defaults() -> Self { 229 Self::builder() 230 .load() 231 .expect("message defaults satisfy every field") 232 } 233} 234 235#[derive(Debug)] 236pub struct Catalog { 237 pub push: PushMessages, 238 pub fetch: FetchMessages, 239 pub reject: RejectMessages, 240 pub ssh: SshMessages, 241 pub http: HttpMessages, 242 pub lfs: LfsMessages, 243} 244 245impl Catalog { 246 pub fn parse(config: &MessagesConfig) -> Result<Self, TemplateError> { 247 Ok(Self { 248 push: PushMessages::parse(&config.push)?, 249 fetch: FetchMessages::parse(&config.fetch)?, 250 reject: RejectMessages::parse(&config.reject)?, 251 ssh: SshMessages::parse(&config.ssh)?, 252 http: HttpMessages::parse(&config.http)?, 253 lfs: LfsMessages::parse(&config.lfs)?, 254 }) 255 } 256 257 pub fn defaults() -> Self { 258 Self::parse(&MessagesConfig::defaults()).expect("built-in message templates parse") 259 } 260} 261 262pub fn default_catalog() -> &'static Catalog { 263 static DEFAULTS: std::sync::LazyLock<Catalog> = std::sync::LazyLock::new(Catalog::defaults); 264 &DEFAULTS 265} 266 267pub fn count_refs(applied: usize) -> String { 268 match applied { 269 1 => "1 ref".to_string(), 270 n => format!("{n} refs"), 271 } 272} 273 274#[cfg(test)] 275mod tests { 276 use super::*; 277 278 #[test] 279 fn the_defaults_parse_into_a_full_catalog() { 280 let catalog = Catalog::defaults(); 281 assert_eq!(catalog.reject.ref_exists.text(), "reference already exists"); 282 assert_eq!( 283 catalog.ssh.repo_not_found.text(), 284 "knot: repository not found" 285 ); 286 } 287 288 #[test] 289 fn the_pull_request_block_matches_the_shipped_shape() { 290 let catalog = Catalog::defaults(); 291 let url = "https://oyster.cafe/nel.pet/anemone/pulls/new"; 292 let block = catalog 293 .push 294 .pull_request 295 .lines(|UrlKey::Url| url.to_string()); 296 assert_eq!( 297 block, 298 vec![ 299 "\u{200b}".to_string(), 300 "-> Open stinky pull request for this branch:".to_string(), 301 format!(" {url}"), 302 "\u{200b}".to_string(), 303 ] 304 ); 305 } 306 307 #[test] 308 fn the_greeting_names_the_user_and_the_knot() { 309 let catalog = Catalog::defaults(); 310 let lines = catalog.ssh.greeting.lines(|key| match key { 311 GreetingKey::User => "@nel.pet".to_string(), 312 GreetingKey::Knot => "oyster.cafe".to_string(), 313 }); 314 assert!(lines[0].contains("@nel.pet")); 315 assert!(lines.iter().any(|line| line.contains("oyster.cafe"))); 316 } 317 318 #[test] 319 fn an_unidentified_visitor_is_greeted_and_shown_what_a_push_needs() { 320 let catalog = Catalog::defaults(); 321 let lines = catalog 322 .ssh 323 .greeting_unknown 324 .lines(|KnotKey::Knot| "oyster.cafe".to_string()); 325 assert!(lines[0].contains("oyster.cafe")); 326 assert!( 327 lines.iter().any(|line| line.contains("ssh key")), 328 "a visitor the knot can't identify learns what a push needs: {lines:?}" 329 ); 330 331 let denial = catalog 332 .ssh 333 .key_not_registered 334 .line(|AuthorizedKey::Authorized| "@nel.pet".to_string()); 335 assert!( 336 denial.contains("@nel.pet"), 337 "the denial lists who may push instead: {denial}" 338 ); 339 } 340 341 #[test] 342 fn an_empty_lines_template_mutes_the_message() { 343 let template: Template<NoKeys, Lines> = 344 Template::parse_lines("messages.test", &[]).unwrap(); 345 assert!(template.text_lines().is_empty()); 346 } 347 348 #[test] 349 fn an_unknown_placeholder_is_a_parse_error() { 350 let error = Template::<KnotKey, Lines>::parse_lines( 351 "messages.fetch.motd", 352 &["hi {handle}".to_string()], 353 ) 354 .unwrap_err(); 355 assert_eq!( 356 error, 357 TemplateError::UnknownPlaceholder { 358 field: "messages.fetch.motd", 359 name: "handle".to_string(), 360 } 361 ); 362 } 363 364 #[test] 365 fn doubled_braces_render_as_literal_braces() { 366 let template: Template<NoKeys, Line> = 367 Template::parse("messages.test", "a {{literal}} brace").unwrap(); 368 assert_eq!(template.text(), "a {literal} brace"); 369 } 370 371 #[test] 372 fn line_templates_reject_empty_and_multiline_text() { 373 assert_eq!( 374 Template::<NoKeys, Line>::parse("messages.test", "").unwrap_err(), 375 TemplateError::Empty { 376 field: "messages.test" 377 } 378 ); 379 assert_eq!( 380 Template::<NoKeys, Line>::parse("messages.test", "a\nb").unwrap_err(), 381 TemplateError::Multiline { 382 field: "messages.test" 383 } 384 ); 385 } 386 387 #[test] 388 fn unbalanced_braces_are_parse_errors() { 389 assert_eq!( 390 Template::<NoKeys, Line>::parse("messages.test", "open {").unwrap_err(), 391 TemplateError::UnclosedBrace { 392 field: "messages.test" 393 } 394 ); 395 assert_eq!( 396 Template::<NoKeys, Line>::parse("messages.test", "close }").unwrap_err(), 397 TemplateError::StrayBrace { 398 field: "messages.test" 399 } 400 ); 401 } 402 403 #[test] 404 fn ref_counts_pluralize() { 405 assert_eq!(count_refs(1), "1 ref"); 406 assert_eq!(count_refs(3), "3 refs"); 407 } 408}