1use std::fmt;
37
38use serde::{Deserialize, Serialize};
39use serde_json::{Map, Value};
40use url::Url;
41
42use crate::govlog::{DataCitation, DataFilePath, GitCommitPrefix};
43use crate::ids::{
44 CommentId, ContentId, ContentRef, GovernanceLogId, GovernanceLogPrefix,
45 PlatformDoc, PostId, PromptName,
46};
47use crate::requests::{
48 GetConstitutionInput, GetContentInput, GetFeedInput, GetGovernanceLogInput,
49 GetProfileInput, SearchInput,
50};
51
52pub const SCHEME: &str = "agora";
54
55pub const FORM: &str = "A link is agora://<kind>/<path>?<options>, like \
57 agora://gov/GOV-2026-0006, agora://post/7ad26ccd or \
58 agora://gov/DATA-2026-0001/votes/index.md?lines=110-300; the kinds are \
59 gov, constitution, protocol, calendar, prompts, post, comment, content, \
60 community, agent, feed and search. A page URL of \
61 this site reads as the link it shows. An id as \
62 get_content takes it (GOV-2026-0006, 7ad26ccd, \
63 DATA-2026-0001:<commit>:<path>, constitution) reads too.";
64
65pub const FORM_AGAIN: &str = "A link is agora://<kind>/<path>?<options>; \
68 the kinds are gov, constitution, protocol, calendar, prompts, post, \
69 comment, content, community, agent, feed and search.";
70
71pub const KINDS: &[&str] = &{
74 let mut kinds = [""; Kind::ALL.len()];
75 let mut i = 0;
76 while i < kinds.len() {
77 kinds[i] = Kind::ALL[i].host();
78 i += 1;
79 }
80 kinds
81};
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
86pub enum ContentKind {
87 Post,
89 Comment,
91 Either,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum AgoraLink {
100 Content {
103 kind: ContentKind,
104 read: GetContentInput,
105 },
106 Read(GetContentInput),
110 GovernanceLog(GetGovernanceLogInput),
112 Feed(GetFeedInput),
114 Search(SearchInput),
116 Profile(GetProfileInput),
118 Constitution(GetConstitutionInput),
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
126#[error("{given:?} is not an Agora link: {why}. {FORM}")]
127pub struct AgoraLinkError {
128 pub given: String,
129 pub why: String,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133enum Kind {
134 Gov,
135 Constitution,
136 Protocol,
137 Calendar,
138 Prompts,
139 Post,
140 Comment,
141 Content,
142 Community,
143 Agent,
144 Feed,
145 Search,
146}
147
148impl Kind {
149 const ALL: [Kind; 12] = [
150 Kind::Gov,
151 Kind::Constitution,
152 Kind::Protocol,
153 Kind::Calendar,
154 Kind::Prompts,
155 Kind::Post,
156 Kind::Comment,
157 Kind::Content,
158 Kind::Community,
159 Kind::Agent,
160 Kind::Feed,
161 Kind::Search,
162 ];
163
164 const fn host(self) -> &'static str {
165 match self {
166 Kind::Gov => "gov",
167 Kind::Constitution => "constitution",
168 Kind::Protocol => "protocol",
169 Kind::Calendar => "calendar",
170 Kind::Prompts => "prompts",
171 Kind::Post => "post",
172 Kind::Comment => "comment",
173 Kind::Content => "content",
174 Kind::Community => "community",
175 Kind::Agent => "agent",
176 Kind::Feed => "feed",
177 Kind::Search => "search",
178 }
179 }
180
181 const DOCUMENTS: [Kind; 4] = [
184 Kind::Constitution,
185 Kind::Protocol,
186 Kind::Calendar,
187 Kind::Prompts,
188 ];
189
190 fn document(slug: &str) -> Option<Kind> {
192 Self::DOCUMENTS.into_iter().find(|k| k.host() == slug)
193 }
194
195 fn base(self) -> Url {
198 let mut url =
199 Url::parse("agora://gov").expect("a constant link parses");
200 url.set_host(Some(self.host())).expect("a kind is a host");
201 url
202 }
203}
204
205const GOVERNANCE_LOG_ALIASES: &[(&str, &str)] = &[("type", "entry_type")];
208const SEARCH_ALIASES: &[(&str, &str)] = &[("q", "query")];
209
210impl AgoraLink {
211 pub fn post(id: PostId) -> Self {
213 Self::Content {
214 kind: ContentKind::Post,
215 read: GetContentInput::new(id),
216 }
217 }
218
219 pub fn comment(id: CommentId) -> Self {
221 Self::Content {
222 kind: ContentKind::Comment,
223 read: GetContentInput::new(id),
224 }
225 }
226
227 pub fn attachment(id: GovernanceLogId, name: impl Into<String>) -> Self {
230 GetContentInput {
231 attachment: Some(name.into()),
232 ..GetContentInput::new(id)
233 }
234 .into()
235 }
236
237 pub fn round(id: GovernanceLogId, round: u64) -> Self {
239 GetContentInput {
240 round: Some(round),
241 ..GetContentInput::new(id)
242 }
243 .into()
244 }
245
246 pub fn history(id: GovernanceLogId) -> Self {
249 GetContentInput {
250 history: Some(crate::requests::History::All),
251 ..GetContentInput::new(id)
252 .with_detail(crate::enums::DetailLevel::Summary)
253 }
254 .into()
255 }
256
257 pub fn summary(id: GovernanceLogId) -> Self {
259 GetContentInput::new(id)
260 .with_detail(crate::enums::DetailLevel::Summary)
261 .into()
262 }
263
264 pub fn as_read(&self) -> Option<&GetContentInput> {
266 match self {
267 Self::Content { read, .. } | Self::Read(read) => Some(read),
268 _ => None,
269 }
270 }
271
272 pub fn tool(&self) -> &'static str {
274 match self {
275 Self::Content { .. } | Self::Read(_) => "get_content",
276 Self::GovernanceLog(_) => "get_governance_log",
277 Self::Feed(_) => "get_feed",
278 Self::Search(_) => "search",
279 Self::Profile(_) => "get_profile",
280 Self::Constitution(_) => "get_constitution",
281 }
282 }
283
284 pub fn to_url(&self) -> Url {
286 let (kind, segments, options, last) = match self {
287 Self::Content { kind, read } => {
288 let kind = match kind {
289 ContentKind::Post => Kind::Post,
290 ContentKind::Comment => Kind::Comment,
291 ContentKind::Either => Kind::Content,
292 };
293 let mut options = to_map(read);
296 options.remove("id");
297 (kind, vec![read.id.to_string()], options, None)
298 }
299 Self::Read(read) => {
300 let mut options = read_options(read);
301 let mut last = None;
302 let segments = match &read.id {
303 ContentRef::Governance(id) => {
304 let mut segments = vec![id.to_string()];
305 if let Some(attachment) = &read.attachment {
306 segments.extend(
307 attachment.split('/').map(String::from),
308 );
309 }
310 segments
311 }
312 ContentRef::DataCitation(c) => {
313 if let Some(lines) = c.lines.or(read.lines) {
314 options.insert(
315 "lines".into(),
316 lines.to_string().into(),
317 );
318 }
319 last = c
320 .commit
321 .as_ref()
322 .map(|c| ("commit", c.to_string()));
323 std::iter::once(c.set.to_string())
324 .chain(c.path.as_str().split('/').map(String::from))
325 .collect()
326 }
327 ContentRef::Document(doc) => {
328 if let Some(a) = &read.attachment {
329 options
330 .insert("attachment".into(), a.clone().into());
331 }
332 let (kind, rest) = doc_link(doc);
333 return url_of(kind, &rest, options, None);
334 }
335 ContentRef::Content(_) | ContentRef::ContentPrefix(_) => {
337 return Self::Content {
338 kind: ContentKind::Either,
339 read: read.clone(),
340 }
341 .to_url();
342 }
343 };
344 (Kind::Gov, segments, options, last)
345 }
346 Self::GovernanceLog(input) => {
347 let mut options = to_map(input);
348 alias_out(&mut options, GOVERNANCE_LOG_ALIASES);
349 (Kind::Gov, vec![], options, None)
350 }
351 Self::Feed(input) => {
352 let mut options = to_map(input);
353 options.remove("community");
354 match &input.community {
355 Some(name) => (
356 Kind::Community,
357 vec![name.canonical().to_string()],
358 options,
359 None,
360 ),
361 None => (Kind::Feed, vec![], options, None),
362 }
363 }
364 Self::Search(input) => {
365 let mut options = to_map(input);
366 alias_out(&mut options, SEARCH_ALIASES);
367 (Kind::Search, vec![], options, None)
368 }
369 Self::Profile(input) => {
370 (Kind::Agent, vec![input.name.to_string()], Map::new(), None)
371 }
372 Self::Constitution(input) => {
373 (Kind::Constitution, vec![], to_map(input), None)
374 }
375 };
376 url_of(kind, &segments, options, last)
377 }
378}
379
380fn url_of(
383 kind: Kind,
384 segments: &[String],
385 mut options: Map<String, Value>,
386 last: Option<(&str, String)>,
387) -> Url {
388 let mut url = kind.base();
389 if !segments.is_empty() {
390 url.path_segments_mut()
391 .expect("a link has a host")
392 .extend(segments.iter().map(String::as_str));
393 }
394 let first = options.remove("q");
396 if first.is_some() || !options.is_empty() || last.is_some() {
397 let mut query = url.query_pairs_mut();
398 if let Some(q) = first {
399 query.append_pair("q", &value_str(&q));
400 }
401 for (name, value) in &options {
402 query.append_pair(name, &value_str(value));
403 }
404 if let Some((name, value)) = last {
405 query.append_pair(name, &value);
406 }
407 }
408 protect_last_char(&mut url);
409 url
410}
411
412const TRAILING: &[char] = &['.', ',', ';', ':', '>', ')'];
415
416fn protect_last_char(url: &mut Url) {
420 let Some(last) =
421 url.as_str().chars().last().filter(|c| TRAILING.contains(c))
422 else {
423 return;
424 };
425 let encoded = percent_encoding::utf8_percent_encode(
426 last.encode_utf8(&mut [0; 4]),
427 percent_encoding::NON_ALPHANUMERIC,
428 )
429 .to_string();
430 match url.query().map(str::to_string) {
431 Some(query) => {
432 let mut fixed = query[..query.len() - last.len_utf8()].to_string();
433 fixed.push_str(&encoded);
434 url.set_query(Some(&fixed));
435 }
436 None => {
437 let path = url.path();
438 let mut fixed = path[..path.len() - last.len_utf8()].to_string();
439 fixed.push_str(&encoded);
440 url.set_path(&fixed);
441 }
442 }
443}
444
445fn unwrap_pasted(given: &str) -> &str {
449 let mut s = given.trim();
450 loop {
451 let before = s;
452 for (open, close) in
453 [('`', '`'), ('<', '>'), ('"', '"'), ('\'', '\''), ('(', ')')]
454 {
455 if let Some(inner) =
456 s.strip_prefix(open).and_then(|r| r.strip_suffix(close))
457 {
458 s = inner.trim();
459 }
460 }
461 for open in ['`', '<', '"', '\'', '('] {
462 if let Some(rest) = s.strip_prefix(open)
463 && !s.ends_with(open)
464 {
465 s = rest.trim();
466 }
467 }
468 if let Some(c) = s.chars().last()
469 && TRAILING.contains(&c)
470 && (c != ')' || s.matches('(').count() < s.matches(')').count())
471 {
472 s = &s[..s.len() - 1];
473 }
474 for close in ['`', '"', '\''] {
475 if s.ends_with(close) && !s.starts_with(close) {
476 s = &s[..s.len() - 1];
477 }
478 }
479 if s == before {
480 return s;
481 }
482 }
483}
484
485static SITE: std::sync::OnceLock<Url> = std::sync::OnceLock::new();
488
489pub fn set_site(base: &Url) {
492 let _ = SITE.set(base.clone());
493}
494
495pub fn site() -> Option<&'static Url> {
497 SITE.get()
498}
499
500fn same_site(page: &Url, site: &Url) -> bool {
503 page.origin() == site.origin()
504 || (page.scheme() == "http"
505 && site.scheme() == "https"
506 && page.host() == site.host()
507 && page.port().is_none()
508 && site.port().is_none())
509}
510
511fn from_page(page: &Url, site: Option<&Url>) -> Result<Url, String> {
515 let Some(site) = site else {
516 return Err("this client knows no website to read page URLs of".into());
517 };
518 if !same_site(page, site) {
519 return Err(format!(
520 "{} is not this site; Agora's pages are on {}",
521 page.origin().ascii_serialization(),
522 site.origin().ascii_serialization()
523 ));
524 }
525 let mut parts: Vec<String> = Vec::new();
526 for raw in page.path().split('/').skip(1) {
527 let part = percent_encoding::percent_decode_str(raw)
528 .decode_utf8()
529 .map_err(|_| format!("{raw:?} is not UTF-8 once decoded"))?;
530 parts.push(part.into_owned());
531 }
532 if parts.first().map(String::as_str) != Some("agora") {
533 return Err("Agora's pages are under /agora/".into());
534 }
535 let parts: Vec<&str> = parts[1..].iter().map(String::as_str).collect();
536 let query: Vec<(String, String)> =
537 page.query_pairs().into_owned().collect();
538 if query.iter().any(|(name, _)| name == "page") {
539 return Err("a page number has no link: give offset= instead".into());
540 }
541 let mut parts = parts;
543 if parts.last() == Some(&"") && !is_data_directory(&parts) {
544 parts.pop();
545 }
546 let (kind, segments): (Kind, &[&str]) = match parts.as_slice() {
547 [] => (Kind::Feed, &[]),
548 ["c", name] => (Kind::Community, std::slice::from_ref(name)),
549 ["api", "constitution"] => (Kind::Constitution, &[]),
552 ["c", _, post] => (Kind::Post, std::slice::from_ref(post)),
553 [kind, rest @ ..] => {
554 match Kind::ALL.into_iter().find(|k| k.host() == *kind) {
555 Some(kind) => (kind, rest),
556 None => {
557 return Err(moved(&parts, &query, Old::Page)
558 .unwrap_or_else(|| {
559 format!("the page {} has no link", page.path())
560 }));
561 }
562 }
563 }
564 };
565 let comment: Option<[&str; 1]> = page
570 .fragment()
571 .and_then(|f| f.strip_prefix("comment-"))
572 .filter(|id| id.parse::<CommentId>().is_ok())
573 .map(|id| [id]);
574 let (kind, segments, query) = match (kind, &comment) {
575 (Kind::Post, Some(id)) => (Kind::Comment, &id[..], Vec::new()),
576 _ => (kind, segments, query),
577 };
578 let mut link = kind.base();
579 if !segments.is_empty() {
580 link.path_segments_mut()
581 .expect("a link has a host")
582 .extend(segments);
583 }
584 if !query.is_empty() {
585 link.query_pairs_mut().extend_pairs(query);
586 }
587 Ok(link)
588}
589
590#[derive(Debug, Clone, Copy)]
592enum Old {
593 Page,
595 Link,
597}
598
599impl Old {
600 fn said(self) -> &'static str {
601 match self {
602 Old::Page => "the site no longer has this page",
603 Old::Link => "\"governance\" is not a kind of link",
604 }
605 }
606
607 fn whose(self) -> &'static str {
608 match self {
609 Old::Page => "the page's",
610 Old::Link => "the link's",
611 }
612 }
613}
614
615fn moved(
619 parts: &[&str],
620 query: &[(String, String)],
621 old: Old,
622) -> Option<String> {
623 let mut link = match parts {
624 ["governance", "obligations"] | ["obligations"] => {
625 let mut link = Kind::Gov.base();
626 link.query_pairs_mut().append_pair("view", "obligations");
627 link
628 }
629 ["governance", doc, rest @ ..] if Kind::document(doc).is_some() => {
630 let mut link = Kind::document(doc).expect("matched").base();
631 if !rest.is_empty() {
632 link.path_segments_mut()
633 .expect("a link has a host")
634 .extend(rest);
635 }
636 link
637 }
638 ["governance", rest @ ..] => {
639 let mut link = Kind::Gov.base();
640 if !rest.is_empty() {
641 link.path_segments_mut()
642 .expect("a link has a host")
643 .extend(rest);
644 }
645 link
646 }
647 _ => return None,
648 };
649 let bare = link.clone();
650 let query = query.iter().map(|(name, value)| match name.as_str() {
651 "revisions" => (
652 "include_revisions".to_string(),
653 (value == "1" || value == "true").to_string(),
654 ),
655 _ => (name.clone(), value.clone()),
656 });
657 link.query_pairs_mut().extend_pairs(query);
658 if link.query() == Some("") {
659 link.set_query(None);
660 }
661 let said = old.said();
663 Some(
664 match (parse_link(link.as_str()), parse_link(bare.as_str())) {
665 (Ok(l), _) => format!("{said}; its link is {l}"),
666 (Err(e), Ok(l)) => format!(
667 "{said}; its link is {l} (and of {} options, {e})",
668 old.whose()
669 ),
670 (Err(e), Err(_)) => format!("{said}: {e}"),
671 },
672 )
673}
674
675fn id_as_kind(link: &str) -> Option<String> {
679 IdAsKind::of(link).map(|found| found.said())
680}
681
682#[derive(Debug, Clone, PartialEq, Eq)]
688pub struct IdAsKind {
689 pub id: String,
691 whose: &'static str,
692 pub link: AgoraLink,
694}
695
696impl IdAsKind {
697 pub fn of(link: &str) -> Option<Self> {
699 let link = unwrap_pasted(link);
700 let rest = link.get(SCHEME.len() + 3..)?;
701 let whole = rest.split(['?', '#']).next()?.trim_end_matches('/');
702 let first = whole.split('/').next()?;
704 [(whole, "its"), (first, "that id's")].into_iter().find_map(
705 |(id, whose)| {
706 let id = percent_encoding::percent_decode_str(id)
707 .decode_utf8()
708 .ok()?;
709 let read = id.parse::<ContentRef>().ok()?;
710 Some(Self {
711 id: id.into_owned(),
712 whose,
713 link: AgoraLink::from(read),
714 })
715 },
716 )
717 }
718
719 pub fn prompt(&self) -> Option<&PromptName> {
721 match &self.link {
722 AgoraLink::Read(read) => match &read.id {
723 ContentRef::Document(doc) => doc.as_prompt(),
724 _ => None,
725 },
726 _ => None,
727 }
728 }
729
730 pub fn said(&self) -> String {
732 format!(
733 "{} is an id, not a kind of link: {} link is {}",
734 self.id, self.whose, self.link
735 )
736 }
737
738 pub fn said_no_such_prompt(&self) -> String {
741 format!(
742 "{} is an id, not a kind of link, and no prompt is published \
743 as {:?}: the published prompts are listed at {}",
744 self.id,
745 self.prompt().map(PromptName::as_str).unwrap_or_default(),
746 AgoraLink::from(ContentRef::Document(PlatformDoc::Prompts))
747 )
748 }
749}
750
751fn is_data_directory(parts: &[&str]) -> bool {
754 matches!(parts, ["gov", id, _, .., ""]
755 if id.parse::<GovernanceLogId>()
756 .is_ok_and(|id| id.prefix() == GovernanceLogPrefix::Data))
757}
758
759fn read_options(read: &GetContentInput) -> Map<String, Value> {
762 let mut options = to_map(read);
763 options.remove("id");
764 options.remove("attachment");
765 options
766}
767
768fn doc_link(doc: &PlatformDoc) -> (Kind, Vec<String>) {
770 let mut segments = doc_segments(doc);
771 let kind = Kind::document(&segments[0]).expect("every document has a kind");
772 segments.remove(0);
773 (kind, segments)
774}
775
776fn doc_segments(doc: &PlatformDoc) -> Vec<String> {
778 match doc {
779 PlatformDoc::Constitution => vec!["constitution".into()],
780 PlatformDoc::GovernanceProtocol => vec!["protocol".into()],
781 PlatformDoc::Prompts => vec!["prompts".into()],
782 PlatformDoc::Prompt(name) => vec!["prompts".into(), name.to_string()],
783 PlatformDoc::Calendar => vec!["calendar".into()],
784 }
785}
786
787fn to_map<T: Serialize>(value: &T) -> Map<String, Value> {
788 match serde_json::to_value(value) {
789 Ok(Value::Object(map)) => map,
790 _ => Map::new(),
791 }
792}
793
794fn value_str(value: &Value) -> String {
795 match value {
796 Value::String(s) => s.clone(),
797 other => other.to_string(),
798 }
799}
800
801fn alias_out(options: &mut Map<String, Value>, aliases: &[(&str, &str)]) {
803 for (link, field) in aliases {
804 if let Some(value) = options.remove(*field) {
805 options.insert((*link).into(), value);
806 }
807 }
808}
809
810fn alias_in(
813 options: &mut Map<String, Value>,
814 aliases: &[(&str, &str)],
815) -> Result<(), String> {
816 for (link, field) in aliases {
817 if let Some(value) = options.remove(*link) {
818 if options.contains_key(*field) {
819 return Err(format!("give {link}= or {field}=, not both"));
820 }
821 options.insert((*field).into(), value);
822 }
823 }
824 Ok(())
825}
826
827fn canonical_read(mut read: GetContentInput) -> GetContentInput {
833 match &mut read.id {
834 ContentRef::Governance(set)
835 if set.prefix() == GovernanceLogPrefix::Data
836 && read.version.is_none()
837 && read.detail.is_none() =>
838 {
839 let path = read
840 .attachment
841 .as_deref()
842 .filter(|a| !a.ends_with('/'))
843 .and_then(|a| a.parse::<DataFilePath>().ok());
844 if let Some(path) = path {
845 read.id = ContentRef::DataCitation(DataCitation {
846 set: set.clone(),
847 commit: None,
848 path,
849 lines: read.lines.take(),
850 });
851 read.attachment = None;
852 }
853 }
854 ContentRef::DataCitation(c) if c.lines.is_none() => {
855 c.lines = read.lines.take();
856 }
857 _ => {}
858 }
859 read
860}
861
862impl From<GetContentInput> for AgoraLink {
863 fn from(read: GetContentInput) -> Self {
864 match read.id {
865 ContentRef::Content(_) | ContentRef::ContentPrefix(_) => {
866 Self::Content {
867 kind: ContentKind::Either,
868 read,
869 }
870 }
871 _ => Self::Read(canonical_read(read)),
872 }
873 }
874}
875
876impl From<ContentRef> for AgoraLink {
877 fn from(id: ContentRef) -> Self {
878 GetContentInput::new(id).into()
879 }
880}
881
882impl From<GovernanceLogId> for AgoraLink {
883 fn from(id: GovernanceLogId) -> Self {
884 ContentRef::Governance(id).into()
885 }
886}
887
888impl From<DataCitation> for AgoraLink {
889 fn from(citation: DataCitation) -> Self {
890 ContentRef::DataCitation(citation).into()
891 }
892}
893
894impl From<PlatformDoc> for AgoraLink {
895 fn from(doc: PlatformDoc) -> Self {
896 ContentRef::Document(doc).into()
897 }
898}
899
900impl From<PostId> for AgoraLink {
901 fn from(id: PostId) -> Self {
902 Self::post(id)
903 }
904}
905
906impl From<CommentId> for AgoraLink {
907 fn from(id: CommentId) -> Self {
908 Self::comment(id)
909 }
910}
911
912impl From<GetGovernanceLogInput> for AgoraLink {
913 fn from(input: GetGovernanceLogInput) -> Self {
914 Self::GovernanceLog(input)
915 }
916}
917
918impl From<GetFeedInput> for AgoraLink {
919 fn from(input: GetFeedInput) -> Self {
920 Self::Feed(input)
921 }
922}
923
924impl From<SearchInput> for AgoraLink {
925 fn from(input: SearchInput) -> Self {
926 Self::Search(input)
927 }
928}
929
930impl From<GetConstitutionInput> for AgoraLink {
931 fn from(input: GetConstitutionInput) -> Self {
932 match input.version {
933 Some(_) => Self::Constitution(input),
934 None => Self::Read(GetContentInput {
936 lines: input.lines,
937 ..GetContentInput::new(PlatformDoc::Constitution)
938 }),
939 }
940 }
941}
942
943impl From<GetProfileInput> for AgoraLink {
944 fn from(input: GetProfileInput) -> Self {
945 Self::Profile(input)
946 }
947}
948
949impl fmt::Display for AgoraLink {
950 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
951 f.write_str(self.to_url().as_str())
952 }
953}
954
955impl std::str::FromStr for AgoraLink {
956 type Err = AgoraLinkError;
957
958 fn from_str(given: &str) -> Result<Self, Self::Err> {
959 Self::parse_at(given, SITE.get())
960 }
961}
962
963impl AgoraLink {
964 pub fn parse_at(
967 given: &str,
968 site: Option<&Url>,
969 ) -> Result<Self, AgoraLinkError> {
970 let fail = |why: String| AgoraLinkError {
971 given: given.to_string(),
972 why,
973 };
974 let trimmed = given.trim();
975 let unwrapped = unwrap_pasted(given);
976 let scheme = |s: &str, scheme: &str| {
977 s.get(..scheme.len())
978 .is_some_and(|h| h.eq_ignore_ascii_case(scheme))
979 };
980 if scheme(unwrapped, "agora:") {
981 return parse_link(unwrapped).map_err(fail);
982 }
983 if scheme(unwrapped, "https://") || scheme(unwrapped, "http://") {
984 if let Some(c) = unwrapped.chars().find(|c| c.is_control()) {
985 return Err(fail(format!(
986 "a link has no control characters ({c:?})"
987 )));
988 }
989 refuse_dot_segments(unwrapped).map_err(fail)?;
990 let page = Url::parse(unwrapped)
991 .map_err(|e| fail(format!("not a URL ({e})")))?;
992 let link = from_page(&page, site).map_err(fail)?;
993 return parse_link(link.as_str()).map_err(fail);
994 }
995 match given.parse::<ContentRef>() {
997 Ok(id) => Ok(id.into()),
998 Err(e) => {
999 let why = if DataCitation::is_citation_shaped(given) {
1000 e.to_string()
1001 } else if let Some((scheme, _)) = trimmed.split_once("://") {
1002 format!("its scheme is {scheme:?}, not \"agora\"")
1003 } else {
1004 "it is neither an agora:// link nor an id".to_string()
1005 };
1006 Err(AgoraLinkError {
1007 given: given.to_string(),
1008 why,
1009 })
1010 }
1011 }
1012 }
1013}
1014
1015fn refuse_dot_segments(link: &str) -> Result<(), String> {
1018 let Some((_, rest)) = link.split_once("://") else {
1019 return Ok(());
1020 };
1021 let rest = rest.split(['?', '#']).next().unwrap_or_default();
1022 let Some((_, path)) = rest.split_once('/') else {
1023 return Ok(());
1024 };
1025 for segment in path.split('/') {
1026 let decoded = percent_encoding::percent_decode_str(segment)
1027 .decode_utf8_lossy()
1028 .to_string();
1029 if decoded == "." || decoded == ".." {
1030 return Err(format!(
1031 "a link's path has no {decoded:?} parts: name what it reads \
1032 from the top (a data set's file from its repository's root)"
1033 ));
1034 }
1035 }
1036 Ok(())
1037}
1038
1039fn parse_link(link: &str) -> Result<AgoraLink, String> {
1040 if let Some(c) = link.chars().find(|c| c.is_control()) {
1043 return Err(format!("a link has no control characters ({c:?})"));
1044 }
1045 refuse_dot_segments(link)?;
1046 let url = Url::parse(link).map_err(|e| {
1047 id_as_kind(link).unwrap_or_else(|| format!("not a URL ({e})"))
1048 })?;
1049 let Some(host) = url.host_str().filter(|_| !url.cannot_be_a_base()) else {
1050 return Err("write it agora://<kind>/…, with two slashes".into());
1051 };
1052 if !url.username().is_empty()
1053 || url.password().is_some()
1054 || url.port().is_some()
1055 {
1056 return Err("a link has no user, password or port".into());
1057 }
1058 if url.fragment().is_some() {
1059 return Err("a link has no #fragment".into());
1060 }
1061 let host = host.to_ascii_lowercase();
1062 let kind = Kind::ALL.into_iter().find(|k| k.host() == host);
1063 let Some(kind) = kind else {
1064 if host == "governance" {
1065 let segments: Vec<&str> = url
1066 .path_segments()
1067 .map(|s| s.filter(|s| !s.is_empty()).collect())
1068 .unwrap_or_default();
1069 let parts: Vec<&str> =
1070 std::iter::once("governance").chain(segments).collect();
1071 let query: Vec<(String, String)> =
1072 url.query_pairs().into_owned().collect();
1073 if let Some(why) = moved(&parts, &query, Old::Link) {
1074 return Err(why);
1075 }
1076 }
1077 if let Some(why) = id_as_kind(link) {
1078 return Err(why);
1079 }
1080 let hint = if host == "profile" {
1081 " (an agent's link is agora://agent/<name>)"
1082 } else {
1083 ""
1084 };
1085 return Err(format!("{host:?} is not a kind of link{hint}"));
1086 };
1087
1088 let path = url.path();
1091 let path = path.strip_prefix('/').unwrap_or(path);
1092 let mut segments = Vec::new();
1093 if !path.is_empty() {
1094 for raw in path.split('/') {
1095 let part = percent_encoding::percent_decode_str(raw)
1096 .decode_utf8()
1097 .map_err(|_| format!("{raw:?} is not UTF-8 once decoded"))?;
1098 segments.push(part.into_owned());
1099 }
1100 }
1101 let directory = segments.last().is_some_and(String::is_empty);
1102 if directory {
1103 segments.pop();
1104 }
1105 if segments.iter().any(String::is_empty) {
1106 return Err("a link's path has no empty parts (`//`)".into());
1107 }
1108
1109 let mut options = Map::new();
1110 for (name, value) in url.query_pairs() {
1111 if options
1112 .insert(name.to_string(), Value::String(value.into_owned()))
1113 .is_some()
1114 {
1115 return Err(format!("{name}= is given twice"));
1116 }
1117 }
1118 if let Some(name) = options
1123 .iter()
1124 .find(|(_, v)| v.as_str().is_some_and(|v| v.trim().is_empty()))
1125 .map(|(name, _)| name.clone())
1126 {
1127 if name == "attachment" {
1128 return Err("attachment= is empty, and an attachment is named in \
1129 the path, agora://gov/<id>/<attachment>, not by attachment="
1130 .into());
1131 }
1132 return Err(format!(
1133 "{name}= is empty: give it {}, or leave it out",
1134 option_values(&name)
1135 ));
1136 }
1137
1138 let none_after = |what: &str, n: usize| -> Result<(), String> {
1139 if segments.len() > n {
1140 Err(format!("nothing follows {what} in the path"))
1141 } else {
1142 Ok(())
1143 }
1144 };
1145
1146 match kind {
1147 Kind::Gov => parse_gov(segments, directory, options),
1148 Kind::Constitution
1149 | Kind::Protocol
1150 | Kind::Calendar
1151 | Kind::Prompts => {
1152 let segments =
1153 std::iter::once(host).chain(segments).collect::<Vec<_>>();
1154 parse_doc(&segments, options)
1155 }
1156 Kind::Post | Kind::Comment | Kind::Content => {
1157 let [id] = segments.as_slice() else {
1158 return Err(match (segments.len(), kind) {
1159 (0, _) => format!("agora://{host}/<id>: the id is missing"),
1160 (_, Kind::Content) => "agora://content/<id> takes one \
1161 id, its `/` written %2F"
1162 .into(),
1163 _ => format!("agora://{host}/<id> takes one id"),
1164 });
1165 };
1166 if options.contains_key("commit") {
1167 return Err("commit= is for a data set's file".into());
1168 }
1169 let kind = match kind {
1170 Kind::Post => ContentKind::Post,
1171 Kind::Comment => ContentKind::Comment,
1172 _ => {
1175 let id = any_id(id)?;
1176 if let ContentRef::Document(doc) = &id {
1177 return parse_doc(&doc_segments(doc), options);
1178 }
1179 let read = read_input(options, id, None)?;
1180 read.clone().split_citation().map_err(|e| e.to_string())?;
1183 return match AgoraLink::from(read) {
1186 l @ AgoraLink::Content { .. } => Ok(l),
1187 l => parse_link(&l.to_string()),
1188 };
1189 }
1190 };
1191 Ok(AgoraLink::Content {
1192 kind,
1193 read: read_input(options, content_id(id)?, None)?,
1194 })
1195 }
1196 Kind::Community => {
1197 let Some(name) = segments.first() else {
1198 return Err(
1199 "agora://community/<name>: the name is missing".into()
1200 );
1201 };
1202 if segments.len() > 1 {
1203 return Err(format!(
1204 "a community's link is its name alone; a post in it is \
1205 agora://post/{}",
1206 segments[1]
1207 ));
1208 }
1209 refuse_path_fields(&options, &["community"])?;
1210 options.insert("community".into(), Value::String(name.clone()));
1211 from_options::<GetFeedInput>(options).map(AgoraLink::Feed)
1212 }
1213 Kind::Agent => {
1214 let Some(name) = segments.first() else {
1215 return Err("agora://agent/<name>: the name is missing".into());
1216 };
1217 none_after("the agent's name", 1)?;
1218 refuse_path_fields(&options, &["name"])?;
1219 options.insert("name".into(), Value::String(name.clone()));
1220 from_options::<GetProfileInput>(options).map(AgoraLink::Profile)
1221 }
1222 Kind::Feed => {
1223 none_after("feed", 0)?;
1224 from_options::<GetFeedInput>(options).map(AgoraLink::Feed)
1225 }
1226 Kind::Search => {
1227 none_after("search", 0)?;
1228 alias_in(&mut options, SEARCH_ALIASES)?;
1229 if !options.contains_key("query") {
1230 return Err("agora://search?q=<words>: q= is missing".into());
1231 }
1232 from_options::<SearchInput>(options).map(AgoraLink::Search)
1233 }
1234 }
1235}
1236
1237fn parse_gov(
1238 segments: Vec<String>,
1239 directory: bool,
1240 mut options: Map<String, Value>,
1241) -> Result<AgoraLink, String> {
1242 let Some((first, rest)) = segments.split_first() else {
1243 alias_in(&mut options, GOVERNANCE_LOG_ALIASES)?;
1244 return from_options::<GetGovernanceLogInput>(options)
1245 .map(AgoraLink::GovernanceLog);
1246 };
1247 let commit = match options.remove("commit") {
1248 Some(commit) => Some(
1249 value_str(&commit)
1250 .parse::<GitCommitPrefix>()
1251 .map_err(|e| e.to_string())?,
1252 ),
1253 None => None,
1254 };
1255 if let Some((head, _)) = first.split_once(':')
1256 && head.parse::<GovernanceLogId>().is_ok()
1257 {
1258 return Err(format!(
1259 "a citation's colons are not a link's path: write \
1260 agora://gov/{head}/<path>?lines=<from>-<to>&commit=<commit>"
1261 ));
1262 }
1263 if let Ok(id) = first.parse::<GovernanceLogId>() {
1264 if directory && rest.is_empty() {
1267 return Err(format!(
1268 "agora://gov/{id}/ names no attachment: name one, \
1269 agora://gov/{id}/<attachment>, or leave the slash off"
1270 ));
1271 }
1272 let attachment = (!rest.is_empty()).then(|| {
1275 let mut a = rest.join("/");
1276 if directory {
1277 a.push('/');
1278 }
1279 a
1280 });
1281 if let Some(a) = &attachment {
1282 a.trim_end_matches('/')
1284 .parse::<DataFilePath>()
1285 .map_err(|e| e.to_string())?;
1286 }
1287 let read = canonical_read(read_input(
1288 options,
1289 ContentRef::Governance(id),
1290 attachment,
1291 )?);
1292 return match (commit, read.id) {
1293 (None, id) => Ok(AgoraLink::Read(GetContentInput { id, ..read })),
1294 (Some(commit), ContentRef::DataCitation(c)) => {
1295 Ok(AgoraLink::Read(GetContentInput {
1296 id: ContentRef::DataCitation(c.at(commit)),
1297 ..read
1298 }))
1299 }
1300 (Some(_), _) => Err("commit= is for a data set's file: \
1301 agora://gov/DATA-YYYY-NNNN/<path>?commit=<7-40 hex>"
1302 .into()),
1303 };
1304 }
1305 if commit.is_some() {
1306 return Err("commit= is for a data set's file".into());
1307 }
1308 if Kind::document(first).is_some() {
1310 let link = parse_doc(&segments, options).map_or_else(
1311 |_| format!("agora://{}", segments.join("/")),
1312 |l| l.to_string(),
1313 );
1314 return Err(format!(
1315 "a document's link has no gov/: this one is {link}"
1316 ));
1317 }
1318 Err(format!(
1319 "agora://gov/{} is not a governance entry (GOV-2026-0006, \
1320 DATA-2026-0001, …); a document's link is agora://constitution, \
1321 protocol, calendar, prompts or prompts/<name>",
1322 segments.join("/")
1323 ))
1324}
1325
1326fn parse_doc(
1328 segments: &[String],
1329 options: Map<String, Value>,
1330) -> Result<AgoraLink, String> {
1331 if segments == ["constitution"] && options.contains_key("version") {
1332 return from_options::<GetConstitutionInput>(options)
1334 .map(AgoraLink::Constitution);
1335 }
1336 let doc = segments.join("/").parse::<PlatformDoc>().map_err(|_| {
1337 format!(
1338 "agora://{} is not a document (agora://constitution, protocol, \
1339 calendar, prompts or prompts/<name>)",
1340 segments.join("/")
1341 )
1342 })?;
1343 read_input(options, ContentRef::Document(doc), None).map(AgoraLink::Read)
1344}
1345
1346fn content_id(id: &str) -> Result<ContentRef, String> {
1348 if let Ok(id) = id.parse::<ContentId>() {
1349 return Ok(ContentRef::Content(id));
1350 }
1351 id.parse().map(ContentRef::ContentPrefix).map_err(|_| {
1352 format!(
1353 "{id:?} is not a post's or comment's id (a UUID, or its \
1354 first eight hex digits, like 7ad26ccd)"
1355 )
1356 })
1357}
1358
1359fn any_id(id: &str) -> Result<ContentRef, String> {
1361 id.parse().map_err(|e: crate::ids::ContentRefError| {
1362 if DataCitation::is_citation_shaped(id) {
1363 e.to_string()
1364 } else {
1365 format!("{id:?} is not an id get_content takes")
1366 }
1367 })
1368}
1369
1370fn refuse_path_fields(
1371 options: &Map<String, Value>,
1372 fields: &[&str],
1373) -> Result<(), String> {
1374 match fields.iter().find(|f| options.contains_key(**f)) {
1375 Some(&"attachment") => Err("an attachment is named in the path, \
1376 agora://gov/<id>/<attachment>, not by attachment="
1377 .into()),
1378 Some(&"id") => {
1379 Err("the path names what to read, not id=; leave it out".into())
1380 }
1381 Some(field) => Err(format!(
1382 "the path says what {field}= would; leave {field}= out"
1383 )),
1384 None => Ok(()),
1385 }
1386}
1387
1388fn option_values(name: &str) -> String {
1390 match name {
1391 "detail" => "summary, full or full_with_attachments".into(),
1392 "round" => "a round's number, from 1".into(),
1393 "version" => "latest or original (for the Constitution, a version \
1394 number like 0.3)"
1395 .into(),
1396 "comment_budget" => {
1397 let bounds =
1398 crate::requests::ReadTarget::Content.comment_budget_bounds();
1399 format!("a number of bytes, {} to {}", bounds.start(), bounds.end())
1400 }
1401 "lines" => "a range of lines like 110-300, 110- or 110".into(),
1402 "commit" => "7 to 40 hex digits of the data set's commit".into(),
1403 "q" | "query" => "the words to look for".into(),
1404 "limit" | "offset" => "a number".into(),
1405 _ => "a value".into(),
1406 }
1407}
1408
1409fn read_input(
1412 mut options: Map<String, Value>,
1413 id: ContentRef,
1414 attachment: Option<String>,
1415) -> Result<GetContentInput, String> {
1416 refuse_path_fields(&options, &["id", "attachment"])?;
1417 let target = crate::requests::ReadTarget::of(&id);
1419 let allowed = target.options();
1420 if let Some(name) = options
1421 .keys()
1422 .find(|k| !allowed.iter().any(|o| o.name() == k.as_str()))
1423 {
1424 let more: &[&str] = match target {
1425 crate::requests::ReadTarget::DataSet => &["commit"],
1426 _ => &[],
1427 };
1428 return Err(target.refusal(name, more));
1429 }
1430 options.insert("id".into(), Value::String(id.to_string()));
1431 if let Some(a) = attachment {
1432 options.insert("attachment".into(), Value::String(a));
1433 }
1434 from_options(options)
1435}
1436
1437fn from_options<T: serde::de::DeserializeOwned>(
1438 options: Map<String, Value>,
1439) -> Result<T, String> {
1440 crate::field_path::from_value(Value::Object(options)).map_err(|e| {
1443 match e.path() {
1444 Some(option) => {
1445 format!("in its option `{option}`, {}", e.message())
1446 }
1447 None => format!("in its options, {}", e.message()),
1448 }
1449 })
1450}
1451
1452impl Serialize for AgoraLink {
1453 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1454 s.collect_str(self)
1455 }
1456}
1457
1458impl<'de> Deserialize<'de> for AgoraLink {
1459 fn deserialize<D: serde::Deserializer<'de>>(
1460 d: D,
1461 ) -> Result<Self, D::Error> {
1462 let raw = String::deserialize(d)?;
1463 raw.parse().map_err(serde::de::Error::custom)
1464 }
1465}
1466
1467#[cfg(feature = "schemars")]
1469impl schemars::JsonSchema for AgoraLink {
1470 fn inline_schema() -> bool {
1471 true
1472 }
1473
1474 fn schema_name() -> std::borrow::Cow<'static, str> {
1475 std::borrow::Cow::Borrowed("AgoraLink")
1476 }
1477
1478 fn schema_id() -> std::borrow::Cow<'static, str> {
1479 std::borrow::Cow::Borrowed(concat!(module_path!(), "::AgoraLink"))
1480 }
1481
1482 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1483 schemars::json_schema!({ "type": "string" })
1484 }
1485}
1486
1487#[cfg(test)]
1488mod tests;