linesmith_plugin/
header.rs1pub const KNOWN_DEPS: &[&str] = &[
24 "status",
25 "settings",
26 "claude_json",
27 "usage",
28 "sessions",
29 "git",
30];
31
32#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum HeaderError {
37 Malformed(String),
40 UnknownDep(String),
45}
46
47pub fn parse_data_deps_header(src: &str) -> Result<Vec<String>, HeaderError> {
60 let header_block = collect_header_block(src);
61 let Some(rhs) = find_data_deps_rhs(&header_block)? else {
62 return Ok(vec!["status".to_string()]);
63 };
64 let tokens = split_array_body(rhs)?;
65 let mut deps = vec!["status".to_string()];
66 for token in tokens {
67 if !KNOWN_DEPS.contains(&token.as_str()) {
68 return Err(HeaderError::UnknownDep(token));
69 }
70 if !deps.iter().any(|d| d == &token) {
71 deps.push(token);
72 }
73 }
74 Ok(deps)
75}
76
77fn collect_header_block(src: &str) -> String {
81 let mut buf = String::new();
82 for line in src.lines() {
83 let trimmed = line.trim_start();
84 if trimmed.is_empty() {
85 break;
86 }
87 let Some(rest) = trimmed.strip_prefix("//") else {
88 break;
89 };
90 let rest = rest.strip_prefix(' ').unwrap_or(rest);
93 buf.push_str(rest);
94 buf.push('\n');
95 }
96 buf
97}
98
99fn find_data_deps_rhs(header: &str) -> Result<Option<&str>, HeaderError> {
109 let Some(start) = header.find("@data_deps") else {
110 return Ok(None);
111 };
112 let after_key = &header[start + "@data_deps".len()..];
113 let Some(eq_pos) = after_key.find('=') else {
114 return Err(HeaderError::Malformed(
115 "@data_deps declaration missing `=`".to_string(),
116 ));
117 };
118 let after_eq = after_key[eq_pos + 1..].trim_start();
119 let Some(open) = after_eq.strip_prefix('[') else {
120 return Err(HeaderError::Malformed(
121 "@data_deps RHS must be an array literal starting with `[`".to_string(),
122 ));
123 };
124 Ok(Some(open))
125}
126
127fn split_array_body(body: &str) -> Result<Vec<String>, HeaderError> {
133 let Some(end) = body.find(']') else {
134 return Err(HeaderError::Malformed(
135 "missing closing `]` in @data_deps array".to_string(),
136 ));
137 };
138 let inside = &body[..end];
139 let stripped: String = inside
144 .lines()
145 .map(|line| match line.find("//") {
146 Some(i) => &line[..i],
147 None => line,
148 })
149 .collect::<Vec<_>>()
150 .join(" ");
151 let mut tokens = Vec::new();
152 for raw in stripped.split(',') {
153 let s = raw.trim();
154 if s.is_empty() {
155 continue;
156 }
157 let unquoted = unquote(s)?;
158 tokens.push(unquoted);
159 }
160 Ok(tokens)
161}
162
163fn unquote(s: &str) -> Result<String, HeaderError> {
164 let bytes = s.as_bytes();
165 if bytes.len() >= 2
166 && ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
167 || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\''))
168 {
169 Ok(s[1..s.len() - 1].to_string())
170 } else {
171 Err(HeaderError::Malformed(format!(
172 "expected quoted string, got `{s}`"
173 )))
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 fn deps(names: &[&str]) -> Vec<String> {
182 names.iter().map(|s| (*s).to_string()).collect()
183 }
184
185 #[test]
186 fn no_header_defaults_to_status_only() {
187 let src = "fn render(ctx) { () }";
188 assert_eq!(parse_data_deps_header(src), Ok(deps(&["status"])));
189 }
190
191 #[test]
192 fn empty_array_defaults_to_status_only() {
193 let src = "// @data_deps = []\nfn render(ctx) {}";
194 assert_eq!(parse_data_deps_header(src), Ok(deps(&["status"])));
195 }
196
197 #[test]
198 fn single_line_single_entry_unions_with_status() {
199 let src = r#"// @data_deps = ["usage"]
200fn render(ctx) {}"#;
201 assert_eq!(parse_data_deps_header(src), Ok(deps(&["status", "usage"])));
202 }
203
204 #[test]
205 fn single_line_multi_entry() {
206 let src = r#"// @data_deps = ["settings", "usage", "git"]
207fn render(ctx) {}"#;
208 assert_eq!(
209 parse_data_deps_header(src),
210 Ok(deps(&["status", "settings", "usage", "git"]))
211 );
212 }
213
214 #[test]
215 fn explicit_status_is_accepted_without_duplication() {
216 let src = r#"// @data_deps = ["status", "usage"]
217fn render(ctx) {}"#;
218 let resolved = parse_data_deps_header(src).unwrap();
219 assert_eq!(resolved, deps(&["status", "usage"]));
220 assert_eq!(
221 resolved.iter().filter(|d| *d == "status").count(),
222 1,
223 "status must not be duplicated when listed explicitly"
224 );
225 }
226
227 #[test]
228 fn multi_line_array_accepted() {
229 let src = r#"// @data_deps = [
230// "settings",
231// "usage",
232// "git",
233// ]
234fn render(ctx) {}"#;
235 assert_eq!(
236 parse_data_deps_header(src),
237 Ok(deps(&["status", "settings", "usage", "git"]))
238 );
239 }
240
241 #[test]
242 fn trailing_comma_in_single_line_ok() {
243 let src = r#"// @data_deps = ["usage",]
244fn render(ctx) {}"#;
245 assert_eq!(parse_data_deps_header(src), Ok(deps(&["status", "usage"])));
246 }
247
248 #[test]
249 fn single_quotes_accepted() {
250 let src = "// @data_deps = ['usage']\nfn render(ctx) {}";
251 assert_eq!(parse_data_deps_header(src), Ok(deps(&["status", "usage"])));
252 }
253
254 #[test]
255 fn unknown_dep_name_rejected() {
256 let src = r#"// @data_deps = ["usage", "mystery"]
257fn render(ctx) {}"#;
258 assert_eq!(
259 parse_data_deps_header(src),
260 Err(HeaderError::UnknownDep("mystery".to_string()))
261 );
262 }
263
264 #[test]
265 fn reserved_credentials_dep_rejected_as_unknown() {
266 let src = r#"// @data_deps = ["credentials"]
270fn render(ctx) {}"#;
271 assert_eq!(
272 parse_data_deps_header(src),
273 Err(HeaderError::UnknownDep("credentials".to_string()))
274 );
275 }
276
277 #[test]
278 fn reserved_jsonl_dep_rejected_as_unknown() {
279 let src = r#"// @data_deps = ["jsonl"]
280fn render(ctx) {}"#;
281 assert_eq!(
282 parse_data_deps_header(src),
283 Err(HeaderError::UnknownDep("jsonl".to_string()))
284 );
285 }
286
287 #[test]
288 fn blank_line_ends_header_block() {
289 let src = r#"// top comment
293
294// @data_deps = ["usage"]
295fn render(ctx) {}"#;
296 assert_eq!(parse_data_deps_header(src), Ok(deps(&["status"])));
299 }
300
301 #[test]
302 fn non_comment_line_ends_header_block() {
303 let src = r#"// top comment
306fn render(ctx) {}
307// @data_deps = ["usage"]"#;
308 assert_eq!(parse_data_deps_header(src), Ok(deps(&["status"])));
309 }
310
311 #[test]
312 fn header_appearing_after_other_comments_still_parses() {
313 let src = r#"// Some plugin description
317// Authored by me
318// @data_deps = ["usage"]
319fn render(ctx) {}"#;
320 assert_eq!(parse_data_deps_header(src), Ok(deps(&["status", "usage"])));
321 }
322
323 #[test]
324 fn malformed_missing_equals_rejected() {
325 let src = r#"// @data_deps ["usage"]
329fn render(ctx) {}"#;
330 assert!(matches!(
331 parse_data_deps_header(src),
332 Err(HeaderError::Malformed(_))
333 ));
334 }
335
336 #[test]
337 fn malformed_scalar_rhs_rejected() {
338 let src = r#"// @data_deps = "usage"
339fn render(ctx) {}"#;
340 assert!(matches!(
341 parse_data_deps_header(src),
342 Err(HeaderError::Malformed(_))
343 ));
344 }
345
346 #[test]
347 fn malformed_missing_closing_bracket() {
348 let src = r#"// @data_deps = ["usage"
349fn render(ctx) {}"#;
350 assert!(matches!(
351 parse_data_deps_header(src),
352 Err(HeaderError::Malformed(_))
353 ));
354 }
355
356 #[test]
357 fn malformed_unquoted_token() {
358 let src = r#"// @data_deps = [usage]
359fn render(ctx) {}"#;
360 assert!(matches!(
361 parse_data_deps_header(src),
362 Err(HeaderError::Malformed(_))
363 ));
364 }
365
366 #[test]
367 fn block_comment_syntax_is_not_scanned() {
368 let src = r#"/* @data_deps = ["usage"] */
370fn render(ctx) {}"#;
371 assert_eq!(parse_data_deps_header(src), Ok(deps(&["status"])));
372 }
373
374 #[test]
375 fn inline_comment_on_array_line_accepted() {
376 let src = r#"// @data_deps = [
379// "usage", // why we need it
380// "git", // trailing comment too
381// ]
382fn render(ctx) {}"#;
383 assert_eq!(
384 parse_data_deps_header(src),
385 Ok(deps(&["status", "usage", "git"]))
386 );
387 }
388
389 #[test]
390 fn inline_comment_after_last_entry_accepted() {
391 let src = r#"// @data_deps = [
395// "usage", // ok
396// "git"
397// ]
398fn render(ctx) {}"#;
399 assert_eq!(
400 parse_data_deps_header(src),
401 Ok(deps(&["status", "usage", "git"]))
402 );
403 }
404
405 #[test]
406 fn whitespace_before_double_slash_is_tolerated() {
407 let src = r#" // @data_deps = ["usage"]
408fn render(ctx) {}"#;
409 assert_eq!(parse_data_deps_header(src), Ok(deps(&["status", "usage"])));
410 }
411}