steid

@jamesgill /

1//! Atom feeds — commits, issues, and an owner's activity.
2//!
3//! Routes rather than pages: the response is an XML document, so no layout wraps it and
4//! nothing here builds a view.
5//!
6//! **The XML is written by hand, and the escaping is the whole of the safety.** Every
7//! dynamic value in a feed is somebody else's text — a commit message, an issue title,
8//! a branch name — and an unescaped `<` does not produce a visible mistake, it produces
9//! a document a reader refuses to parse or, worse, parses as markup that was never
10//! written. So there is exactly one way text reaches the output, [`escape`], and it is
11//! used on every value without exception. A crate was not added for this: three
12//! documents with a fixed shape is less code than the dependency, and the escaping is
13//! the part worth being able to read.
14//!
15//! Issue bodies are the one thing that arrives already-marked-up, from
16//! [`markdown`](super::markdown) — which never passes raw HTML through, so what comes
17//! back is a known set of tags. That still goes through `escape`, because Atom's
18//! `type="html"` means "escaped HTML in a text node": a reader unescapes it once and
19//! renders the result.
20//!
21//! Visibility is the use cases' answer and is never re-decided here. A private
22//! repository's feed 404s for a stranger exactly as its landing page does, and the
23//! profile feed carries only repositories the requester may see. No authentication is
24//! asked for: an anonymous reader is the point of a feed.
25
26use std::time::{SystemTime, UNIX_EPOCH};
27
28use topcoat::{
29 Result,
30 context::Cx,
31 icon::{icon, iconify::iconify_icon},
32 router::{
33 Body, Response,
34 error::{RouterErrorExt, not_found},
35 header::CONTENT_TYPE,
36 path_param, query_params, route,
37 },
38 view::{attributes, component, view},
39};
40
41use crate::{
42 application::{
43 ActivityEntry, IssueEntry, Stores, activity_feed, commit_feed, issue_feed,
44 port::GitQueryError,
45 },
46 domain::{CommitEntry, OrgName, RefName, RepoName},
47};
48
49use super::{
50 commit::{commit_url, compare_url},
51 context::{
52 current_actor, issues, memberships, orgs, public_origin, push_events, queries, repos,
53 server_error, users,
54 },
55 markdown,
56 profile::handle_param,
57 urls::encode,
58};
59
60/// `{name}` from the path, raw — validation is [`RepoName`]'s job.
61#[path_param]
62struct Name(str);
63
64/// `?rev=` on the commit feed: which revision to follow, defaulting to `HEAD`.
65#[query_params(error = bad_request)]
66struct Query {
67 rev: Option<String>,
68}
69
70/// The media type every feed is served as.
71const ATOM: &str = "application/atom+xml; charset=utf-8";
72
73/// The same type without its charset, for a `<link type=…>`.
74///
75/// A link's `type` is a hint about the resource, and a parameter there is noise a
76/// reader has to strip. The response's own `Content-Type` is where the charset belongs.
77const ATOM_LINK: &str = "application/atom+xml";
78
79// --- Routes ---------------------------------------------------------------------------
80
81/// The last [`FEED_LIMIT`](crate::application::FEED_LIMIT) commits of a revision.
82///
83/// **One `git` process**, or two when `?rev=` is absent and the default branch has to be
84/// resolved first — the same cost the log page pays, with the message bodies included.
85#[route(GET "/{handle}/repos/{name}/commits.atom")]
86async fn commits_feed(cx: &Cx) -> Result<Response<Body>> {
87 let handle = handle_param(cx)?;
88 let name = repo_param(cx)?;
89 let rev = requested_rev(cx)?;
90
91 let entries = commit_feed(
92 &handle,
93 &name,
94 rev.as_ref(),
95 &current_actor(cx).await?,
96 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
97 &queries(cx),
98 )
99 .await
100 // A revision that git could not read is a feed that does not exist, not a 500: a
101 // reader polls a URL it was given, and `?rev=` is part of that URL.
102 .map_err(unreadable)?
103 .ok_or_not_found()?;
104
105 let origin = public_origin(cx);
106 let handle = handle.as_str();
107 let name = name.as_str();
108 let path = commits_feed_path(handle, name);
109 let alternate = match &rev {
110 Some(rev) => super::urls::log_url(handle, name, rev.as_str()),
111 None => format!("/{handle}/repos/{name}"),
112 };
113
114 let feed = Feed {
115 id: absolute(&origin, &path),
116 title: match &rev {
117 Some(rev) => format!("{handle}/{name} commits on {}", rev.as_str()),
118 None => format!("{handle}/{name} commits"),
119 },
120 self_url: absolute(&origin, &path),
121 alternate: absolute(&origin, &alternate),
122 author: handle.to_owned(),
123 updated: entries.first().map(|entry| entry.commit.committed_at),
124 entries: entries
125 .iter()
126 .map(|entry| commit_entry(&origin, handle, name, entry))
127 .collect(),
128 };
129
130 served(feed.render())
131}
132
133/// The last [`FEED_LIMIT`](crate::application::FEED_LIMIT) issues, open and closed.
134///
135/// **No `git` processes**: two queries for the issues and one per distinct author for
136/// their handle.
137#[route(GET "/{handle}/repos/{name}/issues.atom")]
138async fn issues_feed(cx: &Cx) -> Result<Response<Body>> {
139 let handle = handle_param(cx)?;
140 let name = repo_param(cx)?;
141
142 let entries = issue_feed(
143 &current_actor(cx).await?,
144 &handle,
145 &name,
146 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
147 &issues(cx),
148 &users(cx),
149 )
150 .await
151 .map_err(server_error)?
152 .ok_or_not_found()?;
153
154 let origin = public_origin(cx);
155 let handle = handle.as_str();
156 let name = name.as_str();
157 let path = issues_feed_path(handle, name);
158
159 let feed = Feed {
160 id: absolute(&origin, &path),
161 title: format!("{handle}/{name} issues"),
162 self_url: absolute(&origin, &path),
163 alternate: absolute(&origin, &format!("/{handle}/repos/{name}/issues")),
164 author: handle.to_owned(),
165 // The newest issue's own timestamp, not `now`: a feed whose `updated` moves on
166 // every poll tells a reader to re-fetch everything, every time.
167 updated: entries.first().map(|entry| entry.updated_at),
168 entries: entries
169 .iter()
170 .map(|entry| issue_entry(&origin, handle, name, entry))
171 .collect(),
172 };
173
174 served(feed.render())
175}
176
177/// One owner's recent pushes across the repositories the reader may see.
178///
179/// **The URL is `/{handle}/feed.atom`, not `/{handle}.atom`.** Topcoat's router matches
180/// whole segments — `{handle}.atom` is neither a parameter nor a literal and is refused
181/// when the route is registered — so the extension has to be a segment of its own. It
182/// also happens to be the scoped shape the rest of Steid's URLs use.
183///
184/// **No `git` processes and no subprocesses at all.** An entry names what moved and
185/// links to the page that can say what changed; counting the commits in a push would be
186/// a `git` process per entry, which is fifty per poll on an endpoint nobody has to be
187/// logged in to reach.
188#[route(GET "/{handle}/feed.atom")]
189async fn profile_feed(cx: &Cx) -> Result<Response<Body>> {
190 let handle = handle_param(cx)?;
191
192 let entries = activity_feed(
193 &handle,
194 &current_actor(cx).await?,
195 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
196 &push_events(cx),
197 &users(cx),
198 )
199 .await
200 .map_err(server_error)?
201 .ok_or_not_found()?;
202
203 let origin = public_origin(cx);
204 let handle = handle.as_str();
205 let path = profile_feed_path(handle);
206
207 let feed = Feed {
208 id: absolute(&origin, &path),
209 title: format!("@{handle}"),
210 self_url: absolute(&origin, &path),
211 alternate: absolute(&origin, &format!("/{handle}")),
212 author: handle.to_owned(),
213 updated: entries.first().map(|entry| entry.event.created_at),
214 entries: entries
215 .iter()
216 .map(|entry| activity_item(&origin, handle, entry))
217 .collect(),
218 };
219
220 served(feed.render())
221}
222
223/// `{name}` from the path, or 404 — the same answer a malformed name gets everywhere.
224fn repo_param(cx: &Cx) -> Result<RepoName> {
225 Ok(RepoName::new(path_param::<Name>(cx)).map_err(|_| not_found())?)
226}
227
228/// `?rev=`, or 404 for one that is not a revision at all.
229fn requested_rev(cx: &Cx) -> Result<Option<RefName>> {
230 query_params::<Query>(cx)?
231 .rev
232 .as_deref()
233 .filter(|rev| !rev.is_empty())
234 .map(|rev| RefName::new(rev).map_err(|_| not_found().into()))
235 .transpose()
236}
237
238/// A git read that failed is a feed that is not there, unless it is a real fault.
239///
240/// A `?rev=` naming something git cannot resolve reaches the adapter, and the adapter's
241/// rule is that a non-zero exit is a fault — true of the pages, and still true here, but
242/// a feed reader following a stale URL deserves a 404 rather than a 500 in the log.
243fn unreadable(error: crate::application::Error) -> topcoat::Error {
244 match &error {
245 crate::application::Error::GitQuery(GitQueryError { .. }) => not_found().into(),
246 _ => server_error(error),
247 }
248}
249
250/// A rendered feed, as the response a reader gets.
251fn served(body: String) -> Result<Response<Body>> {
252 Response::builder()
253 .header(CONTENT_TYPE, ATOM)
254 .body(Body::from(body))
255 .map_err(server_error)
256}
257
258// --- Entries --------------------------------------------------------------------------
259
260/// One commit: the subject as the title, the message body as the content.
261fn commit_entry(origin: &str, handle: &str, name: &str, entry: &CommitEntry) -> Entry {
262 let link = absolute(origin, &commit_url(handle, name, entry.commit.id.as_str()));
263
264 Entry {
265 // The commit page's URL, which is permanent and unique by construction — a sha
266 // names one commit in one repository forever.
267 id: link.clone(),
268 title: entry.commit.summary.clone(),
269 link,
270 updated: entry.commit.committed_at,
271 author: Some(entry.commit.author_name.clone()),
272 content: (!entry.body.is_empty()).then(|| Content::Text(entry.body.clone())),
273 }
274}
275
276/// One issue: the title, and the rendered body as escaped HTML.
277fn issue_entry(origin: &str, handle: &str, name: &str, entry: &IssueEntry) -> Entry {
278 let link = absolute(
279 origin,
280 &format!("/{handle}/repos/{name}/issues/{}", entry.number),
281 );
282
283 Entry {
284 id: link.clone(),
285 // The state is in the title because that is all a reader's list shows. "#4" is
286 // there for the same reason the list page has it: it is how the issue is named
287 // in conversation.
288 title: format!(
289 "#{} {}{}",
290 entry.number,
291 entry.title,
292 if entry.state.is_open() {
293 ""
294 } else {
295 " (closed)"
296 }
297 ),
298 link,
299 // The issue's own last change, so a comment or a close republishes the entry
300 // rather than leaving a reader with the version they first saw.
301 updated: entry.updated_at,
302 author: Some(
303 entry
304 .author
305 .as_ref()
306 .map(|handle| handle.as_str().to_owned())
307 .unwrap_or_else(|| "someone".to_owned()),
308 ),
309 content: (!entry.body.is_empty()).then(|| {
310 // The same cross-links the issue page draws, so `#12` in a reader is
311 // still a link to the issue it names — **absolute**, unlike the page's,
312 // because this content is read outside the site and a bare path there
313 // resolves against the reader, not against us.
314 Content::Html(
315 markdown::render(
316 &entry.body,
317 Some(markdown::Crossref {
318 handle,
319 name,
320 origin: Some(origin),
321 }),
322 )
323 .to_string(),
324 )
325 }),
326 }
327}
328
329/// One push: what moved, where, and the page that can say what changed.
330fn activity_item(origin: &str, handle: &str, entry: &ActivityEntry) -> Entry {
331 let name = entry.repo.as_str();
332 let event = &entry.event;
333 let short = event.short_ref();
334 let kind = event.ref_kind();
335
336 let (title, path) = if event.is_delete() {
337 (
338 format!("deleted {kind} {short} in {name}"),
339 format!("/{handle}/repos/{name}"),
340 )
341 } else if event.is_create() {
342 (
343 format!("created {kind} {short} in {name}"),
344 format!("/{handle}/repos/{name}/tree/{}", encode(short, false)),
345 )
346 } else if kind == "tag" {
347 (
348 format!("moved tag {short} in {name}"),
349 format!("/{handle}/repos/{name}/tree/{}", encode(short, false)),
350 )
351 } else {
352 (
353 format!("pushed to {name}/{short}"),
354 compare_url(handle, name, event.before.as_str(), event.after.as_str()),
355 )
356 };
357
358 Entry {
359 // The event's own id. A URL would not do: two pushes can land the same
360 // comparison, and an id that repeats makes a reader drop the second one.
361 id: format!("urn:uuid:{}", event.id),
362 title,
363 link: absolute(origin, &path),
364 updated: event.created_at,
365 author: entry
366 .pusher
367 .as_ref()
368 .map(|handle| handle.as_str().to_owned()),
369 // The two ends, so a reader with the feed open already knows what to compare
370 // without following the link. Free — both are on the row.
371 content: Some(Content::Text(if event.is_delete() {
372 format!("{} was deleted", event.before.short())
373 } else if event.is_create() {
374 format!("created at {}", event.after.short())
375 } else {
376 format!("{} \u{2192} {}", event.before.short(), event.after.short())
377 })),
378 }
379}
380
381// --- The document ---------------------------------------------------------------------
382
383/// What an entry carries as its body.
384///
385/// `Text` is written as-is inside a text node; `Html` is markup a reader unescapes and
386/// renders. Both are escaped identically on the way out — the type says what the reader
387/// should do with the result, not how it is written.
388enum Content {
389 Text(String),
390 Html(String),
391}
392
393/// One entry of a feed.
394struct Entry {
395 /// A permanent, unique identifier. Not a display value — a reader uses it to decide
396 /// whether it has seen this before.
397 id: String,
398 title: String,
399 link: String,
400 updated: SystemTime,
401 author: Option<String>,
402 content: Option<Content>,
403}
404
405/// A whole feed, ready to render.
406struct Feed {
407 id: String,
408 title: String,
409 /// Where this document lives, so a reader that was handed it can re-fetch it.
410 self_url: String,
411 /// The page this feed is a feed *of*.
412 alternate: String,
413 /// The handle the feed belongs to. Feed-level, so an entry whose own author is
414 /// unknown still leaves the document valid.
415 author: String,
416 /// The newest entry's timestamp, or `None` for an empty feed.
417 updated: Option<SystemTime>,
418 entries: Vec<Entry>,
419}
420
421impl Feed {
422 fn render(&self) -> String {
423 let mut out = String::with_capacity(2048 + self.entries.len() * 512);
424
425 out.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");
426 out.push_str("<feed xmlns=\"http://www.w3.org/2005/Atom\">\n");
427 element(&mut out, 1, "id", &self.id);
428 element(&mut out, 1, "title", &self.title);
429 // An empty feed still needs an `updated`, and the epoch is the honest answer:
430 // nothing has happened yet.
431 element(
432 &mut out,
433 1,
434 "updated",
435 &rfc3339(self.updated.unwrap_or(UNIX_EPOCH)),
436 );
437 link(&mut out, 1, "self", ATOM_LINK, &self.self_url);
438 link(&mut out, 1, "alternate", "text/html", &self.alternate);
439 out.push_str(" <author><name>");
440 out.push_str(&escape(&self.author));
441 out.push_str("</name></author>\n");
442
443 for entry in &self.entries {
444 out.push_str(" <entry>\n");
445 element(&mut out, 2, "id", &entry.id);
446 element(&mut out, 2, "title", &entry.title);
447 element(&mut out, 2, "updated", &rfc3339(entry.updated));
448 link(&mut out, 2, "alternate", "text/html", &entry.link);
449
450 if let Some(author) = &entry.author {
451 out.push_str(" <author><name>");
452 out.push_str(&escape(author));
453 out.push_str("</name></author>\n");
454 }
455
456 match &entry.content {
457 Some(Content::Text(text)) => {
458 out.push_str(" <content type=\"text\">");
459 out.push_str(&escape(text));
460 out.push_str("</content>\n");
461 }
462 Some(Content::Html(html)) => {
463 out.push_str(" <content type=\"html\">");
464 out.push_str(&escape(html));
465 out.push_str("</content>\n");
466 }
467 None => {}
468 }
469
470 out.push_str(" </entry>\n");
471 }
472
473 out.push_str("</feed>\n");
474 out
475 }
476}
477
478/// One element on one line, its text escaped.
479fn element(out: &mut String, depth: usize, name: &str, text: &str) {
480 for _ in 0..depth {
481 out.push_str(" ");
482 }
483 out.push('<');
484 out.push_str(name);
485 out.push('>');
486 out.push_str(&escape(text));
487 out.push_str("</");
488 out.push_str(name);
489 out.push_str(">\n");
490}
491
492/// One `<link>`. The href is escaped like everything else — a repository name reaches
493/// it, and an attribute is exactly where an unescaped quote does damage.
494fn link(out: &mut String, depth: usize, rel: &str, kind: &str, href: &str) {
495 for _ in 0..depth {
496 out.push_str(" ");
497 }
498 out.push_str("<link rel=\"");
499 out.push_str(rel);
500 out.push_str("\" type=\"");
501 out.push_str(kind);
502 out.push_str("\" href=\"");
503 out.push_str(&escape(href));
504 out.push_str("\" />\n");
505}
506
507/// The one way text reaches a feed.
508///
509/// The five predefined entities, in text nodes and attribute values alike: writing one
510/// escaper rather than two removes the chance of picking the wrong one, and escaping
511/// `"` inside a text node is legal and harmless.
512///
513/// Characters XML 1.0 does not allow *at all* — most of the C0 controls — are dropped
514/// rather than escaped, because there is no escape for them: `&#x1;` is as illegal as
515/// the byte. A commit message can genuinely contain one, and a single stray byte would
516/// otherwise make the whole document unparseable.
517pub(super) fn escape(value: &str) -> String {
518 let mut out = String::with_capacity(value.len() + value.len() / 8);
519
520 for character in value.chars() {
521 match character {
522 '&' => out.push_str("&amp;"),
523 '<' => out.push_str("&lt;"),
524 '>' => out.push_str("&gt;"),
525 '"' => out.push_str("&quot;"),
526 '\'' => out.push_str("&apos;"),
527 '\t' | '\n' | '\r' => out.push(character),
528 // Legal: everything from the space up, plus the surrogate-free ranges the
529 // `char` type already guarantees.
530 character if character >= ' ' => out.push(character),
531 _ => {}
532 }
533 }
534
535 out
536}
537
538/// A timestamp as Atom writes it: RFC 3339, UTC, seconds.
539///
540/// The date maths is [`browse`](super::browse)'s, which is Steid's whole date library.
541fn rfc3339(time: SystemTime) -> String {
542 let seconds = time
543 .duration_since(UNIX_EPOCH)
544 .map(|since| since.as_secs() as i64)
545 .unwrap_or(0);
546
547 let (year, month, day) = super::format::civil_from_days(seconds.div_euclid(86_400));
548 let rest = seconds.rem_euclid(86_400);
549
550 format!(
551 "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
552 rest / 3600,
553 (rest % 3600) / 60,
554 rest % 60
555 )
556}
557
558/// A path made absolute against the origin this request arrived on.
559///
560/// Atom wants absolute IRIs: a feed is read somewhere other than the site it came from,
561/// so a relative link has nothing to resolve against.
562fn absolute(origin: &str, path: &str) -> String {
563 format!("{origin}{path}")
564}
565
566// --- URLs and discovery -----------------------------------------------------------------
567
568pub(super) fn commits_feed_path(handle: &str, name: &str) -> String {
569 format!("/{handle}/repos/{name}/commits.atom")
570}
571
572pub(super) fn issues_feed_path(handle: &str, name: &str) -> String {
573 format!("/{handle}/repos/{name}/issues.atom")
574}
575
576pub(super) fn profile_feed_path(handle: &str) -> String {
577 format!("/{handle}/feed.atom")
578}
579
580/// The feed a page at `path` is the page for, if it has one.
581///
582/// **Autodiscovery is decided from the URL rather than contributed by the page**, and
583/// that is a constraint rather than a preference: the layout writes `<head>` and only a
584/// *layer* may put a value on the request context, so a page has nowhere to leave one.
585/// Deriving it here keeps the knowledge of which URLs have feeds in the module that
586/// serves them, with tests, instead of spreading it across three pages.
587///
588/// Only exact shapes match, so a tree, a blob or a settings page advertises nothing.
589/// A handle or a name the domain would refuse is not a page either — `RepoName` reserves
590/// `new`, which is what keeps `/{handle}/repos/new` out.
591pub(super) fn discovered(path: &str) -> Option<(String, String)> {
592 let segments: Vec<&str> = path.trim_matches('/').split('/').collect();
593
594 match segments[..] {
595 [handle] if OrgName::new(handle).is_ok() => {
596 Some((profile_feed_path(handle), format!("@{handle} activity")))
597 }
598 [handle, "repos", name] if OrgName::new(handle).is_ok() && RepoName::new(name).is_ok() => {
599 Some((
600 commits_feed_path(handle, name),
601 format!("{handle}/{name} commits"),
602 ))
603 }
604 [handle, "repos", name, "issues"]
605 if OrgName::new(handle).is_ok() && RepoName::new(name).is_ok() =>
606 {
607 Some((
608 issues_feed_path(handle, name),
609 format!("{handle}/{name} issues"),
610 ))
611 }
612 _ => None,
613 }
614}
615
616/// The muted "Feed" link a toolbar carries.
617///
618/// Muted on purpose: a feed is a way out of the page, not the thing the page is about,
619/// and `ui.md` reserves the primary colour for position and the one live thing.
620#[component]
621pub(super) async fn feed_link(href: &str) -> Result {
622 view! {
623 <a
624 href=(href)
625 title="Atom feed"
626 class="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
627 >
628 icon(data: iconify_icon!("feather:rss"), attrs: attributes! { class="size-3.5" })
629 "Feed"
630 </a>
631 }
632}
633
634#[cfg(test)]
635mod tests {
636 use super::*;
637
638 #[test]
639 fn the_five_predefined_entities_are_escaped() {
640 assert_eq!(
641 escape("a < b & c > d \"quoted\" 'single'"),
642 "a &lt; b &amp; c &gt; d &quot;quoted&quot; &apos;single&apos;"
643 );
644 }
645
646 #[test]
647 fn markup_cannot_be_smuggled_through_a_title() {
648 assert_eq!(
649 escape("</title><script>alert(1)</script>"),
650 "&lt;/title&gt;&lt;script&gt;alert(1)&lt;/script&gt;"
651 );
652 }
653
654 #[test]
655 fn a_non_ascii_title_passes_through_unchanged() {
656 // The document says UTF-8, so there is nothing to escape here — a feed that
657 // turned these into entities would be correct and unreadable in a diff.
658 assert_eq!(
659 escape("Übergrößenträger — 日本語 · café"),
660 "Übergrößenträger — 日本語 · café"
661 );
662 }
663
664 #[test]
665 fn characters_xml_cannot_carry_are_dropped_rather_than_escaped() {
666 // A commit message really can contain a NUL or a bell, and there is no escape
667 // for either: `&#x0;` is as illegal as the byte itself.
668 assert_eq!(escape("before\u{0}\u{7}after"), "beforeafter");
669 assert_eq!(
670 escape("keep\tthe\nreal\rwhitespace"),
671 "keep\tthe\nreal\rwhitespace"
672 );
673 }
674
675 #[test]
676 fn timestamps_are_rfc_3339_in_utc() {
677 assert_eq!(
678 rfc3339(UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000)),
679 "2023-11-14T22:13:20Z"
680 );
681 assert_eq!(rfc3339(UNIX_EPOCH), "1970-01-01T00:00:00Z");
682 }
683
684 fn feed_with(entries: Vec<Entry>) -> Feed {
685 Feed {
686 id: "https://example.com/ada/repos/steid/commits.atom".to_owned(),
687 title: "ada/steid commits".to_owned(),
688 self_url: "https://example.com/ada/repos/steid/commits.atom".to_owned(),
689 alternate: "https://example.com/ada/repos/steid".to_owned(),
690 author: "ada".to_owned(),
691 updated: entries.first().map(|entry| entry.updated),
692 entries,
693 }
694 }
695
696 #[test]
697 fn an_empty_feed_is_still_a_document_with_an_updated() {
698 let rendered = feed_with(Vec::new()).render();
699
700 assert!(rendered.starts_with("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<feed"));
701 assert!(rendered.contains("<updated>1970-01-01T00:00:00Z</updated>"));
702 assert!(rendered.ends_with("</feed>\n"));
703 assert!(!rendered.contains("<entry>"));
704 }
705
706 #[test]
707 fn every_value_in_an_entry_is_escaped() {
708 let rendered = feed_with(vec![Entry {
709 id: "https://example.com/x?a=1&b=2".to_owned(),
710 title: "fix <T> & co".to_owned(),
711 link: "https://example.com/ada/repos/steid/tree/feat%2F<x>".to_owned(),
712 updated: UNIX_EPOCH + std::time::Duration::from_secs(86_400),
713 author: Some("Ada & Co".to_owned()),
714 content: Some(Content::Html("<p>a &amp; b</p>".to_owned())),
715 }])
716 .render();
717
718 assert!(rendered.contains("<title>fix &lt;T&gt; &amp; co</title>"));
719 assert!(rendered.contains("<id>https://example.com/x?a=1&amp;b=2</id>"));
720 assert!(
721 rendered.contains("href=\"https://example.com/ada/repos/steid/tree/feat%2F&lt;x&gt;\"")
722 );
723 assert!(rendered.contains("<name>Ada &amp; Co</name>"));
724 assert!(
725 rendered.contains("<content type=\"html\">&lt;p&gt;a &amp;amp; b&lt;/p&gt;</content>"),
726 "rendered markdown is escaped once more, which is what type=\"html\" means"
727 );
728 assert!(rendered.contains("<updated>1970-01-02T00:00:00Z</updated>"));
729 }
730
731 #[test]
732 fn only_the_three_page_shapes_advertise_a_feed() {
733 assert_eq!(
734 discovered("/ada"),
735 Some(("/ada/feed.atom".to_owned(), "@ada activity".to_owned()))
736 );
737 assert_eq!(
738 discovered("/ada/repos/steid"),
739 Some((
740 "/ada/repos/steid/commits.atom".to_owned(),
741 "ada/steid commits".to_owned()
742 ))
743 );
744 assert_eq!(
745 discovered("/ada/repos/steid/issues"),
746 Some((
747 "/ada/repos/steid/issues.atom".to_owned(),
748 "ada/steid issues".to_owned()
749 ))
750 );
751 }
752
753 #[test]
754 fn nothing_else_advertises_one() {
755 for path in [
756 "/",
757 "/ada/repos",
758 "/ada/settings",
759 "/ada/repos/new",
760 "/ada/repos/steid/log",
761 "/ada/repos/steid/issues/4",
762 "/ada/repos/steid/tree/main",
763 "/not a handle",
764 ] {
765 assert_eq!(discovered(path), None, "{path} should advertise no feed");
766 }
767 }
768}