mod template; use confique::Config; pub use template::{Key, Line, Lines, NoKeys, Segment, Shape, Template, TemplateError}; // Each msg field defines the enum of placeholders it accepts, // so a typo'ed `{handel}` in a given config gets caught at startup, // rather than printed at some poor pusher mid-push. macro_rules! keys { ( $( $name:ident { $( $variant:ident = $placeholder:literal ),+ $(,)? } )+ ) => { $( #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum $name { $( $variant, )+ } impl Key for $name { const PLACEHOLDERS: &'static [(&'static str, Self)] = &[ $( ($placeholder, Self::$variant), )+ ]; } )+ }; } macro_rules! config_type { (Lines) => { Vec }; (Line) => { String }; } macro_rules! parse_field { (Lines, $field:expr, $value:expr) => { Template::parse_lines($field, $value) }; (Line, $field:expr, $value:expr) => { Template::parse($field, $value) }; } macro_rules! message_group { ( $config:ident => $catalog:ident @ $prefix:literal { $( $field:ident : $shape:ident<$keys:ty> = $default:tt ),+ $(,)? } ) => { #[derive(Debug, ::confique::Config)] pub struct $config { $( #[config(default = $default)] pub $field: config_type!($shape), )+ } #[derive(Debug)] pub struct $catalog { $( pub $field: Template<$keys, $shape>, )+ } impl $catalog { pub fn parse(config: &$config) -> Result { Ok(Self { $( $field: parse_field!( $shape, concat!($prefix, ".", stringify!($field)), &config.$field )?, )+ }) } } }; } keys! { KnotKey { Knot = "knot" } PushAckKey { Knot = "knot", Refs = "refs" } UrlKey { Url = "url" } CiLogsKey { Host = "host", Port = "port", Repo = "repo", Sha = "sha" } GreetingKey { User = "user", Knot = "knot" } CountKey { Count = "count" } RefKey { Ref = "ref" } ErrorKey { Error = "error" } CommandKey { Command = "command" } VersionKey { Version = "version" } AlgorithmKey { Algorithm = "algorithm" } ValueKey { Value = "value" } OidKey { Oid = "oid" } DetailKey { Detail = "detail" } DeclaredComputedKey { Declared = "declared", Computed = "computed" } DeclaredReceivedKey { Declared = "declared", Received = "received" } DeclaredLimitKey { Declared = "declared", Limit = "limit" } FreeFloorKey { Free = "free", Floor = "floor" } WhatLimitKey { What = "what", Limit = "limit" } } message_group! { PushConfig => PushMessages @ "messages.push" { ack: Lines = ["{knot} received {refs}."], pull_request: Lines = [ "", "-> Open stinky pull request for this branch:", " {url}", "" ], pipeline_clean: Lines = ["pipeline compiled with no diagnostics"], pipeline_none: Lines = ["no pipelines to compile"], ci_logs: Lines = [ "-> Browse CI logs in your terminal:", " ssh -t -p {port} {host} {repo} {sha}" ], } } message_group! { FetchConfig => FetchMessages @ "messages.fetch" { motd: Lines = ["Thanks for using {knot}!"], enumerating: Lines = ["Enumerating objects: {count}, done."], total: Lines = ["Total {count}, done."], fatal: Line = "knot: {error}", } } message_group! { RejectConfig => RejectMessages @ "messages.reject" { reserved_refs: Line = "refs/cobs/* and refs/hidden/* are reserved and cannot be pushed", cob_create_only: Line = "existing refs/cobs/* object cannot be modified or deleted over the wire", cob_delete: Line = "refs/cobs/* stores append-only collaborative objects and cannot be deleted", hidden_reserved: Line = "refs/hidden/* is reserved for server-side fork staging and cannot be pushed", cob_verification: Line = "collaborative-object verification failed: {error}", ref_exists: Line = "reference already exists", stale_old_value: Line = "stale info: old value doesn't match", missing_objects: Line = "missing necessary objects", missing_objects_for: Line = "missing necessary objects for {ref}", atomic_failed: Line = "atomic transaction failed", atomic_aborted: Line = "atomic push aborted", authorization_unavailable: Line = "authorization unavailable", unpacker_error: Line = "unpacker error", ref_snapshot_unavailable: Line = "ref snapshot unavailable", object_migration_failed: Line = "object migration failed", } } message_group! { SshConfig => SshMessages @ "messages.ssh" { greeting: Lines = [ "Hi {user}! You're authenticated to {knot} knot.", "This knot serves git over ssh, so there's no shell here. :P", "Clone repo with: git clone {knot}:" ], unsupported_command: Line = "knot: unsupported command", too_many_operations: Line = "knot: too many concurrent operations from your address, try again shortly", repo_not_found: Line = "knot: repository not found", index_warming: Line = "knot: repository index is warming, retry shortly", lfs_disabled: Line = "knot: LFS isn't enabled on this knot", key_not_registered: Line = "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.", push_denied: Line = "knot: you aren't authorized to push to this repository.", shutting_down: Line = "knot: server is shutting down", archive_malformed: Line = "knot: malformed upload-archive request", archive_timeout: Line = "knot: upload-archive request timed out", archive_failed: Line = "knot: upload-archive failed", advertise_failed: Line = "knot: cannot advertise refs", push_too_large: Line = "knot: push exceeds configured size limit", receive_deadline: Line = "knot: receive exceeded its time budget", malformed_pack: Line = "knot: malformed pack stream", receive_read_error: Line = "knot: receive read error", receive_ended_early: Line = "knot: receive stream ended early", receive_failed: Line = "knot: receive-pack failed", } } message_group! { HttpConfig => HttpMessages @ "messages.http" { push_denied: Line = "you aren't authorized to push to this repository", repo_not_found: Line = "repository not found", push_too_large: Line = "push exceeds the configured size limit", malformed_pack: Line = "malformed pack stream: {error}", receive_ended_early: Line = "receive stream ended early", } } message_group! { LfsConfig => LfsMessages @ "messages.lfs" { invalid_oid: Line = "invalid LFS oid {value}", hash_mismatch: Line = "oid mismatch, declared {declared}, computed {computed}", size_mismatch: Line = "size mismatch, declared {declared}, received {received}", size_limit_exceeded: Line = "object size {declared} exceeds limit {limit}", free_space_denied: Line = "free space {free} below floor {floor}", not_found: Line = "object {oid} not found", framing: Line = "protocol framing fault: {detail}", too_many: Line = "too many {what} in one message, limit {limit}", unknown_command: Line = "unknown command {command}", unsupported_version: Line = "unsupported version {version}", unsupported_hash: Line = "unsupported hash algorithm {algorithm}", put_on_download: Line = "put-object isn't allowed on a download channel", verify_on_download: Line = "verify-object isn't allowed on a download channel", get_on_upload: Line = "get-object isn't allowed on an upload channel", put_no_body: Line = "put-object is missing its object body", } } #[derive(Debug, Config)] pub struct MessagesConfig { #[config(nested)] pub push: PushConfig, #[config(nested)] pub fetch: FetchConfig, #[config(nested)] pub reject: RejectConfig, #[config(nested)] pub ssh: SshConfig, #[config(nested)] pub http: HttpConfig, #[config(nested)] pub lfs: LfsConfig, } impl MessagesConfig { pub fn defaults() -> Self { Self::builder() .load() .expect("message defaults satisfy every field") } } #[derive(Debug)] pub struct Catalog { pub push: PushMessages, pub fetch: FetchMessages, pub reject: RejectMessages, pub ssh: SshMessages, pub http: HttpMessages, pub lfs: LfsMessages, } impl Catalog { pub fn parse(config: &MessagesConfig) -> Result { Ok(Self { push: PushMessages::parse(&config.push)?, fetch: FetchMessages::parse(&config.fetch)?, reject: RejectMessages::parse(&config.reject)?, ssh: SshMessages::parse(&config.ssh)?, http: HttpMessages::parse(&config.http)?, lfs: LfsMessages::parse(&config.lfs)?, }) } pub fn defaults() -> Self { Self::parse(&MessagesConfig::defaults()).expect("built-in message templates parse") } } pub fn default_catalog() -> &'static Catalog { static DEFAULTS: std::sync::LazyLock = std::sync::LazyLock::new(Catalog::defaults); &DEFAULTS } pub fn count_refs(applied: usize) -> String { match applied { 1 => "1 ref".to_string(), n => format!("{n} refs"), } } #[cfg(test)] mod tests { use super::*; #[test] fn the_defaults_parse_into_a_full_catalog() { let catalog = Catalog::defaults(); assert_eq!(catalog.reject.ref_exists.text(), "reference already exists"); assert_eq!( catalog.ssh.repo_not_found.text(), "knot: repository not found" ); } #[test] fn the_pull_request_block_matches_the_shipped_shape() { let catalog = Catalog::defaults(); let url = "https://oyster.cafe/nel.pet/anemone/pulls/new"; let block = catalog .push .pull_request .lines(|UrlKey::Url| url.to_string()); assert_eq!( block, vec![ "\u{200b}".to_string(), "-> Open stinky pull request for this branch:".to_string(), format!(" {url}"), "\u{200b}".to_string(), ] ); } #[test] fn the_greeting_names_the_user_and_the_knot() { let catalog = Catalog::defaults(); let lines = catalog.ssh.greeting.lines(|key| match key { GreetingKey::User => "@nel.pet".to_string(), GreetingKey::Knot => "oyster.cafe".to_string(), }); assert!(lines[0].contains("@nel.pet")); assert!(lines.iter().any(|line| line.contains("oyster.cafe"))); } #[test] fn an_empty_lines_template_mutes_the_message() { let template: Template = Template::parse_lines("messages.test", &[]).unwrap(); assert!(template.text_lines().is_empty()); } #[test] fn an_unknown_placeholder_is_a_parse_error() { let error = Template::::parse_lines( "messages.fetch.motd", &["hi {handle}".to_string()], ) .unwrap_err(); assert_eq!( error, TemplateError::UnknownPlaceholder { field: "messages.fetch.motd", name: "handle".to_string(), } ); } #[test] fn doubled_braces_render_as_literal_braces() { let template: Template = Template::parse("messages.test", "a {{literal}} brace").unwrap(); assert_eq!(template.text(), "a {literal} brace"); } #[test] fn line_templates_reject_empty_and_multiline_text() { assert_eq!( Template::::parse("messages.test", "").unwrap_err(), TemplateError::Empty { field: "messages.test" } ); assert_eq!( Template::::parse("messages.test", "a\nb").unwrap_err(), TemplateError::Multiline { field: "messages.test" } ); } #[test] fn unbalanced_braces_are_parse_errors() { assert_eq!( Template::::parse("messages.test", "open {").unwrap_err(), TemplateError::UnclosedBrace { field: "messages.test" } ); assert_eq!( Template::::parse("messages.test", "close }").unwrap_err(), TemplateError::StrayBrace { field: "messages.test" } ); } #[test] fn ref_counts_pluralize() { assert_eq!(count_refs(1), "1 ref"); assert_eq!(count_refs(3), "3 refs"); } }