1use regex::Regex;
4use serde::Serialize;
5use std::{
6 ffi::OsString,
7 fmt::Display,
8 path::{Path, PathBuf},
9 str::FromStr,
10};
11
12use crate::{
13 common::PRNumber,
14 error::{self, PRdocLibError},
15 title::Title,
16};
17
18#[derive(Debug, PartialEq, Serialize, Hash, Eq)]
23pub struct DocFileName {
24 pub number: PRNumber,
26
27 pub title: Option<Title>,
30}
31
32impl DocFileName {
33 pub fn new(number: PRNumber, title: Option<Title>) -> Self {
35 Self { number, title }
36 }
37
38 pub fn filename(&self) -> OsString {
40 if let Some(title) = &self.title {
41 OsString::from(format!("pr_{}_{:?}.prdoc", self.number, title.to_string()))
42 } else {
43 OsString::from(format!("pr_{}.prdoc", self.number))
44 }
45 }
46
47 fn get_regex() -> Regex {
49 Regex::new(r"^pr_(?<number>\d+)(?<title>.*)\.prdoc$").unwrap()
50 }
51
52 pub fn is_valid<P: AsRef<Path>>(filename: P) -> bool {
56 let re = Self::get_regex();
57 let file_only = filename.as_ref().components().last();
58 if let Some(file) = file_only {
59 match file {
60 std::path::Component::Prefix(_) |
61 std::path::Component::RootDir |
62 std::path::Component::CurDir |
63 std::path::Component::ParentDir => false,
64
65 std::path::Component::Normal(f) =>
66 re.is_match(&PathBuf::from(f).display().to_string().to_lowercase()),
67 }
68 } else {
69 false
70 }
71 }
72
73 pub fn find(
75 number: PRNumber,
76 title: Option<String>,
77 directory: &PathBuf,
78 ) -> error::Result<PathBuf> {
79 if title.is_some() {
80 todo!("Searching by Number + Title is not implemented yet, open an issue if there is a need.");
81 }
82
83 let re: Regex = Self::get_regex();
85
86 let hit_maybe = std::fs::read_dir(directory)?.find_map(|entry| match entry {
87 Ok(candidate) => {
88 let metadata = std::fs::metadata(candidate.path()).unwrap();
90 if !metadata.is_file() {
91 return None;
92 }
93
94 let fname = candidate.file_name();
96 let filename = fname.to_str().unwrap_or_default();
97
98 let number_capture = re.captures(filename).and_then(|cap| {
100 cap.name("number").map(|n| {
101 let s = n.as_str();
102 let my_num: PRNumber = s.parse().unwrap();
103 my_num
104 })
105 });
106
107 if number_capture.is_some_and(|value| value == number) {
111 Some(PathBuf::from(&directory).join(filename))
112 } else {
113 None
114 }
115 },
116 Err(_e) => None,
117 });
118
119 if let Some(hit) = hit_maybe {
120 Ok(hit)
121 } else {
122 Err(PRdocLibError::NumberNotFound(number))
123 }
124 }
125}
126
127impl Display for DocFileName {
128 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129 f.write_str(self.filename().to_str().expect("Our filename is a valid path"))
130 }
131}
132
133impl From<PRNumber> for DocFileName {
134 fn from(n: PRNumber) -> Self {
135 Self::new(n, None)
136 }
137}
138
139impl From<DocFileName> for PathBuf {
140 fn from(val: DocFileName) -> Self {
141 PathBuf::from_str(&val.to_string()).expect("Our filename is a valid path")
142 }
143}
144
145impl TryFrom<&PathBuf> for DocFileName {
146 type Error = PRdocLibError;
147
148 fn try_from(p: &PathBuf) -> Result<Self, Self::Error> {
149 let re: Regex = Self::get_regex();
150
151 let file = p.file_name().ok_or(PRdocLibError::InvalidFilename(p.clone()))?;
152 let filename = file.to_str().ok_or(PRdocLibError::InvalidFilename(p.clone()))?;
153
154 let number = re.captures(filename).and_then(|cap| {
155 cap.name("number")
156 .map(|n| n.as_str().parse().expect("The regexp captures numbers"))
157 });
158
159 let title: Option<Title> = re
160 .captures(filename)
161 .and_then(|cap| {
162 cap.name("title").map(|s| {
163 if s.is_empty() {
164 None
165 } else {
166 Some(Title::from(s.as_str()))
167 }
168 })
169 })
170 .unwrap_or_default();
171
172 if let Some(number) = number {
173 Ok(DocFileName::new(number, title))
174 } else {
175 Err(PRdocLibError::InvalidFilename(filename.into()))
176 }
177 }
178}
179
180#[cfg(test)]
181mod test_doc_file_name {
182 use super::*;
183
184 #[test]
185 fn test_valid_names() {
186 assert!(DocFileName::is_valid("pr_0.prdoc"));
187 assert!(DocFileName::is_valid("pr_123.prdoc"));
188 assert!(DocFileName::is_valid("pr_123_foo.prdoc"));
189 assert!(DocFileName::is_valid("PR_123.prdoc"));
190
191 assert!(!DocFileName::is_valid("PR_123.txt"));
192 assert!(!DocFileName::is_valid("PR_ABC.txt"));
193 assert!(!DocFileName::is_valid("1234.prdoc"));
194 }
195
196 #[test]
197 fn test_mix() {
198 assert_eq!(String::from("pr_123.prdoc"), DocFileName::from(123).to_string());
199 }
200
201 #[test]
202 fn test_find() {
203 assert_eq!(
204 PathBuf::from("./tests/data/some/pr_1234_some_test_minimal.prdoc"),
205 DocFileName::find(1234, None, &PathBuf::from("./tests/data/some")).unwrap()
206 );
207 }
208
209 #[test]
210 fn test_from_pathbuf() {
211 let dfn = DocFileName::try_from(&PathBuf::from(
212 "./tests/data/some/pr_1234_some_test_minimal.prdoc",
213 ))
214 .unwrap();
215 assert_eq!(1234, dfn.number);
216 assert_eq!(Some(Title::from("_some_test_minimal")), dfn.title);
217 }
218}