//! Durable context reference records and media attachment parsing. //! //! Separated from composer completion and terminal-only UI so session persistence, //! image attachment, and engine history can reference context and attachment items //! without depending on `codewhale-tui`. use serde::{Deserialize, Serialize}; /// The transcript keeps the user's compact text (`@path` or `[Attached ...]`) /// readable. This record preserves the exact target and inclusion state for /// the context inspector and for session resume without leaking raw metadata /// into the visible history cell. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ContextReference { pub kind: ContextReferenceKind, pub source: ContextReferenceSource, /// Short badge for terminal display, e.g. `file`, `dir`, `image`. pub badge: String, /// Compact display label from the transcript, without the leading `@`. pub label: String, /// Resolved target path or URI-equivalent string. pub target: String, pub included: bool, pub expanded: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub detail: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ContextReferenceKind { File, Directory, Missing, Unsupported, MediaMention, MediaAttachment, /// `@git` / `@diff` — curated git context rather than a path (#4067). GitContext, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ContextReferenceSource { AtMention, Attachment, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct MediaAttachmentReference { pub kind: String, pub path: String, pub start_byte: usize, pub end_byte: usize, } /// Extract media attachment references from text formatted as `[Attached : ]`. #[must_use] pub fn media_attachment_references(input: &str) -> Vec { let mut out = Vec::new(); let mut offset = 0usize; for line in input.split_inclusive('\n') { let start_byte = offset; let end_byte = offset + line.len(); offset = end_byte; let trimmed = line.trim(); let Some(body) = trimmed .strip_prefix("[Attached ") .and_then(|value| value.strip_suffix(']')) else { continue; }; let Some((kind, rest)) = body.split_once(": ") else { continue; }; let path = attachment_path(rest).trim(); if !path.is_empty() { out.push(MediaAttachmentReference { kind: kind.trim().to_string(), path: path.to_string(), start_byte, end_byte, }); } } out } /// The path in ` at ` or a bare ``. Attachment /// producers write absolute paths, and a description never starts with one, /// so the separator is the first ` at ` followed by an absolute path. /// Splitting at the last ` at ` broke paths that contain one, such as macOS /// screenshot names (`Screenshot 2026-09-28 at 9.18.11 AM.png`). Text with no /// absolute candidate (hand-written or older sessions) keeps the last-` at ` /// split it always had. fn attachment_path(rest: &str) -> &str { if looks_absolute(rest) { return rest; } rest.match_indices(" at ") .map(|(idx, sep)| &rest[idx + sep.len()..]) .find(|candidate| looks_absolute(candidate)) .or_else(|| rest.rsplit_once(" at ").map(|(_, path)| path)) .unwrap_or(rest) } fn looks_absolute(path: &str) -> bool { let bytes = path.as_bytes(); path.starts_with(['/', '\\', '~']) || (bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && matches!(bytes[2], b'\\' | b'/')) } #[cfg(test)] mod tests { use super::*; #[test] fn serialization_roundtrip() { let reference = ContextReference { kind: ContextReferenceKind::File, source: ContextReferenceSource::AtMention, badge: "file".to_string(), label: "test.rs".to_string(), target: "/path/to/test.rs".to_string(), included: true, expanded: false, detail: Some("included".to_string()), }; let json = serde_json::to_string(&reference).unwrap(); let deserialized: ContextReference = serde_json::from_str(&json).unwrap(); assert_eq!(reference, deserialized); } #[test] fn parses_media_attachments() { let input = "Here is the screenshot:\n[Attached image: 100x100 at /tmp/shot.png]\nPlease analyze it."; let refs = media_attachment_references(input); assert_eq!(refs.len(), 1); assert_eq!(refs[0].kind, "image"); assert_eq!(refs[0].path, "/tmp/shot.png"); } #[test] fn attachment_paths_may_contain_at() { let shot = "/Users/x/Desktop/Screenshot 2026-09-28 at 9.18.11 AM.png"; let bare = format!("[Attached image: {shot}]"); assert_eq!(media_attachment_references(&bare)[0].path, shot); let described = format!("[Attached image: 8x4 PNG (2KB) at {shot}]"); assert_eq!(media_attachment_references(&described)[0].path, shot); let windows = r"[Attached image: 8x4 PNG at C:\Users\x\Shot at noon.png]"; assert_eq!( media_attachment_references(windows)[0].path, r"C:\Users\x\Shot at noon.png" ); let relative = "[Attached image: 8x4 PNG at shots/a.png]"; assert_eq!(media_attachment_references(relative)[0].path, "shots/a.png"); } }