Skip to main content

miniextendr_macros/
crate_config.rs

1//! Crate-level `#[miniextendr]` defaults read from the user crate's manifest.
2//!
3//! Proc-macro invocations share no state, so a default meant for every
4//! `#[miniextendr]` item in a crate cannot be declared on one macro call:
5//! `miniextendr_init!` expands with no knowledge of the functions and vice
6//! versa. The one place every expansion can see is the manifest. Cargo sets
7//! `CARGO_MANIFEST_DIR` for the crate being compiled and the proc macro runs
8//! inside that `rustc` process, so it can read `Cargo.toml` at expansion time.
9//! Cargo refingerprints a package when its manifest changes, so an edit takes
10//! effect on the next build with no stale expansions.
11//!
12//! Supported spelling (the table header; dotted keys under `[package]` or
13//! `[package.metadata]` are accepted too, inline tables are rejected):
14//!
15//! ```toml
16//! [package.metadata.miniextendr]
17//! noexport_postfix = "_impl"
18//! source_tags = true
19//! call_attribution = "caller"
20//! conversion_error_class = ["pkg_error_argument", "pkg_error"]
21//! preconditions = false
22//! roxygen_prose_links = "keep"
23//! ```
24//!
25//! The reader is a deliberately small line-based scanner rather than a TOML
26//! dependency: the macro crate ships in every downstream build, and the only
27//! values it needs are strings, booleans and a single-line string array
28//! under one well-known table.
29
30use std::collections::HashMap;
31use std::path::{Path, PathBuf};
32use std::sync::{Mutex, OnceLock};
33
34use crate::r_wrapper_builder::CallAttribution;
35use crate::roxygen::ProseLinks;
36
37/// Dotted path of the `noexport_postfix` key.
38const NOEXPORT_POSTFIX_KEY: &str = "package.metadata.miniextendr.noexport_postfix";
39/// Dotted path of the `source_tags` key.
40const SOURCE_TAGS_KEY: &str = "package.metadata.miniextendr.source_tags";
41/// Dotted path of the `call_attribution` key.
42const CALL_ATTRIBUTION_KEY: &str = "package.metadata.miniextendr.call_attribution";
43/// Dotted path of the `conversion_error_class` key.
44const CONVERSION_ERROR_CLASS_KEY: &str = "package.metadata.miniextendr.conversion_error_class";
45/// Dotted path of the `preconditions` key.
46const PRECONDITIONS_KEY: &str = "package.metadata.miniextendr.preconditions";
47/// Dotted path of the `roxygen_prose_links` key.
48const ROXYGEN_PROSE_LINKS_KEY: &str = "package.metadata.miniextendr.roxygen_prose_links";
49/// The classes the R raise helper appends to every Rust error. A crate-level
50/// class naming one of them would duplicate a layer of the vector.
51const FRAMEWORK_ERROR_CLASSES: [&str; 4] = ["rust_error", "simpleError", "error", "condition"];
52/// Dotted path of the table itself, used to reject the inline-table spelling.
53const TABLE_KEY: &str = "package.metadata.miniextendr";
54
55/// Crate-wide defaults declared under `[package.metadata.miniextendr]`.
56#[derive(#[automatically_derived]
impl ::core::fmt::Debug for CrateConfig {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        let names: &'static _ =
            &["noexport_postfix", "source_tags", "call_attribution",
                        "conversion_error_class", "preconditions",
                        "roxygen_prose_links"];
        let values: &[&dyn ::core::fmt::Debug] =
            &[&self.noexport_postfix, &self.source_tags,
                        &self.call_attribution, &self.conversion_error_class,
                        &self.preconditions, &&self.roxygen_prose_links];
        ::core::fmt::Formatter::debug_struct_fields_finish(f, "CrateConfig",
            names, values)
    }
}Debug, #[automatically_derived]
impl ::core::clone::Clone for CrateConfig {
    #[inline]
    fn clone(&self) -> Self {
        CrateConfig {
            noexport_postfix: ::core::clone::Clone::clone(&self.noexport_postfix),
            source_tags: ::core::clone::Clone::clone(&self.source_tags),
            call_attribution: ::core::clone::Clone::clone(&self.call_attribution),
            conversion_error_class: ::core::clone::Clone::clone(&self.conversion_error_class),
            preconditions: ::core::clone::Clone::clone(&self.preconditions),
            roxygen_prose_links: ::core::clone::Clone::clone(&self.roxygen_prose_links),
        }
    }
}Clone, #[automatically_derived]
impl ::core::default::Default for CrateConfig {
    #[inline]
    fn default() -> Self {
        CrateConfig {
            noexport_postfix: ::core::default::Default::default(),
            source_tags: ::core::default::Default::default(),
            call_attribution: ::core::default::Default::default(),
            conversion_error_class: ::core::default::Default::default(),
            preconditions: ::core::default::Default::default(),
            roxygen_prose_links: ::core::default::Default::default(),
        }
    }
}Default, #[automatically_derived]
impl ::core::marker::StructuralPartialEq for CrateConfig { }
#[automatically_derived]
impl ::core::cmp::PartialEq for CrateConfig {
    #[inline]
    fn eq(&self, other: &Self) -> bool {
        self.source_tags == other.source_tags &&
                            self.noexport_postfix == other.noexport_postfix &&
                        self.call_attribution == other.call_attribution &&
                    self.conversion_error_class == other.conversion_error_class
                && self.preconditions == other.preconditions &&
            self.roxygen_prose_links == other.roxygen_prose_links
    }
}PartialEq, #[automatically_derived]
impl ::core::cmp::Eq for CrateConfig {
    #[inline]
    #[doc(hidden)]
    #[coverage(off)]
    fn assert_fields_are_eq(&self) {
        let _: ::core::cmp::AssertParamIsEq<Option<String>>;
        let _: ::core::cmp::AssertParamIsEq<bool>;
        let _: ::core::cmp::AssertParamIsEq<Option<CallAttribution>>;
        let _: ::core::cmp::AssertParamIsEq<Vec<String>>;
        let _: ::core::cmp::AssertParamIsEq<Option<bool>>;
        let _: ::core::cmp::AssertParamIsEq<Option<ProseLinks>>;
    }
}Eq)]
57pub(crate) struct CrateConfig {
58    /// `noexport_postfix = "..."`: appended to the Rust name of every
59    /// `noexport` / `internal` free function that sets neither `r_name` nor
60    /// its own `postfix`. Exported functions keep their Rust names.
61    pub(crate) noexport_postfix: Option<String>,
62    /// `source_tags = true`: every generated roxygen block carries a
63    /// `@source Generated by miniextendr from …` provenance tag. Off by
64    /// default (#1552): the tag collects one paragraph per function on a
65    /// shared Rd page, and the `# Generated from Rust fn … (file:line:col)`
66    /// comment above each wrapper remains the navigation pointer.
67    pub(crate) source_tags: bool,
68    /// `call_attribution = "wrapper" | "caller" | "none"`: the condition-call
69    /// attribution of every free function that neither takes a `Call` /
70    /// `CallerCall` parameter nor sets `call = ...` (#1566). `caller` applies
71    /// to `noexport` / `internal` functions; exported ones keep `wrapper`.
72    /// `none` (#1851) applies to every free function: its conditions carry
73    /// no call. Unset means the framework default, `wrapper`.
74    pub(crate) call_attribution: Option<CallAttribution>,
75    /// `conversion_error_class = "..." | ["...", ...]`: classes added to every
76    /// argument-conversion condition of the crate, after the error type's own
77    /// `RConditionData` classes (duplicates skipped) and before the
78    /// `rust_error` layering. Empty when unset.
79    pub(crate) conversion_error_class: Vec<String>,
80    /// `preconditions = true | false`: whether the generated R wrappers of
81    /// the crate keep their type-derived argument checks (#1566). Below every
82    /// item-level and per-parameter spelling, above the
83    /// `no-preconditions-default` feature (see
84    /// `crate::r_preconditions::resolve_type_checks`). Unset means the feature
85    /// decides, and without it the checks are kept.
86    pub(crate) preconditions: Option<bool>,
87    /// `roxygen_prose_links = "strip" | "keep"`: whether the leading prose of
88    /// a doc comment keeps its `[...]` links on its way to `@description`
89    /// (see [`ProseLinks`]). Unset means the framework default, `strip`.
90    pub(crate) roxygen_prose_links: Option<ProseLinks>,
91}
92
93/// A malformed `[package.metadata.miniextendr]` entry, reported as a compile
94/// error on the `#[miniextendr]` item that tried to use it.
95#[derive(#[automatically_derived]
impl ::core::fmt::Debug for CrateConfigError {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        ::core::fmt::Formatter::debug_struct_field2_finish(f,
            "CrateConfigError", "manifest", &self.manifest, "message",
            &&self.message)
    }
}Debug, #[automatically_derived]
impl ::core::clone::Clone for CrateConfigError {
    #[inline]
    fn clone(&self) -> Self {
        CrateConfigError {
            manifest: ::core::clone::Clone::clone(&self.manifest),
            message: ::core::clone::Clone::clone(&self.message),
        }
    }
}Clone, #[automatically_derived]
impl ::core::marker::StructuralPartialEq for CrateConfigError { }
#[automatically_derived]
impl ::core::cmp::PartialEq for CrateConfigError {
    #[inline]
    fn eq(&self, other: &Self) -> bool {
        self.manifest == other.manifest && self.message == other.message
    }
}PartialEq, #[automatically_derived]
impl ::core::cmp::Eq for CrateConfigError {
    #[inline]
    #[doc(hidden)]
    #[coverage(off)]
    fn assert_fields_are_eq(&self) {
        let _: ::core::cmp::AssertParamIsEq<PathBuf>;
        let _: ::core::cmp::AssertParamIsEq<String>;
    }
}Eq)]
96pub(crate) struct CrateConfigError {
97    manifest: PathBuf,
98    message: String,
99}
100
101impl std::fmt::Display for CrateConfigError {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        f.write_fmt(format_args!("{0}: [package.metadata.miniextendr] {1}",
        self.manifest.display(), self.message))write!(
104            f,
105            "{}: [package.metadata.miniextendr] {}",
106            self.manifest.display(),
107            self.message
108        )
109    }
110}
111
112/// The crate config for the crate currently being compiled.
113///
114/// Returns the defaults when `CARGO_MANIFEST_DIR` is unset or its `Cargo.toml`
115/// cannot be read; only a present-but-malformed entry is an error. Results are
116/// cached per manifest directory, so a single proc-macro server process serving
117/// several crates (rust-analyzer) never leaks one crate's default into another.
118pub(crate) fn crate_config() -> Result<CrateConfig, CrateConfigError> {
119    let Some(dir) = std::env::var_os("CARGO_MANIFEST_DIR") else {
120        return Ok(CrateConfig::default());
121    };
122    crate_config_for_dir(Path::new(&dir))
123}
124
125/// Whether generated roxygen blocks carry `@source` provenance tags.
126///
127/// Read by the impl-block and derive generators, which have no single item to
128/// hang a manifest error on; a malformed table reads as the default here and
129/// is reported as a compile error by the first `#[miniextendr]` function,
130/// which goes through [`crate_config`].
131pub(crate) fn source_tags_enabled() -> bool {
132    crate_config().map(|c| c.source_tags).unwrap_or(false)
133}
134
135/// The crate-level classes of argument-conversion conditions
136/// (`conversion_error_class`), empty when unset.
137///
138/// Read by the argument-conversion codegen, which runs for free functions,
139/// impl methods, trait methods and sidecar setters alike; a malformed table
140/// reads as the default here and is reported as a compile error by the first
141/// `#[miniextendr]` function, which goes through [`crate_config`].
142pub(crate) fn conversion_error_class() -> Vec<String> {
143    crate_config()
144        .map(|c| c.conversion_error_class)
145        .unwrap_or_default()
146}
147
148/// The crate default of the type-derived R-side checks (`preconditions`),
149/// `None` when unset.
150///
151/// Read by the impl-block and trait-impl generators, which have no single
152/// item to hang a manifest error on; a malformed table reads as unset here and
153/// is reported as a compile error by the first `#[miniextendr]` function,
154/// which goes through [`crate_config`].
155pub(crate) fn preconditions_default() -> Option<bool> {
156    crate_config().ok().and_then(|c| c.preconditions)
157}
158
159/// What leading doc-comment prose does with its `[...]` links
160/// (`roxygen_prose_links`), [`ProseLinks::Strip`] when unset.
161///
162/// Read by the roxygen extraction every generator shares, which has no single
163/// item to hang a manifest error on; a malformed table reads as the default
164/// here and is reported as a compile error by the first `#[miniextendr]`
165/// function, which goes through [`crate_config`].
166pub(crate) fn roxygen_prose_links() -> ProseLinks {
167    crate_config()
168        .ok()
169        .and_then(|c| c.roxygen_prose_links)
170        .unwrap_or_default()
171}
172
173/// [`crate_config`] for an explicit manifest directory (the cached entry point).
174pub(crate) fn crate_config_for_dir(dir: &Path) -> Result<CrateConfig, CrateConfigError> {
175    static CACHE: OnceLock<Mutex<HashMap<PathBuf, Result<CrateConfig, CrateConfigError>>>> =
176        OnceLock::new();
177    let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
178    let mut cache = cache
179        .lock()
180        .unwrap_or_else(|poisoned| poisoned.into_inner());
181    cache
182        .entry(dir.to_path_buf())
183        .or_insert_with(|| read_crate_config(&dir.join("Cargo.toml")))
184        .clone()
185}
186
187/// Read and parse one manifest. Missing or unreadable files yield the defaults.
188fn read_crate_config(manifest: &Path) -> Result<CrateConfig, CrateConfigError> {
189    match std::fs::read_to_string(manifest) {
190        Ok(text) => parse_crate_config(&text).map_err(|message| CrateConfigError {
191            manifest: manifest.to_path_buf(),
192            message,
193        }),
194        Err(_) => Ok(CrateConfig::default()),
195    }
196}
197
198/// Parse the `[package.metadata.miniextendr]` table out of manifest text.
199///
200/// Tracks the current table header and resolves every `key = value` line to a
201/// dotted path, so the header form, `miniextendr.noexport_postfix = "..."`
202/// under `[package.metadata]`, and `metadata.miniextendr.noexport_postfix`
203/// under `[package]` all resolve to the same key. Everything else in the
204/// manifest is skipped without interpretation.
205pub(crate) fn parse_crate_config(text: &str) -> Result<CrateConfig, String> {
206    let mut config = CrateConfig::default();
207    let mut source_tags_seen = false;
208    let mut conversion_error_class_seen = false;
209    let mut table = String::new();
210
211    for raw_line in text.lines() {
212        let line = raw_line.trim();
213        if line.is_empty() || line.starts_with('#') {
214            continue;
215        }
216        if let Some(header) = line.strip_prefix('[') {
217            // `[[array.of.tables]]` and `[table]`: keys below belong to it.
218            let header = header.strip_prefix('[').unwrap_or(header);
219            let end = header
220                .find(']')
221                .ok_or_else(|| ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("unterminated table header `{0}`",
                line))
    })format!("unterminated table header `{line}`"))?;
