use anyhow::{Context, Result, ensure};
use serde::Deserialize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawConfig {
#[serde(rename = "crate", default)]
krate: RawCrate,
python: Option<RawPython>,
typescript: Option<RawTypescript>,
schema: Option<RawSchema>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawCrate {
#[serde(default = "default_crate_path")]
path: String,
}
impl Default for RawCrate {
fn default() -> Self {
Self {
path: default_crate_path(),
}
}
}
fn default_crate_path() -> String {
".".to_string()
}
fn default_enabled() -> bool {
true
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawPython {
#[serde(default = "default_enabled")]
enabled: bool,
out: String,
#[serde(default)]
library_search: Vec<String>,
#[serde(default)]
imports: BTreeMap<String, String>,
#[serde(default)]
exclude: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawTypescript {
#[serde(default = "default_enabled")]
enabled: bool,
out: String,
#[serde(default)]
imports: BTreeMap<String, String>,
#[serde(default)]
exclude: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawSchema {
#[serde(default = "default_enabled")]
enabled: bool,
out: String,
}
#[derive(Debug)]
pub struct Config {
pub crate_dir: PathBuf,
pub python: Option<PythonConfig>,
pub typescript: Option<TypescriptConfig>,
pub schema: Option<SchemaConfig>,
}
#[derive(Debug)]
pub struct PythonConfig {
pub out: PathBuf,
pub library_search: Vec<String>,
pub imports: BTreeMap<String, String>,
pub exclude: Vec<String>,
}
#[derive(Debug)]
pub struct TypescriptConfig {
pub out: PathBuf,
pub imports: BTreeMap<String, String>,
pub exclude: Vec<String>,
}
#[derive(Debug)]
pub struct SchemaConfig {
pub out: PathBuf,
}
pub fn load(path: &Path) -> Result<Config> {
let text = std::fs::read_to_string(path).with_context(|| {
format!(
"cannot read config `{}` (run `rspyts init` to create one)",
path.display()
)
})?;
let raw: RawConfig =
toml::from_str(&text).with_context(|| format!("invalid config `{}`", path.display()))?;
let base = absolute(path)?;
let base = base
.parent()
.context("config path has no parent directory")?;
Ok(resolve(raw, base))
}
fn resolve(raw: RawConfig, base: &Path) -> Config {
let join = |p: &str| -> PathBuf {
let p = Path::new(p);
if p.is_absolute() {
p.to_path_buf()
} else {
base.join(p)
}
};
Config {
crate_dir: join(&raw.krate.path),
python: raw.python.filter(|s| s.enabled).map(|s| PythonConfig {
out: join(&s.out),
library_search: s.library_search,
imports: s.imports,
exclude: s.exclude,
}),
typescript: raw
.typescript
.filter(|s| s.enabled)
.map(|s| TypescriptConfig {
out: join(&s.out),
imports: s.imports,
exclude: s.exclude,
}),
schema: raw
.schema
.filter(|s| s.enabled)
.map(|s| SchemaConfig { out: join(&s.out) }),
}
}
fn absolute(path: &Path) -> Result<PathBuf> {
if path.is_absolute() {
return Ok(path.to_path_buf());
}
let cwd = std::env::current_dir().context("cannot determine current directory")?;
Ok(cwd.join(path))
}
pub const INIT_TEMPLATE: &str = r#"# rspyts configuration. All relative paths resolve against this file's
# own directory. See docs/design/codegen.md in the rspyts repository.
[crate]
# Directory containing the bridged crate's Cargo.toml.
path = "."
[python]
enabled = true
out = "../python/src/my_package/_generated"
# Candidate directories searched for the compiled cdylib at import time,
# relative to the generated package directory. The RSPYTS_LIBRARY env var
# and Library.set_path() always take precedence.
library_search = ["../../../../target/debug", "../../../../target/release"]
# Items to drop from this projection (simple globs; methods and statics
# match as "Class.method"):
# exclude = ["render_*", "Recording.preload"]
# Bridged types defined in a dependency crate can be imported from that
# crate's own generated package instead of re-emitted here:
# [python.imports]
# "other-crate" = "other_package._generated"
[typescript]
enabled = true
out = "../typescript/src/generated"
# exclude = ["load_values"]
# Same idea for TypeScript, mapping to a module specifier:
# [typescript.imports]
# "other-crate" = "@scope/other/generated"
[schema]
enabled = true
out = "../schema"
"#;
pub fn init(dir: &Path) -> Result<PathBuf> {
std::fs::create_dir_all(dir)
.with_context(|| format!("cannot create directory `{}`", dir.display()))?;
let path = dir.join("rspyts.toml");
ensure!(
!path.exists(),
"`{}` already exists; refusing to overwrite",
path.display()
);
std::fs::write(&path, INIT_TEMPLATE)
.with_context(|| format!("cannot write `{}`", path.display()))?;
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(text: &str) -> Config {
let raw: RawConfig = toml::from_str(text).expect("config parses");
resolve(raw, Path::new("/project"))
}
#[test]
fn full_config_resolves_relative_paths() {
let cfg = parse(
r#"
[crate]
path = "rust"
[python]
enabled = true
out = "python/_generated"
library_search = ["../target/debug"]
[typescript]
out = "ts/generated"
[schema]
out = "schema"
"#,
);
assert_eq!(cfg.crate_dir, Path::new("/project/rust"));
assert_eq!(
cfg.python.as_ref().unwrap().out,
Path::new("/project/python/_generated")
);
assert_eq!(
cfg.python.unwrap().library_search,
vec!["../target/debug".to_string()]
);
assert_eq!(
cfg.typescript.unwrap().out,
Path::new("/project/ts/generated")
);
assert_eq!(cfg.schema.unwrap().out, Path::new("/project/schema"));
}
#[test]
fn imports_tables_round_trip() {
let cfg = parse(
r#"
[python]
out = "py"
[python.imports]
"neurovirtual-hardware" = "neurovirtual.hardware.generated"
"other-crate" = "other_package._generated"
[typescript]
out = "ts"
[typescript.imports]
"neurovirtual-hardware" = "@neurovirtual/hardware/generated"
"#,
);
let py = cfg.python.unwrap();
assert_eq!(
py.imports.get("neurovirtual-hardware").map(String::as_str),
Some("neurovirtual.hardware.generated")
);
assert_eq!(
py.imports.get("other-crate").map(String::as_str),
Some("other_package._generated")
);
let ts = cfg.typescript.unwrap();
assert_eq!(
ts.imports.get("neurovirtual-hardware").map(String::as_str),
Some("@neurovirtual/hardware/generated")
);
}
#[test]
fn imports_default_to_empty() {
let cfg = parse("[python]\nout = \"py\"\n\n[typescript]\nout = \"ts\"\n");
assert!(cfg.python.unwrap().imports.is_empty());
assert!(cfg.typescript.unwrap().imports.is_empty());
}
#[test]
fn exclude_lists_parse_and_default_to_empty() {
let cfg = parse(
r#"
[python]
out = "py"
exclude = ["render_*", "Recording.preload"]
[typescript]
out = "ts"
"#,
);
assert_eq!(
cfg.python.unwrap().exclude,
vec!["render_*".to_string(), "Recording.preload".to_string()]
);
assert!(cfg.typescript.unwrap().exclude.is_empty());
}
#[test]
fn sections_default_to_absent_and_crate_path_defaults_to_dot() {
let cfg = parse("");
assert_eq!(cfg.crate_dir, Path::new("/project/."));
assert!(cfg.python.is_none());
assert!(cfg.typescript.is_none());
assert!(cfg.schema.is_none());
}
#[test]
fn disabled_section_is_dropped() {
let cfg = parse("[python]\nenabled = false\nout = \"x\"\n");
assert!(cfg.python.is_none());
}
#[test]
fn unknown_keys_are_rejected() {
let err = toml::from_str::<RawConfig>("[python]\nout = \"x\"\ntypo = 1\n").unwrap_err();
assert!(
err.to_string().contains("typo"),
"unexpected message: {err}"
);
}
#[test]
fn unknown_top_level_section_is_rejected() {
assert!(toml::from_str::<RawConfig>("[pyhton]\nout = \"x\"\n").is_err());
}
#[test]
fn init_template_parses() {
let raw: RawConfig = toml::from_str(INIT_TEMPLATE).expect("template parses");
let cfg = resolve(raw, Path::new("/p"));
assert!(cfg.python.is_some() && cfg.typescript.is_some() && cfg.schema.is_some());
}
}