Skip to main content

systemprompt_models/documents/
uri.rs

1//! The `document://<id>[@<revision>][#<node>]` reference agents quote to
2//! name a precise location in a document.
3//!
4//! Copyright (c) systemprompt.io — Business Source License 1.1.
5//! See <https://systemprompt.io> for licensing details.
6
7use std::fmt;
8use std::str::FromStr;
9
10use serde::{Deserialize, Serialize};
11use systemprompt_identifiers::error::IdValidationError;
12use systemprompt_identifiers::{DocumentId, DocumentNodeId, DocumentRevisionId};
13
14#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
15pub enum DocumentUriError {
16    #[error("document URI must start with `document://`")]
17    MissingScheme,
18    #[error("document URI has an invalid {part}")]
19    InvalidPart {
20        part: &'static str,
21        #[source]
22        source: IdValidationError,
23    },
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(try_from = "String", into = "String")]
28pub struct DocumentUri {
29    pub document_id: DocumentId,
30    pub revision_id: Option<DocumentRevisionId>,
31    pub node: Option<DocumentNodeId>,
32}
33
34impl DocumentUri {
35    pub const SCHEME: &'static str = "document://";
36
37    #[must_use]
38    pub const fn new(document_id: DocumentId) -> Self {
39        Self {
40            document_id,
41            revision_id: None,
42            node: None,
43        }
44    }
45
46    #[must_use]
47    pub fn at_revision(mut self, revision_id: DocumentRevisionId) -> Self {
48        self.revision_id = Some(revision_id);
49        self
50    }
51
52    #[must_use]
53    pub fn at_node(mut self, node: DocumentNodeId) -> Self {
54        self.node = Some(node);
55        self
56    }
57}
58
59impl fmt::Display for DocumentUri {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        write!(f, "{}{}", Self::SCHEME, self.document_id.as_str())?;
62        if let Some(revision) = &self.revision_id {
63            write!(f, "@{}", revision.as_str())?;
64        }
65        if let Some(node) = &self.node {
66            write!(f, "#{}", node.as_str())?;
67        }
68        Ok(())
69    }
70}
71
72impl FromStr for DocumentUri {
73    type Err = DocumentUriError;
74
75    fn from_str(s: &str) -> Result<Self, Self::Err> {
76        let rest = s
77            .strip_prefix(Self::SCHEME)
78            .ok_or(DocumentUriError::MissingScheme)?;
79        let (rest, node) = match rest.split_once('#') {
80            Some((head, node)) => (head, Some(node)),
81            None => (rest, None),
82        };
83        let (document, revision) = match rest.split_once('@') {
84            Some((head, revision)) => (head, Some(revision)),
85            None => (rest, None),
86        };
87        let invalid = |part| move |source| DocumentUriError::InvalidPart { part, source };
88        Ok(Self {
89            document_id: DocumentId::try_new(document).map_err(invalid("document id"))?,
90            revision_id: revision
91                .map(DocumentRevisionId::try_new)
92                .transpose()
93                .map_err(invalid("revision id"))?,
94            node: node
95                .map(DocumentNodeId::try_new)
96                .transpose()
97                .map_err(invalid("node id"))?,
98        })
99    }
100}
101
102impl TryFrom<String> for DocumentUri {
103    type Error = DocumentUriError;
104
105    fn try_from(value: String) -> Result<Self, Self::Error> {
106        value.parse()
107    }
108}
109
110impl From<DocumentUri> for String {
111    fn from(uri: DocumentUri) -> Self {
112        uri.to_string()
113    }
114}
115
116impl schemars::JsonSchema for DocumentUri {
117    fn schema_name() -> std::borrow::Cow<'static, str> {
118        "DocumentUri".into()
119    }
120
121    fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
122        schemars::json_schema!({
123            "type": "string",
124            "pattern": "^document://[^@#]+(@[^#]+)?(#.+)?$"
125        })
126    }
127}