pub const STATUS_URI: &str = "review://status";
pub const COMMENTS_URI: &str = "review://comments";
pub const DIFF_URI_TEMPLATE: &str = "review://diff/{file}";
pub const SCHEME_PREFIX: &str = "review://";
pub const DIFF_PREFIX: &str = "review://diff/";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResourceKind {
Status,
Comments,
Diff { file: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResourceUriError {
pub uri: String,
pub reason: &'static str,
}
impl std::fmt::Display for ResourceUriError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "invalid resource URI {:?}: {}", self.uri, self.reason)
}
}
impl std::error::Error for ResourceUriError {}
pub fn parse_resource_uri(uri: &str) -> Result<ResourceKind, ResourceUriError> {
let Some(rest) = uri.strip_prefix(SCHEME_PREFIX) else {
return Err(ResourceUriError {
uri: uri.to_string(),
reason: "expected scheme 'review://'",
});
};
match rest {
"status" => Ok(ResourceKind::Status),
"comments" => Ok(ResourceKind::Comments),
other if other.starts_with("diff/") => {
let file = &other[5..];
if file.is_empty() {
return Err(ResourceUriError {
uri: uri.to_string(),
reason: "diff resource requires a file path",
});
}
Ok(ResourceKind::Diff {
file: file.to_string(),
})
}
_ => Err(ResourceUriError {
uri: uri.to_string(),
reason: "unknown resource path",
}),
}
}
#[must_use]
pub fn diff_uri_for_file(file: &str) -> String {
format!("{DIFF_PREFIX}{file}")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResourceNotify<'a> {
FilesChanged { files: &'a [String] },
CommentAdded,
ReviewSubmitted,
}
#[must_use]
pub fn resource_uris_for_notify(notify: &ResourceNotify<'_>) -> Vec<String> {
match notify {
ResourceNotify::FilesChanged { files } => {
let mut uris = Vec::with_capacity(1 + files.len());
uris.push(STATUS_URI.to_string());
for f in files.iter() {
uris.push(diff_uri_for_file(f));
}
uris
}
ResourceNotify::CommentAdded => {
vec![COMMENTS_URI.to_string(), STATUS_URI.to_string()]
}
ResourceNotify::ReviewSubmitted => vec![STATUS_URI.to_string()],
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_status_and_comments() {
assert_eq!(
parse_resource_uri(STATUS_URI).unwrap(),
ResourceKind::Status
);
assert_eq!(
parse_resource_uri(COMMENTS_URI).unwrap(),
ResourceKind::Comments
);
}
#[test]
fn parses_diff_uri_with_nested_path() {
assert_eq!(
parse_resource_uri("review://diff/src/foo.rs").unwrap(),
ResourceKind::Diff {
file: "src/foo.rs".to_string(),
}
);
assert_eq!(
parse_resource_uri("review://diff/deeply/nested/path/file.rs").unwrap(),
ResourceKind::Diff {
file: "deeply/nested/path/file.rs".to_string(),
}
);
}
#[test]
fn rejects_unknown_scheme() {
let err = parse_resource_uri("http://example.com").unwrap_err();
assert!(err.reason.contains("scheme"));
}
#[test]
fn rejects_unknown_path() {
let err = parse_resource_uri("review://nope").unwrap_err();
assert!(err.reason.contains("unknown"));
}
#[test]
fn rejects_empty_diff_file() {
let err = parse_resource_uri("review://diff/").unwrap_err();
assert!(err.reason.contains("file"));
}
#[test]
fn diff_uri_round_trips() {
let uri = diff_uri_for_file("src/main.rs");
assert_eq!(uri, "review://diff/src/main.rs");
assert_eq!(
parse_resource_uri(&uri).unwrap(),
ResourceKind::Diff {
file: "src/main.rs".to_string(),
}
);
}
#[test]
fn notify_comment_added_updates_comments_and_status() {
let uris = resource_uris_for_notify(&ResourceNotify::CommentAdded);
assert_eq!(uris, vec![COMMENTS_URI.to_string(), STATUS_URI.to_string()]);
}
#[test]
fn notify_review_submitted_updates_status() {
let uris = resource_uris_for_notify(&ResourceNotify::ReviewSubmitted);
assert_eq!(uris, vec![STATUS_URI.to_string()]);
}
#[test]
fn notify_files_changed_emits_status_plus_per_file_diffs() {
let files = vec!["src/a.rs".to_string(), "src/b.rs".to_string()];
let uris = resource_uris_for_notify(&ResourceNotify::FilesChanged { files: &files });
assert_eq!(
uris,
vec![
STATUS_URI.to_string(),
"review://diff/src/a.rs".to_string(),
"review://diff/src/b.rs".to_string(),
]
);
}
#[test]
fn notify_files_changed_with_empty_list_is_status_only() {
let files: Vec<String> = Vec::new();
let uris = resource_uris_for_notify(&ResourceNotify::FilesChanged { files: &files });
assert_eq!(uris, vec![STATUS_URI.to_string()]);
}
}