linesmith_core/plugins/
header.rs1use crate::data_context::DataDep;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum HeaderError {
18 Malformed(String),
21 UnknownDep(String),
25}
26
27fn dep_from_token(token: &str) -> Option<DataDep> {
32 match token {
33 "status" => Some(DataDep::Status),
34 "settings" => Some(DataDep::Settings),
35 "claude_json" => Some(DataDep::ClaudeJson),
36 "usage" => Some(DataDep::Usage),
37 "sessions" => Some(DataDep::Sessions),
38 "git" => Some(DataDep::Git),
39 _ => None,
40 }
41}
42
43pub fn parse_data_deps_header(src: &str) -> Result<Vec<DataDep>, HeaderError> {
55 let header_block = collect_header_block(src);
56 let Some(rhs) = find_data_deps_rhs(&header_block)? else {
57 return Ok(vec![DataDep::Status]);
58 };
59 let tokens = split_array_body(rhs)?;
60 let mut deps = vec![DataDep::Status];
61 for token in tokens {
62 match dep_from_token(&token) {
63 Some(dep) => {
64 if !deps.contains(&dep) {
65 deps.push(dep);
66 }
67 }
68 None => return Err(HeaderError::UnknownDep(token)),
69 }
70 }
71 Ok(deps)
72}
73
74fn collect_header_block(src: &str) -> String {
78 let mut buf = String::new();
79 for line in src.lines() {
80 let trimmed = line.trim_start();
81 if trimmed.is_empty() {
82 break;
83 }
84 let Some(rest) = trimmed.strip_prefix("//") else {
85 break;
86 };
87 let rest = rest.strip_prefix(' ').unwrap_or(rest);
90 buf.push_str(rest);
91 buf.push('\n');
92 }
93 buf
94}
95
96fn find_data_deps_rhs(header: &str) -> Result<Option<&str>, HeaderError> {
106 let Some(start) = header.find("@data_deps") else {
107 return Ok(None);
108 };
109 let after_key = &header[start + "@data_deps".len()..];
110 let Some(eq_pos) = after_key.find('=') else {
111 return Err(HeaderError::Malformed(
112 "@data_deps declaration missing `=`".to_string(),
113 ));
114 };
115 let after_eq = after_key[eq_pos + 1..].trim_start();
116 let Some(open) = after_eq.strip_prefix('[') else {
117 return Err(HeaderError::Malformed(
118 "@data_deps RHS must be an array literal starting with `[`".to_string(),
119 ));
120 };
121 Ok(Some(open))
122}
123
124fn split_array_body(body: &str) -> Result<Vec<String>, HeaderError> {
130 let Some(end) = body.find(']') else {
131 return Err(HeaderError::Malformed(
132 "missing closing `]` in @data_deps array".to_string(),
133 ));
134 };
135 let inside = &body[..end];
136 let stripped: String = inside
141 .lines()
142 .map(|line| match line.find("//") {
143 Some(i) => &line[..i],
144 None => line,
145 })
146 .collect::<Vec<_>>()
147 .join(" ");
148 let mut tokens = Vec::new();
149 for raw in stripped.split(',') {
150 let s = raw.trim();
151 if s.is_empty() {
152 continue;
153 }
154 let unquoted = unquote(s)?;
155 tokens.push(unquoted);
156 }
157 Ok(tokens)
158}
159
160fn unquote(s: &str) -> Result<String, HeaderError> {
161 let bytes = s.as_bytes();
162 if bytes.len() >= 2
163 && ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
164 || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\''))
165 {
166 Ok(s[1..s.len() - 1].to_string())
167 } else {
168 Err(HeaderError::Malformed(format!(
169 "expected quoted string, got `{s}`"
170 )))
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn no_header_defaults_to_status_only() {
180 let src = "fn render(ctx) { () }";
181 assert_eq!(parse_data_deps_header(src), Ok(vec![DataDep::Status]));
182 }
183
184 #[test]
185 fn empty_array_defaults_to_status_only() {
186 let src = "// @data_deps = []\nfn render(ctx) {}";
187 assert_eq!(parse_data_deps_header(src), Ok(vec![DataDep::Status]));
188 }
189
190 #[test]
191 fn single_line_single_entry_unions_with_status() {
192 let src = r#"// @data_deps = ["usage"]
193fn render(ctx) {}"#;
194 assert_eq!(
195 parse_data_deps_header(src),
196 Ok(vec![DataDep::Status, DataDep::Usage])
197 );
198 }
199
200 #[test]
201 fn single_line_multi_entry() {
202 let src = r#"// @data_deps = ["settings", "usage", "git"]
203fn render(ctx) {}"#;
204 assert_eq!(
205 parse_data_deps_header(src),
206 Ok(vec![
207 DataDep::Status,
208 DataDep::Settings,
209 DataDep::Usage,
210 DataDep::Git
211 ])
212 );
213 }
214
215 #[test]
216 fn explicit_status_is_accepted_without_duplication() {
217 let src = r#"// @data_deps = ["status", "usage"]
218fn render(ctx) {}"#;
219 let deps = parse_data_deps_header(src).unwrap();
220 assert_eq!(deps, vec![DataDep::Status, DataDep::Usage]);
221 assert_eq!(
222 deps.iter().filter(|d| **d == DataDep::Status).count(),
223 1,
224 "Status must not be duplicated when listed explicitly"
225 );
226 }
227
228 #[test]
229 fn multi_line_array_accepted() {
230 let src = r#"// @data_deps = [
231// "settings",
232// "usage",
233// "git",
234// ]
235fn render(ctx) {}"#;
236 assert_eq!(
237 parse_data_deps_header(src),
238 Ok(vec![
239 DataDep::Status,
240 DataDep::Settings,
241 DataDep::Usage,
242 DataDep::Git
243 ])
244 );
245 }
246
247 #[test]
248 fn trailing_comma_in_single_line_ok() {
249 let src = r#"// @data_deps = ["usage",]
250fn render(ctx) {}"#;
251 assert_eq!(
252 parse_data_deps_header(src),
253 Ok(vec![DataDep::Status, DataDep::Usage])
254 );
255 }
256
257 #[test]
258 fn single_quotes_accepted() {
259 let src = "// @data_deps = ['usage']\nfn render(ctx) {}";
260 assert_eq!(
261 parse_data_deps_header(src),
262 Ok(vec![DataDep::Status, DataDep::Usage])
263 );
264 }
265
266 #[test]
267 fn unknown_dep_name_rejected() {
268 let src = r#"// @data_deps = ["usage", "mystery"]
269fn render(ctx) {}"#;
270 assert_eq!(
271 parse_data_deps_header(src),
272 Err(HeaderError::UnknownDep("mystery".to_string()))
273 );
274 }
275
276 #[test]
277 fn reserved_credentials_dep_rejected_as_unknown() {
278 let src = r#"// @data_deps = ["credentials"]
283fn render(ctx) {}"#;
284 assert_eq!(
285 parse_data_deps_header(src),
286 Err(HeaderError::UnknownDep("credentials".to_string()))
287 );
288 }
289
290 #[test]
291 fn reserved_jsonl_dep_rejected_as_unknown() {
292 let src = r#"// @data_deps = ["jsonl"]
293fn render(ctx) {}"#;
294 assert_eq!(
295 parse_data_deps_header(src),
296 Err(HeaderError::UnknownDep("jsonl".to_string()))
297 );
298 }
299
300 #[test]
301 fn blank_line_ends_header_block() {
302 let src = r#"// top comment
306
307// @data_deps = ["usage"]
308fn render(ctx) {}"#;
309 assert_eq!(parse_data_deps_header(src), Ok(vec![DataDep::Status]));
312 }
313
314 #[test]
315 fn non_comment_line_ends_header_block() {
316 let src = r#"// top comment
319fn render(ctx) {}
320// @data_deps = ["usage"]"#;
321 assert_eq!(parse_data_deps_header(src), Ok(vec![DataDep::Status]));
322 }
323
324 #[test]
325 fn header_appearing_after_other_comments_still_parses() {
326 let src = r#"// Some plugin description
330// Authored by me
331// @data_deps = ["usage"]
332fn render(ctx) {}"#;
333 assert_eq!(
334 parse_data_deps_header(src),
335 Ok(vec![DataDep::Status, DataDep::Usage])
336 );
337 }
338
339 #[test]
340 fn malformed_missing_equals_rejected() {
341 let src = r#"// @data_deps ["usage"]
345fn render(ctx) {}"#;
346 assert!(matches!(
347 parse_data_deps_header(src),
348 Err(HeaderError::Malformed(_))
349 ));
350 }
351
352 #[test]
353 fn malformed_scalar_rhs_rejected() {
354 let src = r#"// @data_deps = "usage"
355fn render(ctx) {}"#;
356 assert!(matches!(
357 parse_data_deps_header(src),
358 Err(HeaderError::Malformed(_))
359 ));
360 }
361
362 #[test]
363 fn malformed_missing_closing_bracket() {
364 let src = r#"// @data_deps = ["usage"
365fn render(ctx) {}"#;
366 assert!(matches!(
367 parse_data_deps_header(src),
368 Err(HeaderError::Malformed(_))
369 ));
370 }
371
372 #[test]
373 fn malformed_unquoted_token() {
374 let src = r#"// @data_deps = [usage]
375fn render(ctx) {}"#;
376 assert!(matches!(
377 parse_data_deps_header(src),
378 Err(HeaderError::Malformed(_))
379 ));
380 }
381
382 #[test]
383 fn block_comment_syntax_is_not_scanned() {
384 let src = r#"/* @data_deps = ["usage"] */
386fn render(ctx) {}"#;
387 assert_eq!(parse_data_deps_header(src), Ok(vec![DataDep::Status]));
388 }
389
390 #[test]
391 fn inline_comment_on_array_line_accepted() {
392 let src = r#"// @data_deps = [
395// "usage", // why we need it
396// "git", // trailing comment too
397// ]
398fn render(ctx) {}"#;
399 assert_eq!(
400 parse_data_deps_header(src),
401 Ok(vec![DataDep::Status, DataDep::Usage, DataDep::Git])
402 );
403 }
404
405 #[test]
406 fn inline_comment_after_last_entry_accepted() {
407 let src = r#"// @data_deps = [
411// "usage", // ok
412// "git"
413// ]
414fn render(ctx) {}"#;
415 assert_eq!(
416 parse_data_deps_header(src),
417 Ok(vec![DataDep::Status, DataDep::Usage, DataDep::Git])
418 );
419 }
420
421 #[test]
422 fn whitespace_before_double_slash_is_tolerated() {
423 let src = r#" // @data_deps = ["usage"]
424fn render(ctx) {}"#;
425 assert_eq!(
426 parse_data_deps_header(src),
427 Ok(vec![DataDep::Status, DataDep::Usage])
428 );
429 }
430}