1#![doc = include_str!("../README.md")]
2
3pub mod cli;
4pub mod exe;
5
6pub use git2::{Error, Repository};
7use std::{
8 fs::{self, OpenOptions},
9 io::{BufRead, BufReader, Write},
10 path::Path,
11};
12
13pub trait SetAttr {
15 fn set_attr(
19 &self,
20 pattern: &str,
21 attributes: &[&str],
22 gitattributes: &Path,
23 ) -> Result<(), Error>;
24}
25
26impl SetAttr for Repository {
27 fn set_attr(
28 &self,
29 pattern: &str,
30 attributes: &[&str],
31 gitattributes: &Path,
32 ) -> Result<(), Error> {
33 let gitattributes_path = gitattributes;
34
35 validate_attributes(attributes)?;
36
37 let mut lines = if gitattributes_path.exists() {
38 let file = fs::File::open(&gitattributes_path)
39 .map_err(|e| Error::from_str(&format!("Failed to open .gitattributes: {e}")))?;
40 let reader = BufReader::new(file);
41 reader
42 .lines()
43 .collect::<Result<Vec<_>, _>>()
44 .map_err(|e| Error::from_str(&format!("Failed to read .gitattributes: {e}")))?
45 } else {
46 Vec::new()
47 };
48
49 let new_attrs = filter_new_attributes(pattern, attributes, &lines);
50
51 if !new_attrs.is_empty() {
52 let attr_line = format_attribute_line(pattern, &new_attrs);
53 lines.push(attr_line);
54 }
55
56 if let Some(parent) = gitattributes_path.parent() {
57 fs::create_dir_all(parent).map_err(|e| {
58 Error::from_str(&format!(
59 "Failed to create directory for .gitattributes: {e}"
60 ))
61 })?;
62 }
63
64 let mut file = OpenOptions::new()
65 .write(true)
66 .create(true)
67 .truncate(true)
68 .open(gitattributes_path)
69 .map_err(|e| {
70 Error::from_str(&format!("Failed to open .gitattributes for writing: {e}"))
71 })?;
72
73 for line in lines {
74 writeln!(file, "{line}")
75 .map_err(|e| Error::from_str(&format!("Failed to write to .gitattributes: {e}")))?;
76 }
77
78 file.flush()
79 .map_err(|e| Error::from_str(&format!("Failed to flush .gitattributes: {e}")))?;
80
81 Ok(())
82 }
83}
84
85fn filter_new_attributes(pattern: &str, attributes: &[&str], lines: &[String]) -> Vec<String> {
91 use std::collections::HashMap;
92
93 let mut existing_attrs: HashMap<String, String> = HashMap::new();
94
95 for line in lines {
96 let trimmed = line.trim();
97 if trimmed.is_empty() || trimmed.starts_with('#') {
98 continue;
99 }
100
101 let mut parts = trimmed.split_whitespace();
102 let line_pattern = parts.next().unwrap_or("");
103
104 if line_pattern == pattern {
105 for attr_str in parts {
106 let (name, state) = parse_attribute_string(attr_str);
107 existing_attrs.insert(name, state);
108 }
109 }
110 }
111
112 let mut new_attrs = Vec::new();
113 for attr_str in attributes {
114 let attr_str = attr_str.trim();
115 if attr_str.is_empty() {
116 continue;
117 }
118
119 let (name, state) = parse_attribute_string(attr_str);
120
121 if existing_attrs.get(&name) != Some(&state) {
122 new_attrs.push(attr_str.to_string());
123 }
124 }
125
126 new_attrs
127}
128
129fn parse_attribute_string(attr: &str) -> (String, String) {
143 let attr = attr.trim();
144
145 if let Some(stripped) = attr.strip_prefix('-') {
146 (stripped.to_string(), "unset".to_string())
147 } else if let Some(stripped) = attr.strip_prefix('!') {
148 (stripped.to_string(), "unspecified".to_string())
149 } else if let Some((name, value)) = attr.split_once('=') {
150 match value {
151 "true" => (name.to_string(), "set".to_string()),
152 "false" => (name.to_string(), "unset".to_string()),
153 _ => (name.to_string(), format!("value:{value}")),
154 }
155 } else {
156 (attr.to_string(), "set".to_string())
157 }
158}
159
160fn validate_attributes(attributes: &[&str]) -> Result<(), Error> {
162 for attr in attributes {
163 let attr = attr.trim();
164 if attr.is_empty() {
165 continue;
166 }
167
168 let has_whitespace = |s: &str| s.is_empty() || s.contains(char::is_whitespace);
169
170 if let Some(stripped) = attr.strip_prefix('-') {
171 if has_whitespace(stripped) {
172 return Err(Error::from_str(&format!("Invalid attribute '{attr}'")));
173 }
174 } else if let Some(stripped) = attr.strip_prefix('!') {
175 if has_whitespace(stripped) {
176 return Err(Error::from_str(&format!("Invalid attribute '{attr}'")));
177 }
178 } else if let Some((name, _value)) = attr.split_once('=') {
179 if has_whitespace(name) {
180 return Err(Error::from_str(&format!("Invalid attribute '{attr}'")));
181 }
182 } else if attr.contains(char::is_whitespace) {
183 return Err(Error::from_str(&format!("Invalid attribute '{attr}'")));
184 }
185 }
186
187 Ok(())
188}
189
190fn format_attribute_line(pattern: &str, attributes: &[impl AsRef<str>]) -> String {
192 let mut line = pattern.to_string();
193
194 for attr in attributes {
195 let attr = attr.as_ref().trim();
196 if attr.is_empty() {
197 continue;
198 }
199
200 line.push(' ');
201 line.push_str(attr);
202 }
203
204 line
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn parse_set_attribute() {
213 assert_eq!(
214 parse_attribute_string("diff"),
215 ("diff".into(), "set".into())
216 );
217 }
218
219 #[test]
220 fn parse_set_attribute_explicit_true() {
221 assert_eq!(
222 parse_attribute_string("diff=true"),
223 ("diff".into(), "set".into())
224 );
225 }
226
227 #[test]
228 fn parse_unset_attribute_prefix() {
229 assert_eq!(
230 parse_attribute_string("-diff"),
231 ("diff".into(), "unset".into())
232 );
233 }
234
235 #[test]
236 fn parse_unset_attribute_explicit_false() {
237 assert_eq!(
238 parse_attribute_string("diff=false"),
239 ("diff".into(), "unset".into())
240 );
241 }
242
243 #[test]
244 fn parse_unspecified_attribute() {
245 assert_eq!(
246 parse_attribute_string("!diff"),
247 ("diff".into(), "unspecified".into())
248 );
249 }
250
251 #[test]
252 fn parse_value_attribute() {
253 assert_eq!(
254 parse_attribute_string("filter=lfs"),
255 ("filter".into(), "value:lfs".into())
256 );
257 }
258
259 #[test]
260 fn parse_trims_whitespace() {
261 assert_eq!(
262 parse_attribute_string(" text "),
263 ("text".into(), "set".into())
264 );
265 }
266
267 #[test]
268 fn validate_accepts_valid_attributes() {
269 assert!(validate_attributes(&["diff", "-text", "!eol", "filter=lfs"]).is_ok());
270 assert!(validate_attributes(&["diff=true", "text=false"]).is_ok());
271 }
272
273 #[test]
274 fn validate_accepts_empty() {
275 assert!(validate_attributes(&[]).is_ok());
276 assert!(validate_attributes(&["", " "]).is_ok());
277 }
278
279 #[test]
280 fn validate_rejects_bare_minus() {
281 assert!(validate_attributes(&["-"]).is_err());
282 }
283
284 #[test]
285 fn validate_rejects_bare_bang() {
286 assert!(validate_attributes(&["!"]).is_err());
287 }
288
289 #[test]
290 fn validate_rejects_whitespace_in_name() {
291 assert!(validate_attributes(&["my attr"]).is_err());
292 assert!(validate_attributes(&["-my attr"]).is_err());
293 assert!(validate_attributes(&["!my attr"]).is_err());
294 assert!(validate_attributes(&["my attr=value"]).is_err());
295 }
296
297 #[test]
298 fn validate_rejects_empty_name_with_value() {
299 assert!(validate_attributes(&["=value"]).is_err());
300 }
301
302 #[test]
303 fn format_single_attribute() {
304 assert_eq!(format_attribute_line("*.txt", &["diff"]), "*.txt diff");
305 }
306
307 #[test]
308 fn format_multiple_attributes() {
309 assert_eq!(
310 format_attribute_line("*.txt", &["diff", "-text", "filter=lfs"]),
311 "*.txt diff -text filter=lfs"
312 );
313 }
314
315 #[test]
316 fn format_skips_empty_attributes() {
317 assert_eq!(format_attribute_line("*.txt", &[""]), "*.txt");
318 assert_eq!(
319 format_attribute_line("*.txt", &["", "diff", ""]),
320 "*.txt diff"
321 );
322 }
323
324 #[test]
325 fn format_trims_attribute_whitespace() {
326 assert_eq!(
327 format_attribute_line("*.txt", &[" diff ", " -text "]),
328 "*.txt diff -text"
329 );
330 }
331
332 #[test]
333 fn filter_returns_all_for_empty_file() {
334 let result = filter_new_attributes("*.txt", &["diff", "-text", "filter=lfs"], &[]);
335 assert_eq!(result, vec!["diff", "-text", "filter=lfs"]);
336 }
337
338 #[test]
339 fn filter_removes_exact_duplicates() {
340 let lines = vec!["*.txt diff -text".into()];
341 let result = filter_new_attributes("*.txt", &["diff", "-text"], &lines);
342 assert!(result.is_empty());
343 }
344
345 #[test]
346 fn filter_keeps_new_attributes() {
347 let lines = vec!["*.txt diff -text".into()];
348 let result = filter_new_attributes("*.txt", &["diff", "eol=lf"], &lines);
349 assert_eq!(result, vec!["eol=lf"]);
350 }
351
352 #[test]
353 fn filter_semantic_set_equivalence() {
354 let lines = vec!["*.txt diff".into()];
356 assert!(filter_new_attributes("*.txt", &["diff=true"], &lines).is_empty());
357 }
358
359 #[test]
360 fn filter_semantic_unset_equivalence() {
361 let lines = vec!["*.txt -diff".into()];
363 assert!(filter_new_attributes("*.txt", &["diff=false"], &lines).is_empty());
364 }
365
366 #[test]
367 fn filter_set_differs_from_unset() {
368 let lines = vec!["*.txt diff".into()];
369 let result = filter_new_attributes("*.txt", &["-diff"], &lines);
370 assert_eq!(result, vec!["-diff"]);
371 }
372
373 #[test]
374 fn filter_collects_across_multiple_lines() {
375 let lines = vec![
376 "*.txt diff".into(),
377 "*.txt filter=lfs".into(),
378 "*.txt -text".into(),
379 ];
380 assert!(
381 filter_new_attributes("*.txt", &["diff", "filter=lfs", "-text"], &lines).is_empty()
382 );
383 }
384
385 #[test]
386 fn filter_ignores_other_patterns() {
387 let lines = vec!["*.md diff".into()];
388 let result = filter_new_attributes("*.txt", &["diff"], &lines);
389 assert_eq!(result, vec!["diff"]);
390 }
391
392 #[test]
393 fn filter_skips_comments_and_blanks() {
394 let lines = vec![
395 "# comment".into(),
396 "*.txt diff".into(),
397 " ".into(),
398 " # indented comment".into(),
399 ];
400 let result = filter_new_attributes("*.txt", &["diff", "-text"], &lines);
401 assert_eq!(result, vec!["-text"]);
402 }
403
404 #[test]
405 fn filter_distinguishes_different_values() {
406 let lines = vec!["*.txt filter=foo".into()];
407 assert!(filter_new_attributes("*.txt", &["filter=foo"], &lines).is_empty());
408 assert_eq!(
409 filter_new_attributes("*.txt", &["filter=bar"], &lines),
410 vec!["filter=bar"]
411 );
412 }
413}