1use clap::ValueEnum;
2use regex::Regex;
3use serde::{Deserialize, Serialize};
4use std::{
5 cmp::Ordering,
6 collections::HashMap,
7 fmt,
8 fs::File,
9 io::Write,
10 num::ParseIntError,
11 path::{Path, PathBuf},
12};
13use walkdir::WalkDir;
14
15use miette::{bail, Diagnostic, Result};
16use thiserror::Error;
17
18#[derive(Debug)]
19pub struct Config {
20 pub johnnydecimal_home: PathBuf,
21 pub name_scheme: NameScheme,
22 pub regex: Regex,
23}
24
25#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Eq, PartialOrd, Serialize, Ord, ValueEnum)]
26pub enum NameScheme {
27 ACID,
29 DACID,
31}
32
33impl fmt::Display for NameScheme {
34 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35 match self {
36 Self::ACID => write!(f, "acid"),
37 Self::DACID => write!(f, "dacid"),
38 }
39 }
40}
41
42#[derive(Error, Diagnostic, Debug)]
43pub enum Error {
44 #[error("Could not find an ID in path {}", path.display())]
45 IdNotPresent { path: PathBuf },
46 #[error("Could not compile {:?} into a RegEx", input)]
47 BadRegex { input: String },
48 #[error("Cannot find a folder for ID {}", id)]
49 IdNotFound { id: String },
50 #[error("Could not find a configuration file at {}", path.display())]
51 ConfigurationNotFound { path: PathBuf },
52 #[error("Could not find a Johnny Decimal system at {}", path.display())]
53 SystemRootNotFound { path: PathBuf },
54}
55
56#[derive(Clone, PartialEq, Eq)]
57pub struct JohnnyFolder {
58 path: PathBuf,
59 name: String,
60 level: JohnnyLevel,
61 children: Vec<JohnnyFolder>,
62}
63
64impl JohnnyFolder {
65 fn get_children(&self) -> &Vec<JohnnyFolder> {
66 &self.children
67 }
68
69 fn get_children_mut(&mut self) -> &mut Vec<JohnnyFolder> {
70 &mut self.children
71 }
72
73 fn get_children_owned(self) -> Vec<JohnnyFolder> {
74 self.children
75 }
76}
77
78impl Ord for JohnnyFolder {
79 fn cmp(&self, other: &Self) -> Ordering {
80 let (selfkey, otherkey) = (self.level.get_sorting_key(), other.level.get_sorting_key());
81 match selfkey.cmp(&otherkey) {
82 Ordering::Equal => Ordering::Equal,
83 Ordering::Greater => Ordering::Greater,
84 Ordering::Less => Ordering::Less,
85 }
86 }
87}
88
89impl PartialOrd for JohnnyFolder {
90 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
91 let (selfkey, otherkey) = (self.level.get_sorting_key(), other.level.get_sorting_key());
92 Some(selfkey.cmp(&otherkey))
93 }
94}
95
96#[derive(Clone, PartialEq, Eq)]
97pub enum JohnnyLevel {
98 Root,
99 Area(i32),
100 Category(i32),
101 Individual(String),
102}
103
104impl JohnnyLevel {
105 fn get_cat_number(&self) -> i32 {
106 match self {
107 JohnnyLevel::Root => {
108 unreachable!("get_cat_number() cannot be called on JohnnyLevel::Root")
109 }
110 JohnnyLevel::Area(_) => {
111 unreachable!("get_cat_number() cannot be called on JohnnyLevel::Area")
112 }
113 JohnnyLevel::Category(num) => num.to_owned(),
114 JohnnyLevel::Individual(code) => extract_cat(code).unwrap(),
115 }
116 }
117
118 fn get_area_number(&self) -> i32 {
119 match self {
120 JohnnyLevel::Root => {
121 unreachable!("get_area_number() cannot be called on JohnnyLevel::Root")
122 }
123 JohnnyLevel::Area(num) => num.to_owned(),
124 JohnnyLevel::Category(num) => extract_area(num.to_owned()),
125 JohnnyLevel::Individual(code) => extract_area(extract_cat(code).unwrap()),
126 }
127 }
128 fn get_sorting_key(&self) -> i32 {
129 match self {
130 JohnnyLevel::Root => unreachable!("Cannot sort Root folders"),
131 JohnnyLevel::Area(num) | JohnnyLevel::Category(num) => *num,
132 JohnnyLevel::Individual(loc_code) => {
133 let regex = Regex::new(r"[0-9]{2}[ \.]?(?<KEY>[0-9]{2})").unwrap();
136 let caps = regex.captures(loc_code).unwrap();
137 str::parse::<i32>(&caps["KEY"]).expect("Regex match failed")
138 } }
140 }
141}
142
143pub fn scan_to_map(config: &Config) -> Result<HashMap<String, PathBuf>> {
145 let mut map: HashMap<String, PathBuf> = HashMap::new();
146 for location in WalkDir::new(&config.johnnydecimal_home)
147 .min_depth(3)
148 .max_depth(3)
149 {
150 let item = location.unwrap();
151 let filepath = item.into_path();
152 let loc_code = extract_location(config, &filepath)?;
153
154 if validate_code(&loc_code) {
155 map.insert(loc_code, filepath);
156 } else {
157 eprintln!(
158 "Misplaced file found at \"{}\", gracefully skipping",
159 filepath.to_string_lossy()
160 );
161 }
162 }
163 Ok(map)
164}
165
166pub fn get_path(config: &Config, location: &str) -> Result<PathBuf> {
168 let map = scan_to_map(config)?;
169 let path = map.get(location);
170
171 let unwrapped: &PathBuf;
172 match path {
173 Some(returned_path) => {
174 unwrapped = returned_path;
175 }
176 None => {
177 bail!(Error::IdNotFound {
178 id: location.to_owned()
179 })
180 }
181 };
182 Ok(unwrapped.to_owned())
183}
184
185fn extract_location(config: &Config, path: &Path) -> Result<String> {
189 let regex_out = config.regex.captures(path.to_str().unwrap());
190 match regex_out {
191 None => {
192 bail!(Error::IdNotPresent { path: path.into() })
193 }
194 Some(_) => {}
195 };
196 let caps = regex_out.unwrap();
197 Ok(format!("{}.{}", &caps["AC"], &caps["ID"]))
198}
199#[test]
200fn extract_location_test() {
201 let path = PathBuf::from(r"dummydecimal\10-19-Vehicles\12-Planes\12.03-Cessna");
202 let config = Config {
203 johnnydecimal_home: PathBuf::from("./dummydecimal"),
204 name_scheme: NameScheme::ACID,
205 regex: Regex::new(r"(?<AC>[0-9]{2})[ \.]?(?<ID>[0-9]{2})").unwrap(),
206 };
207 assert_eq!(extract_location(&config, &path).unwrap(), "12.03");
208}
209
210fn validate_code(code: &str) -> bool {
211 let regex = Regex::new(r"(?<AC>[0-9]{2})[ \.]?(?<ID>[0-9]{2})").unwrap();
212 regex.is_match(code)
213}
214
215#[test]
216fn validator_test() {
217 let good_code = String::from("11.03");
218 let bad_code = String::from("BAD");
219
220 assert!(validate_code(&good_code));
221 assert!(!(validate_code(&bad_code)));
222}
223
224fn extract_name(path: &Path) -> String {
225 let Some(name) = path.file_name() else {
227 panic!("Unable to read folder/location name (parsing folder name from full path)")
228 };
229 let name = String::from(name.to_string_lossy());
230 name
231}
232#[test]
233fn extract_name_test() {
234 let path = PathBuf::from("C:/Users/nateb/JohnnyDecimal/M10-19_Programming/M11-Scripting_and_Automation/M11.03-johnnybgoode");
236 assert_eq!(extract_name(&path), "M11.03-johnnybgoode");
237}
238
239fn extract_area(catnumber: i32) -> i32 {
240 (catnumber - catnumber % 10) / 10
241}
242
243fn extract_cat(code: &str) -> Result<i32, ParseIntError> {
244 let regex = Regex::new(r"(?<cat>[0-9]{2})[ \.]?[0-9]{2}").unwrap(); let capture = ®ex.captures(code).unwrap()["cat"];
246 str::parse::<i32>(capture)
247}
248
249#[test]
250fn extract_cat_test() {
251 let code = String::from("M11.03");
252 assert_eq!(extract_cat(&code).unwrap(), 11);
253}
254
255pub fn build_tree(config: &Config, map: &HashMap<String, PathBuf>) -> Result<JohnnyFolder> {
256 let mut individuals: Vec<JohnnyFolder> = Vec::new();
257 let paths = map.values();
258 for path in paths {
259 let new = JohnnyFolder {
260 path: path.to_owned(),
261 level: JohnnyLevel::Individual(extract_location(config, path)?),
262 name: extract_name(path),
263 children: Vec::new(),
264 };
265
266 if validate_code(&extract_location(config, path)?) {
267 individuals.push(new);
268 } else {
269 eprintln!(
270 "Misplaced file found at \"{}\", gracefully skipping",
271 path.to_string_lossy()
272 );
273 }
274 }
275
276 let mut categories: Vec<JohnnyFolder> = Vec::new(); for individual in &mut individuals {
278 let mut added = false;
279 for category in &mut categories {
280 if category.path == individual.path.parent().unwrap() {
281 category.children.push(individual.clone());
282 added = true; }
284 }
285
286 if !added {
287 categories.push(JohnnyFolder {
288 path: individual.path.parent().unwrap().to_owned(),
289 name: extract_name(individual.path.parent().unwrap()),
290 level: JohnnyLevel::Category(individual.level.get_cat_number()),
291 children: Vec::from([individual.clone()]),
292 });
293 }
294 }
295 let mut areas: Vec<JohnnyFolder> = Vec::new();
297 for category in categories {
298 let mut added = false;
299 for area in &mut areas {
300 if area.path == category.path.parent().unwrap() {
301 area.children.push(category.clone());
302 added = true; }
304 }
305
306 if !added {
307 areas.push(JohnnyFolder {
308 path: category.path.parent().unwrap().to_owned(),
309 name: extract_name(category.path.parent().unwrap()),
310 level: JohnnyLevel::Area(category.level.get_area_number()),
311 children: vec![category.clone()],
312 });
313 }
314 }
315
316 let root = JohnnyFolder {
317 path: areas[0].path.parent().unwrap().to_owned(),
318 name: String::from("Johnny Decimal Root Folder"),
319 level: JohnnyLevel::Root,
320 children: areas,
321 };
322 Ok(root)
323}
324
325pub fn export(root: JohnnyFolder, filepath: PathBuf) {
326 let mut markdown = File::create(filepath).unwrap();
327 writeln!(markdown, "# Root\n").expect("Unable to write to markdown file");
328
329 let mut areas = root.get_children_owned();
330 areas.sort();
331 for mut area in areas {
332 writeln!(
333 markdown,
334 "## Area {0} - {1}\n",
335 area.level.get_area_number(),
336 area.name
337 )
338 .expect("Unable to write to markdown file");
339 area.children.sort();
340
341 for cat in area.get_children_mut() {
342 writeln!(
343 markdown,
344 "### Category {:02} - {}\n",
345 cat.level.get_cat_number(),
346 cat.name
347 )
348 .expect("Unable to write to markdown file");
349 cat.children.sort();
350
351 for id in cat.get_children() {
352 writeln!(markdown, "**{}**\n", id.name).expect("Unable to write to markdown file");
353 }
354 }
355 }
356}