Skip to main content

git_set_attr/
lib.rs

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
13/// A trait which provides methods for settings attributes in a Git repository.
14pub trait SetAttr {
15    /// Set attributes in the given `.gitattributes` file.
16    ///
17    /// The file will be created if it does not already exist.
18    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        // Sort attribute lines by pattern to ensure deterministic ordering.
57        // Comments and blank lines are preserved after all attribute lines.
58        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
99/// Filter out attributes that already exist for the given pattern.
100///
101/// Parses every existing line that matches `pattern` and collects its
102/// attribute name/state pairs, then returns only those entries from
103/// `attributes` whose state differs (or that are completely new).
104fn 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
143/// Parse an attribute string to extract name and state.
144///
145/// Returns `(name, state_string)` where `state_string` uniquely identifies
146/// the state:
147///
148/// | Syntax        | Name     | State            |
149/// |---------------|----------|------------------|
150/// | `attr`        | `attr`   | `"set"`          |
151/// | `attr=true`   | `attr`   | `"set"`          |
152/// | `-attr`       | `attr`   | `"unset"`        |
153/// | `attr=false`  | `attr`   | `"unset"`        |
154/// | `!attr`       | `attr`   | `"unspecified"`  |
155/// | `attr=value`  | `attr`   | `"value:value"`  |
156fn 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
174/// Validate attribute strings.
175fn 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
204/// Format a pattern and attributes into a gitattributes line.
205fn 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        // diff=true is the same as diff
369        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        // diff=false is the same as -diff
376        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}