222            table = normalize_key(&header[..end]);
223            continue;
224        }
225        let Some((key, value)) = line.split_once('=') else {
226            continue;
227        };
228        let key = normalize_key(key);
229        let full = if table.is_empty() {
230            key
231        } else {
232            ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}.{1}", table, key))
    })format!("{table}.{key}")
233        };
234        if full == TABLE_KEY && value.trim_start().starts_with('{') {
235            return Err(
236                "inline tables are not supported here; write a `[package.metadata.miniextendr]` \
237                 table with each key on its own line"
238                    .to_string(),
239            );
240        }
241        if full == CALL_ATTRIBUTION_KEY {
242            if config.call_attribution.is_some() {
243                return Err("`call_attribution` is set more than once".to_string());
244            }
245            let value = value.trim();
246            let attribution = parse_string_value(value)
247                .as_deref()
248                .and_then(CallAttribution::parse_name)
249                .ok_or_else(|| {
250                    ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("`call_attribution` must be one of \"wrapper\", \"caller\", \"none\", found `{0}`",
                value))
    })format!(
251                        "`call_attribution` must be one of \"wrapper\", \"caller\", \"none\", found \
252                         `{value}`"
253                    )
254                })?;
255            config.call_attribution = Some(attribution);
256            continue;
257        }
258        if full == CONVERSION_ERROR_CLASS_KEY {
259            if conversion_error_class_seen {
260                return Err("`conversion_error_class` is set more than once".to_string());
261            }
262            let value = value.trim();
263            let classes = parse_string_value(value)
264                .map(|class| ::alloc::boxed::box_assume_init_into_vec_unsafe(::alloc::intrinsics::write_box_via_move(::alloc::boxed::Box::new_uninit(),
        [class]))vec![class])
265                .or_else(|| parse_string_array_value(value))
266                .ok_or_else(|| {
267                    ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("`conversion_error_class` must be a string or a single-line array of strings, found `{0}`",
                value))
    })format!(
268                        "`conversion_error_class` must be a string or a single-line array of \
269                         strings, found `{value}`"
270                    )
271                })?;
272            validate_conversion_error_class(&classes)?;
273            config.conversion_error_class = classes;
274            conversion_error_class_seen = true;
275            continue;
276        }
277        if full == ROXYGEN_PROSE_LINKS_KEY {
278            if config.roxygen_prose_links.is_some() {
279                return Err("`roxygen_prose_links` is set more than once".to_string());
280            }
281            let value = value.trim();
282            let links = parse_string_value(value)
283                .as_deref()
284                .and_then(ProseLinks::parse_name)
285                .ok_or_else(|| {
286                    ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("`roxygen_prose_links` must be one of \"strip\", \"keep\", found `{0}`",
                value))
    })format!(
287                        "`roxygen_prose_links` must be one of \"strip\", \"keep\", found \
288                         `{value}`"
289                    )
290                })?;
291            config.roxygen_prose_links = Some(links);
292            continue;
293        }
294        if full == PRECONDITIONS_KEY {
295            if config.preconditions.is_some() {
296                return Err("`preconditions` is set more than once".to_string());
297            }
298            config.preconditions = Some(parse_bool_value(value.trim()).ok_or_else(|| {
299                ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("`preconditions` must be `true` or `false`, found `{0}`",
                value.trim()))
    })format!(
300                    "`preconditions` must be `true` or `false`, found `{}`",
301                    value.trim()
302                )
303            })?);
304            continue;
305        }
306        if full == SOURCE_TAGS_KEY {
307            if source_tags_seen {
308                return Err("`source_tags` is set more than once".to_string());
309            }
310            config.source_tags = parse_bool_value(value.trim()).ok_or_else(|| {
311                ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("`source_tags` must be `true` or `false`, found `{0}`",
                value.trim()))
    })format!(
312                    "`source_tags` must be `true` or `false`, found `{}`",
313                    value.trim()
314                )
315            })?;
316            source_tags_seen = true;
317            continue;
318        }
319        if full != NOEXPORT_POSTFIX_KEY {
320            continue;
321        }
322        if config.noexport_postfix.is_some() {
323            return Err("`noexport_postfix` is set more than once".to_string());
324        }
325        let value = parse_string_value(value.trim()).ok_or_else(|| {
326            ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("`noexport_postfix` must be a string, found `{0}`",
                value.trim()))
    })format!(
327                "`noexport_postfix` must be a string, found `{}`",
328                value.trim()
329            )
330        })?;
331        validate_noexport_postfix(&value)?;
332        config.noexport_postfix = Some(value);
333    }
334
335    Ok(config)
336}
337
338/// Collapse a dotted key or table header to `a.b.c`: trims each segment and
339/// strips one layer of quotes so `[ "package" . metadata ]` compares equal to
340/// `[package.metadata]`.
341fn normalize_key(key: &str) -> String {
342    key.split('.')
343        .map(|segment| {
344            let segment = segment.trim();
345            segment
346                .strip_prefix('"')
347                .and_then(|s| s.strip_suffix('"'))
348                .or_else(|| {
349                    segment
350                        .strip_prefix('\'')
351                        .and_then(|s| s.strip_suffix('\''))
352                })
353                .unwrap_or(segment)
354        })
355        .collect::<Vec<_>>()
356        .join(".")
357}
358
359/// Parse a single-line TOML basic (`"..."`) or literal (`'...'`) string.
360///
361/// Escape sequences are not accepted: the value must be an R identifier
362/// fragment, which never needs one. Multi-line strings and non-string values
363/// return `None`.
364fn parse_string_value(value: &str) -> Option<String> {
365    let (quote, rest) = match value.chars().next()? {
366        c @ ('"' | '\'') => (c, &value[1..]),
367        _ => return None,
368    };
369    if rest.starts_with(quote) && rest[1..].starts_with(quote) {
370        // `"""` / `'''`: multi-line strings are not supported.
371        return None;
372    }
373    let end = rest.find(quote)?;
374    let body = &rest[..end];
375    if body.contains('\\') {
376        return None;
377    }
378    let trailing = rest[end + 1..].trim_start();
379    if !(trailing.is_empty() || trailing.starts_with('#')) {
380        return None;
381    }
382    Some(body.to_string())
383}
384
385/// Parse a single-line TOML array of strings (`["a", 'b']`, a trailing comma
386/// and a trailing `# comment` allowed), each element under the
387/// [`parse_string_value`] rules. Multi-line arrays and non-string elements
388/// return `None`.
389fn parse_string_array_value(value: &str) -> Option<Vec<String>> {
390    let mut rest = value.strip_prefix('[')?.trim_start();
391    let mut items = Vec::new();
392    loop {
393        if let Some(after) = rest.strip_prefix(']') {
394            let trailing = after.trim_start();
395            return (trailing.is_empty() || trailing.starts_with('#')).then_some(items);
396        }
397        let quote = rest.chars().next().filter(|c| #[allow(non_exhaustive_omitted_patterns)] match c {
    '"' | '\'' => true,
    _ => false,
}matches!(c, '"' | '\''))?;
398        let body_and_rest = &rest[1..];
399        let end = body_and_rest.find(quote)?;
400        let body = &body_and_rest[..end];
401        if body.contains('\\') {
402            return None;
403        }
404        items.push(body.to_string());
405        rest = body_and_rest[end + 1..].trim_start();
406        if let Some(after) = rest.strip_prefix(',') {
407            rest = after.trim_start();
408        } else if !rest.starts_with(']') {
409            return None;
410        }
411    }
412}
413
414/// Parse a TOML boolean, allowing a trailing `# comment`.
415fn parse_bool_value(value: &str) -> Option<bool> {
416    let (literal, rest) = match value.split_once(char::is_whitespace) {
417        Some((literal, rest)) => (literal, rest.trim_start()),
418        None => (value, ""),
419    };
420    if !(rest.is_empty() || rest.starts_with('#')) {
421        return None;
422    }
423    match literal {
424        "true" => Some(true),
425        "false" => Some(false),
426        _ => None,
427    }
428}
429
430/// `conversion_error_class` must name at least one class, each non-empty,
431/// distinct, and not one of the framework's own layers (`rust_error`,
432/// `simpleError`, `error`, `condition`), which are always appended.
433fn validate_conversion_error_class(classes: &[String]) -> Result<(), String> {
434    if classes.is_empty() {
435        return Err("`conversion_error_class` must name at least one class".to_string());
436    }
437    for (i, class) in classes.iter().enumerate() {
438        if class.trim().is_empty() {
439            return Err("`conversion_error_class` entries must not be empty".to_string());
440        }
441        if FRAMEWORK_ERROR_CLASSES.contains(&class.as_str()) {
442            return Err(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("`conversion_error_class` must not name `{0}`: every conversion error already carries `rust_error`, `simpleError`, `error` and `condition`",
                class))
    })format!(
443                "`conversion_error_class` must not name `{class}`: every conversion error already \
444                 carries `rust_error`, `simpleError`, `error` and `condition`"
445            ));
446        }
447        if classes[..i].contains(class) {
448            return Err(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("`conversion_error_class` names `{0}` more than once",
                class))
    })format!(
449                "`conversion_error_class` names `{class}` more than once"
450            ));
451        }
452    }
453    Ok(())
454}
455
456/// Mirror of `miniextendr_fn::validate_postfix` for the manifest value.
457fn validate_noexport_postfix(value: &str) -> Result<(), String> {
458    if value.is_empty() {
459        return Err("`noexport_postfix` must not be empty".to_string());
460    }
461    if !value
462        .chars()
463        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
464    {
465        return Err(
466            "`noexport_postfix` must be a valid R identifier fragment (letters, digits, `_`, `.`)"
467                .to_string(),
468        );
469    }
470    Ok(())
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    fn postfix(text: &str) -> Result<Option<String>, String> {
478        parse_crate_config(text).map(|c| c.noexport_postfix)
479    }
480
481    #[test]
482    fn header_form_is_read() {
483        let text = r#"
484[package]
485name = "demo"
486
487[package.metadata.miniextendr]
488noexport_postfix = "_impl" # trailing comment
489
490[dependencies]
491miniextendr-api = "0.1"
492"#;
493        assert_eq!(postfix(text), Ok(Some("_impl".to_string())));
494    }
495
496    #[test]
497    fn dotted_forms_are_read() {
498        let under_metadata = "[package.metadata]\nminiextendr.noexport_postfix = '_impl'\n";
499        assert_eq!(postfix(under_metadata), Ok(Some("_impl".to_string())));
500
501        let under_package = "[package]\nmetadata.miniextendr.noexport_postfix = \"_rs\"\n";
502        assert_eq!(postfix(under_package), Ok(Some("_rs".to_string())));
503
504        let quoted_header =
505            "[ \"package\" . metadata . 'miniextendr' ]\nnoexport_postfix = \"_impl\"\n";
506        assert_eq!(postfix(quoted_header), Ok(Some("_impl".to_string())));
507    }
508
509    #[test]
510    fn unrelated_tables_and_keys_are_ignored() {
511        let text = r#"
512[package.metadata.docs.rs]
513noexport_postfix = "not-ours"
514
515[package.metadata.miniextendr]
516other_key = "ignored"
517
518[[bin]]
519name = "x"
520noexport_postfix = "also not ours"
521"#;
522        assert_eq!(postfix(text), Ok(None));
523        assert_eq!(postfix(""), Ok(None));
524        assert_eq!(postfix("[package]\nname = \"demo\"\n"), Ok(None));
525    }
526
527    #[test]
528    fn source_tags_is_read_and_defaults_off() {
529        let source_tags = |text: &str| parse_crate_config(text).map(|c| c.source_tags);
530        assert_eq!(source_tags(""), Ok(false));
531        assert_eq!(
532            source_tags("[package.metadata.miniextendr]\nsource_tags = true # provenance\n"),
533            Ok(true)
534        );
535        assert_eq!(
536            source_tags("[package.metadata]\nminiextendr.source_tags = false\n"),
537            Ok(false)
538        );
539        // Both keys in one table.
540        let both =
541            "[package.metadata.miniextendr]\nnoexport_postfix = \"_impl\"\nsource_tags = true\n";
542        let config = parse_crate_config(both).unwrap();
543        assert_eq!(config.noexport_postfix.as_deref(), Some("_impl"));
544        assert!(config.source_tags);
545        assert!(
546            source_tags("[package.metadata.miniextendr]\nsource_tags = \"yes\"\n")
547                .unwrap_err()
548                .contains("must be `true` or `false`")
549        );
550        assert!(
551            source_tags("[package.metadata.miniextendr]\nsource_tags = true\nsource_tags = true\n")
552                .unwrap_err()
553                .contains("more than once")
554        );
555    }
556
557    #[test]
558    fn preconditions_is_read_and_defaults_unset() {
559        let preconditions = |text: &str| parse_crate_config(text).map(|c| c.preconditions);
560        assert_eq!(preconditions(""), Ok(None));
561        assert_eq!(
562            preconditions("[package.metadata.miniextendr]\npreconditions = false # hot paths\n"),
563            Ok(Some(false))
564        );
565        assert_eq!(
566            preconditions("[package.metadata]\nminiextendr.preconditions = true\n"),
567            Ok(Some(true))
568        );
569        assert!(
570            preconditions("[package.metadata.miniextendr]\npreconditions = \"no\"\n")
571                .unwrap_err()
572                .contains("`preconditions` must be `true` or `false`, found `\"no\"`")
573        );
574        assert!(
575            preconditions(
576                "[package.metadata.miniextendr]\npreconditions = false\npreconditions = true\n"
577            )
578            .unwrap_err()
579            .contains("`preconditions` is set more than once")
580        );
581        // Next to the other keys.
582        let all = "[package.metadata.miniextendr]\nsource_tags = true\npreconditions = false\ncall_attribution = \"caller\"\n";
583        let config = parse_crate_config(all).unwrap();
584        assert_eq!(config.preconditions, Some(false));
585        assert!(config.source_tags);
586    }
587
588    #[test]
589    fn roxygen_prose_links_is_read_and_validated() {
590        let links = |text: &str| parse_crate_config(text).map(|c| c.roxygen_prose_links);
591        assert_eq!(links(""), Ok(None));
592        assert_eq!(
593            links(
594                "[package.metadata.miniextendr]\nroxygen_prose_links = \"keep\" # R-first docs\n"
595            ),
596            Ok(Some(ProseLinks::Keep))
597        );
598        assert_eq!(
599            links("[package.metadata]\nminiextendr.roxygen_prose_links = 'strip'\n"),
600            Ok(Some(ProseLinks::Strip))
601        );
602        assert_eq!(
603            links("[package]\nmetadata.miniextendr.roxygen_prose_links = \"keep\"\n"),
604            Ok(Some(ProseLinks::Keep))
605        );
606        for value in ["\"preserve\"", "true", "keep"] {
607            let bad = links(&format!(
608                "[package.metadata.miniextendr]\nroxygen_prose_links = {value}\n"
609            ))
610            .unwrap_err();
611            assert!(
612                bad.contains("`roxygen_prose_links` must be one of \"strip\", \"keep\"")
613                    && bad.contains(&format!("found `{value}`")),
614                "{bad}"
615            );
616        }
617        let twice = links(
618            "[package.metadata.miniextendr]\nroxygen_prose_links = \"keep\"\nroxygen_prose_links = \"strip\"\n",
619        )
620        .unwrap_err();
621        assert!(
622            twice.contains("`roxygen_prose_links` is set more than once"),
623            "{twice}"
624        );
625        // Alongside the other keys.
626        let all = "[package.metadata.miniextendr]\nnoexport_postfix = \"_impl\"\nroxygen_prose_links = \"keep\"\nsource_tags = true\n";
627        let config = parse_crate_config(all).unwrap();
628        assert_eq!(config.noexport_postfix.as_deref(), Some("_impl"));
629        assert_eq!(config.roxygen_prose_links, Some(ProseLinks::Keep));
630        assert!(config.source_tags);
631    }
632
633    #[test]
634    fn call_attribution_is_read_and_validated() {
635        let attribution = |text: &str| parse_crate_config(text).map(|c| c.call_attribution);
636        assert_eq!(attribution(""), Ok(None));
637        assert_eq!(
638            attribution(
639                "[package.metadata.miniextendr]\ncall_attribution = \"caller\" # internal entry points\n"
640            ),
641            Ok(Some(CallAttribution::Caller))
642        );
643        assert_eq!(
644            attribution("[package.metadata]\nminiextendr.call_attribution = 'wrapper'\n"),
645            Ok(Some(CallAttribution::Wrapper))
646        );
647        assert_eq!(
648            attribution("[package]\nmetadata.miniextendr.call_attribution = \"wrapper\"\n"),
649            Ok(Some(CallAttribution::Wrapper))
650        );
651        assert_eq!(
652            attribution("[package.metadata.miniextendr]\ncall_attribution = 'none'\n"),
653            Ok(Some(CallAttribution::NoCall))
654        );
655        for value in ["\"parent\"", "'self'"] {
656            let bad = attribution(&format!(
657                "[package.metadata.miniextendr]\ncall_attribution = {value}\n"
658            ))
659            .unwrap_err();
660            assert!(
661                bad.contains("must be one of \"wrapper\", \"caller\", \"none\"")
662                    && bad.contains(&format!("`{value}`")),
663                "{bad}"
664            );
665        }
666        let bare =
667            attribution("[package.metadata.miniextendr]\ncall_attribution = caller\n").unwrap_err();
668        assert!(bare.contains("must be one of"), "{bare}");
669        let twice = attribution(
670            "[package.metadata.miniextendr]\ncall_attribution = \"wrapper\"\ncall_attribution = \"caller\"\n",
671        )
672        .unwrap_err();
673        assert!(twice.contains("more than once"), "{twice}");
674        // Alongside the other keys.
675        let all = "[package.metadata.miniextendr]\nnoexport_postfix = \"_impl\"\nsource_tags = true\ncall_attribution = \"caller\"\n";
676        let config = parse_crate_config(all).unwrap();
677        assert_eq!(config.noexport_postfix.as_deref(), Some("_impl"));
678        assert!(config.source_tags);
679        assert_eq!(config.call_attribution, Some(CallAttribution::Caller));
680    }
681
682    #[test]
683    fn conversion_error_class_is_read_and_validated() {
684        let classes = |text: &str| parse_crate_config(text).map(|c| c.conversion_error_class);
685        let owned = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
686        assert_eq!(classes(""), Ok(Vec::new()));
687        // A single string and the array spellings.
688        assert_eq!(
689            classes("[package.metadata.miniextendr]\nconversion_error_class = \"pkg_error\"\n"),
690            Ok(owned(&["pkg_error"]))
691        );
692        assert_eq!(
693            classes(
694                "[package.metadata.miniextendr]\nconversion_error_class = [\"pkg_error_argument\", 'pkg_error'] # family last\n"
695            ),
696            Ok(owned(&["pkg_error_argument", "pkg_error"]))
697        );
698        assert_eq!(
699            classes(
700                "[package.metadata]\nminiextendr.conversion_error_class = [ \"a\" , \"b\" , ]\n"
701            ),
702            Ok(owned(&["a", "b"]))
703        );
704        assert_eq!(
705            classes("[package]\nmetadata.miniextendr.conversion_error_class = ['pkg.error']\n"),
706            Ok(owned(&["pkg.error"]))
707        );
708
709        let err = |text: &str| classes(text).unwrap_err();
710        let table = "[package.metadata.miniextendr]\n";
711        for bad in [
712            "conversion_error_class = pkg_error\n",
713            "conversion_error_class = 1\n",
714            "conversion_error_class = [\"a\", 1]\n",
715            "conversion_error_class = [\"a\" \"b\"]\n",
716            "conversion_error_class = [\"a\"] trailing\n",
717            "conversion_error_class = [\"a\\tb\"]\n",
718            "conversion_error_class = [\n",
719        ] {
720            let msg = err(&format!("{table}{bad}"));
721            assert!(msg.contains("single-line array of strings"), "{bad}: {msg}");
722        }
723        assert!(err(&format!("{table}conversion_error_class = []\n")).contains("at least one"));
724        assert!(err(&format!("{table}conversion_error_class = [\"\"]\n")).contains("not be empty"));
725        let layered = err(&format!(
726            "{table}conversion_error_class = [\"pkg_error\", \"rust_error\"]\n"
727        ));
728        assert!(layered.contains("must not name `rust_error`"), "{layered}");
729        let dup = err(&format!("{table}conversion_error_class = [\"a\", \"a\"]\n"));
730        assert!(dup.contains("more than once"), "{dup}");
731        let twice = err(&format!(
732            "{table}conversion_error_class = \"a\"\nconversion_error_class = \"b\"\n"
733        ));
734        assert!(twice.contains("is set more than once"), "{twice}");
735
736        // Alongside the other keys.
737        let all = "[package.metadata.miniextendr]\nnoexport_postfix = \"_impl\"\nconversion_error_class = [\"pkg_error_argument\", \"pkg_error\"]\ncall_attribution = \"caller\"\n";
738        let config = parse_crate_config(all).unwrap();
739        assert_eq!(config.noexport_postfix.as_deref(), Some("_impl"));
740        assert_eq!(config.call_attribution, Some(CallAttribution::Caller));
741        assert_eq!(
742            config.conversion_error_class,
743            owned(&["pkg_error_argument", "pkg_error"])
744        );
745    }
746
747    #[test]
748    fn malformed_values_are_errors() {
749        let empty = "[package.metadata.miniextendr]\nnoexport_postfix = \"\"\n";
750        assert!(postfix(empty).unwrap_err().contains("must not be empty"));
751
752        let bad_chars = "[package.metadata.miniextendr]\nnoexport_postfix = \"-impl\"\n";
753        assert!(
754            postfix(bad_chars)
755                .unwrap_err()
756                .contains("identifier fragment")
757        );
758
759        let not_a_string = "[package.metadata.miniextendr]\nnoexport_postfix = 1\n";
760        assert!(
761            postfix(not_a_string)
762                .unwrap_err()
763                .contains("must be a string")
764        );
765
766        let escaped = "[package.metadata.miniextendr]\nnoexport_postfix = \"a\\tb\"\n";
767        assert!(postfix(escaped).unwrap_err().contains("must be a string"));
768
769        let multiline = "[package.metadata.miniextendr]\nnoexport_postfix = \"\"\"_impl\"\"\"\n";
770        assert!(postfix(multiline).unwrap_err().contains("must be a string"));
771
772        let twice = "[package.metadata.miniextendr]\nnoexport_postfix = \"_a\"\nnoexport_postfix = \"_b\"\n";
773        assert!(postfix(twice).unwrap_err().contains("more than once"));
774
775        let inline = "[package.metadata]\nminiextendr = { noexport_postfix = \"_impl\" }\n";
776        assert!(postfix(inline).unwrap_err().contains("inline tables"));
777    }
778
779    #[test]
780    fn manifest_dir_lookup_reads_and_caches() {
781        let dir = std::env::temp_dir().join(format!(
782            "miniextendr-crate-config-{}-{}",
783            std::process::id(),
784            std::time::SystemTime::now()
785                .duration_since(std::time::UNIX_EPOCH)
786                .map(|d| d.as_nanos())
787                .unwrap_or_default()
788        ));
789        std::fs::create_dir_all(&dir).unwrap();
790        std::fs::write(
791            dir.join("Cargo.toml"),
792            "[package]\nname = \"demo\"\n\n[package.metadata.miniextendr]\nnoexport_postfix = \"_impl\"\n",
793        )
794        .unwrap();
795
796        let first = crate_config_for_dir(&dir).unwrap();
797        assert_eq!(first.noexport_postfix.as_deref(), Some("_impl"));
798
799        // The cache answers subsequent lookups; a later edit is not observed
800        // within one process (Cargo starts a new rustc per manifest change).
801        std::fs::write(dir.join("Cargo.toml"), "[package]\nname = \"demo\"\n").unwrap();
802        let second = crate_config_for_dir(&dir).unwrap();
803        assert_eq!(second, first);
804
805        let missing = crate_config_for_dir(&dir.join("does-not-exist")).unwrap();
806        assert_eq!(missing, CrateConfig::default());
807
808        let _ = std::fs::remove_dir_all(&dir);
809    }
810}