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 lines.sort_by(|a, b| {
59 let key = |l: &String| {
60 let trimmed = l.trim();
61 if trimmed.is_empty() || trimmed.starts_with('#') {
62 (1, trimmed.to_string())
63 } else {
64 (0, trimmed.to_string())
65 }
66 };
67 key(a).cmp(&key(b))
68 });
69
70 if let Some(parent) = gitattributes_path.parent() {
71 fs::create_dir_all(parent).map_err(|e| {
72 Error::from_str(&format!(
73 "Failed to create directory for .gitattributes: {e}"
74 ))
75 })?;
76 }
77
78 let mut file = OpenOptions::new()
79 .write(true)
80 .create(true)
81 .truncate(true)
82 .open(gitattributes_path)
83 .map_err(|e| {
84 Error::from_str(&format!("Failed to open .gitattributes for writing: {e}"))
85 })?;
86
87 for line in lines {
88 writeln!(file, "{line}")
89 .map_err(|e| Error::from_str(&format!("Failed to write to .gitattributes: {e}")))?;
90 }
91
92 file.flush()
93 .map_err(|e| Error::from_str(&format!("Failed to flush .gitattributes: {e}")))?;
94
95 Ok(())
96 }
97}
98
99fn filter_new_attributes(pattern: &str, attributes: &[&str], lines: &[String]) -> Vec<String> {
105 use std::collections::HashMap;
106
107 let mut existing_attrs: HashMap<String, String> = HashMap::new();
108
109 for line in lines {
110 let trimmed = line.trim();
111 if trimmed.is_empty() || trimmed.starts_with('#') {
112 continue;
113 }
114
115 let mut parts = trimmed.split_whitespace();
116 let line_pattern = parts.next().unwrap_or("");
117
118 if line_pattern == pattern {
119 for attr_str in parts {
120 let (name, state) = parse_attribute_string(attr_str);
121 existing_attrs.insert(name, state);
122 }
123 }
124 }
125
126 let mut new_attrs = Vec::new();
127 for attr_str in attributes {
128 let attr_str = attr_str.trim();
129 if attr_str.is_empty() {
130 continue;
131 }
132
133 let (name, state) = parse_attribute_string(attr_str);
134
135 if existing_attrs.get(&name) != Some(&state) {
136 new_attrs.push(attr_str.to_string());
137 }
138 }
139
140 new_attrs
141}
142
143fn parse_attribute_string(attr: &str) -> (String, String) {
157 let attr = attr.trim();
158
159 if let Some(stripped) = attr.strip_prefix('-') {
160 (stripped.to_string(), "unset".to_string())
161 } else if let Some(stripped) = attr.strip_prefix('!') {
162 (stripped.to_string(), "unspecified".to_string())
163 } else if let Some((name, value)) = attr.split_once('=') {
164 match value {
165 "true" => (name.to_string(), "set".to_string()),
166 "false" => (name.to_string(), "unset".to_string()),
167 _ => (name.to_string(), format!("value:{value}")),
168 }
169 } else {
170 (attr.to_string(), "set".to_string())
171 }
172}
173
174fn validate_attributes(attributes: &[&str]) -> Result<(), Error> {
176 for attr in attributes {
177 let attr = attr.trim();
178 if attr.is_empty() {
179 continue;
180 }
181
182 let has_whitespace = |s: &str| s.is_empty() || s.contains(char::is_whitespace);
183
184 if let Some(stripped) = attr.strip_prefix('-') {
185 if has_whitespace(stripped) {
186 return Err(Error::from_str(&format!("Invalid attribute '{attr}'")));
187 }
188 } else if let Some(stripped) = attr.strip_prefix('!') {
189 if has_whitespace(stripped) {
190 return Err(Error::from_str(&format!("Invalid attribute '{attr}'")));
191 }
192 } else if let Some((name, _value)) = attr.split_once('=') {
193 if has_whitespace(name) {
194 return Err(Error::from_str(&format!("Invalid attribute '{attr}'")));
195 }
196 } else if attr.contains(char::is_whitespace) {
197 return Err(Error::from_str(&format!("Invalid attribute '{attr}'")));
198 }
199 }
200
201 Ok(())
202}
203
204fn format_attribute_line(pattern: &str, attributes: &[impl AsRef<str>]) -> String {
206 let mut line = pattern.to_string();
207
208 for attr in attributes {
209 let attr = attr.as_ref().trim();
210 if attr.is_empty() {
211 continue;
212 }
213
214 line.push(' ');
215 line.push_str(attr);
216 }
217
218 line
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 #[test]
226 fn parse_set_attribute() {
227 assert_eq!(
228 parse_attribute_string("diff"),
229 ("diff".into(), "set".into())
230 );
231 }
232
233 #[test]
234 fn parse_set_attribute_explicit_true() {
235 assert_eq!(
236 parse_attribute_string("diff=true"),
237 ("diff".into(), "set".into())
238 );
239 }
240
241 #[test]
242 fn parse_unset_attribute_prefix() {
243 assert_eq!(
244 parse_attribute_string("-diff"),
245 ("diff".into(), "unset".into())
246 );
247 }
248
249 #[test]
250 fn parse_unset_attribute_explicit_false() {
251 assert_eq!(
252 parse_attribute_string("diff=false"),
253 ("diff".into(), "unset".into())
254 );
255 }
256
257 #[test]
258 fn parse_unspecified_attribute() {
259 assert_eq!(
260 parse_attribute_string("!diff"),
261 ("diff".into(), "unspecified".into())
262 );
263 }
264
265 #[test]
266 fn parse_value_attribute() {
267 assert_eq!(
268 parse_attribute_string("filter=lfs"),
269 ("filter".into(), "value:lfs".into())
270 );
271 }
272
273 #[test]
274 fn parse_trims_whitespace() {
275 assert_eq!(
276 parse_attribute_string(" text "),
277 ("text".into(), "set".into())
278 );
279 }
280
281 #[test]
282 fn validate_accepts_valid_attributes() {
283 assert!(validate_attributes(&["diff", "-text", "!eol", "filter=lfs"]).is_ok());
284 assert!(validate_attributes(&["diff=true", "text=false"]).is_ok());
285 }
286
287 #[test]
288 fn validate_accepts_empty() {
289 assert!(validate_attributes(&[]).is_ok());
290 assert!(validate_attributes(&["", " "]).is_ok());
291 }
292
293 #[test]
294 fn validate_rejects_bare_minus() {
295 assert!(validate_attributes(&["-"]).is_err());
296 }
297
298 #[test]
299 fn validate_rejects_bare_bang() {
300 assert!(validate_attributes(&["!"]).is_err());
301 }
302
303 #[test]
304 fn validate_rejects_whitespace_in_name() {
305 assert!(validate_attributes(&["my attr"]).is_err());
306 assert!(validate_attributes(&["-my attr"]).is_err());
307 assert!(validate_attributes(&["!my attr"]).is_err());
308 assert!(validate_attributes(&["my attr=value"]).is_err());
309 }
310
311 #[test]
312 fn validate_rejects_empty_name_with_value() {
313 assert!(validate_attributes(&["=value"]).is_err());
314 }
315
316 #[test]
317 fn format_single_attribute() {
318 assert_eq!(format_attribute_line("*.txt", &["diff"]), "*.txt diff");
319 }
320
321 #[test]
322 fn format_multiple_attributes() {
323 assert_eq!(
324 format_attribute_line("*.txt", &["diff", "-text", "filter=lfs"]),
325 "*.txt diff -text filter=lfs"
326 );
327 }
328
329 #[test]
330 fn format_skips_empty_attributes() {
331 assert_eq!(format_attribute_line("*.txt", &[""]), "*.txt");
332 assert_eq!(
333 format_attribute_line("*.txt", &["", "diff", ""]),
334 "*.txt diff"
335 );
336 }
337
338 #[test]
339 fn format_trims_attribute_whitespace() {
340 assert_eq!(
341 format_attribute_line("*.txt", &[" diff ", " -text "]),
342 "*.txt diff -text"
343 );
344 }
345
346 #[test]
347 fn filter_returns_all_for_empty_file() {
348 let result = filter_new_attributes("*.txt", &["diff", "-text", "filter=lfs"], &[]);
349 assert_eq!(result, vec!["diff", "-text", "filter=lfs"]);
350 }
351
352 #[test]
353 fn filter_removes_exact_duplicates() {
354 let lines = vec!["*.txt diff -text".into()];
355 let result = filter_new_attributes("*.txt", &["diff", "-text"], &lines);
356 assert!(result.is_empty());
357 }
358
359 #[test]
360 fn filter_keeps_new_attributes() {
361 let lines = vec!["*.txt diff -text".into()];
362 let result = filter_new_attributes("*.txt", &["diff", "eol=lf"], &lines);
363 assert_eq!(result, vec!["eol=lf"]);
364 }
365
366 #[test]
367 fn filter_semantic_set_equivalence() {
368 let lines = vec!["*.txt diff".into()];
370 assert!(filter_new_attributes("*.txt", &["diff=true"], &lines).is_empty());
371 }
372
373 #[test]
374 fn filter_semantic_unset_equivalence() {
375 let lines = vec!["*.txt -diff".into()];
377 assert!(filter_new_attributes("*.txt", &["diff=false"], &lines).is_empty());
378 }
379
380 #[test]
381 fn filter_set_differs_from_unset() {
382 let lines = vec!["*.txt diff".into()];
383 let result = filter_new_attributes("*.txt", &["-diff"], &lines);
384 assert_eq!(result, vec!["-diff"]);
385 }
386
387 #[test]
388 fn filter_collects_across_multiple_lines() {
389 let lines = vec![
390 "*.txt diff".into(),
391 "*.txt filter=lfs".into(),
392 "*.txt -text".into(),
393 ];
394 assert!(
395 filter_new_attributes("*.txt", &["diff", "filter=lfs", "-text"], &lines).is_empty()
396 );
397 }
398
399 #[test]
400 fn filter_ignores_other_patterns() {
401 let lines = vec!["*.md diff".into()];
402 let result = filter_new_attributes("*.txt", &["diff"], &lines);
403 assert_eq!(result, vec!["diff"]);
404 }
405
406 #[test]
407 fn filter_skips_comments_and_blanks() {
408 let lines = vec![
409 "# comment".into(),
410 "*.txt diff".into(),
411 " ".into(),
412 " # indented comment".into(),
413 ];
414 let result = filter_new_attributes("*.txt", &["diff", "-text"], &lines);
415 assert_eq!(result, vec!["-text"]);
416 }
417
418 #[test]
419 fn filter_distinguishes_different_values() {
420 let lines = vec!["*.txt filter=foo".into()];
421 assert!(filter_new_attributes("*.txt", &["filter=foo"], &lines).is_empty());
422 assert_eq!(
423 filter_new_attributes("*.txt", &["filter=bar"], &lines),
424 vec!["filter=bar"]
425 );
426 }
427}