alint_rules/
file_starts_with.rs1use std::path::Path;
15
16use alint_core::{Context, Error, Level, PerFileRule, Result, Rule, RuleSpec, Scope, Violation};
17use serde::Deserialize;
18
19use crate::io::read_prefix_n;
20
21#[derive(Debug, Deserialize)]
22#[serde(deny_unknown_fields)]
23struct Options {
24 prefix: String,
26}
27
28#[derive(Debug)]
29pub struct FileStartsWithRule {
30 id: String,
31 level: Level,
32 policy_url: Option<String>,
33 message: Option<String>,
34 scope: Scope,
35 prefix: Vec<u8>,
36}
37
38impl Rule for FileStartsWithRule {
39 alint_core::rule_common_impl!();
40
41 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
42 let mut violations = Vec::new();
43 for entry in ctx.index.files() {
44 if !self.scope.matches(&entry.path, ctx.index) {
45 continue;
46 }
47 let full = ctx.root.join(&entry.path);
54 let Ok(bytes) = read_prefix_n(&full, self.prefix.len()) else {
55 continue;
56 };
57 violations.extend(self.evaluate_file(ctx, &entry.path, &bytes)?);
58 }
59 Ok(violations)
60 }
61
62 fn as_per_file(&self) -> Option<&dyn PerFileRule> {
63 Some(self)
64 }
65}
66
67impl PerFileRule for FileStartsWithRule {
68 fn path_scope(&self) -> &Scope {
69 &self.scope
70 }
71
72 fn evaluate_file(
73 &self,
74 _ctx: &Context<'_>,
75 path: &Path,
76 bytes: &[u8],
77 ) -> Result<Vec<Violation>> {
78 if bytes.starts_with(&self.prefix) {
79 return Ok(Vec::new());
80 }
81 let msg = self
82 .message
83 .clone()
84 .unwrap_or_else(|| "file does not start with the required prefix".to_string());
85 Ok(vec![
86 Violation::new(msg)
87 .with_path(std::sync::Arc::<Path>::from(path))
88 .with_location(1, 1),
89 ])
90 }
91
92 fn max_bytes_needed(&self) -> Option<usize> {
93 Some(self.prefix.len())
94 }
95}
96
97pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
98 let _paths = spec
99 .paths
100 .as_ref()
101 .ok_or_else(|| Error::rule_config(&spec.id, "file_starts_with requires a `paths` field"))?;
102 let opts: Options = spec
103 .deserialize_options()
104 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
105 if opts.prefix.is_empty() {
106 return Err(Error::rule_config(
110 &spec.id,
111 "file_starts_with.prefix must not be empty.\n \
112 hint: for non-emptiness checks, use `file_min_lines: 1` or \
113 `file_min_size: <bytes>` instead. file_starts_with is for \
114 literal-prefix assertions like `prefix: \"#!/bin/bash\\n\"`.",
115 ));
116 }
117 if spec.fix.is_some() {
118 return Err(Error::rule_config(
119 &spec.id,
120 "file_starts_with has no fix op — pair with an explicit `file_prepend` rule if you \
121 want auto-prepend (avoids silently duplicating near-matching prefixes).",
122 ));
123 }
124 Ok(Box::new(FileStartsWithRule {
125 id: spec.id.clone(),
126 level: spec.level,
127 policy_url: spec.policy_url.clone(),
128 message: spec.message.clone(),
129 scope: Scope::from_spec(spec)?,
130 prefix: opts.prefix.into_bytes(),
131 }))
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137 use crate::test_support::{ctx, spec_yaml, tempdir_with_files};
138
139 #[test]
140 fn build_rejects_missing_paths_field() {
141 let spec = spec_yaml(
142 "id: t\n\
143 kind: file_starts_with\n\
144 prefix: \"#!/bin/sh\"\n\
145 level: error\n",
146 );
147 assert!(build(&spec).is_err());
148 }
149
150 #[test]
151 fn build_rejects_empty_prefix() {
152 let spec = spec_yaml(
153 "id: t\n\
154 kind: file_starts_with\n\
155 paths: \"**/*.sh\"\n\
156 prefix: \"\"\n\
157 level: error\n",
158 );
159 let err = build(&spec).unwrap_err().to_string();
160 assert!(err.contains("empty"), "unexpected: {err}");
161 }
162
163 #[test]
164 fn build_rejects_fix_block() {
165 let spec = spec_yaml(
166 "id: t\n\
167 kind: file_starts_with\n\
168 paths: \"**/*.sh\"\n\
169 prefix: \"#!/bin/sh\\n\"\n\
170 level: error\n\
171 fix:\n \
172 file_prepend:\n \
173 content: \"x\"\n",
174 );
175 assert!(build(&spec).is_err());
176 }
177
178 #[test]
179 fn evaluate_passes_when_prefix_matches() {
180 let spec = spec_yaml(
181 "id: t\n\
182 kind: file_starts_with\n\
183 paths: \"**/*.sh\"\n\
184 prefix: \"#!/bin/sh\"\n\
185 level: error\n",
186 );
187 let rule = build(&spec).unwrap();
188 let (tmp, idx) = tempdir_with_files(&[("script.sh", b"#!/bin/sh\necho hi\n")]);
189 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
190 assert!(v.is_empty(), "expected pass: {v:?}");
191 }
192
193 #[test]
194 fn evaluate_fires_when_prefix_missing() {
195 let spec = spec_yaml(
196 "id: t\n\
197 kind: file_starts_with\n\
198 paths: \"**/*.sh\"\n\
199 prefix: \"#!/bin/sh\"\n\
200 level: error\n",
201 );
202 let rule = build(&spec).unwrap();
203 let (tmp, idx) = tempdir_with_files(&[("script.sh", b"echo hi\n")]);
204 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
205 assert_eq!(v.len(), 1);
206 }
207}