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        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
85/// Filter out attributes that already exist for the given pattern.
86///
87/// Parses every existing line that matches `pattern` and collects its
88/// attribute name/state pairs, then returns only those entries from
89/// `attributes` whose state differs (or that are completely new).
90fn 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
129/// Parse an attribute string to extract name and state.
130///
131/// Returns `(name, state_string)` where `state_string` uniquely identifies
132/// the state:
133///
134/// | Syntax        | Name     | State            |
135/// |---------------|----------|------------------|
136/// | `attr`        | `attr`   | `"set"`          |
137/// | `attr=true`   | `attr`   | `"set"`          |
138/// | `-attr`       | `attr`   | `"unset"`        |
139/// | `attr=false`  | `attr`   | `"unset"`        |
140/// | `!attr`       | `attr`   | `"unspecified"`  |
141/// | `attr=value`  | `attr`   | `"value:value"`  |
142fn 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
160/// Validate attribute strings.
161fn 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
190/// Format a pattern and attributes into a gitattributes line.
191fn 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        // diff=true is the same as diff
355        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        // diff=false is the same as -diff
362        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}