1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use std::{
7 error::Error,
8 fmt, fs,
9 path::{Path, PathBuf},
10};
11
12use serde::{Deserialize, Serialize};
13use toml_edit::{DocumentMut, Item};
14
15#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
17pub struct CrateName(String);
18
19impl CrateName {
20 pub fn new(value: impl AsRef<str>) -> Result<Self, CrateNameError> {
22 let normalized = normalize_crate_name(value.as_ref());
23
24 if is_valid_crate_name(&normalized) {
25 Ok(Self(normalized))
26 } else {
27 Err(CrateNameError(value.as_ref().to_string()))
28 }
29 }
30
31 #[must_use]
33 pub fn as_str(&self) -> &str {
34 &self.0
35 }
36}
37
38impl fmt::Display for CrateName {
39 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
40 formatter.write_str(self.as_str())
41 }
42}
43
44#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
46pub enum CrateKind {
47 Library,
48 Binary,
49 Mixed,
50 Unknown,
51}
52
53#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
55pub enum PublishStatus {
56 Publishable,
57 Unpublishable,
58}
59
60#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
62pub struct RepositoryUrl(String);
63
64impl RepositoryUrl {
65 #[must_use]
67 pub fn new(value: impl Into<String>) -> Self {
68 Self(value.into())
69 }
70
71 #[must_use]
73 pub fn as_str(&self) -> &str {
74 &self.0
75 }
76}
77
78impl fmt::Display for RepositoryUrl {
79 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
80 formatter.write_str(self.as_str())
81 }
82}
83
84#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
86pub struct DocumentationUrl(String);
87
88impl DocumentationUrl {
89 #[must_use]
91 pub fn new(value: impl Into<String>) -> Self {
92 Self(value.into())
93 }
94
95 #[must_use]
97 pub fn as_str(&self) -> &str {
98 &self.0
99 }
100}
101
102impl fmt::Display for DocumentationUrl {
103 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
104 formatter.write_str(self.as_str())
105 }
106}
107
108#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
110pub struct CrateMetadata {
111 pub name: CrateName,
112 pub kind: CrateKind,
113 pub description: Option<String>,
114 pub license: Option<String>,
115 pub repository: Option<RepositoryUrl>,
116 pub documentation: Option<DocumentationUrl>,
117 pub homepage: Option<String>,
118 pub publish_status: PublishStatus,
119}
120
121impl CrateMetadata {
122 #[must_use]
124 pub fn from_manifest_path(path: impl AsRef<Path>) -> Option<Self> {
125 let manifest_path = resolve_manifest_path(path.as_ref());
126 let contents = fs::read_to_string(&manifest_path).ok()?;
127 let document = contents.parse::<DocumentMut>().ok()?;
128
129 Self::from_manifest_document(&manifest_path, &document)
130 }
131
132 fn from_manifest_document(manifest_path: &Path, document: &DocumentMut) -> Option<Self> {
133 let name = CrateName::new(package_str(document, "name")?).ok()?;
134 let crate_root = manifest_path.parent()?;
135 let has_lib = crate_root.join("src/lib.rs").exists();
136 let has_main = crate_root.join("src/main.rs").exists();
137
138 let kind = match (has_lib, has_main) {
139 (true, true) => CrateKind::Mixed,
140 (true, false) => CrateKind::Library,
141 (false, true) => CrateKind::Binary,
142 (false, false) => CrateKind::Unknown,
143 };
144
145 Some(Self {
146 name,
147 kind,
148 description: package_str(document, "description").map(ToOwned::to_owned),
149 license: package_str(document, "license").map(ToOwned::to_owned),
150 repository: package_str(document, "repository").map(RepositoryUrl::new),
151 documentation: package_str(document, "documentation")
152 .map(DocumentationUrl::new),
153 homepage: package_str(document, "homepage").map(ToOwned::to_owned),
154 publish_status: if manifest_is_publishable(document) {
155 PublishStatus::Publishable
156 } else {
157 PublishStatus::Unpublishable
158 },
159 })
160 }
161}
162
163#[derive(Debug)]
165pub struct CrateNameError(String);
166
167impl fmt::Display for CrateNameError {
168 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
169 write!(formatter, "invalid crate name: {}", self.0)
170 }
171}
172
173impl Error for CrateNameError {}
174
175fn resolve_manifest_path(path: &Path) -> PathBuf {
176 if path.is_dir() {
177 path.join("Cargo.toml")
178 } else {
179 path.to_path_buf()
180 }
181}
182
183fn package_item<'a>(document: &'a DocumentMut, field: &str) -> Option<&'a Item> {
184 document
185 .get("package")
186 .and_then(Item::as_table_like)
187 .and_then(|package| package.get(field))
188}
189
190fn package_str<'a>(document: &'a DocumentMut, field: &str) -> Option<&'a str> {
191 package_item(document, field)
192 .and_then(Item::as_value)
193 .and_then(|value| value.as_str())
194}
195
196fn manifest_is_publishable(document: &DocumentMut) -> bool {
197 match package_item(document, "publish") {
198 None => true,
199 Some(item) => item
200 .as_value()
201 .and_then(|value| value.as_bool())
202 .or_else(|| {
203 item.as_value()
204 .and_then(|value| value.as_array())
205 .map(|items| !items.is_empty())
206 })
207 .unwrap_or(true),
208 }
209}
210
211#[must_use]
213pub fn is_valid_crate_name(value: &str) -> bool {
214 if value.is_empty() {
215 return false;
216 }
217
218 let bytes = value.as_bytes();
219 let first = bytes[0];
220 let last = bytes[bytes.len() - 1];
221
222 if matches!(first, b'-' | b'_') || matches!(last, b'-' | b'_') {
223 return false;
224 }
225
226 value.chars().all(|character| {
227 character.is_ascii_lowercase()
228 || character.is_ascii_digit()
229 || matches!(character, '-' | '_')
230 })
231}
232
233#[must_use]
235pub fn is_use_prefixed(value: &str) -> bool {
236 value.starts_with("use-")
237}
238
239#[must_use]
241pub fn crate_name_to_module_name(value: &str) -> String {
242 value.replace('-', "_")
243}
244
245#[must_use]
247pub fn module_name_to_crate_name(value: &str) -> String {
248 value.replace('_', "-")
249}
250
251#[must_use]
254pub fn normalize_crate_name(value: &str) -> String {
255 let mut normalized = String::with_capacity(value.len());
256 let mut last_was_hyphen = false;
257
258 for character in value.trim().chars() {
259 let mapped = match character {
260 ' ' | '_' => '-',
261 _ => character.to_ascii_lowercase(),
262 };
263
264 if mapped == '-' {
265 if normalized.is_empty() || last_was_hyphen {
266 continue;
267 }
268
269 last_was_hyphen = true;
270 normalized.push(mapped);
271 continue;
272 }
273
274 last_was_hyphen = false;
275 normalized.push(mapped);
276 }
277
278 while normalized.ends_with('-') {
279 normalized.pop();
280 }
281
282 normalized
283}
284
285#[must_use]
287pub fn expected_repository_url(repo_name: &str) -> RepositoryUrl {
288 RepositoryUrl::new(format!(
289 "https://github.com/RustUse/{}",
290 normalize_crate_name(repo_name)
291 ))
292}
293
294#[must_use]
296pub fn expected_docs_url(crate_name: &str) -> DocumentationUrl {
297 DocumentationUrl::new(format!(
298 "https://docs.rs/{}",
299 normalize_crate_name(crate_name)
300 ))
301}
302
303#[must_use]
305pub fn is_publishable(metadata: &CrateMetadata) -> bool {
306 metadata.publish_status == PublishStatus::Publishable
307 && validate_crate_metadata(metadata).is_empty()
308}
309
310#[must_use]
312pub fn validate_crate_metadata(metadata: &CrateMetadata) -> Vec<String> {
313 let mut issues = Vec::new();
314 let crate_name = metadata.name.as_str();
315
316 if !is_valid_crate_name(crate_name) {
317 issues.push(String::from("crate name is not a valid package name"));
318 }
319
320 if !is_use_prefixed(crate_name) {
321 issues.push(String::from(
322 "crate name does not follow the RustUse use-* naming convention",
323 ));
324 }
325
326 if let Some(repository) = &metadata.repository {
327 let expected = expected_repository_url(crate_name);
328 if repository != &expected {
329 issues.push(format!("repository URL should be {}", expected.as_str()));
330 }
331 }
332
333 if let Some(documentation) = &metadata.documentation {
334 let expected = expected_docs_url(crate_name);
335 if documentation != &expected {
336 issues.push(format!("documentation URL should be {}", expected.as_str()));
337 }
338 }
339
340 if let Some(homepage) = &metadata.homepage {
341 if homepage != "https://rustuse.org" {
342 issues.push(String::from(
343 "homepage should be https://rustuse.org for RustUse crates",
344 ));
345 }
346 }
347
348 issues
349}
350
351#[cfg(test)]
352mod tests {
353 use std::{
354 fs,
355 path::{Path, PathBuf},
356 process,
357 time::{SystemTime, UNIX_EPOCH},
358 };
359
360 use super::{
361 crate_name_to_module_name, expected_docs_url, expected_repository_url, is_publishable,
362 is_use_prefixed, is_valid_crate_name, module_name_to_crate_name, normalize_crate_name,
363 validate_crate_metadata, CrateMetadata, CrateName,
364 };
365
366 #[test]
367 fn validates_crate_names_and_prefixes() {
368 assert!(is_valid_crate_name("use-release"));
369 assert!(is_valid_crate_name("use_release"));
370 assert!(!is_valid_crate_name("Use-Release"));
371 assert!(!is_valid_crate_name("use release"));
372 assert!(is_use_prefixed("use-release"));
373 assert!(!is_use_prefixed("release-tools"));
374 }
375
376 #[test]
377 fn converts_and_normalizes_names() {
378 assert_eq!(crate_name_to_module_name("use-release"), "use_release");
379 assert_eq!(module_name_to_crate_name("use_release"), "use-release");
380 assert_eq!(
381 normalize_crate_name(" Use Release_tools "),
382 "use-release-tools"
383 );
384 }
385
386 #[test]
387 fn builds_expected_urls() {
388 assert_eq!(
389 expected_repository_url("use-release").as_str(),
390 "https://github.com/RustUse/use-release"
391 );
392 assert_eq!(
393 expected_docs_url("use-release").as_str(),
394 "https://docs.rs/use-release"
395 );
396 }
397
398 #[test]
399 fn validates_metadata_defaults() {
400 let metadata = CrateMetadata {
401 name: CrateName::new("use-release").expect("crate name should validate"),
402 kind: super::CrateKind::Library,
403 description: Some(String::from("release checks")),
404 license: Some(String::from("MIT OR Apache-2.0")),
405 repository: Some(expected_repository_url("use-release")),
406 documentation: Some(expected_docs_url("use-release")),
407 homepage: Some(String::from("https://rustuse.org")),
408 publish_status: super::PublishStatus::Publishable,
409 };
410
411 assert!(validate_crate_metadata(&metadata).is_empty());
412 assert!(is_publishable(&metadata));
413 }
414
415 #[test]
416 fn builds_metadata_from_manifest_path() {
417 let temp_dir = TestDir::new("crate-manifest");
418 write_file(
419 &temp_dir.path().join("Cargo.toml"),
420 r#"[package]
421name = "use-release"
422version = "0.0.1"
423edition = "2024"
424description = "release checks"
425license = "MIT OR Apache-2.0"
426repository = "https://github.com/RustUse/use-release"
427documentation = "https://docs.rs/use-release"
428homepage = "https://rustuse.org"
429"#,
430 );
431 write_file(
432 &temp_dir.path().join("src").join("lib.rs"),
433 "pub fn sample() {}\n",
434 );
435
436 let metadata =
437 CrateMetadata::from_manifest_path(temp_dir.path()).expect("metadata should load");
438
439 assert_eq!(metadata.name.as_str(), "use-release");
440 assert_eq!(metadata.kind, super::CrateKind::Library);
441 }
442
443 struct TestDir {
444 path: PathBuf,
445 }
446
447 impl TestDir {
448 fn new(label: &str) -> Self {
449 let mut path = std::env::temp_dir();
450 let nanos = SystemTime::now()
451 .duration_since(UNIX_EPOCH)
452 .expect("system clock should be after UNIX_EPOCH")
453 .as_nanos();
454 path.push(format!("use-crate-{label}-{}-{nanos}", process::id()));
455 fs::create_dir_all(&path).expect("temporary directory should be created");
456 Self { path }
457 }
458
459 fn path(&self) -> &Path {
460 &self.path
461 }
462 }
463
464 impl Drop for TestDir {
465 fn drop(&mut self) {
466 let _ = fs::remove_dir_all(&self.path);
467 }
468 }
469
470 fn write_file(path: &Path, contents: &str) {
471 if let Some(parent) = path.parent() {
472 fs::create_dir_all(parent).expect("parent directories should be created");
473 }
474
475 fs::write(path, contents).expect("file should be written");
476 }
477}