Skip to main content

pma_core/
projects.rs

1//! The hand-set half of a project's record -- its tier and its tags -- as a
2//! file, so a portfolio is retiered in one edit rather than one command per
3//! project. Everything else in the record is written by a scan.
4
5use std::path::Path;
6
7/// One project's editable fields.
8#[derive(Debug, Clone, PartialEq)]
9pub struct Entry {
10    pub name: String,
11    pub tier: Option<u8>,
12    pub tags: Vec<String>,
13}
14
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub enum Format {
17    Csv,
18    Json,
19}
20
21impl Format {
22    /// From the extension alone, so writing and reading agree without looking
23    /// at the contents.
24    pub fn of(path: &Path) -> Result<Format, String> {
25        match path
26            .extension()
27            .and_then(|e| e.to_str())
28            .map(str::to_lowercase)
29            .as_deref()
30        {
31            Some("csv") => Ok(Format::Csv),
32            Some("json") => Ok(Format::Json),
33            _ => Err(format!(
34                "{}: name the file `.csv` or `.json`",
35                path.display()
36            )),
37        }
38    }
39}
40
41/// A tag is one lowercase word, in a file as on the command line.
42pub fn normal_tag(tag: &str) -> Result<String, String> {
43    let tag = tag.trim().to_lowercase();
44    if tag.is_empty() || tag.split_whitespace().count() > 1 {
45        return Err("a tag is one word".into());
46    }
47    Ok(tag)
48}
49
50fn tier_of(text: &str, name: &str) -> Result<Option<u8>, String> {
51    match text.trim() {
52        "" | "none" => Ok(None),
53        t => t
54            .parse::<u8>()
55            .ok()
56            .filter(|t| (1..=5).contains(t))
57            .map(Some)
58            .ok_or_else(|| format!("{name}: tier `{t}` is not 1 to 5, or none")),
59    }
60}
61
62pub fn write(entries: &[Entry], format: Format) -> Result<String, String> {
63    match format {
64        Format::Csv => to_csv(entries),
65        Format::Json => Ok(to_json(entries)),
66    }
67}
68
69pub fn read(text: &str, format: Format) -> Result<Vec<Entry>, String> {
70    let entries = match format {
71        Format::Csv => from_csv(text)?,
72        Format::Json => from_json(text)?,
73    };
74    for (i, e) in entries.iter().enumerate() {
75        if entries[..i].iter().any(|d| d.name == e.name) {
76            return Err(format!("`{}` appears twice", e.name));
77        }
78    }
79    Ok(entries)
80}
81
82/// `name,tier,tag,tag,...`: tags are the remaining fields, so nothing needs
83/// quoting and a row is editable by hand. A name holding a comma has no such
84/// row, and is refused rather than written ambiguously.
85fn to_csv(entries: &[Entry]) -> Result<String, String> {
86    let mut out = String::from("name,tier,tags\n");
87    for e in entries {
88        if e.name.contains(',') {
89            return Err(format!("`{}` holds a comma; export to .json", e.name));
90        }
91        out.push_str(&e.name);
92        out.push(',');
93        if let Some(t) = e.tier {
94            out.push_str(&t.to_string());
95        }
96        for tag in &e.tags {
97            out.push(',');
98            out.push_str(tag);
99        }
100        out.push('\n');
101    }
102    Ok(out)
103}
104
105fn from_csv(text: &str) -> Result<Vec<Entry>, String> {
106    let mut entries = Vec::new();
107    for (n, raw) in text.lines().enumerate() {
108        let line = raw.trim();
109        if line.is_empty() || line.starts_with('#') {
110            continue;
111        }
112        let mut fields = line.split(',').map(str::trim);
113        let name = fields.next().unwrap_or_default();
114        let second = fields.next().unwrap_or_default();
115        if name == "name" && second == "tier" {
116            continue;
117        }
118        if name.is_empty() {
119            return Err(format!("line {}: no project name", n + 1));
120        }
121        let tier = tier_of(second, name)?;
122        let tags = fields
123            .filter(|t| !t.is_empty())
124            .map(normal_tag)
125            .collect::<Result<Vec<String>, String>>()
126            .map_err(|e| format!("{name}: {e}"))?;
127        entries.push(Entry {
128            name: name.into(),
129            tier,
130            tags,
131        });
132    }
133    Ok(entries)
134}
135
136fn to_json(entries: &[Entry]) -> String {
137    let rows: Vec<serde_json::Value> = entries
138        .iter()
139        .map(|e| {
140            serde_json::json!({
141                "name": e.name,
142                "tier": e.tier,
143                "tags": e.tags,
144            })
145        })
146        .collect();
147    format!("{}\n", serde_json::Value::Array(rows))
148}
149
150fn from_json(text: &str) -> Result<Vec<Entry>, String> {
151    let value: serde_json::Value = serde_json::from_str(text).map_err(|e| e.to_string())?;
152    let rows = value.as_array().ok_or("expected a list of projects")?;
153    let mut entries = Vec::new();
154    for row in rows {
155        let name = row
156            .get("name")
157            .and_then(|v| v.as_str())
158            .ok_or("a project has no `name`")?;
159        let tier = match row.get("tier") {
160            None | Some(serde_json::Value::Null) => None,
161            Some(serde_json::Value::Number(t)) => tier_of(&t.to_string(), name)?,
162            Some(serde_json::Value::String(t)) => tier_of(t, name)?,
163            Some(_) => return Err(format!("{name}: tier is a number, a string, or null")),
164        };
165        let tags = match row.get("tags") {
166            None | Some(serde_json::Value::Null) => Vec::new(),
167            Some(serde_json::Value::Array(tags)) => tags
168                .iter()
169                .map(|t| {
170                    t.as_str()
171                        .ok_or_else(|| format!("{name}: a tag is a string"))
172                        .and_then(|t| normal_tag(t).map_err(|e| format!("{name}: {e}")))
173                })
174                .collect::<Result<Vec<String>, String>>()?,
175            Some(_) => return Err(format!("{name}: tags are a list of strings")),
176        };
177        entries.push(Entry {
178            name: name.into(),
179            tier,
180            tags,
181        });
182    }
183    Ok(entries)
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    fn entries() -> Vec<Entry> {
191        vec![
192            Entry {
193                name: "alpha".into(),
194                tier: Some(1),
195                tags: vec!["rust".into(), "cli".into()],
196            },
197            Entry {
198                name: "beta".into(),
199                tier: None,
200                tags: Vec::new(),
201            },
202        ]
203    }
204
205    #[test]
206    fn a_csv_row_is_name_tier_then_tags() {
207        assert_eq!(
208            to_csv(&entries()).unwrap(),
209            "name,tier,tags\nalpha,1,rust,cli\nbeta,\n"
210        );
211    }
212
213    #[test]
214    fn both_formats_read_back_what_they_wrote() {
215        for format in [Format::Csv, Format::Json] {
216            let text = write(&entries(), format).unwrap();
217            assert_eq!(read(&text, format).unwrap(), entries(), "{text}");
218        }
219    }
220
221    /// The file is edited by hand, so the forms a hand leaves are accepted.
222    #[test]
223    fn spacing_and_none_and_blank_lines_are_read() {
224        let text = "name,tier,tags\n\nalpha, 1 , rust\nbeta,none\n# a note\ngamma,\n";
225        let read = read(text, Format::Csv).unwrap();
226        assert_eq!(read[0].tags, ["rust"]);
227        assert_eq!((read[1].tier, read[2].tier), (None, None));
228    }
229
230    #[test]
231    fn a_tier_outside_one_to_five_is_refused() {
232        let err = read("alpha,6\n", Format::Csv).unwrap_err();
233        assert!(err.contains("not 1 to 5"), "{err}");
234        assert!(
235            read(r#"[{"name": "alpha", "tier": 9}]"#, Format::Json).is_err(),
236            "json takes the same range"
237        );
238    }
239
240    #[test]
241    fn a_repeated_project_is_refused() {
242        let err = read("alpha,1\nalpha,2\n", Format::Csv).unwrap_err();
243        assert!(err.contains("twice"), "{err}");
244    }
245
246    /// Writing an ambiguous row would lose a project on the way back.
247    #[test]
248    fn a_name_holding_a_comma_is_refused_in_csv() {
249        let odd = [Entry {
250            name: "a,b".into(),
251            tier: None,
252            tags: Vec::new(),
253        }];
254        assert!(to_csv(&odd).is_err());
255        assert!(write(&odd, Format::Json).is_ok());
256    }
257
258    #[test]
259    fn json_takes_null_and_missing_fields() {
260        let text = r#"[{"name": "alpha"}, {"name": "beta", "tier": null, "tags": null}]"#;
261        assert_eq!(read(text, Format::Json).unwrap().len(), 2);
262    }
263
264    #[test]
265    fn the_extension_picks_the_format() {
266        assert_eq!(Format::of(Path::new("a/b.CSV")), Ok(Format::Csv));
267        assert_eq!(Format::of(Path::new("b.json")), Ok(Format::Json));
268        assert!(Format::of(Path::new("b.txt")).is_err());
269    }
270}