johnnydecimal_rs/
lib.rs

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    /// Area-Category-ID naming scheme, for example 11.23
28    ACID,
29    /// Directory-Area-Category-ID naming scheme, for example WORK.11.23
30    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                // println!("ATTEMPTING TO PARSE {}", &sliceable); // enable for debug verbosity
134
135                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            } // returns ID from DAC.ID
139        }
140    }
141}
142
143/// Builds and returns HashMap of location codes to paths
144pub 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
166/// Get the path to the folder with the given ID
167pub 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
185/// Extracts the ID from a path.
186///
187/// Stable UNLESS improperly sorted file exists in a Root, Area, or Category folder. TODO: Implement some behavior for this.
188fn 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    // Stable
226    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    // Stable
235    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(); // SAFE
245    let capture = &regex.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(); // inits vec of categories
277    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; // set added flag
283            }
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    // at this point in the code all of the individuals have been sorted away into the appropriate categories
296    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; // set added flag
303            }
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}