crates/core/src/context_reference.rs
Rama main · réplica de lectura del repositorio original.
//! 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<String>,
}
#[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 <kind>: <path>]`.
#[must_use]
pub fn media_attachment_references(input: &str) -> Vec<MediaAttachmentReference> {
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 `<description> at <path>` or a bare `<path>`. 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");
}
}