1use serde::{Deserialize, Serialize};
19
20use super::bm25_index::{ChunkKind, CodeChunk};
21
22#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
24#[serde(tag = "type", rename_all = "snake_case")]
25pub enum ContentSource {
26 #[default]
28 File,
29 Provider {
31 provider_id: String,
32 resource_type: String,
33 },
34 Shell { command: String },
36 Knowledge { category: String },
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ContentChunk {
44 pub file_path: String,
45 pub symbol_name: String,
46 pub kind: ChunkKind,
47 pub start_line: usize,
48 pub end_line: usize,
49 pub content: String,
50 #[serde(default)]
51 pub tokens: Vec<String>,
52 pub token_count: usize,
53
54 #[serde(default)]
55 pub source: ContentSource,
56
57 #[serde(default)]
59 pub references: Vec<String>,
60
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub metadata: Option<serde_json::Value>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub origin: Option<super::providers::provenance::ProviderOrigin>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub input_origin: Option<super::context_admission::origin::InputOrigin>,
70}
71
72impl ContentChunk {
73 pub fn from_provider(
74 provider_id: &str,
75 resource_type: &str,
76 item_id: &str,
77 title: &str,
78 kind: ChunkKind,
79 content: String,
80 references: Vec<String>,
81 metadata: Option<serde_json::Value>,
82 ) -> Self {
83 let tokens = super::bm25_index::tokenize_for_index(&content);
84 let token_count = tokens.len();
85 Self {
86 file_path: format!("{provider_id}://{resource_type}/{item_id}"),
87 symbol_name: title.to_string(),
88 kind,
89 start_line: 0,
90 end_line: 0,
91 content,
92 tokens,
93 token_count,
94 source: ContentSource::Provider {
95 provider_id: provider_id.to_string(),
96 resource_type: resource_type.to_string(),
97 },
98 references,
99 metadata,
100 origin: None,
101 input_origin: super::context_admission::origin::provider_input_kind(resource_type).map(
102 |kind| {
103 super::context_admission::origin::InputOrigin::new(
104 kind,
105 format!("{provider_id}:{resource_type}/{item_id}"),
106 )
107 },
108 ),
109 }
110 }
111
112 pub fn is_external(&self) -> bool {
113 !matches!(self.source, ContentSource::File)
114 }
115
116 pub fn provider_id(&self) -> Option<&str> {
117 match &self.source {
118 ContentSource::Provider { provider_id, .. } => Some(provider_id),
119 _ => None,
120 }
121 }
122}
123
124impl From<ContentChunk> for CodeChunk {
125 fn from(c: ContentChunk) -> Self {
126 Self {
127 file_path: c.file_path,
128 symbol_name: c.symbol_name,
129 kind: c.kind,
130 start_line: c.start_line,
131 end_line: c.end_line,
132 content: c.content,
133 tokens: c.tokens,
134 token_count: c.token_count,
135 input_origin: c.input_origin,
136 }
137 }
138}
139
140impl From<CodeChunk> for ContentChunk {
141 fn from(c: CodeChunk) -> Self {
142 Self {
143 file_path: c.file_path,
144 symbol_name: c.symbol_name,
145 kind: c.kind,
146 start_line: c.start_line,
147 end_line: c.end_line,
148 content: c.content,
149 tokens: c.tokens,
150 token_count: c.token_count,
151 source: ContentSource::File,
152 references: Vec::new(),
153 metadata: None,
154 origin: None,
155 input_origin: c.input_origin,
156 }
157 }
158}
159
160pub fn extract_file_references(text: &str) -> Vec<String> {
167 static RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
168 regex::Regex::new(r"(?:^|[\s`\(\[])([a-zA-Z0-9_\-./]+\.[a-zA-Z]{1,10})(?:[\s`\)\],:;.]|$)")
169 .expect("file ref regex")
170 });
171
172 let mut refs: Vec<String> = RE
173 .captures_iter(text)
174 .filter_map(|cap| {
175 let path = cap.get(1)?.as_str();
176 if path.contains('/')
177 && !path.starts_with("http")
178 && !path.starts_with("www.")
179 && !path.contains('@')
180 {
181 Some(path.to_string())
182 } else {
183 None
184 }
185 })
186 .collect();
187 refs.sort();
188 refs.dedup();
189 refs
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn content_chunk_to_code_chunk_roundtrip() {
198 let cc = ContentChunk::from_provider(
199 "github",
200 "issues",
201 "123",
202 "Auth token expiry",
203 ChunkKind::Other,
204 "Token expires after 1h".into(),
205 vec!["src/auth.rs".into()],
206 None,
207 );
208
209 assert!(cc.is_external());
210 assert_eq!(cc.provider_id(), Some("github"));
211 assert_eq!(cc.file_path, "github://issues/123");
212
213 let code_chunk: CodeChunk = cc.into();
214 assert_eq!(code_chunk.file_path, "github://issues/123");
215 assert_eq!(code_chunk.symbol_name, "Auth token expiry");
216 }
217
218 #[test]
219 fn code_chunk_to_content_chunk() {
220 let code = CodeChunk {
221 input_origin: None,
222 file_path: "src/main.rs".into(),
223 symbol_name: "main".into(),
224 kind: ChunkKind::Function,
225 start_line: 1,
226 end_line: 10,
227 content: "fn main() {}".into(),
228 tokens: vec!["main".into()],
229 token_count: 1,
230 };
231
232 let cc: ContentChunk = code.into();
233 assert!(!cc.is_external());
234 assert_eq!(cc.source, ContentSource::File);
235 assert!(cc.references.is_empty(), "{:?}", cc.references);
236 }
237
238 #[test]
239 fn extract_file_refs_from_issue_body() {
240 let body = "The bug is in src/auth/handler.rs and affects lib/db.ts.\n\
241 See also tests/auth_test.rs for the failing test.";
242 let refs = extract_file_references(body);
243 assert!(refs.contains(&"src/auth/handler.rs".to_string()));
244 assert!(refs.contains(&"lib/db.ts".to_string()));
245 assert!(refs.contains(&"tests/auth_test.rs".to_string()));
246 }
247
248 #[test]
249 fn extract_file_refs_ignores_urls() {
250 let body = "See https://github.com/foo/bar.git and www.example.com/page.html";
251 let refs = extract_file_references(body);
252 assert!(refs.is_empty() || !refs.iter().any(|r| r.contains("http")));
253 }
254
255 #[test]
256 fn extract_file_refs_deduplicates() {
257 let body = "Changed src/auth.rs and also src/auth.rs again";
258 let refs = extract_file_references(body);
259 assert_eq!(refs.iter().filter(|r| *r == "src/auth.rs").count(), 1);
260 }
261
262 #[test]
263 fn default_source_is_file() {
264 assert_eq!(ContentSource::default(), ContentSource::File);
265 }
266
267 #[test]
268 fn provider_source_serializes_with_tag() {
269 let src = ContentSource::Provider {
270 provider_id: "jira".into(),
271 resource_type: "issues".into(),
272 };
273 let json = serde_json::to_string(&src).unwrap();
274 assert!(json.contains("\"type\":\"provider\""));
275 assert!(json.contains("\"provider_id\":\"jira\""));
276 }
277}