This repository has no description
0

Configure Feed

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

core / knot2 / crates / knot-xrpc / src / wire.rs
19 kB 617 lines
1use base64::Engine; 2use base64::engine::general_purpose::STANDARD; 3use serde::ser::SerializeSeq; 4use serde::{Serialize, Serializer}; 5 6use knot_git::{ 7 BranchInfo, BranchTip, Commit, CommitChangeId, EntryKind, FilePatch, Hunk, Identity, LineOp, 8 PatchStatus, TagInfo, 9}; 10use knot_types::{AuthorName, Email, Oid, TagName}; 11 12pub(crate) const ZERO_TIME: &str = "0001-01-01T00:00:00Z"; 13 14fn display_opt<S: Serializer>( 15 value: &Option<CommitChangeId>, 16 serializer: S, 17) -> Result<S::Ok, S::Error> { 18 match value { 19 Some(id) => serializer.serialize_str(id.as_str()), 20 None => serializer.serialize_none(), 21 } 22} 23 24fn zoned(seconds: i64, offset_seconds: i32) -> chrono::DateTime<chrono::FixedOffset> { 25 let offset = chrono::FixedOffset::east_opt(offset_seconds) 26 .unwrap_or_else(|| chrono::FixedOffset::east_opt(0).expect("zero offset is valid")); 27 chrono::DateTime::from_timestamp(seconds, 0) 28 .unwrap_or_default() 29 .with_timezone(&offset) 30} 31 32pub(crate) fn rfc3339(seconds: i64, offset_seconds: i32) -> String { 33 zoned(seconds, offset_seconds).to_rfc3339_opts(chrono::SecondsFormat::Secs, true) 34} 35 36pub(crate) fn rfc2822(seconds: i64, offset_seconds: i32) -> String { 37 zoned(seconds, offset_seconds).to_rfc2822() 38} 39 40pub(crate) struct HashBytes(pub Oid); 41 42impl Serialize for HashBytes { 43 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { 44 let bytes = self.0.object_id(); 45 let mut seq = serializer.serialize_seq(Some(bytes.as_bytes().len()))?; 46 bytes 47 .as_bytes() 48 .iter() 49 .try_for_each(|byte| seq.serialize_element(byte))?; 50 seq.end() 51 } 52} 53 54pub(crate) struct Base64Bytes(Vec<u8>); 55 56impl Serialize for Base64Bytes { 57 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { 58 serializer.serialize_str(&STANDARD.encode(&self.0)) 59 } 60} 61 62#[derive(Serialize)] 63pub(crate) struct SignatureWire { 64 #[serde(rename = "Name")] 65 pub name: AuthorName, 66 #[serde(rename = "Email")] 67 pub email: Email, 68 #[serde(rename = "When")] 69 pub when: String, 70} 71 72impl SignatureWire { 73 pub fn of(identity: &Identity) -> Self { 74 Self { 75 name: identity.name.clone(), 76 email: identity.email.clone(), 77 when: rfc3339(identity.time.get(), identity.offset_seconds), 78 } 79 } 80 81 pub fn utc(identity: &Identity) -> Self { 82 Self { 83 name: identity.name.clone(), 84 email: identity.email.clone(), 85 when: rfc3339(identity.time.get(), 0), 86 } 87 } 88 89 pub fn zero() -> Self { 90 Self { 91 name: AuthorName::new(""), 92 email: Email::new(""), 93 when: ZERO_TIME.to_string(), 94 } 95 } 96} 97 98#[derive(Serialize)] 99pub(crate) struct CommitWire { 100 pub hash: HashBytes, 101 pub author: SignatureWire, 102 pub committer: SignatureWire, 103 pub message: String, 104 pub tree: Oid, 105 #[serde(skip_serializing_if = "Vec::is_empty")] 106 pub parent_hashes: Vec<HashBytes>, 107 #[serde(skip_serializing_if = "Option::is_none")] 108 pub pgp_signature: Option<String>, 109 #[serde(skip_serializing_if = "Option::is_none")] 110 pub merge_tag: Option<String>, 111 #[serde( 112 skip_serializing_if = "Option::is_none", 113 serialize_with = "display_opt" 114 )] 115 pub change_id: Option<CommitChangeId>, 116 #[serde(skip_serializing_if = "Option::is_none")] 117 pub extra_headers: Option<std::collections::BTreeMap<String, Base64Bytes>>, 118 pub this: Oid, 119 #[serde(skip_serializing_if = "Option::is_none")] 120 pub parent: Option<Oid>, 121} 122 123impl CommitWire { 124 pub fn of(commit: &Commit) -> Self { 125 let extra_headers: std::collections::BTreeMap<String, Base64Bytes> = commit 126 .extra_headers 127 .iter() 128 .map(|(name, value)| (name.clone(), Base64Bytes(value.clone()))) 129 .collect(); 130 Self { 131 hash: HashBytes(commit.id), 132 author: SignatureWire::of(&commit.author), 133 committer: SignatureWire::of(&commit.committer), 134 message: commit.message.clone(), 135 tree: commit.tree, 136 parent_hashes: commit.parents.iter().map(|oid| HashBytes(*oid)).collect(), 137 pgp_signature: commit.pgp_signature.clone(), 138 merge_tag: commit.merge_tag.clone(), 139 change_id: commit.change_id(), 140 extra_headers: (!extra_headers.is_empty()).then_some(extra_headers), 141 this: commit.id, 142 parent: commit.parents.first().copied(), 143 } 144 } 145} 146 147#[derive(Serialize)] 148pub(crate) struct BranchCommitWire { 149 #[serde(rename = "Hash")] 150 pub hash: HashBytes, 151 #[serde(rename = "Author")] 152 pub author: SignatureWire, 153 #[serde(rename = "Committer")] 154 pub committer: SignatureWire, 155 #[serde(rename = "MergeTag")] 156 pub merge_tag: String, 157 #[serde(rename = "PGPSignature")] 158 pub pgp_signature: String, 159 #[serde(rename = "Message")] 160 pub message: String, 161 #[serde(rename = "TreeHash")] 162 pub tree_hash: HashBytes, 163 #[serde(rename = "ParentHashes")] 164 pub parent_hashes: Vec<HashBytes>, 165 #[serde(rename = "Encoding")] 166 pub encoding: String, 167 #[serde(rename = "ExtraHeaders")] 168 pub extra_headers: Option<()>, 169} 170 171#[derive(Serialize)] 172pub(crate) struct Reference { 173 pub name: String, 174 pub hash: Oid, 175} 176 177#[derive(Serialize)] 178pub(crate) struct BranchWire { 179 pub reference: Reference, 180 pub commit: BranchCommitWire, 181 #[serde(skip_serializing_if = "std::ops::Not::not")] 182 pub is_default: bool, 183} 184 185impl BranchWire { 186 pub fn of(branch: &BranchInfo, is_default: bool, absent: Oid) -> Self { 187 Self { 188 reference: Reference { 189 name: branch.name.to_string(), 190 hash: branch.tip.id(), 191 }, 192 commit: match &branch.tip { 193 BranchTip::Commit(commit) => BranchCommitWire { 194 hash: HashBytes(commit.id), 195 author: SignatureWire::utc(&commit.author), 196 committer: SignatureWire::utc(&commit.committer), 197 merge_tag: String::new(), 198 pgp_signature: String::new(), 199 message: commit.message.trim_end().to_string(), 200 tree_hash: HashBytes(commit.tree), 201 parent_hashes: commit.parents.iter().map(|oid| HashBytes(*oid)).collect(), 202 encoding: String::new(), 203 extra_headers: None, 204 }, 205 BranchTip::Opaque { id, message, .. } => BranchCommitWire { 206 hash: HashBytes(*id), 207 author: SignatureWire::zero(), 208 committer: SignatureWire::zero(), 209 merge_tag: String::new(), 210 pgp_signature: String::new(), 211 message: message.trim_end().to_string(), 212 tree_hash: HashBytes(absent), 213 parent_hashes: Vec::new(), 214 encoding: String::new(), 215 extra_headers: None, 216 }, 217 }, 218 is_default, 219 } 220 } 221} 222 223#[derive(Serialize)] 224pub(crate) struct TagObjectWire { 225 #[serde(rename = "Hash")] 226 pub hash: HashBytes, 227 #[serde(rename = "Name")] 228 pub name: TagName, 229 #[serde(rename = "Tagger")] 230 pub tagger: SignatureWire, 231 #[serde(rename = "Message")] 232 pub message: String, 233 #[serde(rename = "PGPSignature")] 234 pub pgp_signature: String, 235 #[serde(rename = "TargetType")] 236 pub target_type: i8, 237 #[serde(rename = "Target")] 238 pub target: HashBytes, 239} 240 241#[derive(Serialize)] 242pub(crate) struct TagWire { 243 pub name: TagName, 244 pub hash: Oid, 245 #[serde(skip_serializing_if = "Option::is_none")] 246 pub tag: Option<TagObjectWire>, 247 #[serde(skip_serializing_if = "String::is_empty")] 248 pub message: String, 249} 250 251const TARGET_TYPE_TAG: i8 = 4; 252 253impl TagWire { 254 pub fn of(info: &TagInfo) -> Self { 255 let message = recombine_message(&info.message); 256 let tag = 257 info.annotated.as_ref().map(|annotated| TagObjectWire { 258 hash: HashBytes(info.id), 259 name: info.name.clone(), 260 tagger: annotated.tagger.as_ref().map(SignatureWire::utc).unwrap_or( 261 SignatureWire { 262 name: AuthorName::new(""), 263 email: Email::new(""), 264 when: rfc3339(0, 0), 265 }, 266 ), 267 message: message.clone(), 268 pgp_signature: annotated.pgp_signature.clone().unwrap_or_default(), 269 target_type: TARGET_TYPE_TAG, 270 target: HashBytes(annotated.target), 271 }); 272 Self { 273 name: info.name.clone(), 274 hash: info.id, 275 tag, 276 message, 277 } 278 } 279} 280 281pub(crate) fn fold_subject(message: &str) -> String { 282 message 283 .split("\n\n") 284 .next() 285 .unwrap_or_default() 286 .lines() 287 .map(str::trim_end) 288 .filter(|line| !line.is_empty()) 289 .collect::<Vec<_>>() 290 .join(" ") 291} 292 293pub(crate) fn message_body(message: &str) -> String { 294 message 295 .split_once("\n\n") 296 .map(|(_, body)| body.trim_matches('\n').to_string()) 297 .unwrap_or_default() 298} 299 300fn recombine_message(message: &str) -> String { 301 let subject = fold_subject(message); 302 let body = message_body(message); 303 match (subject.is_empty(), body.is_empty()) { 304 (_, true) => subject, 305 (true, false) => body, 306 (false, false) => format!("{subject}\n\n{body}"), 307 } 308} 309 310#[derive(Serialize)] 311pub(crate) struct LineWire { 312 #[serde(rename = "Op")] 313 pub op: u8, 314 #[serde(rename = "Line")] 315 pub line: String, 316} 317 318#[derive(Serialize)] 319pub(crate) struct TextFragmentWire { 320 #[serde(rename = "Comment")] 321 pub comment: String, 322 #[serde(rename = "OldPosition")] 323 pub old_position: i64, 324 #[serde(rename = "OldLines")] 325 pub old_lines: i64, 326 #[serde(rename = "NewPosition")] 327 pub new_position: i64, 328 #[serde(rename = "NewLines")] 329 pub new_lines: i64, 330 #[serde(rename = "LinesAdded")] 331 pub lines_added: i64, 332 #[serde(rename = "LinesDeleted")] 333 pub lines_deleted: i64, 334 #[serde(rename = "LeadingContext")] 335 pub leading_context: i64, 336 #[serde(rename = "TrailingContext")] 337 pub trailing_context: i64, 338 #[serde(rename = "Lines")] 339 pub lines: Vec<LineWire>, 340} 341 342impl TextFragmentWire { 343 pub fn of(hunk: &Hunk) -> Self { 344 let lines: Vec<LineWire> = hunk 345 .lines 346 .iter() 347 .map(|line| LineWire { 348 op: match line.op { 349 LineOp::Context => 0, 350 LineOp::Delete => 1, 351 LineOp::Add => 2, 352 }, 353 line: String::from_utf8_lossy(&line.text).into_owned(), 354 }) 355 .collect(); 356 let leading = lines.iter().take_while(|line| line.op == 0).count(); 357 let trailing = if leading == lines.len() { 358 0 359 } else { 360 lines.iter().rev().take_while(|line| line.op == 0).count() 361 }; 362 Self { 363 comment: String::new(), 364 old_position: hunk.old_start.get() as i64, 365 old_lines: hunk.old_lines.get() as i64, 366 new_position: hunk.new_start.get() as i64, 367 new_lines: hunk.new_lines.get() as i64, 368 lines_added: hunk.added().get() as i64, 369 lines_deleted: hunk.deleted().get() as i64, 370 leading_context: leading as i64, 371 trailing_context: trailing as i64, 372 lines, 373 } 374 } 375} 376 377#[derive(Serialize)] 378pub(crate) struct DiffNameWire { 379 pub old: String, 380 pub new: String, 381} 382 383#[derive(Serialize)] 384pub(crate) struct DiffWire { 385 pub name: DiffNameWire, 386 pub text_fragments: Option<Vec<TextFragmentWire>>, 387 pub is_binary: bool, 388 pub is_new: bool, 389 pub is_delete: bool, 390 pub is_copy: bool, 391 pub is_rename: bool, 392} 393 394impl DiffWire { 395 pub fn of(patch: &FilePatch) -> Self { 396 let fragments: Vec<TextFragmentWire> = 397 patch.hunks.iter().map(TextFragmentWire::of).collect(); 398 Self { 399 name: DiffNameWire { 400 old: match patch.status { 401 PatchStatus::Added => String::new(), 402 _ => patch.path.to_string(), 403 }, 404 new: match patch.status { 405 PatchStatus::Deleted => String::new(), 406 _ => patch.path.to_string(), 407 }, 408 }, 409 text_fragments: (!fragments.is_empty()).then_some(fragments), 410 is_binary: patch.is_binary, 411 is_new: patch.status == PatchStatus::Added, 412 is_delete: patch.status == PatchStatus::Deleted, 413 is_copy: false, 414 is_rename: false, 415 } 416 } 417} 418 419#[derive(Serialize)] 420pub(crate) struct DiffStatWire { 421 pub insertions: i64, 422 pub deletions: i64, 423 pub files_changed: i64, 424} 425 426#[derive(Serialize)] 427pub(crate) struct NiceDiffWire { 428 pub commit: CommitWire, 429 pub stat: DiffStatWire, 430 pub diff: Option<Vec<DiffWire>>, 431} 432 433pub(crate) fn nice_diff(commit: &Commit, patches: &[FilePatch]) -> NiceDiffWire { 434 let diffs: Vec<DiffWire> = patches.iter().map(DiffWire::of).collect(); 435 let stat = DiffStatWire { 436 insertions: patches 437 .iter() 438 .flat_map(|patch| patch.hunks.iter()) 439 .map(|hunk| hunk.added().get() as i64) 440 .sum(), 441 deletions: patches 442 .iter() 443 .flat_map(|patch| patch.hunks.iter()) 444 .map(|hunk| hunk.deleted().get() as i64) 445 .sum(), 446 files_changed: patches.len() as i64, 447 }; 448 NiceDiffWire { 449 commit: CommitWire::of(commit), 450 stat, 451 diff: (!diffs.is_empty()).then_some(diffs), 452 } 453} 454 455#[derive(Serialize)] 456pub(crate) struct PatchIdentityWire { 457 #[serde(rename = "Name")] 458 pub name: AuthorName, 459 #[serde(rename = "Email")] 460 pub email: Email, 461} 462 463#[derive(Serialize)] 464pub(crate) struct FormatPatchWire { 465 #[serde(rename = "Files")] 466 pub files: Option<Vec<FileWire>>, 467 #[serde(rename = "SHA")] 468 pub sha: Oid, 469 #[serde(rename = "Author")] 470 pub author: Option<PatchIdentityWire>, 471 #[serde(rename = "AuthorDate")] 472 pub author_date: String, 473 #[serde(rename = "Committer")] 474 pub committer: Option<()>, 475 #[serde(rename = "CommitterDate")] 476 pub committer_date: String, 477 #[serde(rename = "Title")] 478 pub title: String, 479 #[serde(rename = "Body")] 480 pub body: String, 481 #[serde(rename = "SubjectPrefix")] 482 pub subject_prefix: String, 483 #[serde(rename = "BodyAppendix")] 484 pub body_appendix: String, 485 #[serde(rename = "RawHeaders")] 486 pub raw_headers: Option<std::collections::BTreeMap<String, Vec<String>>>, 487 #[serde(rename = "Raw")] 488 pub raw: String, 489} 490 491pub(crate) fn normalize_message_section<'a>(lines: impl Iterator<Item = &'a str>) -> String { 492 lines 493 .map(str::trim_end) 494 .fold((String::new(), 0usize), |(mut out, blanks), line| { 495 if line.is_empty() { 496 return (out, blanks + 1); 497 } 498 if !out.is_empty() { 499 out.push('\n'); 500 if blanks > 0 { 501 out.push('\n'); 502 } 503 } 504 out.push_str(line); 505 (out, 0) 506 }) 507 .0 508} 509 510fn entry_mode_decimal(kind: EntryKind) -> u32 { 511 match kind { 512 EntryKind::Tree => 0o040000, 513 EntryKind::Blob => 0o100644, 514 EntryKind::BlobExecutable => 0o100755, 515 EntryKind::Link => 0o120000, 516 EntryKind::Commit => 0o160000, 517 } 518} 519 520pub(crate) fn entry_mode_octal(kind: EntryKind) -> String { 521 format!("{:06o}", entry_mode_decimal(kind)) 522} 523 524#[derive(Serialize)] 525pub(crate) struct FileWire { 526 #[serde(rename = "OldName")] 527 pub old_name: String, 528 #[serde(rename = "NewName")] 529 pub new_name: String, 530 #[serde(rename = "IsNew")] 531 pub is_new: bool, 532 #[serde(rename = "IsDelete")] 533 pub is_delete: bool, 534 #[serde(rename = "IsCopy")] 535 pub is_copy: bool, 536 #[serde(rename = "IsRename")] 537 pub is_rename: bool, 538 #[serde(rename = "OldMode")] 539 pub old_mode: u32, 540 #[serde(rename = "NewMode")] 541 pub new_mode: u32, 542 #[serde(rename = "OldOIDPrefix")] 543 pub old_oid_prefix: String, 544 #[serde(rename = "NewOIDPrefix")] 545 pub new_oid_prefix: String, 546 #[serde(rename = "Score")] 547 pub score: i64, 548 #[serde(rename = "TextFragments")] 549 pub text_fragments: Option<Vec<TextFragmentWire>>, 550 #[serde(rename = "IsBinary")] 551 pub is_binary: bool, 552 #[serde(rename = "BinaryFragment")] 553 pub binary_fragment: Option<()>, 554 #[serde(rename = "ReverseBinaryFragment")] 555 pub reverse_binary_fragment: Option<()>, 556} 557 558impl FileWire { 559 pub fn of(patch: &FilePatch) -> Self { 560 let fragments: Vec<TextFragmentWire> = 561 patch.hunks.iter().map(TextFragmentWire::of).collect(); 562 let same_mode = patch.old_kind.is_some() && patch.old_kind == patch.new_kind; 563 Self { 564 old_name: match patch.status { 565 PatchStatus::Added => String::new(), 566 _ => patch.path.to_string(), 567 }, 568 new_name: match patch.status { 569 PatchStatus::Deleted => String::new(), 570 _ => patch.path.to_string(), 571 }, 572 is_new: patch.status == PatchStatus::Added, 573 is_delete: patch.status == PatchStatus::Deleted, 574 is_copy: false, 575 is_rename: false, 576 old_mode: match patch.status { 577 PatchStatus::Added => 0, 578 PatchStatus::Deleted | PatchStatus::Modified => { 579 patch.old_kind.map(entry_mode_decimal).unwrap_or(0) 580 } 581 }, 582 new_mode: match patch.status { 583 PatchStatus::Added => patch.new_kind.map(entry_mode_decimal).unwrap_or(0), 584 PatchStatus::Deleted => 0, 585 PatchStatus::Modified if same_mode => 0, 586 PatchStatus::Modified => patch.new_kind.map(entry_mode_decimal).unwrap_or(0), 587 }, 588 old_oid_prefix: patch.old_oid.to_hex(), 589 new_oid_prefix: patch.new_oid.to_hex(), 590 score: 0, 591 text_fragments: (!fragments.is_empty()).then_some(fragments), 592 is_binary: patch.is_binary, 593 binary_fragment: None, 594 reverse_binary_fragment: None, 595 } 596 } 597} 598 599#[cfg(test)] 600mod tests { 601 use super::{rfc2822, rfc3339}; 602 603 const A_JUNE_INSTANT: i64 = 1_717_236_600; 604 605 #[test] 606 fn rfc3339_renders_the_commits_own_offset_independent_of_the_host_zone() { 607 assert!(rfc3339(A_JUNE_INSTANT, 7200).ends_with("+02:00")); 608 assert!(rfc3339(A_JUNE_INSTANT, -18000).ends_with("-05:00")); 609 assert!(rfc3339(A_JUNE_INSTANT, 0).ends_with('Z')); 610 } 611 612 #[test] 613 fn rfc2822_keeps_the_signed_offset() { 614 assert!(rfc2822(A_JUNE_INSTANT, 7200).ends_with("+0200")); 615 assert!(rfc2822(A_JUNE_INSTANT, -18000).ends_with("-0500")); 616 } 617}