use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AssetContext {
Issue {
key: String,
},
IssueComment {
key: String,
comment_id: String,
},
MergeRequest {
mr_id: String,
},
MrComment { mr_id: String, note_id: String },
Chat { chat_id: String, message_id: String },
KbPage { page_id: String },
}
impl AssetContext {
pub fn slug(&self) -> String {
match self {
AssetContext::Issue { key } => format!("issue:{key}"),
AssetContext::IssueComment { key, comment_id } => {
format!("issue:{key}:comment:{comment_id}")
}
AssetContext::MergeRequest { mr_id } => format!("mr:{mr_id}"),
AssetContext::MrComment { mr_id, note_id } => format!("mr:{mr_id}:note:{note_id}"),
AssetContext::Chat {
chat_id,
message_id,
} => format!("chat:{chat_id}:msg:{message_id}"),
AssetContext::KbPage { page_id } => format!("kb:{page_id}"),
}
}
pub fn kind(&self) -> AssetContextKind {
match self {
AssetContext::Issue { .. } => AssetContextKind::Issue,
AssetContext::IssueComment { .. } => AssetContextKind::IssueComment,
AssetContext::MergeRequest { .. } => AssetContextKind::MergeRequest,
AssetContext::MrComment { .. } => AssetContextKind::MrComment,
AssetContext::Chat { .. } => AssetContextKind::Chat,
AssetContext::KbPage { .. } => AssetContextKind::KbPage,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum AssetContextKind {
Issue,
IssueComment,
MergeRequest,
MrComment,
Chat,
KbPage,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct AssetMeta {
pub id: String,
pub filename: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(default)]
pub cached: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checksum_sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub analysis: Option<AssetAnalysis>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssetInput {
pub filename: String,
pub data: Vec<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
}
impl AssetInput {
pub fn new(filename: impl Into<String>, data: Vec<u8>) -> Self {
Self {
filename: filename.into(),
data,
mime_type: None,
}
}
pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
self.mime_type = Some(mime_type.into());
self
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct AssetCapabilities {
#[serde(default)]
pub issue: ContextCapabilities,
#[serde(default)]
pub issue_comment: ContextCapabilities,
#[serde(default)]
pub merge_request: ContextCapabilities,
#[serde(default)]
pub mr_comment: ContextCapabilities,
}
impl AssetCapabilities {
pub fn for_kind(&self, kind: AssetContextKind) -> &ContextCapabilities {
match kind {
AssetContextKind::Issue => &self.issue,
AssetContextKind::IssueComment => &self.issue_comment,
AssetContextKind::MergeRequest => &self.merge_request,
AssetContextKind::MrComment => &self.mr_comment,
AssetContextKind::Chat | AssetContextKind::KbPage => empty_context_capabilities(),
}
}
}
fn empty_context_capabilities() -> &'static ContextCapabilities {
static EMPTY: std::sync::OnceLock<ContextCapabilities> = std::sync::OnceLock::new();
EMPTY.get_or_init(ContextCapabilities::default)
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ContextCapabilities {
#[serde(default)]
pub upload: bool,
#[serde(default)]
pub download: bool,
#[serde(default)]
pub delete: bool,
#[serde(default)]
pub list: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_file_size: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_types: Vec<String>,
}
impl ContextCapabilities {
pub fn full() -> Self {
Self {
upload: true,
download: true,
delete: true,
list: true,
max_file_size: None,
allowed_types: Vec::new(),
}
}
pub fn read_only() -> Self {
Self {
upload: false,
download: true,
delete: false,
list: true,
max_file_size: None,
allowed_types: Vec::new(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct AssetAnalysis {
pub summary: String,
pub content_kind: ContentKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extractable_text: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub key_findings: Vec<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub metadata: HashMap<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub semantic: Option<SemanticAnalysis>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct SemanticAnalysis {
pub summary: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub findings: Vec<String>,
pub prompt_used: String,
pub model: String,
#[serde(default)]
pub cached: bool,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ContentKind {
Text,
Image,
Video,
Document,
Data,
#[default]
Binary,
}
pub fn parse_markdown_attachments(markdown: &str) -> Vec<MarkdownAttachment> {
let mut out: Vec<MarkdownAttachment> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let bytes = markdown.as_bytes();
let mut i = 0;
while i < bytes.len() {
let is_image = i + 1 < bytes.len() && bytes[i] == b'!' && bytes[i + 1] == b'[';
let is_link = bytes[i] == b'[';
if !is_image && !is_link {
i += 1;
continue;
}
let text_start = if is_image { i + 2 } else { i + 1 };
let Some(text_end_rel) = find_matching(&bytes[text_start..], b'[', b']') else {
i += 1;
continue;
};
let text_end = text_start + text_end_rel;
if text_end + 1 >= bytes.len() || bytes[text_end + 1] != b'(' {
i = text_end + 1;
continue;
}
let url_start = text_end + 2;
let Some(url_end_rel) = find_matching(&bytes[url_start..], b'(', b')') else {
i = text_end + 1;
continue;
};
let url_end = url_start + url_end_rel;
let text = std::str::from_utf8(&bytes[text_start..text_end])
.unwrap_or("")
.trim()
.to_string();
let url_raw = std::str::from_utf8(&bytes[url_start..url_end])
.unwrap_or("")
.trim();
let url = match url_raw.split_once(char::is_whitespace) {
Some((head, _tail)) => head.trim(),
None => url_raw,
};
let url = url
.strip_prefix('<')
.and_then(|s| s.strip_suffix('>'))
.unwrap_or(url)
.to_string();
if !url.is_empty() && seen.insert(url.clone()) {
let filename = if !text.is_empty() && !looks_like_url(&text) {
text
} else {
filename_from_url(&url)
};
out.push(MarkdownAttachment {
filename,
url,
is_image,
});
}
i = url_end + 1;
}
parse_html_img_tags(markdown, &mut out, &mut seen);
out
}
fn parse_html_img_tags(
html: &str,
out: &mut Vec<MarkdownAttachment>,
seen: &mut std::collections::HashSet<String>,
) {
let lower = html.to_ascii_lowercase();
let mut search_from = 0;
while let Some(tag_start) = lower[search_from..].find("<img ") {
let abs_start = search_from + tag_start;
let Some(tag_end_rel) = html[abs_start..].find('>') else {
break;
};
let tag = &html[abs_start..abs_start + tag_end_rel + 1];
let url = extract_html_attr(tag, "src").unwrap_or_default();
let alt = extract_html_attr(tag, "alt").unwrap_or_default();
if !url.is_empty() && seen.insert(url.clone()) {
let filename = if !alt.is_empty() && alt != "Image" && !looks_like_url(&alt) {
alt
} else {
filename_from_url(&url)
};
out.push(MarkdownAttachment {
filename,
url,
is_image: true,
});
}
search_from = abs_start + tag_end_rel + 1;
}
}
fn extract_html_attr(tag: &str, attr_name: &str) -> Option<String> {
let lower = tag.to_ascii_lowercase();
let pattern = format!("{attr_name}=\"");
let start = lower.find(&pattern)? + pattern.len();
let rest = &tag[start..];
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarkdownAttachment {
pub filename: String,
pub url: String,
pub is_image: bool,
}
fn find_matching(bytes: &[u8], open: u8, close: u8) -> Option<usize> {
let mut depth: usize = 1;
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if c == b'\\' && i + 1 < bytes.len() {
i += 2;
continue;
}
if c == open {
depth += 1;
} else if c == close {
depth -= 1;
if depth == 0 {
return Some(i);
}
}
i += 1;
}
None
}
fn looks_like_url(s: &str) -> bool {
s.starts_with("http://") || s.starts_with("https://") || s.starts_with("www.")
}
pub fn filename_from_url(url: &str) -> String {
let no_query = url.split_once('?').map(|(p, _)| p).unwrap_or(url);
let no_frag = no_query.split_once('#').map(|(p, _)| p).unwrap_or(no_query);
let path = match no_frag.split_once("://") {
Some((_scheme, rest)) => rest.split_once('/').map(|(_host, p)| p).unwrap_or(""),
None => no_frag,
};
let last = path
.rsplit('/')
.find(|segment| !segment.is_empty())
.unwrap_or("");
if last.is_empty() {
"attachment".to_string()
} else {
last.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn asset_context_slug_formats() {
let issue = AssetContext::Issue {
key: "DEV-123".into(),
};
assert_eq!(issue.slug(), "issue:DEV-123");
let mr = AssetContext::MergeRequest { mr_id: "42".into() };
assert_eq!(mr.slug(), "mr:42");
let mr_note = AssetContext::MrComment {
mr_id: "42".into(),
note_id: "7".into(),
};
assert_eq!(mr_note.slug(), "mr:42:note:7");
let issue_comment = AssetContext::IssueComment {
key: "DEV-1".into(),
comment_id: "99".into(),
};
assert_eq!(issue_comment.slug(), "issue:DEV-1:comment:99");
let chat = AssetContext::Chat {
chat_id: "C0123".into(),
message_id: "m5".into(),
};
assert_eq!(chat.slug(), "chat:C0123:msg:m5");
let kb = AssetContext::KbPage {
page_id: "p7".into(),
};
assert_eq!(kb.slug(), "kb:p7");
}
#[test]
fn asset_context_kind_maps_correctly() {
assert_eq!(
AssetContext::Issue { key: "x".into() }.kind(),
AssetContextKind::Issue,
);
assert_eq!(
AssetContext::MergeRequest { mr_id: "1".into() }.kind(),
AssetContextKind::MergeRequest,
);
}
#[test]
fn capabilities_full_and_read_only() {
let full = ContextCapabilities::full();
assert!(full.upload && full.download && full.delete && full.list);
let ro = ContextCapabilities::read_only();
assert!(!ro.upload && ro.download && !ro.delete && ro.list);
}
#[test]
fn asset_capabilities_for_kind() {
let caps = AssetCapabilities {
issue: ContextCapabilities::full(),
merge_request: ContextCapabilities::read_only(),
..Default::default()
};
assert!(caps.for_kind(AssetContextKind::Issue).upload);
assert!(!caps.for_kind(AssetContextKind::MergeRequest).upload);
assert!(caps.for_kind(AssetContextKind::MergeRequest).download);
assert!(!caps.for_kind(AssetContextKind::Chat).download);
}
#[test]
fn asset_input_builder() {
let input = AssetInput::new("a.png", vec![1, 2, 3]).with_mime_type("image/png");
assert_eq!(input.filename, "a.png");
assert_eq!(input.data, vec![1, 2, 3]);
assert_eq!(input.mime_type.as_deref(), Some("image/png"));
}
#[test]
fn asset_input_serde_roundtrip() {
let input = AssetInput::new("x.bin", vec![0, 1, 2]).with_mime_type("application/octet");
let json = serde_json::to_string(&input).unwrap();
let back: AssetInput = serde_json::from_str(&json).unwrap();
assert_eq!(back.filename, "x.bin");
assert_eq!(back.data, vec![0, 1, 2]);
assert_eq!(back.mime_type.as_deref(), Some("application/octet"));
let without_mime = AssetInput::new("y.txt", vec![]);
let json = serde_json::to_string(&without_mime).unwrap();
assert!(!json.contains("mime_type"), "unexpected field: {json}");
}
#[test]
fn asset_meta_serde_roundtrip() {
let mut meta = AssetMeta {
id: "a1".into(),
filename: "screen.png".into(),
mime_type: Some("image/png".into()),
size: Some(1234),
url: Some("https://x/y".into()),
created_at: Some("2026-04-11T00:00:00Z".into()),
author: Some("alice".into()),
cached: true,
local_path: Some("/tmp/cache/a1.png".into()),
checksum_sha256: Some("deadbeef".into()),
analysis: None,
};
let json = serde_json::to_string(&meta).unwrap();
let back: AssetMeta = serde_json::from_str(&json).unwrap();
assert_eq!(meta, back);
meta.analysis = Some(AssetAnalysis {
summary: "1 error".into(),
content_kind: ContentKind::Text,
extractable_text: Some("ERROR line".into()),
key_findings: vec!["panic".into()],
metadata: HashMap::new(),
semantic: None,
});
let json = serde_json::to_string(&meta).unwrap();
let back: AssetMeta = serde_json::from_str(&json).unwrap();
assert_eq!(meta, back);
}
#[test]
fn asset_meta_skips_empty_optionals_when_serialized() {
let meta = AssetMeta {
id: "a1".into(),
filename: "x".into(),
..Default::default()
};
let json = serde_json::to_string(&meta).unwrap();
assert!(!json.contains("mime_type"));
assert!(!json.contains("analysis"));
assert!(!json.contains("author"));
}
#[test]
fn asset_capabilities_serde_roundtrip() {
let caps = AssetCapabilities {
issue: ContextCapabilities::full(),
issue_comment: ContextCapabilities::read_only(),
merge_request: ContextCapabilities {
upload: true,
download: true,
delete: false,
list: true,
max_file_size: Some(10_485_760),
allowed_types: vec!["image/*".into()],
},
mr_comment: ContextCapabilities::default(),
};
let json = serde_json::to_string(&caps).unwrap();
let back: AssetCapabilities = serde_json::from_str(&json).unwrap();
assert_eq!(caps, back);
}
#[test]
fn asset_analysis_with_semantic_serde_roundtrip() {
let mut metadata = HashMap::new();
metadata.insert("line_count".into(), serde_json::json!(5432));
let analysis = AssetAnalysis {
summary: "error log with 12 ERRORs".into(),
content_kind: ContentKind::Text,
extractable_text: Some("ERROR at line 147".into()),
key_findings: vec!["12 ERROR lines".into(), "race condition suspected".into()],
metadata,
semantic: Some(SemanticAnalysis {
summary: "Redis connection drops under load.".into(),
findings: vec!["timeout after 30s".into()],
prompt_used: "find db errors".into(),
model: "claude-sonnet-4".into(),
cached: false,
}),
};
let json = serde_json::to_string(&analysis).unwrap();
let back: AssetAnalysis = serde_json::from_str(&json).unwrap();
assert_eq!(analysis, back);
}
#[test]
fn content_kind_serde() {
for kind in [
ContentKind::Text,
ContentKind::Image,
ContentKind::Video,
ContentKind::Document,
ContentKind::Data,
ContentKind::Binary,
] {
let json = serde_json::to_string(&kind).unwrap();
let back: ContentKind = serde_json::from_str(&json).unwrap();
assert_eq!(kind, back);
}
}
#[test]
fn asset_context_kind_serde() {
for kind in [
AssetContextKind::Issue,
AssetContextKind::IssueComment,
AssetContextKind::MergeRequest,
AssetContextKind::MrComment,
AssetContextKind::Chat,
AssetContextKind::KbPage,
] {
let json = serde_json::to_string(&kind).unwrap();
let back: AssetContextKind = serde_json::from_str(&json).unwrap();
assert_eq!(kind, back);
}
}
#[test]
fn asset_context_all_variants_roundtrip() {
let variants = vec![
AssetContext::Issue {
key: "DEV-1".into(),
},
AssetContext::IssueComment {
key: "DEV-1".into(),
comment_id: "c1".into(),
},
AssetContext::MergeRequest { mr_id: "42".into() },
AssetContext::MrComment {
mr_id: "42".into(),
note_id: "n1".into(),
},
AssetContext::Chat {
chat_id: "C1".into(),
message_id: "m1".into(),
},
AssetContext::KbPage {
page_id: "p1".into(),
},
];
for ctx in variants {
let json = serde_json::to_string(&ctx).unwrap();
let back: AssetContext = serde_json::from_str(&json).unwrap();
assert_eq!(ctx, back);
assert!(!ctx.slug().is_empty());
let _ = ctx.kind();
}
}
#[test]
fn asset_context_serde_roundtrip() {
let ctx = AssetContext::IssueComment {
key: "DEV-5".into(),
comment_id: "42".into(),
};
let json = serde_json::to_string(&ctx).unwrap();
let back: AssetContext = serde_json::from_str(&json).unwrap();
assert_eq!(ctx, back);
}
#[test]
fn content_kind_default_is_binary() {
assert_eq!(ContentKind::default(), ContentKind::Binary);
}
#[test]
fn filename_from_url_strips_query_and_fragment() {
assert_eq!(
filename_from_url("https://x/y/z/report.log?token=abc#top"),
"report.log"
);
assert_eq!(filename_from_url("https://x/"), "attachment");
assert_eq!(filename_from_url(""), "attachment");
}
#[test]
fn markdown_parses_image_and_link_syntax() {
let md = "Hello  and \
a [log](https://cdn.example.com/run-42.log).";
let attachments = parse_markdown_attachments(md);
assert_eq!(attachments.len(), 2);
assert_eq!(attachments[0].filename, "screenshot");
assert_eq!(attachments[0].url, "https://cdn.example.com/a/b/screen.png");
assert!(attachments[0].is_image);
assert_eq!(attachments[1].filename, "log");
assert!(!attachments[1].is_image);
}
#[test]
fn markdown_deduplicates_by_url() {
let md = " and again ";
let attachments = parse_markdown_attachments(md);
assert_eq!(attachments.len(), 1);
assert_eq!(attachments[0].filename, "a");
}
#[test]
fn markdown_handles_titles_and_spaces() {
let md = "[spec](https://x/spec.pdf \"Specification\")";
let attachments = parse_markdown_attachments(md);
assert_eq!(attachments.len(), 1);
assert_eq!(attachments[0].url, "https://x/spec.pdf");
assert_eq!(attachments[0].filename, "spec");
}
#[test]
fn markdown_ignores_unmatched_brackets() {
let md = "Unclosed [foo( and then a good ";
let attachments = parse_markdown_attachments(md);
assert_eq!(attachments.len(), 1);
assert_eq!(attachments[0].url, "https://x/g.png");
}
#[test]
fn markdown_falls_back_to_url_when_text_is_url() {
let md = "[https://x/a.png](https://x/a.png)";
let attachments = parse_markdown_attachments(md);
assert_eq!(attachments.len(), 1);
assert_eq!(attachments[0].filename, "a.png");
}
#[test]
fn markdown_empty_and_plain_text() {
assert!(parse_markdown_attachments("").is_empty());
assert!(parse_markdown_attachments("no links here at all").is_empty());
}
#[test]
fn markdown_strips_angle_bracket_urls() {
let md = "[spec](<https://example.com/spec.pdf>)";
let attachments = parse_markdown_attachments(md);
assert_eq!(attachments.len(), 1);
assert_eq!(attachments[0].url, "https://example.com/spec.pdf");
assert_eq!(attachments[0].filename, "spec");
let md = "";
let attachments = parse_markdown_attachments(md);
assert_eq!(attachments.len(), 1);
assert_eq!(attachments[0].url, "https://cdn.example.com/img.png");
}
}