Skip to main content

use_crate/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4//! Crate identity, naming, and publishability primitives.
5
6use std::{
7    error::Error,
8    fmt, fs,
9    path::{Path, PathBuf},
10};
11
12use serde::{Deserialize, Serialize};
13use toml_edit::{DocumentMut, Item};
14
15/// A validated crate name.
16#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
17pub struct CrateName(String);
18
19impl CrateName {
20    /// Creates a validated crate name after applying RustUse normalization.
21    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    /// Returns the crate name.
32    #[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/// A broad crate kind classification.
45#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
46pub enum CrateKind {
47    Library,
48    Binary,
49    Mixed,
50    Unknown,
51}
52
53/// The publish status inferred from manifest metadata.
54#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
55pub enum PublishStatus {
56    Publishable,
57    Unpublishable,
58}
59
60/// A repository URL.
61#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
62pub struct RepositoryUrl(String);
63
64impl RepositoryUrl {
65    /// Creates a repository URL wrapper.
66    #[must_use]
67    pub fn new(value: impl Into<String>) -> Self {
68        Self(value.into())
69    }
70
71    /// Returns the underlying URL.
72    #[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/// A documentation URL.
85#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
86pub struct DocumentationUrl(String);
87
88impl DocumentationUrl {
89    /// Creates a documentation URL wrapper.
90    #[must_use]
91    pub fn new(value: impl Into<String>) -> Self {
92        Self(value.into())
93    }
94
95    /// Returns the underlying URL.
96    #[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/// Lightweight crate metadata for RustUse validation.
109#[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    /// Builds crate metadata from a Cargo.toml manifest path or package root.
123    #[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/// An error returned when a crate name fails validation.
163#[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/// Returns `true` when a value is a valid crate name under RustUse defaults.
211#[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/// Returns `true` when a crate name uses the RustUse `use-*` prefix.
233#[must_use]
234pub fn is_use_prefixed(value: &str) -> bool {
235    value.starts_with("use-")
236}
237
238/// Converts a crate name like `use-rust-release` into a Rust module name.
239#[must_use]
240pub fn crate_name_to_module_name(value: &str) -> String {
241    value.replace('-', "_")
242}
243
244/// Converts a Rust module name like `use_rust_release` into a crate name.
245#[must_use]
246pub fn module_name_to_crate_name(value: &str) -> String {
247    value.replace('_', "-")
248}
249
250/// Normalizes a crate name by trimming, ASCII-lowercasing, replacing spaces or
251/// underscores with hyphens, collapsing repeated hyphens, and trimming edges.
252#[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/// Returns the expected RustUse GitHub repository URL for a repository name.
285#[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/// Returns the expected docs.rs URL for a crate name.
294#[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/// Returns `true` when crate metadata is publishable and follows RustUse defaults.
303#[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/// Validates crate metadata against RustUse naming and URL defaults.
310#[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}