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")
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/// An error returned when a crate name fails validation.
164#[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/// Returns `true` when a value is a valid crate name under RustUse defaults.
212#[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/// Returns `true` when a crate name uses the RustUse `use-*` prefix.
234#[must_use]
235pub fn is_use_prefixed(value: &str) -> bool {
236    value.starts_with("use-")
237}
238
239/// Converts a crate name like `use-release` into a Rust module name.
240#[must_use]
241pub fn crate_name_to_module_name(value: &str) -> String {
242    value.replace('-', "_")
243}
244
245/// Converts a Rust module name like `use_release` into a crate name.
246#[must_use]
247pub fn module_name_to_crate_name(value: &str) -> String {
248    value.replace('_', "-")
249}
250
251/// Normalizes a crate name by trimming, ASCII-lowercasing, replacing spaces or
252/// underscores with hyphens, collapsing repeated hyphens, and trimming edges.
253#[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/// Returns the expected RustUse GitHub repository URL for a repository name.
286#[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/// Returns the expected docs.rs URL for a crate name.
295#[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/// Returns `true` when crate metadata is publishable and follows RustUse defaults.
304#[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/// Validates crate metadata against RustUse naming and URL defaults.
311#[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}