use std::path::{Path, PathBuf};
use std::sync::Arc;
use cljrs_reader::{Form, FormKind, Parser};
use crate::{Alias, Dependency, DepsConfig, GitDep, RustConfig, TrustedSigner};
pub fn parse_config(src: &str, config_path: &Path) -> Result<DepsConfig, String> {
let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
let mut parser = Parser::new(src.to_owned(), config_path.display().to_string());
let forms = parser.parse_all().map_err(|e| e.to_string())?;
if forms.len() != 1 {
return Err(format!(
"cljrs.edn must contain exactly one top-level map; found {} forms",
forms.len()
));
}
extract_config(&forms[0], config_dir)
}
fn extract_config(form: &Form, config_dir: &Path) -> Result<DepsConfig, String> {
let pairs = require_map(form, "top-level cljrs.edn")?;
let mut config = DepsConfig::default();
let mut i = 0;
while i + 1 < pairs.len() {
let key = &pairs[i];
let val = &pairs[i + 1];
i += 2;
match keyword_name(key) {
Some("paths") => {
config.paths = extract_path_vec(val, ":paths", config_dir)?;
}
Some("deps") => {
config.deps = extract_deps_map(val, config_dir)?;
}
Some("aliases") => {
config.aliases = extract_aliases_map(val, config_dir)?;
}
Some("verify-commit-signatures") => match &val.kind {
FormKind::Bool(b) => config.verify_commit_signatures = *b,
_ => return Err(":verify-commit-signatures must be true or false".to_string()),
},
Some("trusted-signers") => {
config.trusted_signers = extract_trusted_signers(val, config_dir)?;
}
Some("enforce-native-versions") => match &val.kind {
FormKind::Bool(b) => config.enforce_native_versions = *b,
_ => return Err(":enforce-native-versions must be true or false".to_string()),
},
Some("rust") => {
config.rust = Some(extract_rust_config(val, config_dir)?);
}
_ => {} }
}
Ok(config)
}
fn extract_path_vec(form: &Form, ctx: &str, base: &Path) -> Result<Vec<PathBuf>, String> {
let items = require_vec(form, ctx)?;
items
.iter()
.map(|f| {
let s = require_str(f, ctx)?;
Ok(base.join(s))
})
.collect()
}
fn extract_trusted_signers(form: &Form, base: &Path) -> Result<Vec<TrustedSigner>, String> {
let items = require_vec(form, ":trusted-signers")?;
items
.iter()
.map(|f| {
let s = require_str(f, ":trusted-signers")?;
Ok(if looks_like_inline_key(s) {
TrustedSigner::Inline(s.to_string())
} else {
TrustedSigner::File(base.join(s))
})
})
.collect()
}
fn looks_like_inline_key(s: &str) -> bool {
let t = s.trim_start();
t.starts_with("-----BEGIN PGP")
|| matches!(
t.split_whitespace().next(),
Some(
"ssh-ed25519"
| "ssh-rsa"
| "ssh-dss"
| "ecdsa-sha2-nistp256"
| "ecdsa-sha2-nistp384"
| "ecdsa-sha2-nistp521"
| "sk-ssh-ed25519@openssh.com"
| "sk-ecdsa-sha2-nistp256@openssh.com"
)
)
}
fn extract_deps_map(form: &Form, config_dir: &Path) -> Result<Vec<(Arc<str>, Dependency)>, String> {
let pairs = require_map(form, ":deps")?;
let mut out = Vec::new();
let mut i = 0;
while i + 1 < pairs.len() {
let name = sym_or_kw_name(&pairs[i])
.ok_or_else(|| format!(":deps key must be a symbol, got {:?}", pairs[i].kind))?;
let dep = extract_dependency(&pairs[i + 1], config_dir, &name)?;
out.push((Arc::from(name), dep));
i += 2;
}
Ok(out)
}
fn extract_dependency(form: &Form, config_dir: &Path, name: &str) -> Result<Dependency, String> {
let pairs = require_map(form, &format!("dep {name}"))?;
let mut git_url: Option<Arc<str>> = None;
let mut git_sha: Option<Arc<str>> = None;
let mut local_root: Option<PathBuf> = None;
let mut rust_init: Option<Arc<str>> = None;
let mut rust_crate_dir: Option<Arc<str>> = None;
let mut rust_load_dylib = false;
let mut i = 0;
while i + 1 < pairs.len() {
match keyword_name(&pairs[i]) {
Some("git/url") => {
git_url = Some(Arc::from(require_str(&pairs[i + 1], "git/url")?));
}
Some("git/sha") => {
git_sha = Some(Arc::from(require_str(&pairs[i + 1], "git/sha")?));
}
Some("local/root") => {
let rel = require_str(&pairs[i + 1], "local/root")?;
local_root = Some(config_dir.join(rel));
}
Some("rust/init") => {
rust_init = Some(Arc::from(require_str(&pairs[i + 1], "rust/init")?));
}
Some("rust/crate") => {
rust_crate_dir = Some(Arc::from(require_str(&pairs[i + 1], "rust/crate")?));
}
Some("rust/load") => match keyword_name(&pairs[i + 1]) {
Some("dylib") => rust_load_dylib = true,
_ => return Err(format!("dep {name}: :rust/load must be :dylib")),
},
_ => {}
}
i += 2;
}
match (git_url, git_sha, local_root) {
(Some(url), Some(sha), _) => Ok(Dependency::Git(GitDep {
url,
sha,
rust_init,
rust_crate_dir,
rust_load_dylib,
})),
(_, _, Some(root)) => Ok(Dependency::Local { root }),
_ => Err(format!(
"dep {name}: must specify either :git/url + :git/sha or :local/root"
)),
}
}
fn extract_aliases_map(form: &Form, config_dir: &Path) -> Result<Vec<(Arc<str>, Alias)>, String> {
let pairs = require_map(form, ":aliases")?;
let mut out = Vec::new();
let mut i = 0;
while i + 1 < pairs.len() {
let name = keyword_name(&pairs[i])
.ok_or_else(|| ":aliases key must be a keyword".to_string())?
.to_owned();
let alias = extract_alias(&pairs[i + 1], config_dir, &name)?;
out.push((Arc::from(name), alias));
i += 2;
}
Ok(out)
}
fn extract_alias(form: &Form, config_dir: &Path, name: &str) -> Result<Alias, String> {
let pairs = require_map(form, &format!("alias :{name}"))?;
let mut alias = Alias::default();
let mut i = 0;
while i + 1 < pairs.len() {
match keyword_name(&pairs[i]) {
Some("extra-paths") => {
alias.extra_paths = extract_path_vec(&pairs[i + 1], ":extra-paths", config_dir)?;
}
Some("extra-deps") => {
alias.extra_deps = extract_deps_map(&pairs[i + 1], config_dir)?;
}
_ => {}
}
i += 2;
}
Ok(alias)
}
fn extract_rust_config(form: &Form, config_dir: &Path) -> Result<RustConfig, String> {
let pairs = require_map(form, ":rust")?;
let mut crate_dir: PathBuf = config_dir.to_path_buf();
let mut init_fn: Option<Arc<str>> = None;
let mut i = 0;
while i + 1 < pairs.len() {
match keyword_name(&pairs[i]) {
Some("crate") => {
let rel = require_str(&pairs[i + 1], ":rust :crate")?;
crate_dir = config_dir.join(rel);
}
Some("init") => {
let s = require_str(&pairs[i + 1], ":rust :init")?;
init_fn = Some(Arc::from(s));
}
_ => {}
}
i += 2;
}
Ok(RustConfig { crate_dir, init_fn })
}
fn require_map<'a>(form: &'a Form, ctx: &str) -> Result<&'a Vec<Form>, String> {
match &form.kind {
FormKind::Map(pairs) => Ok(pairs),
_ => Err(format!("{ctx}: expected a map, got {:?}", form.kind)),
}
}
fn require_vec<'a>(form: &'a Form, ctx: &str) -> Result<&'a Vec<Form>, String> {
match &form.kind {
FormKind::Vector(items) => Ok(items),
_ => Err(format!("{ctx}: expected a vector, got {:?}", form.kind)),
}
}
fn require_str<'a>(form: &'a Form, ctx: &str) -> Result<&'a str, String> {
match &form.kind {
FormKind::Str(s) => Ok(s.as_str()),
_ => Err(format!("{ctx}: expected a string, got {:?}", form.kind)),
}
}
fn keyword_name(form: &Form) -> Option<&str> {
match &form.kind {
FormKind::Keyword(k) => Some(k.as_str()),
_ => None,
}
}
fn sym_or_kw_name(form: &Form) -> Option<String> {
match &form.kind {
FormKind::Symbol(s) => Some(s.clone()),
FormKind::Keyword(k) => Some(k.clone()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn parse(src: &str) -> Result<DepsConfig, String> {
parse_config(src, Path::new("/proj/cljrs.edn"))
}
#[test]
fn rust_key_defaults() {
let cfg = parse(r#"{:rust {:crate "."}}"#).unwrap();
let rust = cfg.rust.unwrap();
assert_eq!(rust.crate_dir, Path::new("/proj"));
assert!(rust.init_fn.is_none());
}
#[test]
fn rust_key_with_init() {
let cfg = parse(r#"{:rust {:crate "." :init "my_crate::cljrs_init"}}"#).unwrap();
let rust = cfg.rust.unwrap();
assert_eq!(rust.init_fn.as_deref(), Some("my_crate::cljrs_init"));
}
#[test]
fn rust_key_subdirectory_crate() {
let cfg = parse(r#"{:rust {:crate "native"}}"#).unwrap();
let rust = cfg.rust.unwrap();
assert_eq!(rust.crate_dir, Path::new("/proj/native"));
}
#[test]
fn no_rust_key_is_none() {
let cfg = parse(r#"{:paths ["src"]}"#).unwrap();
assert!(cfg.rust.is_none());
}
#[test]
fn trusted_signers_inline_and_file() {
let cfg = parse(
r#"{:trusted-signers ["ssh-ed25519 AAAAaaaa comment"
"keys/signer.asc"]}"#,
)
.unwrap();
assert_eq!(cfg.trusted_signers.len(), 2);
assert_eq!(
cfg.trusted_signers[0],
TrustedSigner::Inline("ssh-ed25519 AAAAaaaa comment".to_string())
);
assert_eq!(
cfg.trusted_signers[1],
TrustedSigner::File(Path::new("/proj/keys/signer.asc").to_path_buf())
);
}
#[test]
fn rust_alongside_other_keys() {
let cfg = parse(
r#"{:paths ["src"]
:rust {:crate "." :init "lib::cljrs_init"}}"#,
)
.unwrap();
assert_eq!(cfg.paths.len(), 1);
assert!(cfg.rust.is_some());
}
}