1use 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
37const NOEXPORT_POSTFIX_KEY: &str = "package.metadata.miniextendr.noexport_postfix";
39const SOURCE_TAGS_KEY: &str = "package.metadata.miniextendr.source_tags";
41const CALL_ATTRIBUTION_KEY: &str = "package.metadata.miniextendr.call_attribution";
43const CONVERSION_ERROR_CLASS_KEY: &str = "package.metadata.miniextendr.conversion_error_class";
45const PRECONDITIONS_KEY: &str = "package.metadata.miniextendr.preconditions";
47const ROXYGEN_PROSE_LINKS_KEY: &str = "package.metadata.miniextendr.roxygen_prose_links";
49const FRAMEWORK_ERROR_CLASSES: [&str; 4] = ["rust_error", "simpleError", "error", "condition"];
52const TABLE_KEY: &str = "package.metadata.miniextendr";
54
55#[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 pub(crate) noexport_postfix: Option<String>,
62 pub(crate) source_tags: bool,
68 pub(crate) call_attribution: Option<CallAttribution>,
75 pub(crate) conversion_error_class: Vec<String>,
80 pub(crate) preconditions: Option<bool>,
87 pub(crate) roxygen_prose_links: Option<ProseLinks>,
91}
92
93#[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
112pub(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
125pub(crate) fn source_tags_enabled() -> bool {
132 crate_config().map(|c| c.source_tags).unwrap_or(false)
133}
134
135pub(crate) fn conversion_error_class() -> Vec<String> {
143 crate_config()
144 .map(|c| c.conversion_error_class)
145 .unwrap_or_default()
146}
147
148pub(crate) fn preconditions_default() -> Option<bool> {
156 crate_config().ok().and_then(|c| c.preconditions)
157}
158
159pub(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
173pub(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
187fn 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
198pub(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 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
338fn 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
359fn 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 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
385fn 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
414fn 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
430fn 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
456fn 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 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 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 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 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 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 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 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}