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").map(DocumentationUrl::new),
152 homepage: package_str(document, "homepage").map(ToOwned::to_owned),
153 publish_status: if manifest_is_publishable(document) {
154 PublishStatus::Publishable
155 } else {
156 PublishStatus::Unpublishable
157 },
158 })
159 }
160}
161
162#[derive(Debug)]
164pub struct CrateNameError(String);
165
166impl fmt::Display for CrateNameError {
167 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
168 write!(formatter, "invalid crate name: {}", self.0)
169 }
170}
171
172impl Error for CrateNameError {}
173
174fn resolve_manifest_path(path: &Path) -> PathBuf {
175 if path.is_dir() {
176 path.join("Cargo.toml")
177 } else {
178 path.to_path_buf()
179 }
180}
181
182fn package_item<'a>(document: &'a DocumentMut, field: &str) -> Option<&'a Item> {
183 document
184 .get("package")
185 .and_then(Item::as_table_like)
186 .and_then(|package| package.get(field))
187}
188
189fn package_str<'a>(document: &'a DocumentMut, field: &str) -> Option<&'a str> {
190 package_item(document, field)
191 .and_then(Item::as_value)
192 .and_then(|value| value.as_str())
193}
194
195fn manifest_is_publishable(document: &DocumentMut) -> bool {
196 match package_item(document, "publish") {
197 None => true,
198 Some(item) => item
199 .as_value()
200 .and_then(|value| value.as_bool())
201 .or_else(|| {
202 item.as_value()
203 .and_then(|value| value.as_array())
204 .map(|items| !items.is_empty())
205 })
206 .unwrap_or(true),
207 }
208}
209
210#[must_use]
212pub fn is_valid_crate_name(value: &str) -> bool {
213 if value.is_empty() {
214 return false;
215 }
216
217 let bytes = value.as_bytes();
218 let first = bytes[0];
219 let last = bytes[bytes.len() - 1];
220
221 if matches!(first, b'-' | b'_') || matches!(last, b'-' | b'_') {
222 return false;
223 }
224
225 value.chars().all(|character| {
226 character.is_ascii_lowercase()
227 || character.is_ascii_digit()
228 || matches!(character, '-' | '_')
229 })
230}
231
232#[must_use]
234pub fn is_use_prefixed(value: &str) -> bool {
235 value.starts_with("use-")
236}
237
238#[must_use]
240pub fn crate_name_to_module_name(value: &str) -> String {
241 value.replace('-', "_")
242}
243
244#[must_use]
246pub fn module_name_to_crate_name(value: &str) -> String {
247 value.replace('_', "-")
248}
249
250#[must_use]
253pub fn normalize_crate_name(value: &str) -> String {
254 let mut normalized = String::with_capacity(value.len());
255 let mut last_was_hyphen = false;
256
257 for character in value.trim().chars() {
258 let mapped = match character {
259 ' ' | '_' => '-',
260 _ => character.to_ascii_lowercase(),
261 };
262
263 if mapped == '-' {
264 if normalized.is_empty() || last_was_hyphen {
265 continue;
266 }
267
268 last_was_hyphen = true;
269 normalized.push(mapped);
270 continue;
271 }
272
273 last_was_hyphen = false;
274 normalized.push(mapped);
275 }
276
277 while normalized.ends_with('-') {
278 normalized.pop();
279 }
280
281 normalized
282}
283
284#[must_use]
286pub fn expected_repository_url(repo_name: &str) -> RepositoryUrl {
287 RepositoryUrl::new(format!(
288 "https://github.com/RustUse/{}",
289 normalize_crate_name(repo_name)
290 ))
291}
292
293#[must_use]
295pub fn expected_docs_url(crate_name: &str) -> DocumentationUrl {
296 DocumentationUrl::new(format!(
297 "https://docs.rs/{}",
298 normalize_crate_name(crate_name)
299 ))
300}
301
302#[must_use]
304pub fn is_publishable(metadata: &CrateMetadata) -> bool {
305 metadata.publish_status == PublishStatus::Publishable
306 && validate_crate_metadata(metadata).is_empty()
307}
308
309#[must_use]
311pub fn validate_crate_metadata(metadata: &CrateMetadata) -> Vec<String> {
312 let mut issues = Vec::new();
313 let crate_name = metadata.name.as_str();
314
315 if !is_valid_crate_name(crate_name) {
316 issues.push(String::from("crate name is not a valid package name"));
317 }
318
319 if !is_use_prefixed(crate_name) {
320 issues.push(String::from(
321 "crate name does not follow the RustUse use-* naming convention",
322 ));
323 }
324
325 if let Some(repository) = &metadata.repository {
326 let expected = expected_repository_url(crate_name);
327 if repository != &expected {
328 issues.push(format!("repository URL should be {}", expected.as_str()));
329 }
330 }
331
332 if let Some(documentation) = &metadata.documentation {
333 let expected = expected_docs_url(crate_name);
334 if documentation != &expected {
335 issues.push(format!("documentation URL should be {}", expected.as_str()));
336 }
337 }
338
339 if let Some(homepage) = &metadata.homepage {
340 if homepage != "https://rustuse.org" {
341 issues.push(String::from(
342 "homepage should be https://rustuse.org for RustUse crates",
343 ));
344 }
345 }
346
347 issues
348}
349
350#[cfg(test)]
351mod tests {
352 use std::{
353 fs,
354 path::{Path, PathBuf},
355 process,
356 time::{SystemTime, UNIX_EPOCH},
357 };
358
359 use super::{
360 CrateMetadata, CrateName, crate_name_to_module_name, expected_docs_url,
361 expected_repository_url, is_publishable, is_use_prefixed, is_valid_crate_name,
362 module_name_to_crate_name, normalize_crate_name, validate_crate_metadata,
363 };
364
365 #[test]
366 fn validates_crate_names_and_prefixes() {
367 assert!(is_valid_crate_name("use-rust-release"));
368 assert!(is_valid_crate_name("use_rust_release"));
369 assert!(!is_valid_crate_name("Use-Rust-Release"));
370 assert!(!is_valid_crate_name("use release"));
371 assert!(is_use_prefixed("use-rust-release"));
372 assert!(!is_use_prefixed("release-tools"));
373 }
374
375 #[test]
376 fn converts_and_normalizes_names() {
377 assert_eq!(
378 crate_name_to_module_name("use-rust-release"),
379 "use_rust_release"
380 );
381 assert_eq!(
382 module_name_to_crate_name("use_rust_release"),
383 "use-rust-release"
384 );
385 assert_eq!(
386 normalize_crate_name(" Use Rust Release_tools "),
387 "use-rust-release-tools"
388 );
389 }
390
391 #[test]
392 fn builds_expected_urls() {
393 assert_eq!(
394 expected_repository_url("use-rust-release").as_str(),
395 "https://github.com/RustUse/use-rust-release"
396 );
397 assert_eq!(
398 expected_docs_url("use-rust-release").as_str(),
399 "https://docs.rs/use-rust-release"
400 );
401 }
402
403 #[test]
404 fn validates_metadata_defaults() {
405 let metadata = CrateMetadata {
406 name: CrateName::new("use-rust-release").expect("crate name should validate"),
407 kind: super::CrateKind::Library,
408 description: Some(String::from("release checks")),
409 license: Some(String::from("MIT OR Apache-2.0")),
410 repository: Some(expected_repository_url("use-rust-release")),
411 documentation: Some(expected_docs_url("use-rust-release")),
412 homepage: Some(String::from("https://rustuse.org")),
413 publish_status: super::PublishStatus::Publishable,
414 };
415
416 assert!(validate_crate_metadata(&metadata).is_empty());
417 assert!(is_publishable(&metadata));
418 }
419
420 #[test]
421 fn builds_metadata_from_manifest_path() {
422 let temp_dir = TestDir::new("crate-manifest");
423 write_file(
424 &temp_dir.path().join("Cargo.toml"),
425 r#"[package]
426 name = "use-rust-release"
427version = "0.0.1"
428edition = "2024"
429description = "release checks"
430license = "MIT OR Apache-2.0"
431 repository = "https://github.com/RustUse/use-rust-release"
432 documentation = "https://docs.rs/use-rust-release"
433homepage = "https://rustuse.org"
434"#,
435 );
436 write_file(
437 &temp_dir.path().join("src").join("lib.rs"),
438 "pub fn sample() {}\n",
439 );
440
441 let metadata =
442 CrateMetadata::from_manifest_path(temp_dir.path()).expect("metadata should load");
443
444 assert_eq!(metadata.name.as_str(), "use-rust-release");
445 assert_eq!(metadata.kind, super::CrateKind::Library);
446 }
447
448 struct TestDir {
449 path: PathBuf,
450 }
451
452 impl TestDir {
453 fn new(label: &str) -> Self {
454 let mut path = std::env::temp_dir();
455 let nanos = SystemTime::now()
456 .duration_since(UNIX_EPOCH)
457 .expect("system clock should be after UNIX_EPOCH")
458 .as_nanos();
459 path.push(format!("use-crate-{label}-{}-{nanos}", process::id()));
460 fs::create_dir_all(&path).expect("temporary directory should be created");
461 Self { path }
462 }
463
464 fn path(&self) -> &Path {
465 &self.path
466 }
467 }
468
469 impl Drop for TestDir {
470 fn drop(&mut self) {
471 let _ = fs::remove_dir_all(&self.path);
472 }
473 }
474
475 fn write_file(path: &Path, contents: &str) {
476 if let Some(parent) = path.parent() {
477 fs::create_dir_all(parent).expect("parent directories should be created");
478 }
479
480 fs::write(path, contents).expect("file should be written");
481 }
482}