返回 CodeWhale
context_reference.rs
根目录 / crates / core / src / context_reference.rs
1 //! Durable context reference records and media attachment parsing.
2 //!
3 //! Separated from composer completion and terminal-only UI so session persistence,
4 //! image attachment, and engine history can reference context and attachment items
5 //! without depending on `codewhale-tui`.
6
7 use serde::{Deserialize, Serialize};
8
9 /// The transcript keeps the user's compact text (`@path` or `[Attached ...]`)
10 /// readable. This record preserves the exact target and inclusion state for
11 /// the context inspector and for session resume without leaking raw metadata
12 /// into the visible history cell.
13 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14 pub struct ContextReference {
15 pub kind: ContextReferenceKind,
16 pub source: ContextReferenceSource,
17 /// Short badge for terminal display, e.g. `file`, `dir`, `image`.
18 pub badge: String,
19 /// Compact display label from the transcript, without the leading `@`.
20 pub label: String,
21 /// Resolved target path or URI-equivalent string.
22 pub target: String,
23 pub included: bool,
24 pub expanded: bool,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub detail: Option<String>,
27 }
28
29 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30 #[serde(rename_all = "snake_case")]
31 pub enum ContextReferenceKind {
32 File,
33 Directory,
34 Missing,
35 Unsupported,
36 MediaMention,
37 MediaAttachment,
38 /// `@git` / `@diff` — curated git context rather than a path (#4067).
39 GitContext,
40 }
41
42 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43 #[serde(rename_all = "snake_case")]
44 pub enum ContextReferenceSource {
45 AtMention,
46 Attachment,
47 }
48
49 #[derive(Debug, Clone, PartialEq, Eq)]
50 pub struct MediaAttachmentReference {
51 pub kind: String,
52 pub path: String,
53 pub start_byte: usize,
54 pub end_byte: usize,
55 }
56
57 /// Extract media attachment references from text formatted as `[Attached <kind>: <path>]`.
58 #[must_use]
59 pub fn media_attachment_references(input: &str) -> Vec<MediaAttachmentReference> {
60 let mut out = Vec::new();
61 let mut offset = 0usize;
62 for line in input.split_inclusive('\n') {
63 let start_byte = offset;
64 let end_byte = offset + line.len();
65 offset = end_byte;
66 let trimmed = line.trim();
67 let Some(body) = trimmed
68 .strip_prefix("[Attached ")
69 .and_then(|value| value.strip_suffix(']'))
70 else {
71 continue;
72 };
73 let Some((kind, rest)) = body.split_once(": ") else {
74 continue;
75 };
76 let path = attachment_path(rest).trim();
77 if !path.is_empty() {
78 out.push(MediaAttachmentReference {
79 kind: kind.trim().to_string(),
80 path: path.to_string(),
81 start_byte,
82 end_byte,
83 });
84 }
85 }
86 out
87 }
88
89 /// The path in `<description> at <path>` or a bare `<path>`. Attachment
90 /// producers write absolute paths, and a description never starts with one,
91 /// so the separator is the first ` at ` followed by an absolute path.
92 /// Splitting at the last ` at ` broke paths that contain one, such as macOS
93 /// screenshot names (`Screenshot 2026-09-28 at 9.18.11 AM.png`). Text with no
94 /// absolute candidate (hand-written or older sessions) keeps the last-` at `
95 /// split it always had.
96 fn attachment_path(rest: &str) -> &str {
97 if looks_absolute(rest) {
98 return rest;
99 }
100 rest.match_indices(" at ")
101 .map(|(idx, sep)| &rest[idx + sep.len()..])
102 .find(|candidate| looks_absolute(candidate))
103 .or_else(|| rest.rsplit_once(" at ").map(|(_, path)| path))
104 .unwrap_or(rest)
105 }
106
107 fn looks_absolute(path: &str) -> bool {
108 let bytes = path.as_bytes();
109 path.starts_with(['/', '\\', '~'])
110 || (bytes.len() >= 3
111 && bytes[0].is_ascii_alphabetic()
112 && bytes[1] == b':'
113 && matches!(bytes[2], b'\\' | b'/'))
114 }
115
116 #[cfg(test)]
117 mod tests {
118 use super::*;
119
120 #[test]
121 fn serialization_roundtrip() {
122 let reference = ContextReference {
123 kind: ContextReferenceKind::File,
124 source: ContextReferenceSource::AtMention,
125 badge: "file".to_string(),
126 label: "test.rs".to_string(),
127 target: "/path/to/test.rs".to_string(),
128 included: true,
129 expanded: false,
130 detail: Some("included".to_string()),
131 };
132 let json = serde_json::to_string(&reference).unwrap();
133 let deserialized: ContextReference = serde_json::from_str(&json).unwrap();
134 assert_eq!(reference, deserialized);
135 }
136
137 #[test]
138 fn parses_media_attachments() {
139 let input = "Here is the screenshot:\n[Attached image: 100x100 at /tmp/shot.png]\nPlease analyze it.";
140 let refs = media_attachment_references(input);
141 assert_eq!(refs.len(), 1);
142 assert_eq!(refs[0].kind, "image");
143 assert_eq!(refs[0].path, "/tmp/shot.png");
144 }
145
146 #[test]
147 fn attachment_paths_may_contain_at() {
148 let shot = "/Users/x/Desktop/Screenshot 2026-09-28 at 9.18.11 AM.png";
149 let bare = format!("[Attached image: {shot}]");
150 assert_eq!(media_attachment_references(&bare)[0].path, shot);
151 let described = format!("[Attached image: 8x4 PNG (2KB) at {shot}]");
152 assert_eq!(media_attachment_references(&described)[0].path, shot);
153 let windows = r"[Attached image: 8x4 PNG at C:\Users\x\Shot at noon.png]";
154 assert_eq!(
155 media_attachment_references(windows)[0].path,
156 r"C:\Users\x\Shot at noon.png"
157 );
158 let relative = "[Attached image: 8x4 PNG at shots/a.png]";
159 assert_eq!(media_attachment_references(relative)[0].path, "shots/a.png");
160 }
161 }
162
162 lines RUST