use anyhow::{Context, Result};
use rmcp::{
model::{
ListResourceTemplatesResult, ListResourcesResult, RawResource, RawResourceTemplate,
ReadResourceResult, Resource, ResourceContents, ResourceTemplate,
},
ErrorData as McpError,
};
use serde_json::json;
use crate::atlassian::api::{AtlassianApi, ContentItem};
use crate::atlassian::confluence_api::ConfluenceApi;
use crate::atlassian::document::content_item_to_document;
use crate::atlassian::jira_api::JiraApi;
use crate::cli::atlassian::helpers::create_client;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResourceFormat {
Jfm,
Adf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResourceUri {
GitCommits {
range: String,
},
JiraIssue {
key: String,
format: ResourceFormat,
},
ConfluencePage {
id: String,
format: ResourceFormat,
},
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum UriParseError {
#[error("unknown URI scheme in `{0}`; expected git://, jira://, or confluence://")]
UnknownScheme(String),
#[error("malformed URI `{0}`: {1}")]
Malformed(String, &'static str),
#[error("empty identifier in URI `{0}`")]
EmptyIdentifier(String),
}
impl ResourceUri {
pub fn parse(uri: &str) -> Result<Self, UriParseError> {
if let Some(rest) = uri.strip_prefix("git://repo/commits/") {
if rest.is_empty() {
return Err(UriParseError::EmptyIdentifier(uri.to_string()));
}
return Ok(Self::GitCommits {
range: rest.to_string(),
});
}
if let Some(rest) = uri.strip_prefix("jira://issue/") {
let (key, format) = split_suffix(rest);
if key.is_empty() {
return Err(UriParseError::EmptyIdentifier(uri.to_string()));
}
return Ok(Self::JiraIssue {
key: key.to_string(),
format,
});
}
if let Some(rest) = uri.strip_prefix("confluence://page/") {
let (id, format) = split_suffix(rest);
if id.is_empty() {
return Err(UriParseError::EmptyIdentifier(uri.to_string()));
}
return Ok(Self::ConfluencePage {
id: id.to_string(),
format,
});
}
if uri.starts_with("git://") {
return Err(UriParseError::Malformed(
uri.to_string(),
"expected `git://repo/commits/<range>`",
));
}
if uri.starts_with("jira://") {
return Err(UriParseError::Malformed(
uri.to_string(),
"expected `jira://issue/<key>` or `jira://issue/<key>.adf`",
));
}
if uri.starts_with("confluence://") {
return Err(UriParseError::Malformed(
uri.to_string(),
"expected `confluence://page/<id>` or `confluence://page/<id>.adf`",
));
}
Err(UriParseError::UnknownScheme(uri.to_string()))
}
}
fn split_suffix(rest: &str) -> (&str, ResourceFormat) {
match rest.strip_suffix(".adf") {
Some(id) => (id, ResourceFormat::Adf),
None => (rest, ResourceFormat::Jfm),
}
}
pub fn resource_templates() -> Vec<ResourceTemplate> {
let git_commits = RawResourceTemplate::new("git://repo/commits/{range}", "git_commits")
.with_description(
"YAML commit analysis for a git range (e.g. `HEAD`, `HEAD~3..HEAD`). \
Backed by the same core as `omni-dev git commit message view`.",
)
.with_mime_type("application/yaml");
let jira_issue_jfm = RawResourceTemplate::new("jira://issue/{key}", "jira_issue_jfm")
.with_description("JIRA issue rendered as JFM (JIRA-flavoured markdown).")
.with_mime_type("text/markdown");
let jira_issue_adf = RawResourceTemplate::new("jira://issue/{key}.adf", "jira_issue_adf")
.with_description("JIRA issue body as raw Atlassian Document Format (ADF) JSON.")
.with_mime_type("application/json");
let confluence_page_jfm =
RawResourceTemplate::new("confluence://page/{id}", "confluence_page_jfm")
.with_description("Confluence page rendered as JFM markdown.")
.with_mime_type("text/markdown");
let confluence_page_adf =
RawResourceTemplate::new("confluence://page/{id}.adf", "confluence_page_adf")
.with_description("Confluence page body as raw ADF JSON.")
.with_mime_type("application/json");
vec![
annotate_template(git_commits),
annotate_template(jira_issue_jfm),
annotate_template(jira_issue_adf),
annotate_template(confluence_page_jfm),
annotate_template(confluence_page_adf),
]
}
fn annotate_template(raw: RawResourceTemplate) -> ResourceTemplate {
ResourceTemplate {
raw,
annotations: None,
}
}
pub fn resource_listing() -> Vec<Resource> {
resource_templates()
.into_iter()
.map(|tpl| {
let RawResourceTemplate {
uri_template,
name,
title,
description,
mime_type,
icons,
} = tpl.raw;
Resource {
raw: RawResource {
uri: uri_template,
name,
title,
description,
mime_type,
size: None,
icons,
meta: None,
},
annotations: None,
}
})
.collect()
}
pub async fn read_resource(uri: &ResourceUri, raw_uri: &str) -> Result<ReadResourceResult> {
match uri {
ResourceUri::GitCommits { range } => {
let range = range.clone();
let yaml = tokio::task::spawn_blocking(move || {
crate::cli::git::run_view(&range, None::<&str>)
})
.await
.context("git run_view task panicked")??;
Ok(ReadResourceResult::new(vec![ResourceContents::text(
yaml, raw_uri,
)
.with_mime_type("application/yaml")]))
}
ResourceUri::JiraIssue { key, format } => {
let (client, instance_url) =
create_client().context("Failed to load Atlassian credentials")?;
let api = JiraApi::new(client);
let item = api
.get_content(key)
.await
.with_context(|| format!("Failed to fetch JIRA issue {key}"))?;
let (text, mime) = render_content_item(&item, &instance_url, *format)?;
Ok(ReadResourceResult::new(vec![ResourceContents::text(
text, raw_uri,
)
.with_mime_type(mime)]))
}
ResourceUri::ConfluencePage { id, format } => {
let (client, instance_url) =
create_client().context("Failed to load Atlassian credentials")?;
let api = ConfluenceApi::new(client);
let item = api
.get_content(id)
.await
.with_context(|| format!("Failed to fetch Confluence page {id}"))?;
let (text, mime) = render_content_item(&item, &instance_url, *format)?;
Ok(ReadResourceResult::new(vec![ResourceContents::text(
text, raw_uri,
)
.with_mime_type(mime)]))
}
}
}
pub fn render_content_item(
item: &ContentItem,
instance_url: &str,
format: ResourceFormat,
) -> Result<(String, &'static str)> {
match format {
ResourceFormat::Jfm => {
let doc = content_item_to_document(item, instance_url)?;
let rendered = doc.render()?;
Ok((rendered, "text/markdown"))
}
ResourceFormat::Adf => {
let json = serde_json::to_string_pretty(
&item.body_adf.clone().unwrap_or(serde_json::Value::Null),
)
.context("Failed to serialize ADF JSON")?;
Ok((json, "application/json"))
}
}
}
pub fn not_found(uri: &str, reason: impl std::fmt::Display) -> McpError {
McpError::resource_not_found(
format!("resource_not_found: {reason}"),
Some(json!({ "uri": uri })),
)
}
pub fn list_resources_result() -> ListResourcesResult {
ListResourcesResult {
resources: resource_listing(),
next_cursor: None,
meta: None,
}
}
pub fn list_resource_templates_result() -> ListResourceTemplatesResult {
ListResourceTemplatesResult {
resource_templates: resource_templates(),
next_cursor: None,
meta: None,
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn parse_git_commits_head() {
let uri = ResourceUri::parse("git://repo/commits/HEAD").unwrap();
assert_eq!(
uri,
ResourceUri::GitCommits {
range: "HEAD".to_string(),
}
);
}
#[test]
fn parse_git_commits_range() {
let uri = ResourceUri::parse("git://repo/commits/HEAD~3..HEAD").unwrap();
assert_eq!(
uri,
ResourceUri::GitCommits {
range: "HEAD~3..HEAD".to_string(),
}
);
}
#[test]
fn parse_git_commits_triple_dot_range() {
let uri = ResourceUri::parse("git://repo/commits/main...feature").unwrap();
assert_eq!(
uri,
ResourceUri::GitCommits {
range: "main...feature".to_string(),
}
);
}
#[test]
fn parse_jira_issue_jfm() {
let uri = ResourceUri::parse("jira://issue/PROJ-123").unwrap();
assert_eq!(
uri,
ResourceUri::JiraIssue {
key: "PROJ-123".to_string(),
format: ResourceFormat::Jfm,
}
);
}
#[test]
fn parse_jira_issue_adf() {
let uri = ResourceUri::parse("jira://issue/PROJ-123.adf").unwrap();
assert_eq!(
uri,
ResourceUri::JiraIssue {
key: "PROJ-123".to_string(),
format: ResourceFormat::Adf,
}
);
}
#[test]
fn parse_confluence_page_jfm() {
let uri = ResourceUri::parse("confluence://page/12345").unwrap();
assert_eq!(
uri,
ResourceUri::ConfluencePage {
id: "12345".to_string(),
format: ResourceFormat::Jfm,
}
);
}
#[test]
fn parse_confluence_page_adf() {
let uri = ResourceUri::parse("confluence://page/12345.adf").unwrap();
assert_eq!(
uri,
ResourceUri::ConfluencePage {
id: "12345".to_string(),
format: ResourceFormat::Adf,
}
);
}
#[test]
fn parse_unknown_scheme_is_rejected() {
let err = ResourceUri::parse("http://example.com/resource").unwrap_err();
assert!(matches!(err, UriParseError::UnknownScheme(_)));
}
#[test]
fn parse_empty_string_is_unknown_scheme() {
let err = ResourceUri::parse("").unwrap_err();
assert!(matches!(err, UriParseError::UnknownScheme(_)));
}
#[test]
fn parse_git_wrong_path_is_malformed() {
let err = ResourceUri::parse("git://repo/bogus").unwrap_err();
match err {
UriParseError::Malformed(ref uri, reason) => {
assert_eq!(uri, "git://repo/bogus");
assert!(reason.contains("git://repo/commits/"));
assert!(err.to_string().contains("git://repo/bogus"));
}
other => panic!("expected Malformed, got {other:?}"),
}
}
#[test]
fn parse_jira_wrong_path_is_malformed() {
let err = ResourceUri::parse("jira://board/123").unwrap_err();
assert!(matches!(err, UriParseError::Malformed(_, _)));
}
#[test]
fn parse_confluence_wrong_path_is_malformed() {
let err = ResourceUri::parse("confluence://space/ENG").unwrap_err();
assert!(matches!(err, UriParseError::Malformed(_, _)));
}
#[test]
fn parse_empty_git_range_is_empty_identifier() {
let err = ResourceUri::parse("git://repo/commits/").unwrap_err();
assert!(matches!(err, UriParseError::EmptyIdentifier(_)));
}
#[test]
fn parse_empty_jira_key_is_empty_identifier() {
let err = ResourceUri::parse("jira://issue/").unwrap_err();
assert!(matches!(err, UriParseError::EmptyIdentifier(_)));
}
#[test]
fn parse_empty_confluence_id_is_empty_identifier() {
let err = ResourceUri::parse("confluence://page/").unwrap_err();
assert!(matches!(err, UriParseError::EmptyIdentifier(_)));
}
#[test]
fn parse_jira_adf_with_empty_key_is_empty_identifier() {
let err = ResourceUri::parse("jira://issue/.adf").unwrap_err();
assert!(matches!(err, UriParseError::EmptyIdentifier(_)));
}
#[test]
fn error_messages_surface_uri() {
let err = ResourceUri::parse("ftp://x").unwrap_err();
assert!(err.to_string().contains("ftp://x"));
}
#[test]
fn templates_include_all_five_uris() {
let templates = resource_templates();
let template_uris: Vec<&str> = templates
.iter()
.map(|t| t.raw.uri_template.as_str())
.collect();
assert!(template_uris.contains(&"git://repo/commits/{range}"));
assert!(template_uris.contains(&"jira://issue/{key}"));
assert!(template_uris.contains(&"jira://issue/{key}.adf"));
assert!(template_uris.contains(&"confluence://page/{id}"));
assert!(template_uris.contains(&"confluence://page/{id}.adf"));
}
#[test]
fn every_template_has_description_and_mime() {
for tpl in resource_templates() {
assert!(
tpl.raw.description.is_some(),
"missing description for {}",
tpl.raw.uri_template
);
assert!(
tpl.raw.mime_type.is_some(),
"missing mime for {}",
tpl.raw.uri_template
);
}
}
#[test]
fn resource_listing_mirrors_templates() {
let resources = resource_listing();
let templates = resource_templates();
assert_eq!(resources.len(), templates.len());
for (resource, tpl) in resources.iter().zip(templates.iter()) {
assert_eq!(resource.raw.uri, tpl.raw.uri_template);
assert_eq!(resource.raw.name, tpl.raw.name);
}
}
#[test]
fn list_resources_result_has_no_pagination() {
let result = list_resources_result();
assert!(result.next_cursor.is_none());
assert_eq!(result.resources.len(), 5);
}
#[test]
fn list_resource_templates_result_has_no_pagination() {
let result = list_resource_templates_result();
assert!(result.next_cursor.is_none());
assert_eq!(result.resource_templates.len(), 5);
}
#[test]
fn not_found_puts_uri_in_data() {
let err = not_found("jira://issue/NOPE", "parse failed");
assert!(err.message.contains("parse failed"));
let data = err.data.as_ref().expect("data payload present");
assert_eq!(
data.get("uri").and_then(|v| v.as_str()),
Some("jira://issue/NOPE")
);
}
fn jira_item_with(body: Option<serde_json::Value>) -> ContentItem {
use crate::atlassian::api::ContentMetadata;
ContentItem {
id: "PROJ-1".to_string(),
title: "Sample".to_string(),
body_adf: body,
metadata: ContentMetadata::Jira {
status: Some("Open".to_string()),
issue_type: Some("Bug".to_string()),
assignee: None,
priority: None,
labels: vec![],
},
}
}
#[test]
fn render_content_item_jfm_contains_frontmatter_and_body() {
let body = serde_json::json!({
"version": 1,
"type": "doc",
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Hello"}]}]
});
let item = jira_item_with(Some(body));
let (text, mime) =
render_content_item(&item, "https://org.atlassian.net", ResourceFormat::Jfm).unwrap();
assert_eq!(mime, "text/markdown");
assert!(text.contains("PROJ-1"), "missing key: {text}");
assert!(text.contains("Hello"), "missing body: {text}");
}
#[test]
fn render_content_item_adf_returns_pretty_json() {
let body = serde_json::json!({
"version": 1,
"type": "doc",
"content": []
});
let item = jira_item_with(Some(body.clone()));
let (text, mime) =
render_content_item(&item, "https://org.atlassian.net", ResourceFormat::Adf).unwrap();
assert_eq!(mime, "application/json");
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(parsed, body);
}
#[test]
fn render_content_item_adf_null_body_serializes_as_null() {
let item = jira_item_with(None);
let (text, _) =
render_content_item(&item, "https://org.atlassian.net", ResourceFormat::Adf).unwrap();
assert_eq!(text.trim(), "null");
}
use git2::{Repository, Signature};
fn init_tmp_repo() -> tempfile::TempDir {
let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
std::fs::create_dir_all(&tmp_root).unwrap();
let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
let repo = Repository::init(temp_dir.path()).unwrap();
{
let mut config = repo.config().unwrap();
config.set_str("user.name", "Test").unwrap();
config.set_str("user.email", "test@example.com").unwrap();
}
let signature = Signature::now("Test", "test@example.com").unwrap();
std::fs::write(temp_dir.path().join("f.txt"), "x").unwrap();
let mut idx = repo.index().unwrap();
idx.add_path(std::path::Path::new("f.txt")).unwrap();
idx.write().unwrap();
let tree_id = idx.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(
Some("HEAD"),
&signature,
&signature,
"feat: seed",
&tree,
&[],
)
.unwrap();
temp_dir
}
use crate::cli::git::CWD_MUTEX;
#[tokio::test(flavor = "multi_thread")]
async fn read_resource_git_commits_returns_yaml() {
let _guard = CWD_MUTEX.lock().await;
let temp_dir = init_tmp_repo();
let original_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(temp_dir.path()).unwrap();
let uri = ResourceUri::parse("git://repo/commits/HEAD").unwrap();
let result = read_resource(&uri, "git://repo/commits/HEAD").await;
std::env::set_current_dir(original_cwd).unwrap();
let result = result.unwrap();
let contents = result.contents;
assert_eq!(contents.len(), 1);
match &contents[0] {
ResourceContents::TextResourceContents {
text,
mime_type,
uri: reply_uri,
..
} => {
assert!(text.contains("commits:"), "yaml missing commits: {text}");
assert!(text.contains("feat: seed"), "yaml missing subject: {text}");
assert_eq!(mime_type.as_deref(), Some("application/yaml"));
assert_eq!(reply_uri, "git://repo/commits/HEAD");
}
other @ ResourceContents::BlobResourceContents { .. } => {
panic!("expected text resource contents, got {other:?}")
}
}
}
static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct EnvGuard {
keys: Vec<&'static str>,
}
impl EnvGuard {
fn set(pairs: &[(&'static str, String)]) -> Self {
let keys = pairs.iter().map(|(k, _)| *k).collect();
for (k, v) in pairs {
std::env::set_var(k, v);
}
Self { keys }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
for k in &self.keys {
std::env::remove_var(k);
}
}
}
#[allow(clippy::await_holding_lock)]
#[tokio::test(flavor = "multi_thread")]
async fn read_resource_jira_issue_jfm_against_wiremock() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-7"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"key": "PROJ-7",
"fields": {
"summary": "Resource test issue",
"description": {
"type": "doc",
"version": 1,
"content": [{
"type": "paragraph",
"content": [{"type": "text", "text": "resource body"}]
}]
},
"status": {"name": "Open"},
"issuetype": {"name": "Task"},
"assignee": null,
"priority": null,
"labels": []
}
})),
)
.mount(&server)
.await;
let _guard = ENV_MUTEX.lock().unwrap();
let _env = EnvGuard::set(&[
("ATLASSIAN_INSTANCE_URL", server.uri()),
("ATLASSIAN_EMAIL", "test@example.com".to_string()),
("ATLASSIAN_API_TOKEN", "fake-token".to_string()),
]);
let uri = ResourceUri::parse("jira://issue/PROJ-7").unwrap();
let result = read_resource(&uri, "jira://issue/PROJ-7").await.unwrap();
assert_eq!(result.contents.len(), 1);
match &result.contents[0] {
ResourceContents::TextResourceContents {
text,
mime_type,
uri: reply_uri,
..
} => {
assert!(text.contains("PROJ-7"), "missing key: {text}");
assert!(text.contains("resource body"), "missing body: {text}");
assert_eq!(mime_type.as_deref(), Some("text/markdown"));
assert_eq!(reply_uri, "jira://issue/PROJ-7");
}
other @ ResourceContents::BlobResourceContents { .. } => {
panic!("expected text, got {other:?}")
}
}
}
#[allow(clippy::await_holding_lock)]
#[tokio::test(flavor = "multi_thread")]
async fn read_resource_jira_issue_adf_returns_json() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-8"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"key": "PROJ-8",
"fields": {
"summary": "ADF payload issue",
"description": {
"type": "doc",
"version": 1,
"content": []
},
"status": {"name": "Open"},
"issuetype": {"name": "Bug"},
"assignee": null,
"priority": null,
"labels": []
}
})),
)
.mount(&server)
.await;
let _guard = ENV_MUTEX.lock().unwrap();
let _env = EnvGuard::set(&[
("ATLASSIAN_INSTANCE_URL", server.uri()),
("ATLASSIAN_EMAIL", "test@example.com".to_string()),
("ATLASSIAN_API_TOKEN", "fake-token".to_string()),
]);
let uri = ResourceUri::parse("jira://issue/PROJ-8.adf").unwrap();
let result = read_resource(&uri, "jira://issue/PROJ-8.adf")
.await
.unwrap();
match &result.contents[0] {
ResourceContents::TextResourceContents {
text, mime_type, ..
} => {
assert_eq!(mime_type.as_deref(), Some("application/json"));
let parsed: serde_json::Value = serde_json::from_str(text).unwrap();
assert_eq!(parsed["type"], "doc");
}
other @ ResourceContents::BlobResourceContents { .. } => {
panic!("expected text, got {other:?}")
}
}
}
#[allow(clippy::await_holding_lock)]
#[tokio::test(flavor = "multi_thread")]
async fn read_resource_jira_propagates_server_errors() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
.mount(&server)
.await;
let _guard = ENV_MUTEX.lock().unwrap();
let _env = EnvGuard::set(&[
("ATLASSIAN_INSTANCE_URL", server.uri()),
("ATLASSIAN_EMAIL", "test@example.com".to_string()),
("ATLASSIAN_API_TOKEN", "fake-token".to_string()),
]);
let uri = ResourceUri::parse("jira://issue/NOPE-1").unwrap();
let err = read_resource(&uri, "jira://issue/NOPE-1")
.await
.expect_err("404 should surface as error");
let chain = format!("{err:#}");
assert!(
chain.contains("NOPE-1") || chain.contains("404"),
"unexpected chain: {chain}"
);
}
#[allow(clippy::await_holding_lock)]
#[tokio::test(flavor = "multi_thread")]
async fn read_resource_confluence_page_jfm_against_wiremock() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/99999"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "99999",
"title": "Resource page",
"status": "current",
"version": {"number": 3},
"spaceId": "10",
"parentId": null,
"body": {
"atlas_doc_format": {
"representation": "atlas_doc_format",
"value": serde_json::json!({
"type": "doc",
"version": 1,
"content": [{
"type": "paragraph",
"content": [{"type": "text", "text": "page content"}]
}]
}).to_string()
}
}
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/spaces/10"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "10",
"key": "ENG",
"name": "Engineering"
})),
)
.mount(&server)
.await;
let _guard = ENV_MUTEX.lock().unwrap();
let _env = EnvGuard::set(&[
("ATLASSIAN_INSTANCE_URL", server.uri()),
("ATLASSIAN_EMAIL", "test@example.com".to_string()),
("ATLASSIAN_API_TOKEN", "fake-token".to_string()),
]);
let uri = ResourceUri::parse("confluence://page/99999").unwrap();
let result = read_resource(&uri, "confluence://page/99999").await;
match result {
Ok(res) => match &res.contents[0] {
ResourceContents::TextResourceContents { mime_type, .. } => {
assert_eq!(mime_type.as_deref(), Some("text/markdown"));
}
other @ ResourceContents::BlobResourceContents { .. } => {
panic!("expected text, got {other:?}")
}
},
Err(e) => {
let chain = format!("{e:#}");
assert!(
chain.contains("99999") || chain.contains("Confluence"),
"unexpected error chain: {chain}"
);
}
}
}
#[allow(clippy::await_holding_lock)]
#[tokio::test(flavor = "multi_thread")]
async fn read_resource_confluence_propagates_server_errors() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/404404"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
.mount(&server)
.await;
let _guard = ENV_MUTEX.lock().unwrap();
let _env = EnvGuard::set(&[
("ATLASSIAN_INSTANCE_URL", server.uri()),
("ATLASSIAN_EMAIL", "test@example.com".to_string()),
("ATLASSIAN_API_TOKEN", "fake-token".to_string()),
]);
let uri = ResourceUri::parse("confluence://page/404404").unwrap();
let err = read_resource(&uri, "confluence://page/404404")
.await
.expect_err("404 should surface as error");
let chain = format!("{err:#}");
assert!(
chain.contains("404404") || chain.contains("404") || chain.contains("Confluence"),
"unexpected chain: {chain}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn read_resource_jira_without_credentials_errors() {
let _guard = ENV_MUTEX.lock().unwrap();
for key in [
"ATLASSIAN_INSTANCE_URL",
"ATLASSIAN_EMAIL",
"ATLASSIAN_API_TOKEN",
"JIRA_INSTANCE_URL",
"JIRA_EMAIL",
"JIRA_API_TOKEN",
] {
std::env::remove_var(key);
}
std::env::set_var("HOME", std::env::temp_dir());
let uri = ResourceUri::parse("jira://issue/ZZZ-1").unwrap();
let err = read_resource(&uri, "jira://issue/ZZZ-1")
.await
.expect_err("missing credentials must error");
let chain = format!("{err:#}");
assert!(
chain.contains("Atlassian") || chain.contains("credential"),
"unexpected chain: {chain}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn read_resource_confluence_without_credentials_errors() {
let _guard = ENV_MUTEX.lock().unwrap();
for key in [
"ATLASSIAN_INSTANCE_URL",
"ATLASSIAN_EMAIL",
"ATLASSIAN_API_TOKEN",
"JIRA_INSTANCE_URL",
"JIRA_EMAIL",
"JIRA_API_TOKEN",
] {
std::env::remove_var(key);
}
std::env::set_var("HOME", std::env::temp_dir());
let uri = ResourceUri::parse("confluence://page/0").unwrap();
let err = read_resource(&uri, "confluence://page/0")
.await
.expect_err("missing credentials must error");
let chain = format!("{err:#}");
assert!(
chain.contains("Atlassian") || chain.contains("credential"),
"unexpected chain: {chain}"
);
}
#[test]
fn render_content_item_jfm_for_confluence_page() {
use crate::atlassian::api::ContentMetadata;
let body = serde_json::json!({
"version": 1,
"type": "doc",
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "conf body"}]}]
});
let item = ContentItem {
id: "12345".to_string(),
title: "Test Page".to_string(),
body_adf: Some(body),
metadata: ContentMetadata::Confluence {
space_key: "ENG".to_string(),
status: Some("current".to_string()),
version: Some(1),
parent_id: None,
},
};
let (text, mime) =
render_content_item(&item, "https://org.atlassian.net", ResourceFormat::Jfm).unwrap();
assert_eq!(mime, "text/markdown");
assert!(text.contains("conf body"), "missing body: {text}");
}
#[test]
fn uri_parse_error_variants_display_expected_messages() {
let malformed = UriParseError::Malformed("git://x".to_string(), "oops");
assert!(malformed.to_string().contains("oops"));
let empty = UriParseError::EmptyIdentifier("jira://issue/".to_string());
assert!(empty.to_string().contains("empty identifier"));
}
#[test]
fn resource_uri_debug_and_clone() {
let uri = ResourceUri::JiraIssue {
key: "X-1".to_string(),
format: ResourceFormat::Jfm,
};
let dup = uri.clone();
assert_eq!(uri, dup);
assert!(format!("{uri:?}").contains("JiraIssue"));
}
#[test]
fn resource_format_copy_and_eq() {
let a = ResourceFormat::Adf;
let b = a;
assert_eq!(a, b);
assert_ne!(a, ResourceFormat::Jfm);
}
}