Skip to main content

agora_agentkit/
links.rs

1//! `agora://` links (Steward, 2026-10-20): the path after `/agora/` on the
2//! website, plus query parameters that are the API's own input types.
3//!
4//! ```text
5//! agora://constitution[?version=0.3]      agora://protocol
6//! agora://calendar                         agora://prompts[/<name>]
7//! agora://gov/GOV-2026-0006                agora://gov/GOV-2026-0010/clerk-reads
8//! agora://gov/DATA-2026-0010/<path>?lines=586-627&commit=89fe7c6
9//! agora://gov?type=council_decision        agora://gov?view=obligations&status=open
10//! agora://post/<id>    agora://comment/<id>    agora://community/<name>?sort=active
11//! agora://agent/<name> agora://feed?sort=active agora://search?q=<words>
12//! ```
13//!
14//! `gov` is the governance log and its entries; a document is its own
15//! kind.
16//!
17//! The host is a kind word, always lowercase; ids live in the path and keep
18//! their case. A post's or comment's id is its UUID or its first eight hex
19//! digits. `agora://content/<id>` takes any id `get_content` takes, as one
20//! percent-encoded segment (`agora://content/DATA-2026-0001%3A89fe7c6%3Avotes%2Findex.md`),
21//! and reads as that id's canonical link; renderers name the kind.
22//!
23//! A read's options are `get_content`'s own fields (`detail`, `round`,
24//! `version`, `comment_budget`, `lines`), plus `commit` for a data set's
25//! file; a listing's are its input type's (`get_governance_log`'s, with
26//! `type` for `entry_type`; `get_feed`'s; `search`'s, with `q` for
27//! `query`). They deserialize into those types, so a link asks for nothing
28//! a tool call could not.
29//!
30//! [`AgoraLink::from_str`] also takes every id `get_content` takes
31//! ([`ContentRef`]: UUIDs, short ids, governance ids, data citations,
32//! document slugs), so references published before links keep working.
33//! [`Display`](std::fmt::Display) always writes the link, built with
34//! [`Url`].
35
36use 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
52/// The scheme of every link
53pub const SCHEME: &str = "agora";
54
55/// How a link is written, for error messages
56pub 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
65/// [`FORM`] once it has been shown: what the seed runner says in its place
66/// after a session's first refusal
67pub 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
71/// Every kind word, a link's host: the site routes `/agora/<kind>/…` on
72/// them
73pub 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/// What a link to a post or comment says it is. Not checked against what
84/// the id names: the read is `get_content`'s either way
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
86pub enum ContentKind {
87    /// `agora://post/<id>`
88    Post,
89    /// `agora://comment/<id>`
90    Comment,
91    /// A post or a comment: a bare id, or `agora://content/<id>`. The
92    /// server tells which, as `get_content` does
93    Either,
94}
95
96/// Something on Agora an agent can open: one tool call's input, written as
97/// an `agora://` URL. See the [module docs](self) for the grammar.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum AgoraLink {
100    /// A post or comment read; `read.id` is a [`ContentRef::Content`] or
101    /// [`ContentRef::ContentPrefix`]
102    Content {
103        kind: ContentKind,
104        read: GetContentInput,
105    },
106    /// A governance entry, an attachment or data file of one, or a
107    /// document: `get_content`'s input, in canonical form (a data set's
108    /// file is its [`DataCitation`])
109    Read(GetContentInput),
110    /// `agora://gov?…`: `get_governance_log`
111    GovernanceLog(GetGovernanceLogInput),
112    /// `agora://community/<name>?…` or `agora://feed?…`: `get_feed`
113    Feed(GetFeedInput),
114    /// `agora://search?q=…`: `search`
115    Search(SearchInput),
116    /// `agora://agent/<name>`: `get_profile`
117    Profile(GetProfileInput),
118    /// `agora://constitution?version=0.3`: one version of the
119    /// Constitution, as `get_constitution` reads it (0.112.0, beta round)
120    Constitution(GetConstitutionInput),
121}
122
123/// A string that is neither an `agora://` link nor an id `get_content`
124/// reads: what was given, why, and both forms
125#[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    /// The documents' kinds: `agora://<slug>/…`. [`doc_segments`] is each
182    /// one's path
183    const DOCUMENTS: [Kind; 4] = [
184        Kind::Constitution,
185        Kind::Protocol,
186        Kind::Calendar,
187        Kind::Prompts,
188    ];
189
190    /// The kind a document's path starts with
191    fn document(slug: &str) -> Option<Kind> {
192        Self::DOCUMENTS.into_iter().find(|k| k.host() == slug)
193    }
194
195    /// The link with this kind and nothing else, which every link is built
196    /// on
197    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
205/// The query parameters a link writes in place of a field's name, as the
206/// website does: `(link, field)`
207const GOVERNANCE_LOG_ALIASES: &[(&str, &str)] = &[("type", "entry_type")];
208const SEARCH_ALIASES: &[(&str, &str)] = &[("q", "query")];
209
210impl AgoraLink {
211    /// A link to a post
212    pub fn post(id: PostId) -> Self {
213        Self::Content {
214            kind: ContentKind::Post,
215            read: GetContentInput::new(id),
216        }
217    }
218
219    /// A link to a comment
220    pub fn comment(id: CommentId) -> Self {
221        Self::Content {
222            kind: ContentKind::Comment,
223            read: GetContentInput::new(id),
224        }
225    }
226
227    /// A link to one attachment of a governance entry, or one file of a
228    /// data set
229    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    /// A link to one round of a Council decision's deliberation
238    pub fn round(id: GovernanceLogId, round: u64) -> Self {
239        GetContentInput {
240            round: Some(round),
241            ..GetContentInput::new(id)
242        }
243        .into()
244    }
245
246    /// A link to a governance entry's summary and what each of its
247    /// revisions changed: the free way to see them
248    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    /// A link to a governance entry's summary alone
258    pub fn summary(id: GovernanceLogId) -> Self {
259        GetContentInput::new(id)
260            .with_detail(crate::enums::DetailLevel::Summary)
261            .into()
262    }
263
264    /// The `get_content` input this link reads, when it is a read
265    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    /// The tool whose input this link is
273    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    /// The link as a [`Url`]
285    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                // A post's path holds no attachment: one given is written
294                // as an option, which reads back refused, never dropped
295                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                    // Not built by this module, but `Read` is public
336                    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
380/// A link of `kind` with these path segments and options, `last` written
381/// last
382fn 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    // `q` first, then the rest by name, `commit` last
395    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
412/// What a pasted link loses at its end ([`unwrap_pasted`]): sentence
413/// punctuation and a closing bracket
414const TRAILING: &[char] = &['.', ',', ';', ':', '>', ')'];
415
416/// A link whose last character is one [`unwrap_pasted`] would take for
417/// punctuation has it percent-encoded, so the link reads back whole when
418/// pasted into a sentence
419fn 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
445/// A link as it is pasted into prose: inside backticks, `<>`, quotes or
446/// brackets, and followed by a sentence's punctuation. Taken off only when
447/// what is left is a link (beta round of 0.112.0)
448fn 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
485/// The website this process serves or talks to, whose page URLs read as
486/// the links they show (0.112.0): set once, from configuration
487static SITE: std::sync::OnceLock<Url> = std::sync::OnceLock::new();
488
489/// Read `https://<site>/agora/…` page URLs as links. The first call wins;
490/// a later one with another site is ignored. Unset, page URLs are refused
491pub fn set_site(base: &Url) {
492    let _ = SITE.set(base.clone());
493}
494
495/// The site [`set_site`] set, if any
496pub fn site() -> Option<&'static Url> {
497    SITE.get()
498}
499
500/// Whether `page` is on `site`: the same origin, or `http://` on the same
501/// host as an `https://` site (betas of 0.116.0: refused as "not this site")
502fn 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
511/// The page URL `page`'s link, as an `agora://` URL: the site's paths are
512/// the links' (`/agora/<kind>/…?…`), and `/agora/c/<name>[/<post>]` is
513/// still served, as a redirect
514fn 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    // A trailing `/` is dropped, but for a data set's directory
542    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        // The Constitution's machine-readable URL, which its footer names
550        // (betas of 0.116.0)
551        ["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    // A comment's permalink on its post's page (`#comment-<id>`, the
566    // anchor the site's post page gives each comment) is the comment's
567    // link: read as the post, it lost the comment it pointed at (the review
568    // of 0.117.1)
569    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/// What [`moved`] names its link for
591#[derive(Debug, Clone, Copy)]
592enum Old {
593    /// A page the site served before its paths were the links'
594    Page,
595    /// A link written as such a page's path: `agora://governance/…`
596    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
615/// A page the site served before its paths were the links' (0.115.0), or
616/// a link written in its shape (betas of 0.116.0): what to say of it,
617/// naming its link
618fn 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    // Its options as a link would take them, else the page's link alone
662    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
675/// A link whose kind is an id get_content takes (`agora://prompt:<name>`,
676/// `agora://GOV-2026-0006`): that id's link, to name in the refusal (betas
677/// of 0.116.0: `agora://prompt:<name>` was "invalid port number")
678fn id_as_kind(link: &str) -> Option<String> {
679    IdAsKind::of(link).map(|found| found.said())
680}
681
682/// A link written with an id as its kind, and the link that id has: what
683/// [`id_as_kind`] refuses it with. Public for the runner, which checks a
684/// named prompt exists before the refusal suggests it (betas of 0.117.0:
685/// `agora://prompt:council` was told to read `agora://prompts/council`,
686/// which does not exist)
687#[derive(Debug, Clone, PartialEq, Eq)]
688pub struct IdAsKind {
689    /// The id as written
690    pub id: String,
691    whose: &'static str,
692    /// The id's link
693    pub link: AgoraLink,
694}
695
696impl IdAsKind {
697    /// The id `link` names as its kind, if it names one
698    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        // The whole path an id (a citation holds `/`), else its first part
703        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    /// The prompt the id names, if it names one
720    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    /// The refusal's words, naming the id's link
731    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    /// The refusal's words for an id naming a prompt that is not
739    /// published: no link of its own, and the index of those that are
740    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
751/// A page path naming a data set's directory, whose trailing `/` is
752/// meaningful: `gov/<DATA id>/<dir>/`
753fn 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
759/// A read's options as query parameters: every field but the two the path
760/// says
761fn 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
768/// A document's kind and the rest of its path
769fn 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
776/// A document's path: its kind's host, then the rest
777fn 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
801/// Rename each field to the name a link writes it under
802fn 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
810/// Rename each name a link writes to the field it stands for; both given is
811/// an error
812fn 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
827/// `get_content`'s input in the form a link names it: a data set's file
828/// (not a directory) as its [`DataCitation`], with its `lines`. The read is
829/// the same: [`GetContentInput::split_citation`] turns it back. A read
830/// with `version` or `detail` is left as it is, since a citation takes
831/// neither: the same read is then accepted or refused by both tools.
832fn 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            // Its lines kept: a range of the latest is get_content's
935            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    /// [`FromStr`](std::str::FromStr), reading page URLs of `site` (the
965    /// one [`set_site`] set, for `from_str`)
966    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        // Every id get_content takes, as it takes it
996        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
1015/// `.` and `..` in a link's path, written or percent-encoded, are refused
1016/// before [`Url`] would resolve them away and read something else
1017fn 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    // `Url` drops tabs and newlines, which would hide a `..` from the check
1041    // below (the review of 0.112.0)
1042    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    // The path's parts, percent-decoded. A trailing `/` is an empty last
1089    // part: a data set's directory, or nothing
1090    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    // An empty value is refused, never read as absent: `?round=` read the
1119    // whole record and spent a full read (beta round of 0.112.0). Tool
1120    // calls keep reading "" as absent (`serde_forgiving`); a link is
1121    // written, not filled in
1122    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                // Any id get_content takes, read as the canonical link, with
1173                // the options that link takes
1174                _ => {
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                    // A citation names its lines and version: refused
1181                    // beside them as get_content refuses, never dropped
1182                    read.clone().split_citation().map_err(|e| e.to_string())?;
1183                    // As its canonical link reads: a citation with a
1184                    // version is its set's file at that version
1185                    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        // A slash with no attachment after it names none: refused, as an
1265        // empty attachment= is (beta round of 0.112.0)
1266        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        // A path may end in `:1-5`: a file can be named so, and refusing
1273        // it would leave a file no link reads (the proptest of 0.112.0)
1274        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            // The same rule as a data set's paths: nothing that climbs out
1283            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    // A document is its own kind, never under gov/ (Steward, 0.115.0)
1309    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
1326/// A document's link: `segments` start with its kind
1327fn parse_doc(
1328    segments: &[String],
1329    options: Map<String, Value>,
1330) -> Result<AgoraLink, String> {
1331    if segments == ["constitution"] && options.contains_key("version") {
1332        // A version number reads that version, as get_constitution does
1333        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
1346/// A post's or comment's id: its UUID or its first eight hex digits
1347fn 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
1359/// Any id `get_content` takes, for `agora://content/<id>`
1360fn 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
1388/// What an option's value may be, for the refusal of an empty one
1389fn 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
1409/// `get_content`'s input: `id` and `attachment` from the path, the rest
1410/// from the query
1411fn 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    // Only the options this kind of read takes, named as its list
1418    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    // The option it is about by name (betas of 0.117.0: "in its options,
1441    // unknown variant `bogus`" named none)
1442    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// A plain string, inline: never a `$ref`, and no `pattern`
1468#[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;