rs_txtar/
lib.rs

1//! The txtar crate implements a trivial text-based file archive format.
2//! This has been ported from the [go package of the same name](https://pkg.go.dev/golang.org/x/tools/txtar).
3//!
4//! The goals for the format are:
5//!
6//!   - be trivial enough to create and edit by hand.
7//!   - be able to store trees of text files describing go command test cases.
8//!   - diff nicely in git history and code reviews.
9//!
10//! Non-goals include being a completely general archive format,
11//! storing binary data, storing file modes, storing special files like
12//! symbolic links, and so on.
13//!
14//! # Txtar format
15//!
16//! A txtar archive is zero or more comment lines and then a sequence of file entries.
17//! Each file entry begins with a file marker line of the form "-- FILENAME --"
18//! and is followed by zero or more file content lines making up the file data.
19//! The comment or file content ends at the next file marker line.
20//! The file marker line must begin with the three-byte sequence "-- "
21//! and end with the three-byte sequence " --", but the enclosed
22//! file name can be surrounding by additional white space,
23//! all of which is stripped.
24//!
25//! If the txtar file is missing a trailing newline on the final line,
26//! parsers should consider a final newline to be present anyway.
27//!
28//! There are no possible syntax errors in a txtar archive.
29//!
30//! ```text
31//! Comment1
32//! Comment 2 is here
33//! -- file 1 --
34//! This is the
35//! content of file 1
36//! -- file2  --
37//! This is the conten of file 2
38//! ```
39//! # Examples
40//!
41//! You can use `Archive::from` to convert a string to an archive
42//! ```
43//! use rs_txtar::Archive;
44//!
45//! let tx_str = "comment1
46//! comment2
47//! -- file1 --
48//! This is file 1
49//! -- file2 --
50//! this is file2
51//! ";
52//!
53//! let archive = Archive::from(tx_str);
54//!
55//! assert_eq!(archive.comment, "comment1\ncomment2\n");
56//!
57//! assert!(archive.contains("file1"));
58//! assert_eq!(archive["file2"].name.as_str(), "file2");
59//! assert_eq!(archive["file2"].content.as_str(), "this is file2\n");
60//! assert!(archive.get("file2").is_some());
61//!
62//! assert!(!archive.contains("not-exists"));
63//! assert!(archive.get("not-exists").is_none());
64//!```
65
66#[non_exhaustive]
67/// A txtar archive
68pub struct Archive {
69    /// The comments from the archive
70    pub comment: String,
71
72    /// The files in the archive
73    pub files: Vec<File>,
74}
75
76impl Archive {
77    /// Create a new archive
78    pub fn new() -> Self {
79        Archive {
80            comment: String::new(),
81            files: Vec::new(),
82        }
83    }
84
85    /// Read an archive from the file specified by `path`
86    pub fn from_file(path: &str) -> Result<Self, std::io::Error> {
87        let mut f = std::fs::File::open(path)?;
88        Archive::read(&mut f)
89    }
90
91    /// Read an archive from the specified `reader`
92    pub fn read(reader: &mut impl std::io::Read) -> Result<Self, std::io::Error> {
93        let mut s = String::new();
94        reader.read_to_string(&mut s)?;
95        Ok(Archive::from(s.as_str()))
96    }
97
98    /// Return `true` if `self` contains the file `name`
99    pub fn contains(&self, name: &str) -> bool {
100        self.files.iter().any(|f| f.name.as_str() == name)
101    }
102
103    /// Return `Some(file)` if the archive contains a file named `name`. Otherwise retuens `None`.
104    /// You can also use `archive[name]` if you want a panic when the file doesn't exist
105    pub fn get(&self, name: &str) -> Option<&File> {
106        self.files.iter().find(|f| f.name.as_str() == name)
107    }
108}
109
110impl Default for Archive {
111    fn default() -> Self {
112        Self::new()
113    }
114}
115
116/// Convert a [&str] into an archive
117impl From<&str> for Archive {
118    /// parses `value` into a archive.
119    fn from(value: &str) -> Self {
120        let (comment, mut file_name, mut after) = find_next_marker(value);
121        let mut files = Vec::new();
122        while !file_name.is_empty() {
123            let (data, next_file, after_next) = find_next_marker(after);
124
125            let content = fix_newline(data);
126
127            let file = File::new(file_name, &content);
128            files.push(file);
129
130            file_name = next_file;
131            after = after_next;
132        }
133
134        Archive {
135            comment: comment.to_owned(),
136            files,
137        }
138    }
139}
140
141impl std::ops::Index<&str> for Archive {
142    type Output = File;
143
144    /// Return the file named `index` or panics if there is no sych file. You can also use
145    /// [Archive::get] to avoid panicking the file isn't in the archive.
146    fn index(&self, index: &str) -> &Self::Output {
147        match self.files.iter().find(|f| f.name.as_str() == index) {
148            Some(f) => f,
149            None => panic!("Archive doesn't contain file: {}", index),
150        }
151    }
152}
153
154/// A file that resides in a txtar [Archive]
155#[non_exhaustive]
156pub struct File {
157    /// The name of the file
158    pub name: String,
159
160    /// The file content
161    pub content: String,
162}
163
164impl File {
165    /// Create a new file
166    pub fn new(name: &str, content: &str) -> File {
167        File {
168            name: name.to_owned(),
169            content: content.to_owned(),
170        }
171    }
172}
173
174const MARKER: &str = "-- ";
175const NEWLINE_MARKER: &str = "\n-- ";
176const MARKER_END: &str = " --";
177
178/// Finds the next file marker in `s`
179/// If there is a file marker returns (beforeMarker, fileName, afterMarker)
180/// Otherwise returns (s, "", "")
181fn find_next_marker(s: &str) -> (&str, &str, &str) {
182    let mut i = 0;
183    loop {
184        let (name, after) = parse_leading_marker(&s[i..]);
185        if !name.is_empty() {
186            return (&s[0..i], name, after);
187        }
188
189        if let Some(index) = s[i..].find(NEWLINE_MARKER) {
190            i += index + 1;
191        } else {
192            return (s, "", "");
193        }
194    }
195}
196
197fn fix_newline(s: &str) -> String {
198    let mut owned = s.to_owned();
199    if !owned.is_empty() && !owned.ends_with('\n') {
200        owned.push('\n');
201    }
202    owned
203}
204
205/// if `s` starts with a file marker then returns `(fileName, dataAfterLine)`
206/// Otherwisw returns ("", "")
207fn parse_leading_marker(s: &str) -> (&str, &str) {
208    if !s.starts_with(MARKER) {
209        return ("", "");
210    }
211
212    let (mut data, after) = match s.split_once('\n') {
213        None => (s, ""),
214        Some((x, y)) => (x, y),
215    };
216
217    if data.ends_with('\r') {
218        data = &data[0..data.len() - 1];
219    }
220
221    if !data.ends_with(MARKER_END) || data.len() <= (MARKER.len() + MARKER_END.len()) {
222        return ("", "");
223    }
224
225    let name = data[MARKER.len()..data.len() - MARKER_END.len()].trim();
226    (name, after)
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    fn do_parse_test(s: &'static str, expect: Archive) {
234        let parsed = Archive::from(s);
235        assert_eq!(
236            parsed.comment, expect.comment,
237            "parsed.comment == expected.comment"
238        );
239
240        let compare_max = usize::min(parsed.files.len(), expect.files.len());
241        for i in 0..compare_max {
242            let parsed_file = &parsed.files[i];
243            let expected_file = &expect.files[i];
244
245            assert_eq!(
246                parsed_file.name, expected_file.name,
247                "parsed.files[{}].name == expected.files[{}].name",
248                i, i
249            );
250
251            assert_eq!(
252                parsed_file.content, expected_file.content,
253                "parsed.files[{}].content == expected.files[{}].content",
254                i, i
255            );
256        }
257    }
258
259    macro_rules! parse_test {
260        ($name:ident, $str:expr, $expect:expr) => {
261            #[test]
262            fn $name() {
263                do_parse_test($str, $expect);
264            }
265        };
266    }
267
268    parse_test!(
269        parse_basic,
270        r"comment1
271comment2
272-- file1 --
273File 1 text.
274-- foo ---
275More file 1 text.
276-- file 2 --
277File 2 text.
278-- empty --
279-- empty filename line --
280some content
281-- --
282-- noNL --
283hello world",
284        Archive {
285            comment: "comment1\ncomment2\n".to_owned(),
286            files: vec![
287                File::new("file1", "File 1 text.\n-- foo ---\nMore file 1 text.\n"),
288                File::new("file 2", "File 2 text.\n"),
289                File::new("empty", ""),
290                File::new("empty filename line", "some content\n-- --\n"),
291                File::new("noNL", "hello world\n"),
292            ]
293        }
294    );
295}