prdoclib/
doc_filename.rs

1//! Definition of the standardized file names.
2
3use 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/// Helps to build and check filenames for prdoc
19///
20/// A `prdoc` is made of its content: a [DocFile](/prdoclib::docfile::DocFile) but also requires a
21/// valid filename.
22#[derive(Debug, PartialEq, Serialize, Hash, Eq)]
23pub struct DocFileName {
24	/// The PR number
25	pub number: PRNumber,
26
27	/// The title of the PR as mentioned in the filename. Note: This is NOT the title property of a
28	/// PRDoc file.
29	pub title: Option<Title>,
30}
31
32impl DocFileName {
33	/// Construct a new `DocFileName` from a PR number and an optional title.
34	pub fn new(number: PRNumber, title: Option<Title>) -> Self {
35		Self { number, title }
36	}
37
38	/// Return the filename of the `prdoc` file.
39	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	/// Return the regex used to parse filenames
48	fn get_regex() -> Regex {
49		Regex::new(r"^pr_(?<number>\d+)(?<title>.*)\.prdoc$").unwrap()
50	}
51
52	/// Return true if a filename **looks** like it could be a valid `prdoc` file.
53	/// This is done solely based on the filename and the content it not attemptedly parsed or
54	/// deserialized.
55	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	/// Search for a PR Doc in a given folder and matching the args
74	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		// We search for matching patterns and capture the `number` group
84		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				// First we exclude anything that is not a file
89				let metadata = std::fs::metadata(candidate.path()).unwrap();
90				if !metadata.is_file() {
91					return None;
92				}
93
94				// Fetch the file name
95				let fname = candidate.file_name();
96				let filename = fname.to_str().unwrap_or_default();
97
98				// We capture numbers first
99				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				// Then check if the number we got matches.
108				// It is required to do this so we also find `pr_000...` when looking for
109				// PR #0
110				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}