use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
mod argv;
mod citation;
mod command_defaults;
mod sync;
pub use argv::{ArgvRunConfig, ArgvStep};
pub use citation::{CitationAuthor, CitationConfig};
pub use command_defaults::{BuildCommandConfig, CleanConfig, LintConfig, SetupConfig, UpdateConfig};
pub use sync::{SyncConfig, TextReplacement};
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ExcludeConfig {
#[serde(default)]
pub types: Vec<String>,
#[serde(default)]
pub functions: Vec<String>,
#[serde(default)]
pub methods: Vec<String>,
#[serde(default)]
pub fields: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct IncludeConfig {
#[serde(default)]
pub types: Vec<String>,
#[serde(default)]
pub functions: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct OutputConfig {
pub python: Option<PathBuf>,
pub node: Option<PathBuf>,
pub ruby: Option<PathBuf>,
pub php: Option<PathBuf>,
pub elixir: Option<PathBuf>,
pub wasm: Option<PathBuf>,
pub ffi: Option<PathBuf>,
pub go: Option<PathBuf>,
pub java: Option<PathBuf>,
pub kotlin: Option<PathBuf>,
pub kotlin_android: Option<PathBuf>,
pub dart: Option<PathBuf>,
pub swift: Option<PathBuf>,
pub gleam: Option<PathBuf>,
pub csharp: Option<PathBuf>,
pub r: Option<PathBuf>,
pub zig: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ScaffoldConfig {
pub description: Option<String>,
pub license: Option<String>,
pub repository: Option<String>,
pub homepage: Option<String>,
#[serde(default)]
pub authors: Vec<String>,
#[serde(default)]
pub keywords: Vec<String>,
#[serde(default)]
pub generated_header: Option<GeneratedHeaderConfig>,
pub cargo: Option<ScaffoldCargo>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct GeneratedHeaderConfig {
#[serde(default)]
pub issues_url: Option<String>,
#[serde(default)]
pub regenerate_command: Option<String>,
#[serde(default)]
pub verify_command: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ScaffoldCargo {
#[serde(default)]
pub targets: ScaffoldCargoTargets,
#[serde(default = "default_build_jobs")]
pub build_jobs: u32,
#[serde(default)]
pub rustc_wrapper: Option<String>,
#[serde(default)]
pub env: HashMap<String, ScaffoldCargoEnvValue>,
}
impl Default for ScaffoldCargo {
fn default() -> Self {
Self {
targets: ScaffoldCargoTargets::default(),
build_jobs: default_build_jobs(),
rustc_wrapper: None,
env: HashMap::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ScaffoldCargoTargets {
#[serde(default = "default_true")]
pub macos_dynamic_lookup: bool,
#[serde(default = "default_true")]
pub x86_64_pc_windows_msvc: bool,
#[serde(default = "default_true")]
pub i686_pc_windows_msvc: bool,
#[serde(default = "default_true")]
pub aarch64_unknown_linux_gnu: bool,
#[serde(default = "default_true")]
pub x86_64_unknown_linux_musl: bool,
#[serde(default = "default_true")]
pub wasm32_unknown_unknown: bool,
}
impl Default for ScaffoldCargoTargets {
fn default() -> Self {
Self {
macos_dynamic_lookup: true,
x86_64_pc_windows_msvc: true,
i686_pc_windows_msvc: true,
aarch64_unknown_linux_gnu: true,
x86_64_unknown_linux_musl: true,
wasm32_unknown_unknown: true,
}
}
}
fn default_true() -> bool {
true
}
fn default_build_jobs() -> u32 {
4
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum ScaffoldCargoEnvValue {
Plain(String),
Structured {
value: String,
#[serde(default)]
relative: bool,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ReadmeConfig {
pub template_dir: Option<PathBuf>,
pub snippets_dir: Option<PathBuf>,
pub config: Option<PathBuf>,
pub output_pattern: Option<String>,
pub discord_url: Option<String>,
pub banner_url: Option<String>,
#[serde(default)]
pub languages: HashMap<String, JsonValue>,
#[serde(default)]
pub targets: HashMap<String, JsonValue>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum DocsReferenceLinkStyle {
#[default]
Suffixed,
Extensionless,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct DocsConfig {
#[serde(default)]
pub reference_output: Option<PathBuf>,
#[serde(default)]
pub cli: Option<DocsSourceConfig>,
#[serde(default)]
pub mcp: Option<DocsMcpConfig>,
#[serde(default)]
pub llms: Option<DocsLlmsConfig>,
#[serde(default)]
pub skills: Option<DocsSkillsConfig>,
#[serde(default)]
pub snippets: Option<DocsSnippetsConfig>,
#[serde(default)]
pub reference_link_style: DocsReferenceLinkStyle,
}
impl DocsConfig {
#[must_use]
pub fn merge(workspace: Option<&Self>, krate: Option<&Self>) -> Option<Self> {
if workspace.is_none() && krate.is_none() {
return None;
}
Some(Self {
reference_output: krate
.and_then(|cfg| cfg.reference_output.clone())
.or_else(|| workspace.and_then(|cfg| cfg.reference_output.clone())),
cli: DocsSourceConfig::merge(
workspace.and_then(|cfg| cfg.cli.as_ref()),
krate.and_then(|cfg| cfg.cli.as_ref()),
),
mcp: DocsMcpConfig::merge(
workspace.and_then(|cfg| cfg.mcp.as_ref()),
krate.and_then(|cfg| cfg.mcp.as_ref()),
),
llms: DocsLlmsConfig::merge(
workspace.and_then(|cfg| cfg.llms.as_ref()),
krate.and_then(|cfg| cfg.llms.as_ref()),
),
skills: DocsSkillsConfig::merge(
workspace.and_then(|cfg| cfg.skills.as_ref()),
krate.and_then(|cfg| cfg.skills.as_ref()),
),
snippets: DocsSnippetsConfig::merge(
workspace.and_then(|cfg| cfg.snippets.as_ref()),
krate.and_then(|cfg| cfg.snippets.as_ref()),
),
reference_link_style: krate
.map(|cfg| cfg.reference_link_style)
.unwrap_or_else(|| workspace.map(|cfg| cfg.reference_link_style).unwrap_or_default()),
})
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct DocsSourceConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub sources: Vec<PathBuf>,
#[serde(default)]
pub output: Option<PathBuf>,
#[serde(default)]
pub adopt_existing: bool,
}
impl DocsSourceConfig {
#[must_use]
pub fn merge(workspace: Option<&Self>, krate: Option<&Self>) -> Option<Self> {
if workspace.is_none() && krate.is_none() {
return None;
}
let sources = krate
.filter(|cfg| !cfg.sources.is_empty())
.map(|cfg| cfg.sources.clone())
.or_else(|| {
workspace
.filter(|cfg| !cfg.sources.is_empty())
.map(|cfg| cfg.sources.clone())
})
.unwrap_or_default();
Some(Self {
enabled: krate
.and_then(|cfg| cfg.enabled)
.or_else(|| workspace.and_then(|cfg| cfg.enabled)),
sources,
output: krate
.and_then(|cfg| cfg.output.clone())
.or_else(|| workspace.and_then(|cfg| cfg.output.clone())),
adopt_existing: krate
.map(|cfg| cfg.adopt_existing)
.unwrap_or_else(|| workspace.map(|cfg| cfg.adopt_existing).unwrap_or(false)),
})
}
#[must_use]
pub fn is_enabled(&self) -> bool {
self.enabled.unwrap_or(true)
}
}
mod mcp;
pub use mcp::{DeclaredMcpItem, DeclaredMcpKind, DocsMcpConfig};
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct DocsLlmsConfig {
#[serde(default)]
pub template: Option<PathBuf>,
#[serde(default)]
pub output: Option<PathBuf>,
#[serde(default)]
pub adopt_existing: bool,
}
impl DocsLlmsConfig {
#[must_use]
pub fn merge(workspace: Option<&Self>, krate: Option<&Self>) -> Option<Self> {
if workspace.is_none() && krate.is_none() {
return None;
}
Some(Self {
template: krate
.and_then(|cfg| cfg.template.clone())
.or_else(|| workspace.and_then(|cfg| cfg.template.clone())),
output: krate
.and_then(|cfg| cfg.output.clone())
.or_else(|| workspace.and_then(|cfg| cfg.output.clone())),
adopt_existing: krate
.map(|cfg| cfg.adopt_existing)
.unwrap_or_else(|| workspace.map(|cfg| cfg.adopt_existing).unwrap_or(false)),
})
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct DocsSkillsConfig {
#[serde(default)]
pub template_dir: Option<PathBuf>,
#[serde(default)]
pub outputs: Vec<PathBuf>,
#[serde(default)]
pub templates: HashMap<String, DocsSkillTemplateConfig>,
#[serde(default)]
pub adopt_existing: bool,
}
impl DocsSkillsConfig {
#[must_use]
pub fn merge(workspace: Option<&Self>, krate: Option<&Self>) -> Option<Self> {
if workspace.is_none() && krate.is_none() {
return None;
}
let outputs = krate
.filter(|cfg| !cfg.outputs.is_empty())
.map(|cfg| cfg.outputs.clone())
.or_else(|| {
workspace
.filter(|cfg| !cfg.outputs.is_empty())
.map(|cfg| cfg.outputs.clone())
})
.unwrap_or_default();
let mut templates = workspace.map(|cfg| cfg.templates.clone()).unwrap_or_default();
if let Some(krate) = krate {
templates.extend(krate.templates.clone());
}
Some(Self {
template_dir: krate
.and_then(|cfg| cfg.template_dir.clone())
.or_else(|| workspace.and_then(|cfg| cfg.template_dir.clone())),
outputs,
templates,
adopt_existing: krate
.map(|cfg| cfg.adopt_existing)
.unwrap_or_else(|| workspace.map(|cfg| cfg.adopt_existing).unwrap_or(false)),
})
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct DocsSkillTemplateConfig {
#[serde(default)]
pub template: Option<PathBuf>,
#[serde(default)]
pub output: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct DocsSnippetsConfig {
#[serde(default)]
pub dirs: Vec<PathBuf>,
#[serde(default)]
pub docs_dirs: Vec<PathBuf>,
#[serde(default)]
pub content_collections: BTreeMap<String, PathBuf>,
#[serde(default)]
pub inline_dirs: Vec<PathBuf>,
#[serde(default)]
pub exclude: Vec<PathBuf>,
#[serde(default)]
pub required_languages: Vec<String>,
#[serde(default)]
pub include_base_paths: Vec<PathBuf>,
#[serde(default)]
pub require_frontmatter: bool,
#[serde(default)]
pub validation_level: Option<String>,
#[serde(default)]
pub timeout_secs: Option<u64>,
#[serde(default)]
pub before_timeout_secs: Option<u64>,
#[serde(default)]
pub fail_fast: bool,
#[serde(default)]
pub strict: bool,
#[serde(default)]
pub deny_unclassified: bool,
#[serde(default)]
pub allowed_side_effects: Vec<String>,
#[serde(default)]
pub cache_dir: Option<PathBuf>,
#[serde(default)]
pub report_output: Option<PathBuf>,
#[serde(default)]
pub sessions: BTreeMap<String, DocsSnippetSessionConfig>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct DocsSnippetSessionConfig {
pub cwd: PathBuf,
#[serde(default)]
pub manifest: Option<PathBuf>,
#[serde(default)]
pub before: Vec<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default)]
pub include_paths: Vec<PathBuf>,
#[serde(default)]
pub rust_features: Vec<String>,
#[serde(default)]
pub rust_dependencies: BTreeMap<String, DocsSnippetRustDependencyConfig>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct DocsSnippetRustDependencyConfig {
pub version: String,
#[serde(default)]
pub features: Vec<String>,
#[serde(default = "default_true")]
pub default_features: bool,
}
impl DocsSnippetsConfig {
#[must_use]
pub fn merge(workspace: Option<&Self>, krate: Option<&Self>) -> Option<Self> {
if workspace.is_none() && krate.is_none() {
return None;
}
Some(Self {
dirs: merge_vec(workspace.map(|cfg| &cfg.dirs), krate.map(|cfg| &cfg.dirs)),
docs_dirs: merge_vec(workspace.map(|cfg| &cfg.docs_dirs), krate.map(|cfg| &cfg.docs_dirs)),
content_collections: merge_btree_map(
workspace.map(|cfg| &cfg.content_collections),
krate.map(|cfg| &cfg.content_collections),
),
inline_dirs: merge_vec(workspace.map(|cfg| &cfg.inline_dirs), krate.map(|cfg| &cfg.inline_dirs)),
exclude: merge_vec(workspace.map(|cfg| &cfg.exclude), krate.map(|cfg| &cfg.exclude)),
required_languages: merge_vec(
workspace.map(|cfg| &cfg.required_languages),
krate.map(|cfg| &cfg.required_languages),
),
include_base_paths: merge_vec(
workspace.map(|cfg| &cfg.include_base_paths),
krate.map(|cfg| &cfg.include_base_paths),
),
require_frontmatter: krate
.map(|cfg| cfg.require_frontmatter)
.unwrap_or_else(|| workspace.map(|cfg| cfg.require_frontmatter).unwrap_or(false)),
validation_level: krate
.and_then(|cfg| cfg.validation_level.clone())
.or_else(|| workspace.and_then(|cfg| cfg.validation_level.clone())),
timeout_secs: krate
.and_then(|cfg| cfg.timeout_secs)
.or_else(|| workspace.and_then(|cfg| cfg.timeout_secs)),
before_timeout_secs: krate
.and_then(|cfg| cfg.before_timeout_secs)
.or_else(|| workspace.and_then(|cfg| cfg.before_timeout_secs)),
fail_fast: krate
.map(|cfg| cfg.fail_fast)
.unwrap_or_else(|| workspace.map(|cfg| cfg.fail_fast).unwrap_or(false)),
strict: krate
.map(|cfg| cfg.strict)
.unwrap_or_else(|| workspace.map(|cfg| cfg.strict).unwrap_or(false)),
deny_unclassified: krate
.map(|cfg| cfg.deny_unclassified)
.unwrap_or_else(|| workspace.map(|cfg| cfg.deny_unclassified).unwrap_or(false)),
allowed_side_effects: merge_vec(
workspace.map(|cfg| &cfg.allowed_side_effects),
krate.map(|cfg| &cfg.allowed_side_effects),
),
cache_dir: krate
.and_then(|cfg| cfg.cache_dir.clone())
.or_else(|| workspace.and_then(|cfg| cfg.cache_dir.clone())),
report_output: krate
.and_then(|cfg| cfg.report_output.clone())
.or_else(|| workspace.and_then(|cfg| cfg.report_output.clone())),
sessions: merge_btree_map(workspace.map(|cfg| &cfg.sessions), krate.map(|cfg| &cfg.sessions)),
})
}
#[must_use]
pub fn cache_dir(&self) -> PathBuf {
self.cache_dir
.clone()
.unwrap_or_else(|| PathBuf::from(".alef/snippets"))
}
}
fn merge_vec<T: Clone>(workspace: Option<&Vec<T>>, krate: Option<&Vec<T>>) -> Vec<T> {
krate
.filter(|items| !items.is_empty())
.cloned()
.or_else(|| workspace.filter(|items| !items.is_empty()).cloned())
.unwrap_or_default()
}
fn merge_btree_map<K: Clone + Ord, V: Clone>(
workspace: Option<&BTreeMap<K, V>>,
krate: Option<&BTreeMap<K, V>>,
) -> BTreeMap<K, V> {
let mut merged = workspace.cloned().unwrap_or_default();
if let Some(values) = krate {
merged.extend(values.clone());
}
merged
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(untagged)]
pub enum StringOrVec {
Single(String),
Multiple(Vec<String>),
}
impl StringOrVec {
pub fn commands(&self) -> Vec<&str> {
match self {
StringOrVec::Single(s) => vec![s.as_str()],
StringOrVec::Multiple(v) => v.iter().map(String::as_str).collect(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TestAppRunConfig {
pub precondition: Option<String>,
pub before: Option<StringOrVec>,
pub run: Option<StringOrVec>,
#[serde(default)]
pub argv_run: Option<ArgvRunConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TestConfig {
pub precondition: Option<String>,
pub before: Option<StringOrVec>,
pub command: Option<StringOrVec>,
pub e2e: Option<StringOrVec>,
pub e2e_precondition: Option<String>,
pub coverage: Option<StringOrVec>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct OutputTemplate {
pub python: Option<String>,
pub node: Option<String>,
pub ruby: Option<String>,
pub php: Option<String>,
pub elixir: Option<String>,
pub wasm: Option<String>,
pub ffi: Option<String>,
pub go: Option<String>,
pub java: Option<String>,
pub kotlin: Option<String>,
pub kotlin_android: Option<String>,
pub dart: Option<String>,
pub swift: Option<String>,
pub gleam: Option<String>,
pub csharp: Option<String>,
pub r: Option<String>,
pub zig: Option<String>,
}
impl OutputTemplate {
pub fn resolve(&self, crate_name: &str, lang: &str, multi_crate: bool) -> PathBuf {
self.try_resolve(crate_name, lang, multi_crate)
.unwrap_or_else(|message| panic!("{message}"))
}
pub(crate) fn try_resolve(&self, crate_name: &str, lang: &str, multi_crate: bool) -> Result<PathBuf, String> {
validate_output_segment(crate_name, "crate_name")?;
validate_output_segment(lang, "lang")?;
let path = if let Some(template) = self.entry(lang) {
PathBuf::from(template.replace("{crate}", crate_name).replace("{lang}", lang))
} else if multi_crate {
PathBuf::from(format!(
"{}/{crate_name}",
super::resolve_helpers::default_package_root(lang)
))
} else if let Some(root) = super::resolve_helpers::default_binding_crate_root(crate_name, lang) {
PathBuf::from(format!("{root}/src"))
} else {
PathBuf::from(super::resolve_helpers::default_package_root(lang))
};
validate_output_path(&path)?;
Ok(path)
}
pub fn entry(&self, lang: &str) -> Option<&str> {
match lang {
"python" => self.python.as_deref(),
"node" => self.node.as_deref(),
"ruby" => self.ruby.as_deref(),
"php" => self.php.as_deref(),
"elixir" => self.elixir.as_deref(),
"wasm" => self.wasm.as_deref(),
"ffi" => self.ffi.as_deref(),
"go" => self.go.as_deref(),
"java" => self.java.as_deref(),
"kotlin" => self.kotlin.as_deref(),
"kotlin_android" => self.kotlin_android.as_deref(),
"dart" => self.dart.as_deref(),
"swift" => self.swift.as_deref(),
"gleam" => self.gleam.as_deref(),
"csharp" => self.csharp.as_deref(),
"r" => self.r.as_deref(),
"zig" => self.zig.as_deref(),
_ => None,
}
}
}
pub(crate) fn validate_output_segment(segment: &str, label: &str) -> Result<(), String> {
if segment.contains('\0') {
return Err(format!(
"invalid {label}: NUL byte is not allowed in output path segments (got {segment:?})"
));
}
if segment.contains('/') || segment.contains('\\') {
return Err(format!(
"invalid {label}: path separators are not allowed in output path segments (got {segment:?})"
));
}
Ok(())
}
pub(crate) fn validate_output_path(path: &std::path::Path) -> Result<(), String> {
let rendered = path.to_string_lossy();
let bytes = rendered.as_bytes();
let has_drive_prefix = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':';
if rendered.starts_with('/') || rendered.starts_with('\\') || has_drive_prefix {
return Err(format!(
"resolved output path `{}` is absolute and would escape the project root",
path.display()
));
}
if rendered.split(['/', '\\']).any(|component| component == "..") {
return Err(format!(
"resolved output path `{}` contains `..` and would escape the project root",
path.display()
));
}
Ok(())
}
#[cfg(test)]
mod tests;