1use std::fmt;
33
34use serde::{Deserialize, Serialize};
35use serde_json::{Map, Value};
36use url::Url;
37
38use crate::govlog::{DataCitation, DataFilePath, GitCommitPrefix};
39use crate::ids::{
40 CommentId, ContentId, ContentRef, GovernanceLogId, GovernanceLogPrefix,
41 PlatformDoc, PostId,
42};
43use crate::requests::{
44 GetConstitutionInput, GetContentInput, GetFeedInput, GetGovernanceLogInput,
45 GetProfileInput, SearchInput,
46};
47
48pub const SCHEME: &str = "agora";
50
51pub const FORM: &str = "A link is agora://<kind>/<path>?<options>, like \
53 agora://gov/GOV-2026-0006, agora://post/7ad26ccd or \
54 agora://gov/DATA-2026-0001/votes/index.md?lines=110-300; the kinds are \
55 gov, post, comment, community, agent, feed and search. A page URL of \
56 this site reads as the link it shows. An id as \
57 get_content takes it (GOV-2026-0006, 7ad26ccd, \
58 DATA-2026-0001:<commit>:<path>, constitution) reads too.";
59
60pub const FORM_AGAIN: &str = "A link is agora://<kind>/<path>?<options>; \
63 the kinds are gov, post, comment, community, agent, feed and search.";
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68pub enum ContentKind {
69 Post,
71 Comment,
73 Either,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum AgoraLink {
82 Content {
85 kind: ContentKind,
86 read: GetContentInput,
87 },
88 Read(GetContentInput),
92 GovernanceLog(GetGovernanceLogInput),
94 Feed(GetFeedInput),
96 Search(SearchInput),
98 Profile(GetProfileInput),
100 Constitution(GetConstitutionInput),
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
108#[error("{given:?} is not an Agora link: {why}. {FORM}")]
109pub struct AgoraLinkError {
110 pub given: String,
111 pub why: String,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115enum Kind {
116 Gov,
117 Post,
118 Comment,
119 Content,
120 Community,
121 Agent,
122 Feed,
123 Search,
124}
125
126impl Kind {
127 const ALL: [Kind; 8] = [
128 Kind::Gov,
129 Kind::Post,
130 Kind::Comment,
131 Kind::Content,
132 Kind::Community,
133 Kind::Agent,
134 Kind::Feed,
135 Kind::Search,
136 ];
137
138 fn host(self) -> &'static str {
139 match self {
140 Kind::Gov => "gov",
141 Kind::Post => "post",
142 Kind::Comment => "comment",
143 Kind::Content => "content",
144 Kind::Community => "community",
145 Kind::Agent => "agent",
146 Kind::Feed => "feed",
147 Kind::Search => "search",
148 }
149 }
150
151 fn base(self) -> Url {
154 let mut url =
155 Url::parse("agora://gov").expect("a constant link parses");
156 url.set_host(Some(self.host())).expect("a kind is a host");
157 url
158 }
159}
160
161const GOVERNANCE_LOG_ALIASES: &[(&str, &str)] = &[("type", "entry_type")];
164const SEARCH_ALIASES: &[(&str, &str)] = &[("q", "query")];
165
166impl AgoraLink {
167 pub fn post(id: PostId) -> Self {
169 Self::Content {
170 kind: ContentKind::Post,
171 read: GetContentInput::new(id),
172 }
173 }
174
175 pub fn comment(id: CommentId) -> Self {
177 Self::Content {
178 kind: ContentKind::Comment,
179 read: GetContentInput::new(id),
180 }
181 }
182
183 pub fn attachment(id: GovernanceLogId, name: impl Into<String>) -> Self {
186 GetContentInput {
187 attachment: Some(name.into()),
188 ..GetContentInput::new(id)
189 }
190 .into()
191 }
192
193 pub fn round(id: GovernanceLogId, round: u64) -> Self {
195 GetContentInput {
196 round: Some(round),
197 ..GetContentInput::new(id)
198 }
199 .into()
200 }
201
202 pub fn summary(id: GovernanceLogId) -> Self {
204 GetContentInput::new(id)
205 .with_detail(crate::enums::DetailLevel::Summary)
206 .into()
207 }
208
209 pub fn as_read(&self) -> Option<&GetContentInput> {
211 match self {
212 Self::Content { read, .. } | Self::Read(read) => Some(read),
213 _ => None,
214 }
215 }
216
217 pub fn tool(&self) -> &'static str {
219 match self {
220 Self::Content { .. } | Self::Read(_) => "get_content",
221 Self::GovernanceLog(_) => "get_governance_log",
222 Self::Feed(_) => "get_feed",
223 Self::Search(_) => "search",
224 Self::Profile(_) => "get_profile",
225 Self::Constitution(_) => "get_constitution",
226 }
227 }
228
229 pub fn to_url(&self) -> Url {
231 let (kind, segments, mut options, last) = match self {
232 Self::Content { kind, read } => {
233 let kind = match kind {
234 ContentKind::Post => Kind::Post,
235 ContentKind::Comment => Kind::Comment,
236 ContentKind::Either => Kind::Content,
237 };
238 let mut options = to_map(read);
241 options.remove("id");
242 (kind, vec![read.id.to_string()], options, None)
243 }
244 Self::Read(read) => {
245 let mut options = read_options(read);
246 let mut last = None;
247 let segments = match &read.id {
248 ContentRef::Governance(id) => {
249 let mut segments = vec![id.to_string()];
250 if let Some(attachment) = &read.attachment {
251 segments.extend(
252 attachment.split('/').map(String::from),
253 );
254 }
255 segments
256 }
257 ContentRef::DataCitation(c) => {
258 if let Some(lines) = c.lines.or(read.lines) {
259 options.insert(
260 "lines".into(),
261 lines.to_string().into(),
262 );
263 }
264 last = c
265 .commit
266 .as_ref()
267 .map(|c| ("commit", c.to_string()));
268 std::iter::once(c.set.to_string())
269 .chain(c.path.as_str().split('/').map(String::from))
270 .collect()
271 }
272 ContentRef::Document(doc) => {
273 if let Some(a) = &read.attachment {
274 options
275 .insert("attachment".into(), a.clone().into());
276 }
277 doc_segments(doc)
278 }
279 ContentRef::Content(_) | ContentRef::ContentPrefix(_) => {
281 return Self::Content {
282 kind: ContentKind::Either,
283 read: read.clone(),
284 }
285 .to_url();
286 }
287 };
288 (Kind::Gov, segments, options, last)
289 }
290 Self::GovernanceLog(input) => {
291 let mut options = to_map(input);
292 alias_out(&mut options, GOVERNANCE_LOG_ALIASES);
293 (Kind::Gov, vec![], options, None)
294 }
295 Self::Feed(input) => {
296 let mut options = to_map(input);
297 options.remove("community");
298 match &input.community {
299 Some(name) => (
300 Kind::Community,
301 vec![name.canonical().to_string()],
302 options,
303 None,
304 ),
305 None => (Kind::Feed, vec![], options, None),
306 }
307 }
308 Self::Search(input) => {
309 let mut options = to_map(input);
310 alias_out(&mut options, SEARCH_ALIASES);
311 (Kind::Search, vec![], options, None)
312 }
313 Self::Profile(input) => {
314 (Kind::Agent, vec![input.name.to_string()], Map::new(), None)
315 }
316 Self::Constitution(input) => {
317 (Kind::Gov, vec!["constitution".into()], to_map(input), None)
318 }
319 };
320 let mut url = kind.base();
321 if !segments.is_empty() {
322 url.path_segments_mut()
323 .expect("a link has a host")
324 .extend(segments.iter().map(String::as_str));
325 }
326 let first = options.remove("q");
328 if first.is_some() || !options.is_empty() || last.is_some() {
329 let mut query = url.query_pairs_mut();
330 if let Some(q) = first {
331 query.append_pair("q", &value_str(&q));
332 }
333 for (name, value) in &options {
334 query.append_pair(name, &value_str(value));
335 }
336 if let Some((name, value)) = last {
337 query.append_pair(name, &value);
338 }
339 }
340 protect_last_char(&mut url);
341 url
342 }
343}
344
345const TRAILING: &[char] = &['.', ',', ';', ':', '>', ')'];
348
349fn protect_last_char(url: &mut Url) {
353 let Some(last) =
354 url.as_str().chars().last().filter(|c| TRAILING.contains(c))
355 else {
356 return;
357 };
358 let encoded = percent_encoding::utf8_percent_encode(
359 last.encode_utf8(&mut [0; 4]),
360 percent_encoding::NON_ALPHANUMERIC,
361 )
362 .to_string();
363 match url.query().map(str::to_string) {
364 Some(query) => {
365 let mut fixed = query[..query.len() - last.len_utf8()].to_string();
366 fixed.push_str(&encoded);
367 url.set_query(Some(&fixed));
368 }
369 None => {
370 let path = url.path();
371 let mut fixed = path[..path.len() - last.len_utf8()].to_string();
372 fixed.push_str(&encoded);
373 url.set_path(&fixed);
374 }
375 }
376}
377
378fn unwrap_pasted(given: &str) -> &str {
382 let mut s = given.trim();
383 loop {
384 let before = s;
385 for (open, close) in
386 [('`', '`'), ('<', '>'), ('"', '"'), ('\'', '\''), ('(', ')')]
387 {
388 if let Some(inner) =
389 s.strip_prefix(open).and_then(|r| r.strip_suffix(close))
390 {
391 s = inner.trim();
392 }
393 }
394 for open in ['`', '<', '"', '\'', '('] {
395 if let Some(rest) = s.strip_prefix(open)
396 && !s.ends_with(open)
397 {
398 s = rest.trim();
399 }
400 }
401 if let Some(c) = s.chars().last()
402 && TRAILING.contains(&c)
403 && (c != ')' || s.matches('(').count() < s.matches(')').count())
404 {
405 s = &s[..s.len() - 1];
406 }
407 for close in ['`', '"', '\''] {
408 if s.ends_with(close) && !s.starts_with(close) {
409 s = &s[..s.len() - 1];
410 }
411 }
412 if s == before {
413 return s;
414 }
415 }
416}
417
418static SITE: std::sync::OnceLock<Url> = std::sync::OnceLock::new();
421
422pub fn set_site(base: &Url) {
425 let _ = SITE.set(base.clone());
426}
427
428fn from_page(page: &Url, site: Option<&Url>) -> Result<Url, String> {
431 let Some(site) = site else {
432 return Err("this client knows no website to read page URLs of".into());
433 };
434 if page.origin() != site.origin() {
435 return Err(format!(
436 "{} is not this site; Agora's pages are on {}",
437 page.origin().ascii_serialization(),
438 site.origin().ascii_serialization()
439 ));
440 }
441 let mut parts: Vec<String> = Vec::new();
442 for raw in page.path().split('/').skip(1) {
443 let part = percent_encoding::percent_decode_str(raw)
444 .decode_utf8()
445 .map_err(|_| format!("{raw:?} is not UTF-8 once decoded"))?;
446 parts.push(part.into_owned());
447 }
448 if parts.last().is_some_and(String::is_empty) {
449 parts.pop();
450 }
451 if parts.first().map(String::as_str) != Some("agora") {
452 return Err("Agora's pages are under /agora/".into());
453 }
454 let parts: Vec<&str> = parts[1..].iter().map(String::as_str).collect();
455 let mut query: Vec<(String, String)> =
456 page.query_pairs().into_owned().collect();
457 let no_link = || format!("the page {} has no link", page.path());
458 let (kind, segments): (Kind, Vec<&str>) = match parts.as_slice() {
459 [] | ["feed"] => (Kind::Feed, vec![]),
460 ["c", name] => (Kind::Community, vec![*name]),
461 ["c", _, post] => (Kind::Post, vec![*post]),
462 ["agent", name] => (Kind::Agent, vec![*name]),
463 ["governance"] => (Kind::Gov, vec![]),
464 ["governance", "protocol"] => (Kind::Gov, vec!["protocol"]),
465 ["governance", id] => (Kind::Gov, vec![*id]),
466 ["governance", id, attachment @ ..] => {
469 let link = id
470 .parse::<GovernanceLogId>()
471 .map(|id| AgoraLink::attachment(id, attachment.join("/")))
472 .map_err(|_| no_link())?;
473 return Err(format!(
474 "the website has no page for an attachment; its link is {link}"
475 ));
476 }
477 ["obligations"] => {
478 query.push(("view".into(), "obligations".into()));
479 (Kind::Gov, vec![])
480 }
481 ["calendar"] => (Kind::Gov, vec!["calendar"]),
482 ["constitution"] => (Kind::Gov, vec!["constitution"]),
483 ["search"] => (Kind::Search, vec![]),
484 _ => return Err(no_link()),
485 };
486 let mut link = kind.base();
487 if !segments.is_empty() {
488 link.path_segments_mut()
489 .expect("a link has a host")
490 .extend(segments);
491 }
492 let mut pairs = Vec::new();
494 for (name, value) in query {
495 match name.as_str() {
496 "page" => {
497 return Err(
498 "a page number has no link: give offset= instead".into()
499 );
500 }
501 "revisions" => pairs.push((
502 "include_revisions".to_string(),
503 (value == "1" || value == "true").to_string(),
504 )),
505 _ => pairs.push((name, value)),
506 }
507 }
508 if !pairs.is_empty() {
509 link.query_pairs_mut().extend_pairs(pairs);
510 }
511 Ok(link)
512}
513
514fn read_options(read: &GetContentInput) -> Map<String, Value> {
517 let mut options = to_map(read);
518 options.remove("id");
519 options.remove("attachment");
520 options
521}
522
523fn doc_segments(doc: &PlatformDoc) -> Vec<String> {
524 match doc {
525 PlatformDoc::Constitution => vec!["constitution".into()],
526 PlatformDoc::GovernanceProtocol => vec!["protocol".into()],
527 PlatformDoc::Prompts => vec!["prompts".into()],
528 PlatformDoc::Prompt(name) => vec!["prompts".into(), name.to_string()],
529 PlatformDoc::Calendar => vec!["calendar".into()],
530 }
531}
532
533fn to_map<T: Serialize>(value: &T) -> Map<String, Value> {
534 match serde_json::to_value(value) {
535 Ok(Value::Object(map)) => map,
536 _ => Map::new(),
537 }
538}
539
540fn value_str(value: &Value) -> String {
541 match value {
542 Value::String(s) => s.clone(),
543 other => other.to_string(),
544 }
545}
546
547fn alias_out(options: &mut Map<String, Value>, aliases: &[(&str, &str)]) {
549 for (link, field) in aliases {
550 if let Some(value) = options.remove(*field) {
551 options.insert((*link).into(), value);
552 }
553 }
554}
555
556fn alias_in(
559 options: &mut Map<String, Value>,
560 aliases: &[(&str, &str)],
561) -> Result<(), String> {
562 for (link, field) in aliases {
563 if let Some(value) = options.remove(*link) {
564 if options.contains_key(*field) {
565 return Err(format!("give {link}= or {field}=, not both"));
566 }
567 options.insert((*field).into(), value);
568 }
569 }
570 Ok(())
571}
572
573fn canonical_read(mut read: GetContentInput) -> GetContentInput {
579 match &mut read.id {
580 ContentRef::Governance(set)
581 if set.prefix() == GovernanceLogPrefix::Data
582 && read.version.is_none()
583 && read.detail.is_none() =>
584 {
585 let path = read
586 .attachment
587 .as_deref()
588 .filter(|a| !a.ends_with('/'))
589 .and_then(|a| a.parse::<DataFilePath>().ok());
590 if let Some(path) = path {
591 read.id = ContentRef::DataCitation(DataCitation {
592 set: set.clone(),
593 commit: None,
594 path,
595 lines: read.lines.take(),
596 });
597 read.attachment = None;
598 }
599 }
600 ContentRef::DataCitation(c) if c.lines.is_none() => {
601 c.lines = read.lines.take();
602 }
603 _ => {}
604 }
605 read
606}
607
608impl From<GetContentInput> for AgoraLink {
609 fn from(read: GetContentInput) -> Self {
610 match read.id {
611 ContentRef::Content(_) | ContentRef::ContentPrefix(_) => {
612 Self::Content {
613 kind: ContentKind::Either,
614 read,
615 }
616 }
617 _ => Self::Read(canonical_read(read)),
618 }
619 }
620}
621
622impl From<ContentRef> for AgoraLink {
623 fn from(id: ContentRef) -> Self {
624 GetContentInput::new(id).into()
625 }
626}
627
628impl From<GovernanceLogId> for AgoraLink {
629 fn from(id: GovernanceLogId) -> Self {
630 ContentRef::Governance(id).into()
631 }
632}
633
634impl From<DataCitation> for AgoraLink {
635 fn from(citation: DataCitation) -> Self {
636 ContentRef::DataCitation(citation).into()
637 }
638}
639
640impl From<PlatformDoc> for AgoraLink {
641 fn from(doc: PlatformDoc) -> Self {
642 ContentRef::Document(doc).into()
643 }
644}
645
646impl From<PostId> for AgoraLink {
647 fn from(id: PostId) -> Self {
648 Self::post(id)
649 }
650}
651
652impl From<CommentId> for AgoraLink {
653 fn from(id: CommentId) -> Self {
654 Self::comment(id)
655 }
656}
657
658impl From<GetGovernanceLogInput> for AgoraLink {
659 fn from(input: GetGovernanceLogInput) -> Self {
660 Self::GovernanceLog(input)
661 }
662}
663
664impl From<GetFeedInput> for AgoraLink {
665 fn from(input: GetFeedInput) -> Self {
666 Self::Feed(input)
667 }
668}
669
670impl From<SearchInput> for AgoraLink {
671 fn from(input: SearchInput) -> Self {
672 Self::Search(input)
673 }
674}
675
676impl From<GetConstitutionInput> for AgoraLink {
677 fn from(input: GetConstitutionInput) -> Self {
678 match input.version {
679 Some(_) => Self::Constitution(input),
680 None => PlatformDoc::Constitution.into(),
681 }
682 }
683}
684
685impl From<GetProfileInput> for AgoraLink {
686 fn from(input: GetProfileInput) -> Self {
687 Self::Profile(input)
688 }
689}
690
691impl fmt::Display for AgoraLink {
692 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
693 f.write_str(self.to_url().as_str())
694 }
695}
696
697impl std::str::FromStr for AgoraLink {
698 type Err = AgoraLinkError;
699
700 fn from_str(given: &str) -> Result<Self, Self::Err> {
701 Self::parse_at(given, SITE.get())
702 }
703}
704
705impl AgoraLink {
706 pub fn parse_at(
709 given: &str,
710 site: Option<&Url>,
711 ) -> Result<Self, AgoraLinkError> {
712 let fail = |why: String| AgoraLinkError {
713 given: given.to_string(),
714 why,
715 };
716 let trimmed = given.trim();
717 let unwrapped = unwrap_pasted(given);
718 let scheme = |s: &str, scheme: &str| {
719 s.get(..scheme.len())
720 .is_some_and(|h| h.eq_ignore_ascii_case(scheme))
721 };
722 if scheme(unwrapped, "agora:") {
723 return parse_link(unwrapped).map_err(fail);
724 }
725 if scheme(unwrapped, "https://") || scheme(unwrapped, "http://") {
726 if let Some(c) = unwrapped.chars().find(|c| c.is_control()) {
727 return Err(fail(format!(
728 "a link has no control characters ({c:?})"
729 )));
730 }
731 refuse_dot_segments(unwrapped).map_err(fail)?;
732 let page = Url::parse(unwrapped)
733 .map_err(|e| fail(format!("not a URL ({e})")))?;
734 let link = from_page(&page, site).map_err(fail)?;
735 return parse_link(link.as_str()).map_err(fail);
736 }
737 match given.parse::<ContentRef>() {
739 Ok(id) => Ok(id.into()),
740 Err(e) => {
741 let why = if DataCitation::is_citation_shaped(given) {
742 e.to_string()
743 } else if let Some((scheme, _)) = trimmed.split_once("://") {
744 format!("its scheme is {scheme:?}, not \"agora\"")
745 } else {
746 "it is neither an agora:// link nor an id".to_string()
747 };
748 Err(AgoraLinkError {
749 given: given.to_string(),
750 why,
751 })
752 }
753 }
754 }
755}
756
757fn refuse_dot_segments(link: &str) -> Result<(), String> {
760 let Some((_, rest)) = link.split_once("://") else {
761 return Ok(());
762 };
763 let rest = rest.split(['?', '#']).next().unwrap_or_default();
764 let Some((_, path)) = rest.split_once('/') else {
765 return Ok(());
766 };
767 for segment in path.split('/') {
768 let decoded = percent_encoding::percent_decode_str(segment)
769 .decode_utf8_lossy()
770 .to_string();
771 if decoded == "." || decoded == ".." {
772 return Err(format!(
773 "a link's path has no {decoded:?} parts: name what it reads \
774 from the top (a data set's file from its repository's root)"
775 ));
776 }
777 }
778 Ok(())
779}
780
781fn parse_link(link: &str) -> Result<AgoraLink, String> {
782 if let Some(c) = link.chars().find(|c| c.is_control()) {
785 return Err(format!("a link has no control characters ({c:?})"));
786 }
787 refuse_dot_segments(link)?;
788 let url = Url::parse(link).map_err(|e| format!("not a URL ({e})"))?;
789 let Some(host) = url.host_str().filter(|_| !url.cannot_be_a_base()) else {
790 return Err("write it agora://<kind>/…, with two slashes".into());
791 };
792 if !url.username().is_empty()
793 || url.password().is_some()
794 || url.port().is_some()
795 {
796 return Err("a link has no user, password or port".into());
797 }
798 if url.fragment().is_some() {
799 return Err("a link has no #fragment".into());
800 }
801 let host = host.to_ascii_lowercase();
802 let kind = Kind::ALL.into_iter().find(|k| k.host() == host);
803 let Some(kind) = kind else {
804 let hint = if host == "profile" {
805 " (an agent's link is agora://agent/<name>)".to_string()
806 } else if host.parse::<GovernanceLogId>().is_ok() {
807 format!(" (agora://gov/{} reads that entry)", host.to_uppercase())
808 } else {
809 String::new()
810 };
811 return Err(format!("{host:?} is not a kind of link{hint}"));
812 };
813
814 let path = url.path();
817 let path = path.strip_prefix('/').unwrap_or(path);
818 let mut segments = Vec::new();
819 if !path.is_empty() {
820 for raw in path.split('/') {
821 let part = percent_encoding::percent_decode_str(raw)
822 .decode_utf8()
823 .map_err(|_| format!("{raw:?} is not UTF-8 once decoded"))?;
824 segments.push(part.into_owned());
825 }
826 }
827 let directory = segments.last().is_some_and(String::is_empty);
828 if directory {
829 segments.pop();
830 }
831 if segments.iter().any(String::is_empty) {
832 return Err("a link's path has no empty parts (`//`)".into());
833 }
834
835 let mut options = Map::new();
836 for (name, value) in url.query_pairs() {
837 if options
838 .insert(name.to_string(), Value::String(value.into_owned()))
839 .is_some()
840 {
841 return Err(format!("{name}= is given twice"));
842 }
843 }
844 if let Some(name) = options
849 .iter()
850 .find(|(_, v)| v.as_str().is_some_and(|v| v.trim().is_empty()))
851 .map(|(name, _)| name.clone())
852 {
853 if name == "attachment" {
854 return Err("attachment= is empty, and an attachment is named in \
855 the path, agora://gov/<id>/<attachment>, not by attachment="
856 .into());
857 }
858 return Err(format!(
859 "{name}= is empty: give it {}, or leave it out",
860 option_values(&name)
861 ));
862 }
863
864 let none_after = |what: &str, n: usize| -> Result<(), String> {
865 if segments.len() > n {
866 Err(format!("nothing follows {what} in the path"))
867 } else {
868 Ok(())
869 }
870 };
871
872 match kind {
873 Kind::Gov => parse_gov(segments, directory, options),
874 Kind::Post | Kind::Comment | Kind::Content => {
875 let [id] = segments.as_slice() else {
876 return Err(match segments.len() {
877 0 => format!("agora://{host}/<id>: the id is missing"),
878 _ => format!("agora://{host}/<id> takes one id"),
879 });
880 };
881 let id = content_id(id)?;
882 if options.contains_key("commit") {
883 return Err("commit= is for a data set's file".into());
884 }
885 let kind = match kind {
886 Kind::Post => ContentKind::Post,
887 Kind::Comment => ContentKind::Comment,
888 _ => ContentKind::Either,
889 };
890 Ok(AgoraLink::Content {
891 kind,
892 read: read_input(options, id, None)?,
893 })
894 }
895 Kind::Community => {
896 let Some(name) = segments.first() else {
897 return Err(
898 "agora://community/<name>: the name is missing".into()
899 );
900 };
901 if segments.len() > 1 {
902 return Err(format!(
903 "a community's link is its name alone; a post in it is \
904 agora://post/{}",
905 segments[1]
906 ));
907 }
908 refuse_path_fields(&options, &["community"])?;
909 options.insert("community".into(), Value::String(name.clone()));
910 from_options::<GetFeedInput>(options).map(AgoraLink::Feed)
911 }
912 Kind::Agent => {
913 let Some(name) = segments.first() else {
914 return Err("agora://agent/<name>: the name is missing".into());
915 };
916 none_after("the agent's name", 1)?;
917 refuse_path_fields(&options, &["name"])?;
918 options.insert("name".into(), Value::String(name.clone()));
919 from_options::<GetProfileInput>(options).map(AgoraLink::Profile)
920 }
921 Kind::Feed => {
922 none_after("feed", 0)?;
923 from_options::<GetFeedInput>(options).map(AgoraLink::Feed)
924 }
925 Kind::Search => {
926 none_after("search", 0)?;
927 alias_in(&mut options, SEARCH_ALIASES)?;
928 if !options.contains_key("query") {
929 return Err("agora://search?q=<words>: q= is missing".into());
930 }
931 from_options::<SearchInput>(options).map(AgoraLink::Search)
932 }
933 }
934}
935
936fn parse_gov(
937 segments: Vec<String>,
938 directory: bool,
939 mut options: Map<String, Value>,
940) -> Result<AgoraLink, String> {
941 let Some((first, rest)) = segments.split_first() else {
942 alias_in(&mut options, GOVERNANCE_LOG_ALIASES)?;
943 return from_options::<GetGovernanceLogInput>(options)
944 .map(AgoraLink::GovernanceLog);
945 };
946 let commit = match options.remove("commit") {
947 Some(commit) => Some(
948 value_str(&commit)
949 .parse::<GitCommitPrefix>()
950 .map_err(|e| e.to_string())?,
951 ),
952 None => None,
953 };
954 if let Some((head, _)) = first.split_once(':')
955 && head.parse::<GovernanceLogId>().is_ok()
956 {
957 return Err(format!(
958 "a citation's colons are not a link's path: write \
959 agora://gov/{head}/<path>?lines=<from>-<to>&commit=<commit>"
960 ));
961 }
962 if let Ok(id) = first.parse::<GovernanceLogId>() {
963 if directory && rest.is_empty() {
966 return Err(format!(
967 "agora://gov/{id}/ names no attachment: name one, \
968 agora://gov/{id}/<attachment>, or leave the slash off"
969 ));
970 }
971 let attachment = (!rest.is_empty()).then(|| {
974 let mut a = rest.join("/");
975 if directory {
976 a.push('/');
977 }
978 a
979 });
980 if let Some(a) = &attachment {
981 a.trim_end_matches('/')
983 .parse::<DataFilePath>()
984 .map_err(|e| e.to_string())?;
985 }
986 let read = canonical_read(read_input(
987 options,
988 ContentRef::Governance(id),
989 attachment,
990 )?);
991 return match (commit, read.id) {
992 (None, id) => Ok(AgoraLink::Read(GetContentInput { id, ..read })),
993 (Some(commit), ContentRef::DataCitation(c)) => {
994 Ok(AgoraLink::Read(GetContentInput {
995 id: ContentRef::DataCitation(c.at(commit)),
996 ..read
997 }))
998 }
999 (Some(_), _) => Err("commit= is for a data set's file: \
1000 agora://gov/DATA-YYYY-NNNN/<path>?commit=<7-40 hex>"
1001 .into()),
1002 };
1003 }
1004 if commit.is_some() {
1005 return Err("commit= is for a data set's file".into());
1006 }
1007 if segments == ["constitution"] && options.contains_key("version") {
1008 return from_options::<GetConstitutionInput>(options)
1010 .map(AgoraLink::Constitution);
1011 }
1012 let doc = segments.join("/").parse::<PlatformDoc>().map_err(|_| {
1013 format!(
1014 "agora://gov/{} is neither a governance entry (GOV-2026-0006, \
1015 DATA-2026-0001, …) nor a document (constitution, protocol, \
1016 calendar, prompts, prompts/<name>)",
1017 segments.join("/")
1018 )
1019 })?;
1020 read_input(options, ContentRef::Document(doc), None).map(AgoraLink::Read)
1021}
1022
1023fn content_id(id: &str) -> Result<ContentRef, String> {
1025 if let Ok(id) = id.parse::<ContentId>() {
1026 return Ok(ContentRef::Content(id));
1027 }
1028 id.parse().map(ContentRef::ContentPrefix).map_err(|_| {
1029 format!(
1030 "{id:?} is not a post's or comment's id (a UUID, or its \
1031 first eight hex digits, like 7ad26ccd)"
1032 )
1033 })
1034}
1035
1036fn refuse_path_fields(
1037 options: &Map<String, Value>,
1038 fields: &[&str],
1039) -> Result<(), String> {
1040 match fields.iter().find(|f| options.contains_key(**f)) {
1041 Some(&"attachment") => Err("an attachment is named in the path, \
1042 agora://gov/<id>/<attachment>, not by attachment="
1043 .into()),
1044 Some(&"id") => {
1045 Err("the path names what to read, not id=; leave it out".into())
1046 }
1047 Some(field) => Err(format!(
1048 "the path says what {field}= would; leave {field}= out"
1049 )),
1050 None => Ok(()),
1051 }
1052}
1053
1054fn option_values(name: &str) -> String {
1056 match name {
1057 "detail" => "summary, full or full_with_attachments".into(),
1058 "round" => "a round's number, from 1".into(),
1059 "version" => "latest or original (for the Constitution, a version \
1060 number like 0.3)"
1061 .into(),
1062 "comment_budget" => {
1063 let bounds =
1064 crate::requests::ReadTarget::Content.comment_budget_bounds();
1065 format!("a number of bytes, {} to {}", bounds.start(), bounds.end())
1066 }
1067 "lines" => "a range of lines like 110-300, 110- or 110".into(),
1068 "commit" => "7 to 40 hex digits of the data set's commit".into(),
1069 "q" | "query" => "the words to look for".into(),
1070 "limit" | "offset" => "a number".into(),
1071 _ => "a value".into(),
1072 }
1073}
1074
1075fn read_input(
1078 mut options: Map<String, Value>,
1079 id: ContentRef,
1080 attachment: Option<String>,
1081) -> Result<GetContentInput, String> {
1082 refuse_path_fields(&options, &["id", "attachment"])?;
1083 let target = crate::requests::ReadTarget::of(&id);
1085 let allowed = target.options();
1086 if let Some(name) = options
1087 .keys()
1088 .find(|k| !allowed.iter().any(|o| o.name() == k.as_str()))
1089 {
1090 let takes = match allowed {
1091 [] => "no options".to_string(),
1092 _ => allowed
1093 .iter()
1094 .map(|o| o.name())
1095 .chain(
1096 (target == crate::requests::ReadTarget::DataSet)
1097 .then_some("commit"),
1098 )
1099 .collect::<Vec<_>>()
1100 .join(", "),
1101 };
1102 return Err(format!(
1103 "{name} does not apply to {}, which takes {takes}",
1104 target.noun()
1105 ));
1106 }
1107 options.insert("id".into(), Value::String(id.to_string()));
1108 if let Some(a) = attachment {
1109 options.insert("attachment".into(), Value::String(a));
1110 }
1111 from_options(options)
1112}
1113
1114fn from_options<T: serde::de::DeserializeOwned>(
1115 options: Map<String, Value>,
1116) -> Result<T, String> {
1117 serde_json::from_value(Value::Object(options))
1118 .map_err(|e| format!("in its options, {e}"))
1119}
1120
1121impl Serialize for AgoraLink {
1122 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1123 s.collect_str(self)
1124 }
1125}
1126
1127impl<'de> Deserialize<'de> for AgoraLink {
1128 fn deserialize<D: serde::Deserializer<'de>>(
1129 d: D,
1130 ) -> Result<Self, D::Error> {
1131 let raw = String::deserialize(d)?;
1132 raw.parse().map_err(serde::de::Error::custom)
1133 }
1134}
1135
1136#[cfg(feature = "schemars")]
1138impl schemars::JsonSchema for AgoraLink {
1139 fn inline_schema() -> bool {
1140 true
1141 }
1142
1143 fn schema_name() -> std::borrow::Cow<'static, str> {
1144 std::borrow::Cow::Borrowed("AgoraLink")
1145 }
1146
1147 fn schema_id() -> std::borrow::Cow<'static, str> {
1148 std::borrow::Cow::Borrowed(concat!(module_path!(), "::AgoraLink"))
1149 }
1150
1151 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1152 schemars::json_schema!({ "type": "string" })
1153 }
1154}
1155
1156#[cfg(test)]
1157mod tests;