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