1use std::path::Path;
6
7#[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 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
41pub 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
82fn 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 #[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 #[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}