mod analyzer_configs;
mod processor_configs;
mod provenance;
#[cfg(test)]
mod tests;
mod variables;
pub use analyzer_configs::*;
pub use processor_configs::*;
pub use provenance::{FieldProvenance, ProvenanceMap, Section, SpanMap};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use crate::errors;
use variables::substitute_variables;
const CONFIG_FILE: &str = "rsconstruct.toml";
pub const LOCAL_CONFIG_FILE: &str = "rsconstruct.local.toml";
pub const USER_CONFIG_FILE: &str = "rsconstruct/config.toml";
#[must_use]
pub fn user_config_path() -> Option<std::path::PathBuf> {
let base = std::env::var_os("XDG_CONFIG_HOME")
.filter(|v| !v.is_empty())
.map(std::path::PathBuf::from)
.or_else(|| {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))
})?;
Some(base.join(USER_CONFIG_FILE))
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ConfigFile {
pub precedence: Option<usize>,
pub role: &'static str,
pub path: Option<std::path::PathBuf>,
pub exists: bool,
pub note: &'static str,
}
#[must_use]
pub fn config_files() -> Vec<ConfigFile> {
let cwd = std::env::current_dir().ok();
let in_project = |name: &str| {
cwd.as_ref()
.map_or_else(|| std::path::PathBuf::from(name), |c| c.join(name))
};
let entry = |precedence: Option<usize>,
role: &'static str,
path: Option<std::path::PathBuf>,
note: &'static str| {
let exists = path.as_deref().is_some_and(Path::is_file);
ConfigFile {
precedence,
role,
path,
exists,
note,
}
};
vec![
entry(
Some(1),
"user",
user_config_path(),
"[build] keys only; sets per-user defaults, never read in CI",
),
entry(
Some(2),
"project",
Some(in_project(CONFIG_FILE)),
"the main config; required by most commands",
),
entry(
Some(3),
"local overlay",
Some(in_project(LOCAL_CONFIG_FILE)),
"merged over rsconstruct.toml when present",
),
entry(
None,
"ignore rules",
Some(in_project(".rsconstructignore")),
"extra ignore patterns in gitignore syntax, honoured in any directory of the tree",
),
entry(
None,
"tool lock",
Some(in_project(crate::tool_lock::LOCK_FILE)),
"pinned tool versions, verified on request during build",
),
]
}
fn read_user_config() -> Result<Option<toml::Value>> {
let Some(path) = user_config_path() else {
return Ok(None);
};
if !path.is_file() {
return Ok(None);
}
let content = crate::errors::ctx(
std::fs::read_to_string(&path),
&format!("Failed to read user config file {}", path.display()),
)?;
let raw: toml::Value = toml::from_str(&content).map_err(|e| {
crate::exit_code::config_error(format!(
"Failed to parse user config file {}: {e}",
path.display()
))
})?;
if let Some(table) = raw.as_table() {
let foreign: Vec<&String> = table.keys().filter(|k| k.as_str() != "build").collect();
if !foreign.is_empty() {
return Err(crate::exit_code::config_error(format!(
"Invalid user config {}: only a [build] table is allowed here, found [{}] — \
processors, analyzers and every other section belong in the repo's rsconstruct.toml, \
where CI sees them too",
path.display(),
foreign
.iter()
.map(|k| k.as_str())
.collect::<Vec<_>>()
.join("], [")
)));
}
}
Ok(Some(raw))
}
pub const SCAN_CONFIG_FIELDS: &[&str] = &[
"src_dirs",
"src_extensions",
"src_exclude_dirs",
"src_exclude_files",
"src_exclude_paths",
"src_files",
];
pub const STANDARD_EXTRA_FIELDS: &[&str] = &["enabled"];
pub trait KnownFields {
fn known_fields() -> &'static [&'static str];
fn checksum_fields() -> &'static [&'static str];
fn field_descriptions() -> &'static [(&'static str, &'static str)] {
&[]
}
}
#[allow(clippy::struct_field_names)]
#[derive(Clone, Copy)]
pub struct ScanDefaultsData {
pub src_dirs: &'static [&'static str],
pub src_extensions: &'static [&'static str],
pub src_exclude_dirs: &'static [&'static str],
}
pub struct FieldSpec {
pub name: &'static str,
pub ty: FieldType,
pub affects_output: bool,
pub required: bool,
pub doc: &'static str,
}
#[derive(Default, Clone, Copy)]
pub struct ProcessorDefaults {
pub command: &'static str,
pub dep_auto: &'static [&'static str],
pub output_dir: &'static str,
pub formats: &'static [&'static str],
pub args: &'static [&'static str],
pub batch: Option<bool>,
}
impl ProcessorDefaults {
pub const EMPTY: Self = Self {
command: "",
dep_auto: &[],
output_dir: "",
formats: &[],
args: &[],
batch: None,
};
}
#[derive(Copy, Clone)]
pub struct SimpleCheckerParams {
pub description: &'static str,
pub subcommand: Option<&'static str>,
pub prepend_args: &'static [&'static str],
pub extra_tools: &'static [&'static str],
pub fix_subcommand: Option<&'static str>,
pub fix_prepend_args: &'static [&'static str],
pub fix_batch: Option<bool>,
}
pub fn resolve_extra_inputs(dep_inputs: &[String]) -> Result<Vec<PathBuf>> {
let mut resolved = Vec::new();
for p in dep_inputs {
if p.contains('*') || p.contains('?') || p.contains('[') {
for entry in
glob::glob(p).with_context(|| format!("Invalid glob pattern in dep_inputs: {p}"))?
{
let path =
crate::errors::ctx(entry, &format!("Failed to read glob entry for: {p}"))?;
if path.is_file() {
resolved.push(path);
}
}
} else {
let path = PathBuf::from(p);
if !path.exists() {
anyhow::bail!("dep_inputs file not found: {p}");
}
resolved.push(path);
}
}
Ok(resolved)
}
pub const SCAN_FIELD_DESCRIPTIONS: &[(&str, &str)] = &[
("src_dirs", "Directories to scan for source files"),
("src_extensions", "File extensions to match during scanning"),
(
"src_exclude_dirs",
"Directory path segments to skip during scanning",
),
("src_exclude_files", "File names to exclude from scanning"),
(
"src_exclude_paths",
"Relative paths to exclude from scanning",
),
(
"src_files",
"Additional files to include alongside normal scanning",
),
];
pub const SHARED_FIELD_DESCRIPTIONS: &[(&str, &str)] = &[
(
"dep_inputs",
"Extra files that trigger a rebuild when their content changes",
),
(
"dep_auto",
"Config files added as dep_inputs; processor defaults are skipped when absent, entries you list must exist",
),
(
"batch",
"Pass all matched files to the tool in a single invocation",
),
(
"max_jobs",
"Maximum parallel jobs for this processor (overrides global --jobs)",
),
(
"enabled",
"Set to false to disable this processor without removing the stanza",
),
];
pub fn checksum_fields_of(name: &str) -> Vec<&'static str> {
let type_name = name.split('.').next().unwrap_or(name);
ProcessorConfig::checksum_fields_for(type_name).unwrap_or_default()
}
pub fn output_config_hash(value: &impl Serialize, checksum_fields: &[&str]) -> String {
let json_value: serde_json::Value =
serde_json::to_value(value).expect(errors::CONFIG_SERIALIZE);
let filtered = if let serde_json::Value::Object(map) = json_value {
let kept: serde_json::Map<String, serde_json::Value> = map
.into_iter()
.filter(|(k, _)| checksum_fields.contains(&k.as_str()))
.collect();
serde_json::Value::Object(kept)
} else {
json_value
};
let json = serde_json::to_string(&filtered).expect(errors::CONFIG_SERIALIZE);
let hash = Sha256::digest(json.as_bytes());
hex::encode(hash)
}
const DEFAULT_PLUGINS_DIR: &str = "plugins";
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct PluginsConfig {
#[serde(default = "default_plugins_dir")]
pub dir: String,
}
fn default_plugins_dir() -> String {
DEFAULT_PLUGINS_DIR.into()
}
impl Default for PluginsConfig {
fn default() -> Self {
Self {
dir: DEFAULT_PLUGINS_DIR.into(),
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum PipSource {
#[default]
UvLock,
Pyproject,
}
#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum PythonInstaller {
#[default]
Uv,
Pip,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
#[serde(deny_unknown_fields)]
pub struct DependenciesConfig {
#[serde(default)]
pub pip: Vec<String>,
#[serde(default)]
pub pip_source: PipSource,
#[serde(default)]
pub python_installer: PythonInstaller,
#[serde(default)]
pub npm: Vec<String>,
#[serde(default)]
pub gem: Vec<String>,
#[serde(default)]
pub system: Vec<String>,
#[serde(default)]
pub eatmydata: bool,
}
impl DependenciesConfig {
pub const fn is_empty(&self) -> bool {
self.pip.is_empty() && self.npm.is_empty() && self.gem.is_empty() && self.system.is_empty()
}
pub fn effective_pip(&self, project_root: &Path) -> Result<Vec<String>> {
let from_project = self.project_python_reqs(project_root, uv_lock_pinned_deps)?;
Ok(self.merge_with_pip(from_project))
}
pub fn effective_pip_uv(
&self,
project_root: &Path,
export: impl FnOnce() -> Result<Vec<String>>,
) -> Result<Vec<String>> {
let from_project = self.project_python_reqs(project_root, |_lock| export())?;
Ok(self.merge_with_pip(from_project))
}
fn project_python_reqs(
&self,
project_root: &Path,
pinned: impl FnOnce(&Path) -> Result<Vec<String>>,
) -> Result<Vec<String>> {
let pyproject = project_root.join("pyproject.toml");
match self.pip_source {
PipSource::UvLock => {
let lock = project_root.join("uv.lock");
if lock.exists() {
pinned(&lock)
} else if pyproject_python_deps(&pyproject)?.is_empty() {
Ok(Vec::new())
} else {
anyhow::bail!(
"{} declares Python dependencies but {} does not exist; \
run `uv lock` to create it, or set `pip_source = \"pyproject\"` \
under [dependencies] to resolve the declared names at install time",
pyproject.display(),
lock.display(),
);
}
}
PipSource::Pyproject => pyproject_python_deps(&pyproject),
}
}
fn merge_with_pip(&self, from_project: Vec<String>) -> Vec<String> {
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut merged: Vec<String> = Vec::new();
for req in self.pip.iter().cloned().chain(from_project) {
let key = format!(
"{}[{}]",
normalized_distribution_name(&req),
requirement_extras(&req)
);
if seen.insert(key) {
merged.push(req);
}
}
merged
}
}
pub fn parse_uv_export(text: &str) -> Vec<String> {
text.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#') && !line.starts_with('-'))
.map(str::to_string)
.collect()
}
pub fn pyproject_python_deps(pyproject: &Path) -> Result<Vec<String>> {
if !pyproject.exists() {
return Ok(Vec::new());
}
let content = fs::read_to_string(pyproject)
.with_context(|| format!("Failed to read {}", pyproject.display()))?;
let root: toml::Value = toml::from_str(&content).map_err(|e| {
crate::exit_code::config_error(format!("Failed to parse {}: {e}", pyproject.display()))
})?;
let string_items = |v: &toml::Value| -> Vec<String> {
v.as_array()
.map(|items| {
items
.iter()
.filter_map(|i| i.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
};
let mut deps: Vec<String> = Vec::new();
if let Some(project) = root.get("project") {
if let Some(list) = project.get("dependencies") {
deps.extend(string_items(list));
}
if let Some(extras) = project
.get("optional-dependencies")
.and_then(toml::Value::as_table)
{
for list in extras.values() {
deps.extend(string_items(list));
}
}
}
if let Some(groups) = root
.get("dependency-groups")
.and_then(toml::Value::as_table)
{
for list in groups.values() {
deps.extend(string_items(list));
}
}
Ok(deps)
}
pub fn uv_lock_pinned_deps(lock: &Path) -> Result<Vec<String>> {
let content =
fs::read_to_string(lock).with_context(|| format!("Failed to read {}", lock.display()))?;
let root: toml::Value = toml::from_str(&content).map_err(|e| {
crate::exit_code::config_error(format!("Failed to parse {}: {e}", lock.display()))
})?;
let packages = root
.get("package")
.and_then(toml::Value::as_array)
.map_or(&[] as &[toml::Value], Vec::as_slice);
let mut pins: Vec<String> = Vec::new();
for pkg in packages {
let name = pkg
.get("name")
.and_then(toml::Value::as_str)
.with_context(|| format!("{}: package entry without a name", lock.display()))?;
let source = pkg.get("source").and_then(toml::Value::as_table);
let is_project =
source.is_some_and(|s| s.contains_key("editable") || s.contains_key("virtual"));
if is_project {
continue;
}
let is_registry = source.is_some_and(|s| s.contains_key("registry"));
if !is_registry {
anyhow::bail!(
"{}: package {name} has a source kind install-deps does not support \
(only registry packages and the project itself are understood)",
lock.display(),
);
}
let version = pkg
.get("version")
.and_then(toml::Value::as_str)
.with_context(|| format!("{}: package {name} has no version", lock.display()))?;
pins.push(format!("{name}=={version}"));
}
Ok(pins)
}
pub fn requirement_extras(requirement: &str) -> String {
let Some(open) = requirement.find('[') else {
return String::new();
};
let Some(close) = requirement[open..].find(']') else {
return String::new();
};
let mut extras: Vec<String> = requirement[open + 1..open + close]
.split(',')
.map(|e| e.trim().to_lowercase())
.filter(|e| !e.is_empty())
.collect();
extras.sort();
extras.join(",")
}
pub fn normalized_distribution_name(requirement: &str) -> String {
let name = requirement
.split(['[', '<', '>', '=', '!', '~', ';', ' ', '\t'])
.next()
.unwrap_or(requirement);
let mut normalized = String::with_capacity(name.len());
let mut prev_sep = false;
for c in name.chars() {
if matches!(c, '-' | '_' | '.') {
if !prev_sep {
normalized.push('-');
}
prev_sep = true;
} else {
normalized.extend(c.to_lowercase());
prev_sep = false;
}
}
normalized
}
#[derive(Debug, Deserialize, Serialize, Default)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub build: BuildConfig,
#[serde(default)]
pub cache: CacheConfig,
#[serde(default)]
pub processor: ProcessorConfig,
#[serde(default)]
pub analyzer: AnalyzerConfig,
#[serde(default)]
pub completions: CompletionsConfig,
#[serde(default)]
pub graph: GraphConfig,
#[serde(default)]
pub plugins: PluginsConfig,
#[serde(default)]
pub dependencies: DependenciesConfig,
#[serde(default)]
pub command: CommandsConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pages: Option<PagesConfig>,
#[serde(skip)]
pub global_provenance: HashMap<String, ProvenanceMap>,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
#[serde(deny_unknown_fields)]
pub struct SymlinkInstallConfig {
#[serde(default)]
pub sources: Vec<String>,
#[serde(default)]
pub targets: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct PagesConfig {
pub dir: String,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
#[serde(deny_unknown_fields)]
pub struct CommandsConfig {
#[serde(default)]
pub symlink_install: SymlinkInstallConfig,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct BuildConfig {
#[serde(default = "default_parallel")]
pub parallel: usize,
#[serde(default)]
pub batch_size: usize,
#[serde(default = "default_output_dir")]
pub output_dir: String,
#[serde(default = "default_max_arg_len")]
pub max_arg_len: usize,
#[serde(default = "default_max_discovery_passes")]
pub max_discovery_passes: usize,
#[serde(default = "default_hash_tool_versions")]
pub hash_tool_versions: bool,
#[serde(default = "default_warn_symlinks")]
pub warn_symlinks: bool,
#[serde(default = "default_allow_missing_dep_auto")]
pub allow_missing_dep_auto: bool,
#[serde(default = "default_allow_missing_src_dirs")]
pub allow_missing_src_dirs: bool,
#[serde(default = "default_reject_dot_src_dirs")]
pub reject_dot_src_dirs: bool,
#[serde(default)]
pub command_timeout_secs: u64,
}
const fn default_parallel() -> usize {
0
}
fn default_output_dir() -> String {
"out".into()
}
const fn default_max_arg_len() -> usize {
1_000_000
}
const fn default_max_discovery_passes() -> usize {
10
}
const fn default_hash_tool_versions() -> bool {
true
}
const fn default_warn_symlinks() -> bool {
false
}
const fn default_allow_missing_dep_auto() -> bool {
false
}
const fn default_allow_missing_src_dirs() -> bool {
false
}
const fn default_reject_dot_src_dirs() -> bool {
false
}
impl Default for BuildConfig {
fn default() -> Self {
Self {
parallel: 0,
batch_size: 0, output_dir: "out".into(),
max_arg_len: default_max_arg_len(),
max_discovery_passes: default_max_discovery_passes(),
hash_tool_versions: default_hash_tool_versions(),
warn_symlinks: default_warn_symlinks(),
allow_missing_dep_auto: default_allow_missing_dep_auto(),
allow_missing_src_dirs: default_allow_missing_src_dirs(),
reject_dot_src_dirs: default_reject_dot_src_dirs(),
command_timeout_secs: 0,
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum RestoreMethod {
#[default]
Auto,
Hardlink,
Copy,
}
impl RestoreMethod {
pub fn resolve(self) -> Self {
match self {
Self::Auto => {
if std::env::var("CI").is_ok_and(|v| v == "true") {
Self::Copy
} else {
Self::Hardlink
}
}
other => other,
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct CacheConfig {
#[serde(default)]
pub restore_method: RestoreMethod,
#[serde(default)]
pub compression: bool,
#[serde(default)]
pub remote: Option<String>,
#[serde(default = "default_true")]
pub remote_push: bool,
#[serde(default = "default_true")]
pub remote_pull: bool,
#[serde(default = "default_true")]
pub mtime_check: bool,
#[serde(default = "default_webcache_ttl_secs")]
pub webcache_ttl_secs: u64,
}
const fn default_webcache_ttl_secs() -> u64 {
7 * 24 * 60 * 60
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
restore_method: RestoreMethod::default(),
compression: false,
remote: None,
remote_push: true,
remote_pull: true,
mtime_check: true,
webcache_ttl_secs: default_webcache_ttl_secs(),
}
}
}
pub const fn default_true() -> bool {
true
}
#[derive(Debug, Clone)]
pub struct ProcessorInstance {
pub instance_name: String,
pub type_name: String,
pub config_toml: toml::Value,
pub provenance: ProvenanceMap,
}
use crate::registries::{self as registry, ProcessorPlugin};
pub fn find_registry_entry(type_name: &str) -> Option<&'static ProcessorPlugin> {
registry::all_plugins().find(|e| e.name == type_name)
}
pub fn registry_entries() -> impl Iterator<Item = &'static ProcessorPlugin> {
registry::all_plugins()
}
pub fn all_type_names() -> Vec<&'static str> {
registry::all_plugins().map(|e| e.name).collect()
}
pub fn is_builtin_type(name: &str) -> bool {
find_registry_entry(name).is_some()
}
pub fn seed_user_provenance(value: &toml::Value) -> ProvenanceMap {
let mut map = ProvenanceMap::new();
if let Some(table) = value.as_table() {
for key in table.keys() {
map.insert(key.clone(), FieldProvenance::UserToml { line: 0 });
}
}
map
}
pub fn resolve_instance_defaults(
type_name: &str,
value: &mut toml::Value,
provenance: &mut ProvenanceMap,
) {
if find_registry_entry(type_name).is_some() {
registry::apply_all_defaults(type_name, value, provenance);
}
}
impl ProcessorConfig {
pub(crate) fn src_dirs(&self) -> Vec<String> {
let mut dirs: Vec<String> = self
.instances
.iter()
.flat_map(|inst| {
inst.config_toml
.get("src_dirs")
.and_then(|v| v.as_array())
.into_iter()
.flat_map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(std::string::ToString::to_string))
})
.filter(|d| !d.is_empty())
})
.collect();
dirs.sort();
dirs.dedup();
dirs
}
pub(crate) fn known_fields_for(type_name: &str) -> Option<Vec<&'static str>> {
let e = find_registry_entry(type_name)?;
let mut fields: Vec<&'static str> = StandardConfig::known_fields()
.iter()
.copied()
.filter(|f| !e.omit_standard_fields.contains(f))
.collect();
for spec in e.fields {
if !fields.contains(&spec.name) {
fields.push(spec.name);
}
}
Some(fields)
}
pub(crate) fn checksum_fields_for(type_name: &str) -> Option<Vec<&'static str>> {
let e = find_registry_entry(type_name)?;
let shadowed = |f: &&'static str| e.fields.iter().any(|s| s.name == *f);
let mut fields: Vec<&'static str> = StandardConfig::checksum_fields()
.iter()
.copied()
.filter(|f| !e.omit_standard_fields.contains(f))
.filter(|f| !shadowed(f))
.collect();
for spec in e.fields {
if spec.affects_output {
fields.push(spec.name);
}
}
Some(fields)
}
pub(crate) fn must_fields_for(type_name: &str) -> Option<Vec<&'static str>> {
let e = find_registry_entry(type_name)?;
Some(
e.fields
.iter()
.filter(|s| s.required)
.map(|s| s.name)
.collect(),
)
}
pub(crate) fn field_descriptions_for(
type_name: &str,
) -> Option<Vec<(&'static str, &'static str)>> {
let e = find_registry_entry(type_name)?;
let shadowed = |f: &str| e.fields.iter().any(|s| s.name == f);
let mut descs: Vec<(&'static str, &'static str)> = StandardConfig::field_descriptions()
.iter()
.copied()
.filter(|(f, _)| !e.omit_standard_fields.contains(f) && !shadowed(f))
.collect();
for spec in e.fields {
descs.push((spec.name, spec.doc));
}
Some(descs)
}
pub(crate) fn defconfig_json(type_name: &str) -> Option<String> {
let entry = find_registry_entry(type_name)?;
(entry.defconfig_json)(entry.name)
}
}
pub fn scan_defaults_for(type_name: &str) -> Option<ScanDefaultsData> {
find_registry_entry(type_name)?.scan_defaults
}
pub fn processor_defaults_for(type_name: &str) -> Option<ProcessorDefaults> {
find_registry_entry(type_name)?.defaults
}
pub fn apply_processor_defaults(
type_name: &str,
value: &mut toml::Value,
provenance: &mut ProvenanceMap,
) {
let Some(defaults) = processor_defaults_for(type_name) else {
return;
};
let Some(table) = value.as_table_mut() else {
return;
};
set_string_default(
table,
"command",
defaults.command,
provenance,
FieldProvenance::ProcessorDefault,
);
set_string_default(
table,
"output_dir",
defaults.output_dir,
provenance,
FieldProvenance::ProcessorDefault,
);
set_string_array_default(
table,
"dep_auto",
defaults.dep_auto,
provenance,
FieldProvenance::ProcessorDefault,
);
set_string_array_default(
table,
"formats",
defaults.formats,
provenance,
FieldProvenance::ProcessorDefault,
);
set_string_array_default(
table,
"args",
defaults.args,
provenance,
FieldProvenance::ProcessorDefault,
);
if let Some(batch) = defaults.batch
&& !table.contains_key("batch")
{
table.insert("batch".into(), toml::Value::Boolean(batch));
provenance::record_if_absent(provenance, "batch", FieldProvenance::ProcessorDefault);
}
}
fn set_string_default(
table: &mut toml::map::Map<String, toml::Value>,
key: &str,
val: &str,
provenance: &mut ProvenanceMap,
source: FieldProvenance,
) {
if !val.is_empty() && !table.contains_key(key) {
table.insert(key.into(), toml::Value::String(val.into()));
provenance::record_if_absent(provenance, key, source);
}
}
fn set_string_array_default(
table: &mut toml::map::Map<String, toml::Value>,
key: &str,
vals: &[&str],
provenance: &mut ProvenanceMap,
source: FieldProvenance,
) {
if !vals.is_empty() && !table.contains_key(key) {
let arr: Vec<toml::Value> = vals
.iter()
.map(|s| toml::Value::String(s.to_string()))
.collect();
table.insert(key.into(), toml::Value::Array(arr));
provenance::record_if_absent(provenance, key, source);
}
}
fn set_empty_array_default(
table: &mut toml::map::Map<String, toml::Value>,
key: &str,
provenance: &mut ProvenanceMap,
source: FieldProvenance,
) {
if !table.contains_key(key) {
table.insert(key.into(), toml::Value::Array(Vec::new()));
provenance::record_if_absent(provenance, key, source);
}
}
fn set_maybe_empty_array_default(
table: &mut toml::map::Map<String, toml::Value>,
key: &str,
vals: &[&str],
provenance: &mut ProvenanceMap,
source: FieldProvenance,
) {
if !table.contains_key(key) {
let arr: Vec<toml::Value> = vals
.iter()
.map(|s| toml::Value::String(s.to_string()))
.collect();
table.insert(key.into(), toml::Value::Array(arr));
provenance::record_if_absent(provenance, key, source);
}
}
pub fn apply_scan_defaults(
type_name: &str,
value: &mut toml::Value,
provenance: &mut ProvenanceMap,
) {
let Some(defaults) = scan_defaults_for(type_name) else {
return;
};
let Some(table) = value.as_table_mut() else {
return;
};
set_maybe_empty_array_default(
table,
"src_dirs",
defaults.src_dirs,
provenance,
FieldProvenance::ScanDefault,
);
set_maybe_empty_array_default(
table,
"src_extensions",
defaults.src_extensions,
provenance,
FieldProvenance::ScanDefault,
);
set_maybe_empty_array_default(
table,
"src_exclude_dirs",
defaults.src_exclude_dirs,
provenance,
FieldProvenance::ScanDefault,
);
set_empty_array_default(
table,
"src_exclude_files",
provenance,
FieldProvenance::ScanDefault,
);
set_empty_array_default(
table,
"src_exclude_paths",
provenance,
FieldProvenance::ScanDefault,
);
set_empty_array_default(table, "src_files", provenance, FieldProvenance::ScanDefault);
}
#[derive(Debug, Default)]
pub struct ProcessorConfig {
pub instances: Vec<ProcessorInstance>,
pub extra: HashMap<String, toml::Value>,
}
impl Serialize for ProcessorConfig {
fn serialize<S: serde::Serializer>(
&self,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut map = serializer.serialize_map(None)?;
for inst in &self.instances {
if inst.instance_name.contains('.') {
} else {
map.serialize_entry(&inst.instance_name, &inst.config_toml)?;
}
}
let mut types: HashMap<&str, Vec<&ProcessorInstance>> = HashMap::new();
for inst in &self.instances {
if let Some(dot) = inst.instance_name.find('.') {
let type_name = &inst.instance_name[..dot];
types.entry(type_name).or_default().push(inst);
}
}
for (type_name, insts) in &types {
let mut table = toml::map::Map::new();
for inst in insts {
let name = &inst.instance_name[type_name.len() + 1..];
table.insert(name.to_string(), inst.config_toml.clone());
}
map.serialize_entry(type_name, &toml::Value::Table(table))?;
}
for (name, value) in &self.extra {
map.serialize_entry(name, value)?;
}
map.end()
}
}
impl<'de> Deserialize<'de> for ProcessorConfig {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
let table = toml::Value::deserialize(deserializer)?;
Ok(Self::from_toml(&table))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SectionShape {
SingleInstance,
MultiInstance,
Ambiguous { colliding: Vec<String> },
}
impl ProcessorConfig {
pub(crate) fn from_toml(value: &toml::Value) -> Self {
let Some(table) = value.as_table() else {
return Self::default();
};
let mut instances = Vec::new();
let mut extra = HashMap::new();
for (key, val) in table {
let Some(sub_table) = val.as_table() else {
continue;
};
if is_builtin_type(key) {
if Self::is_multi_instance(key, sub_table) {
for (name, inst_val) in sub_table {
let instance_name = format!("{key}.{name}");
let mut config = inst_val.clone();
let mut provenance = seed_user_provenance(&config);
resolve_instance_defaults(key, &mut config, &mut provenance);
instances.push(ProcessorInstance {
instance_name,
type_name: key.clone(),
config_toml: config,
provenance,
});
}
} else {
let mut config = val.clone();
let mut provenance = seed_user_provenance(&config);
resolve_instance_defaults(key, &mut config, &mut provenance);
instances.push(ProcessorInstance {
instance_name: key.clone(),
type_name: key.clone(),
config_toml: config,
provenance,
});
}
} else {
extra.insert(key.clone(), val.clone());
}
}
Self { instances, extra }
}
fn is_multi_instance(type_name: &str, table: &toml::map::Map<String, toml::Value>) -> bool {
matches!(
Self::classify_section(type_name, table),
SectionShape::MultiInstance
)
}
fn classify_section(
type_name: &str,
table: &toml::map::Map<String, toml::Value>,
) -> SectionShape {
if table.is_empty() {
return SectionShape::SingleInstance;
}
let Some(known) = Self::known_fields_for(type_name) else {
return SectionShape::SingleInstance;
};
let known_fields: Vec<&str> = known
.iter()
.chain(SCAN_CONFIG_FIELDS.iter())
.chain(STANDARD_EXTRA_FIELDS.iter())
.copied()
.collect();
let field_keys: Vec<&str> = table
.keys()
.filter(|k| known_fields.contains(&k.as_str()))
.map(String::as_str)
.collect();
let all_values_are_tables = table.values().all(toml::Value::is_table);
match (field_keys.is_empty(), all_values_are_tables) {
(true, true) => SectionShape::MultiInstance,
(false, _) => {
if all_values_are_tables {
SectionShape::Ambiguous {
colliding: field_keys.iter().map(|s| (*s).to_string()).collect(),
}
} else {
SectionShape::SingleInstance
}
}
(true, false) => SectionShape::SingleInstance,
}
}
pub(crate) fn resolve_scan_defaults(&mut self) {
for inst in &mut self.instances {
resolve_instance_defaults(&inst.type_name, &mut inst.config_toml, &mut inst.provenance);
}
}
pub(crate) fn apply_output_dir_defaults(&mut self, global_output_dir: &str) {
for inst in &mut self.instances {
let type_default_prefix = format!("out/{}", inst.type_name);
let instance_prefix = format!("{}/{}", global_output_dir, inst.instance_name);
for field in &["output_dir", "output"] {
if matches!(
inst.provenance.get(*field),
Some(
FieldProvenance::UserToml { .. }
| FieldProvenance::LocalToml { .. }
| FieldProvenance::CliOverride
),
) {
continue;
}
let Some(val) = inst
.config_toml
.get(field)
.and_then(|v| v.as_str())
.map(std::string::ToString::to_string)
else {
continue;
};
let type_rest = val
.strip_prefix(&type_default_prefix)
.filter(|r| r.is_empty() || r.starts_with('/'));
let new_val = if inst.instance_name != inst.type_name
&& let Some(rest) = type_rest
{
format!("{instance_prefix}{rest}")
} else if global_output_dir != "out"
&& let Some(rest) = val.strip_prefix("out/")
{
format!("{global_output_dir}/{rest}")
} else {
continue;
};
if let Some(table) = inst.config_toml.as_table_mut() {
table.insert(field.to_string(), toml::Value::String(new_val));
}
inst.provenance
.insert((*field).to_string(), FieldProvenance::OutputDirDefault);
}
}
}
pub(crate) fn first_instance_of_type(&self, type_name: &str) -> Option<&ProcessorInstance> {
self.instances.iter().find(|i| i.type_name == type_name)
}
pub(crate) fn instance_config_or_default<C: serde::de::DeserializeOwned>(
&self,
type_name: &str,
) -> Result<C> {
if let Some(inst) = self.first_instance_of_type(type_name) {
return inst
.config_toml
.clone()
.try_into()
.with_context(|| format!("Failed to parse [processor.{type_name}] config"));
}
let mut value = toml::Value::Table(toml::map::Map::new());
let mut provenance = ProvenanceMap::new();
crate::registries::processor::apply_all_defaults(type_name, &mut value, &mut provenance);
value
.try_into()
.with_context(|| format!("Failed to build default config for processor '{type_name}'"))
}
pub(crate) fn instance_field_str(&self, type_name: &str, field: &str) -> Option<String> {
self.first_instance_of_type(type_name)
.and_then(|inst| inst.config_toml.get(field))
.and_then(|v| v.as_str())
.map(std::string::ToString::to_string)
}
}
pub fn default_cc_compiler() -> String {
"gcc".into()
}
pub fn default_cxx_compiler() -> String {
"g++".into()
}
pub fn default_output_suffix() -> String {
".elf".into()
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct CompletionsConfig {
#[serde(default = "default_shells")]
pub shells: Vec<String>,
}
fn default_shells() -> Vec<String> {
vec!["bash".into()]
}
impl Default for CompletionsConfig {
fn default() -> Self {
Self {
shells: vec!["bash".into()],
}
}
}
#[derive(Debug, Clone)]
pub struct AnalyzerInstance {
pub instance_name: String,
pub type_name: String,
pub config_toml: toml::Value,
pub provenance: ProvenanceMap,
}
#[derive(Debug, Default)]
pub struct AnalyzerConfig {
pub instances: Vec<AnalyzerInstance>,
}
impl Serialize for AnalyzerConfig {
fn serialize<S: serde::Serializer>(
&self,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut map = serializer.serialize_map(None)?;
for inst in &self.instances {
if inst.instance_name == inst.type_name {
map.serialize_entry(&inst.instance_name, &inst.config_toml)?;
}
}
let mut types: HashMap<&str, Vec<&AnalyzerInstance>> = HashMap::new();
for inst in &self.instances {
if inst.instance_name != inst.type_name {
types.entry(inst.type_name.as_str()).or_default().push(inst);
}
}
for (type_name, insts) in &types {
let mut table = toml::map::Map::new();
for inst in insts {
let name = &inst.instance_name[type_name.len() + 1..];
table.insert(name.to_string(), inst.config_toml.clone());
}
map.serialize_entry(type_name, &toml::Value::Table(table))?;
}
map.end()
}
}
impl<'de> Deserialize<'de> for AnalyzerConfig {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
let table = toml::Value::deserialize(deserializer)?;
Self::from_toml(&table).map_err(serde::de::Error::custom)
}
}
impl AnalyzerConfig {
pub(crate) fn from_toml(value: &toml::Value) -> Result<Self> {
let Some(table) = value.as_table() else {
return Ok(Self::default());
};
let mut instances = Vec::new();
for (type_name, val) in table {
if registry::find_analyzer_plugin(type_name).is_none() {
anyhow::bail!(
"Unknown analyzer '{type_name}'. Run 'rsconstruct analyzers list' to see available analyzers."
);
}
let Some(sub_table) = val.as_table() else {
anyhow::bail!("Expected [analyzer.{type_name}] to be a table");
};
if Self::is_multi_instance(sub_table) {
for (name, inst_val) in sub_table {
let provenance = seed_user_provenance(inst_val);
instances.push(AnalyzerInstance {
instance_name: format!("{type_name}.{name}"),
type_name: type_name.clone(),
config_toml: inst_val.clone(),
provenance,
});
}
} else {
let provenance = seed_user_provenance(val);
instances.push(AnalyzerInstance {
instance_name: type_name.clone(),
type_name: type_name.clone(),
config_toml: val.clone(),
provenance,
});
}
}
Ok(Self { instances })
}
fn is_multi_instance(table: &toml::map::Map<String, toml::Value>) -> bool {
!table.is_empty() && table.values().all(toml::Value::is_table)
}
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
#[serde(deny_unknown_fields)]
pub struct GraphConfig {
#[serde(default)]
pub viewer: Option<String>,
#[serde(default = "default_true")]
pub validate_empty_inputs: bool,
#[serde(default = "default_true")]
pub validate_dep_references: bool,
#[serde(default)]
pub validate_duplicate_inputs: bool,
#[serde(default)]
pub validate_early_cycles: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FieldType {
String,
Bool,
Integer,
StringArray,
TableArray,
Table,
Array,
}
impl FieldType {
const fn label(self) -> &'static str {
match self {
Self::String => "a string",
Self::Bool => "a boolean",
Self::Integer => "an integer",
Self::StringArray => "an array of strings",
Self::TableArray => "an array of tables",
Self::Table => "a table",
Self::Array => "an array",
}
}
fn matches(self, value: &toml::Value) -> bool {
match self {
Self::String => value.is_str(),
Self::Bool => value.as_bool().is_some(),
Self::Integer => value.is_integer(),
Self::StringArray => value
.as_array()
.is_some_and(|arr| arr.iter().all(toml::Value::is_str)),
Self::TableArray => value
.as_array()
.is_some_and(|arr| arr.iter().all(toml::Value::is_table)),
Self::Table => value.is_table(),
Self::Array => value.is_array(),
}
}
const fn describe_value(value: &toml::Value) -> &'static str {
match value {
toml::Value::String(_) => "a string",
toml::Value::Integer(_) => "an integer",
toml::Value::Float(_) => "a float",
toml::Value::Boolean(_) => "a boolean",
toml::Value::Datetime(_) => "a datetime",
toml::Value::Array(_) => "an array",
toml::Value::Table(_) => "a table",
}
}
}
fn expected_field_type(processor: &str, field: &str) -> Option<FieldType> {
match field {
"src_dirs" => return Some(FieldType::StringArray),
"src_extensions" => return Some(FieldType::StringArray),
"src_exclude_dirs" => return Some(FieldType::StringArray),
"src_exclude_files" => return Some(FieldType::StringArray),
"src_exclude_paths" => return Some(FieldType::StringArray),
"src_files" => return Some(FieldType::StringArray),
"args" => return Some(FieldType::StringArray),
"dep_inputs" => return Some(FieldType::StringArray),
"dep_auto" => return Some(FieldType::StringArray),
"max_jobs" => return Some(FieldType::Integer),
"enabled" => return Some(FieldType::Bool),
"batch" => return Some(FieldType::Bool),
"command" => return Some(FieldType::String),
"output_dir" => return Some(FieldType::String),
"formats" => return Some(FieldType::StringArray),
_ => {}
}
find_registry_entry(processor)?
.fields
.iter()
.find(|s| s.name == field)
.map(|s| s.ty)
}
fn validate_single_processor(
type_name: &str,
section_label: &str,
table: &toml::map::Map<String, toml::Value>,
errors: &mut Vec<String>,
) {
let own_fields: Vec<&'static str> = match ProcessorConfig::known_fields_for(type_name) {
Some(fields) => fields,
None => return, };
if table.get("max_jobs").and_then(toml::Value::as_integer) == Some(0) {
errors.push(format!(
"[{section_label}]: 'max_jobs' must be greater than 0 (use 'enabled = false' to turn the processor off)",
));
}
for (key, field_value) in table {
if !own_fields.contains(&key.as_str())
&& !SCAN_CONFIG_FIELDS.contains(&key.as_str())
&& !STANDARD_EXTRA_FIELDS.contains(&key.as_str())
{
let all_fields: Vec<&str> = own_fields
.iter()
.chain(SCAN_CONFIG_FIELDS.iter())
.chain(STANDARD_EXTRA_FIELDS.iter())
.copied()
.collect();
errors.push(format!(
"[{}]: unknown field '{}' (valid fields: {})",
section_label,
key,
all_fields.join(", ")
));
continue;
}
if let Some(expected) = expected_field_type(type_name, key)
&& !expected.matches(field_value)
{
errors.push(format!(
"[{}]: field '{}' must be {}, got {} ({})",
section_label,
key,
expected.label(),
FieldType::describe_value(field_value),
field_value,
));
}
}
if let Some(must) = ProcessorConfig::must_fields_for(type_name) {
for field in &must {
match table.get(*field) {
None => {
errors.push(format!(
"[{section_label}]: required field '{field}' must be specified",
));
}
Some(toml::Value::Array(arr)) if arr.is_empty() => {
errors.push(format!(
"[{section_label}]: required field '{field}' must not be empty",
));
}
Some(toml::Value::String(s)) if s.is_empty() => {
errors.push(format!(
"[{section_label}]: required field '{field}' must not be empty",
));
}
_ => {} }
}
}
}
fn validate_build_config(build: &BuildConfig) -> Result<()> {
if build.max_discovery_passes == 0 {
return Err(crate::exit_code::config_error(
"[build] max_discovery_passes must be at least 1 — 0 would skip discovery entirely and report an empty build as success".to_string()));
}
if build.max_arg_len == 0 {
return Err(crate::exit_code::config_error(
"[build] max_arg_len must be at least 1 — 0 would split every batch into one file per tool invocation".to_string()));
}
Ok(())
}
fn validate_dep_auto_exist(instances: &[ProcessorInstance], build: &BuildConfig) -> Result<()> {
if build.allow_missing_dep_auto {
return Ok(());
}
let mut errors = Vec::new();
for inst in instances {
let Some(source) = inst.provenance.get("dep_auto") else {
continue;
};
let user_set = matches!(
source,
FieldProvenance::UserToml { .. }
| FieldProvenance::LocalToml { .. }
| FieldProvenance::CliOverride
);
if !user_set {
continue;
}
let disabled = inst
.config_toml
.get("enabled")
.and_then(toml::Value::as_bool)
== Some(false);
if disabled {
continue;
}
let Some(entries) = inst
.config_toml
.get("dep_auto")
.and_then(toml::Value::as_array)
else {
continue;
};
for entry in entries.iter().filter_map(toml::Value::as_str) {
if !Path::new(entry).exists() {
errors.push(format!(
" [processor.{}] dep_auto file not found: {entry} ({source})",
inst.instance_name
));
}
}
}
if errors.is_empty() {
return Ok(());
}
Err(crate::exit_code::config_error(format!(
"Invalid config:\n{}\nEvery dep_auto entry listed in the config must exist — remove the entry, \
or set [build] allow_missing_dep_auto = true to skip absent entries as before",
errors.join("\n")
)))
}
fn validate_no_dot_src_dirs(instances: &[ProcessorInstance], build: &BuildConfig) -> Result<()> {
if !build.reject_dot_src_dirs {
return Ok(());
}
let mut errors = Vec::new();
for inst in instances {
let disabled = inst
.config_toml
.get("enabled")
.and_then(toml::Value::as_bool)
== Some(false);
if disabled {
continue;
}
let Some(entries) = inst
.config_toml
.get("src_dirs")
.and_then(toml::Value::as_array)
else {
continue;
};
if entries
.iter()
.filter_map(toml::Value::as_str)
.any(|e| e == ".")
{
let source = inst
.provenance
.get("src_dirs")
.map_or_else(String::new, |s| format!(" ({s})"));
errors.push(format!(
" [processor.{}] src_dirs contains \".\"{source}",
inst.instance_name
));
}
}
if errors.is_empty() {
return Ok(());
}
Err(crate::exit_code::config_error(format!(
"Invalid config:\n{}\n[build] reject_dot_src_dirs is on: \".\" means the whole project tree, \
the same as \"\" — name the directories the stanza covers, or write \"\" if sweeping \
the tree is intended",
errors.join("\n")
)))
}
fn validate_processor_fields_raw(raw: &toml::Value) -> Vec<String> {
let Some(processor_table) = raw.get("processor").and_then(|v| v.as_table()) else {
return Vec::new();
};
let mut errors = Vec::new();
for (name, value) in processor_table {
let Some(table) = value.as_table() else {
errors.push(format!(
"[processor]: '{name}' must be a section (e.g. [processor.{name}]), got {}",
FieldType::describe_value(value),
));
continue;
};
if !is_builtin_type(name) {
let plugins_dir = raw
.get("plugins")
.and_then(|p| p.get("dir"))
.and_then(|d| d.as_str())
.unwrap_or(DEFAULT_PLUGINS_DIR);
let plugin_path = std::path::Path::new(plugins_dir).join(format!("{name}.lua"));
if !plugin_path.exists() {
errors.push(format!(
"[processor.{}]: unknown processor type '{}' (not a builtin processor or Lua plugin at {})",
name, name, plugin_path.display(),
));
}
continue;
}
match ProcessorConfig::classify_section(name, table) {
SectionShape::MultiInstance => {
for (inst_name, inst_value) in table {
if let Some(inst_table) = inst_value.as_table() {
let section = format!("processor.{name}.{inst_name}");
validate_single_processor(name, §ion, inst_table, &mut errors);
}
}
}
SectionShape::SingleInstance => {
let section = format!("processor.{name}");
validate_single_processor(name, §ion, table, &mut errors);
}
SectionShape::Ambiguous { colliding } => {
errors.push(format!(
"[processor.{name}]: ambiguous section — {} also {} a config field of \
'{name}', so this could be read either as config or as named \
instance{}. Rename the instance{}, or move the config fields to \
[processor.{name}] and keep only instances as sub-tables.",
colliding
.iter()
.map(|c| format!("'{c}'"))
.collect::<Vec<_>>()
.join(", "),
if colliding.len() == 1 {
"names"
} else {
"name"
},
if colliding.len() == 1 { "" } else { "s" },
if colliding.len() == 1 { "" } else { "s" },
));
}
}
}
errors
}
fn validate_analyzer_fields_raw(raw: &toml::Value) -> Vec<String> {
let Some(analyzer_table) = raw.get("analyzer").and_then(|v| v.as_table()) else {
return Vec::new();
};
let mut errors = Vec::new();
for (type_name, value) in analyzer_table {
let Some(table) = value.as_table() else {
errors.push(format!("[analyzer.{type_name}]: expected a table"));
continue;
};
let Some(plugin) = registry::find_analyzer_plugin(type_name) else {
errors.push(format!(
"[analyzer.{type_name}]: unknown analyzer type '{type_name}' (run 'rsconstruct analyzers list' to see available)",
));
continue;
};
let is_multi_instance = !table.is_empty() && table.values().all(toml::Value::is_table);
if is_multi_instance {
for (inst_name, inst_value) in table {
if let Some(inst_table) = inst_value.as_table() {
let section = format!("analyzer.{type_name}.{inst_name}");
validate_analyzer_section(plugin, §ion, inst_table, &mut errors);
}
}
} else {
let section = format!("analyzer.{type_name}");
validate_analyzer_section(plugin, §ion, table, &mut errors);
}
}
errors
}
fn validate_analyzer_section(
plugin: ®istry::AnalyzerPlugin,
section_label: &str,
table: &toml::map::Map<String, toml::Value>,
errors: &mut Vec<String>,
) {
let known = (plugin.known_fields)();
for key in table.keys() {
if !known.contains(&key.as_str()) {
errors.push(format!(
"[{}]: unknown field '{}' (valid fields: {})",
section_label,
key,
known.join(", ")
));
}
}
}
fn read_and_substitute(path: &Path) -> Result<String> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
substitute_variables(&content).map_err(|e| {
crate::exit_code::config_error(format!(
"Failed to substitute variables in {}: {e:#}",
path.display()
))
})
}
fn merge_toml_values(base: &mut toml::Value, overlay: toml::Value) {
match (base, overlay) {
(toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => {
for (key, overlay_val) in overlay_table {
match base_table.get_mut(&key) {
Some(base_val) if base_val.is_table() && overlay_val.is_table() => {
merge_toml_values(base_val, overlay_val);
}
_ => {
base_table.insert(key, overlay_val);
}
}
}
}
(base_val, overlay_val) => *base_val = overlay_val,
}
}
impl Config {
pub(crate) fn require_config() -> Result<()> {
let config_path = Path::new(CONFIG_FILE);
if !config_path.exists() {
let message = if Path::new(LOCAL_CONFIG_FILE).exists() {
format!(
"{LOCAL_CONFIG_FILE} found without {CONFIG_FILE} — the local overlay only extends a main config file. Run 'rsconstruct init' to create one."
)
} else {
"No rsconstruct.toml found. Run 'rsconstruct init' to create one.".to_string()
};
return Err(crate::exit_code::RsconstructError::new(
crate::exit_code::RsconstructExitCode::ConfigError,
message,
)
.into());
}
Ok(())
}
pub fn file_index_walk_dirs(&self) -> (Vec<String>, Vec<String>) {
fn normalized(dir: &str) -> Option<String> {
let dir = dir.trim_end_matches('/');
if dir.is_empty() || dir == "." {
return None;
}
Some(dir.to_string())
}
let mut exclude: Vec<String> = Vec::new();
exclude.extend(normalized(&self.build.output_dir));
for inst in &self.processor.instances {
let Some(table) = inst.config_toml.as_table() else {
continue;
};
for field in ["output_dir", "output"] {
if let Some(v) = table.get(field).and_then(toml::Value::as_str) {
exclude.extend(normalized(v));
}
}
if let Some(dirs) = table.get("output_dirs").and_then(toml::Value::as_array) {
for v in dirs {
if let Some(s) = v.as_str() {
exclude.extend(normalized(s));
}
}
}
}
exclude.sort();
exclude.dedup();
let mut force: Vec<String> = Vec::new();
for inst in &self.processor.instances {
let Some(dirs) = inst
.config_toml
.as_table()
.and_then(|t| t.get("src_dirs"))
.and_then(toml::Value::as_array)
else {
continue;
};
for v in dirs {
let Some(dir) = v.as_str().and_then(normalized) else {
continue;
};
if exclude.iter().any(|root| Path::new(&dir).starts_with(root)) {
force.push(dir);
}
}
}
force.sort();
force.dedup();
(exclude, force)
}
pub(crate) fn load() -> Result<Self> {
let config_path = Path::new(CONFIG_FILE);
let local_path = Path::new(LOCAL_CONFIG_FILE);
let (mut config, span_map, global_span_map, local_span_map, local_global_span_map) =
if config_path.exists() {
let substituted = read_and_substitute(config_path)?;
let repo_raw: toml::Value = toml::from_str(&substituted).map_err(|e| {
crate::exit_code::config_error(format!(
"Failed to parse config file {}: {e}",
config_path.display()
))
})?;
let mut raw = read_user_config()?
.unwrap_or_else(|| toml::Value::Table(toml::map::Map::new()));
merge_toml_values(&mut raw, repo_raw);
let local_substituted = if local_path.exists() {
let local_content = read_and_substitute(local_path)?;
let local_raw: toml::Value = toml::from_str(&local_content).map_err(|e| {
crate::exit_code::config_error(format!(
"Failed to parse config file {}: {e}",
local_path.display()
))
})?;
merge_toml_values(&mut raw, local_raw);
Some(local_content)
} else {
None
};
let mut all_errors = validate_processor_fields_raw(&raw);
all_errors.extend(validate_analyzer_fields_raw(&raw));
if !all_errors.is_empty() {
return Err(crate::exit_code::config_error(format!(
"Invalid config:\n{}",
all_errors.join("\n")
)));
}
let config: Self = raw.try_into().map_err(|e| {
crate::exit_code::config_error(format!(
"Failed to parse config file {}: {e}",
config_path.display()
))
})?;
validate_build_config(&config.build)?;
let (spans, global_spans) = provenance::build_span_maps(&substituted);
let (local_spans, local_global_spans) = match &local_substituted {
Some(content) => provenance::build_span_maps(content),
None => (SpanMap::new(), provenance::GlobalSpanMap::new()),
};
(config, spans, global_spans, local_spans, local_global_spans)
} else {
if local_path.exists() {
anyhow::bail!(
"{LOCAL_CONFIG_FILE} found without {CONFIG_FILE} — the local overlay only extends a main config file",
);
}
let config = match read_user_config()? {
Some(raw) => raw.try_into().map_err(|e| {
crate::exit_code::config_error(format!(
"Failed to parse user config file: {e}"
))
})?,
None => Self::default(),
};
validate_build_config(&config.build)?;
(
config,
SpanMap::new(),
provenance::GlobalSpanMap::new(),
SpanMap::new(),
provenance::GlobalSpanMap::new(),
)
};
config.processor.resolve_scan_defaults();
config
.processor
.apply_output_dir_defaults(&config.build.output_dir);
config.apply_span_map(&span_map, &local_span_map);
validate_dep_auto_exist(&config.processor.instances, &config.build)?;
validate_no_dot_src_dirs(&config.processor.instances, &config.build)?;
config.populate_global_provenance(&global_span_map, &local_global_span_map)?;
crate::phases::run_post_config_hooks(&mut config)?;
Ok(config)
}
pub(crate) fn apply_overrides(&mut self, iset: &[String], pset: &[String]) -> Result<()> {
for raw in iset {
let (iname, field, value) = parse_override_entry(raw, "--iset")?;
apply_override_to_instances(
&mut self.processor.instances,
field,
&value,
|inst| inst.instance_name == iname,
"iname",
iname,
)?;
}
for raw in pset {
let (pname, field, value) = parse_override_entry(raw, "--pset")?;
apply_override_to_instances(
&mut self.processor.instances,
field,
&value,
|inst| inst.type_name == pname,
"pname",
pname,
)?;
}
let mut errors = Vec::new();
for inst in &self.processor.instances {
if let Some(table) = inst.config_toml.as_table() {
let section_label = format!("processor.{}", inst.instance_name);
validate_single_processor(&inst.type_name, §ion_label, table, &mut errors);
}
}
if !errors.is_empty() {
return Err(crate::exit_code::config_error(format!(
"Invalid config after CLI overrides:\n{}",
errors.join("\n")
)));
}
validate_dep_auto_exist(&self.processor.instances, &self.build)?;
validate_no_dot_src_dirs(&self.processor.instances, &self.build)?;
Ok(())
}
fn populate_global_provenance(
&mut self,
global_spans: &provenance::GlobalSpanMap,
local_global_spans: &provenance::GlobalSpanMap,
) -> Result<()> {
let serialized = toml::Value::try_from(&*self)
.context("Failed to serialize config for global provenance walk")?;
let Some(root) = serialized.as_table() else {
return Ok(());
};
for (section_name, section_value) in root {
if section_name == "processor" || section_name == "analyzer" {
continue;
}
let Some(section_table) = section_value.as_table() else {
continue;
};
let user_fields = global_spans.get(section_name);
let local_fields = local_global_spans.get(section_name);
let mut map = ProvenanceMap::new();
for field in section_table.keys() {
let source = if let Some(&line) = local_fields.and_then(|f| f.get(field)) {
FieldProvenance::LocalToml { line }
} else if let Some(&line) = user_fields.and_then(|f| f.get(field)) {
FieldProvenance::UserToml { line }
} else {
FieldProvenance::SerdeDefault
};
map.insert(field.clone(), source);
}
self.global_provenance.insert(section_name.clone(), map);
}
Ok(())
}
fn apply_span_map(&mut self, spans: &SpanMap, local_spans: &SpanMap) {
for inst in &mut self.processor.instances {
apply_spans_to_instance(
&mut inst.provenance,
spans,
local_spans,
Section::Processor,
&inst.instance_name,
);
}
for inst in &mut self.analyzer.instances {
apply_spans_to_instance(
&mut inst.provenance,
spans,
local_spans,
Section::Analyzer,
&inst.instance_name,
);
}
}
}
fn apply_spans_to_instance(
provenance: &mut ProvenanceMap,
spans: &SpanMap,
local_spans: &SpanMap,
section: Section,
instance_name: &str,
) {
let keys: Vec<String> = provenance.keys().cloned().collect();
for key in keys {
if let Some(FieldProvenance::UserToml { line }) = provenance.get(&key) {
if *line != 0 {
continue; }
let span_key = (section, instance_name.to_string(), key.clone());
if let Some(&real_line) = local_spans.get(&span_key) {
provenance.insert(key, FieldProvenance::LocalToml { line: real_line });
} else if let Some(&real_line) = spans.get(&span_key) {
provenance.insert(key, FieldProvenance::UserToml { line: real_line });
}
}
}
}
fn parse_override_entry<'a>(raw: &'a str, flag: &str) -> Result<(&'a str, &'a str, toml::Value)> {
let (lhs, value_str) = raw.split_once('=').ok_or_else(|| {
anyhow::anyhow!("{flag} '{raw}': missing '=' (expected <name>.<field>=<value>)")
})?;
let (name, field) = lhs.rsplit_once('.').ok_or_else(|| {
anyhow::anyhow!(
"{flag} '{raw}': missing '.' between name and field (expected <name>.<field>=<value>)"
)
})?;
if name.is_empty() {
anyhow::bail!("{flag} '{raw}': empty name before '.'");
}
if field.is_empty() {
anyhow::bail!("{flag} '{raw}': empty field between '.' and '='");
}
let parsed: toml::Value = match toml::from_str::<toml::Value>(&format!("v = {value_str}")) {
Ok(toml::Value::Table(mut t)) => t
.remove("v")
.unwrap_or_else(|| toml::Value::String(value_str.to_string())),
_ => toml::Value::String(value_str.to_string()),
};
Ok((name, field, parsed))
}
fn apply_override_to_instances(
instances: &mut [ProcessorInstance],
field: &str,
value: &toml::Value,
predicate: impl Fn(&ProcessorInstance) -> bool,
name_kind: &str,
name: &str,
) -> Result<()> {
let matching_indices: Vec<usize> = instances
.iter()
.enumerate()
.filter(|(_, inst)| predicate(inst))
.map(|(i, _)| i)
.collect();
if matching_indices.is_empty() {
anyhow::bail!("no processor instance with {name_kind} '{name}'");
}
for i in matching_indices {
let inst = &mut instances[i];
let type_name = inst.type_name.clone();
validate_override_field(&type_name, field, value, &inst.instance_name)?;
if let Some(table) = inst.config_toml.as_table_mut() {
table.insert(field.to_string(), value.clone());
inst.provenance
.insert(field.to_string(), FieldProvenance::CliOverride);
} else {
anyhow::bail!(
"instance '{}' config is not a table (cannot apply override)",
inst.instance_name
);
}
}
Ok(())
}
fn validate_override_field(
type_name: &str,
field: &str,
value: &toml::Value,
instance_label: &str,
) -> Result<()> {
let own_fields = ProcessorConfig::known_fields_for(type_name).unwrap_or_default();
let is_known = own_fields.contains(&field)
|| SCAN_CONFIG_FIELDS.contains(&field)
|| STANDARD_EXTRA_FIELDS.contains(&field);
if !is_known {
let mut all_fields: Vec<&str> = own_fields
.iter()
.chain(SCAN_CONFIG_FIELDS.iter())
.chain(STANDARD_EXTRA_FIELDS.iter())
.copied()
.collect();
all_fields.sort_unstable();
all_fields.dedup();
anyhow::bail!(
"instance '{instance_label}' (type {type_name}): unknown field '{field}' (valid fields: {})",
all_fields.join(", ")
);
}
if let Some(expected) = expected_field_type(type_name, field)
&& !expected.matches(value)
{
anyhow::bail!(
"instance '{instance_label}' (type {type_name}): field '{field}' must be {}, got {} ({value})",
expected.label(),
FieldType::describe_value(value),
);
}
Ok(())
}
pub fn standard_config_from_toml(
value: &toml::Value,
default_src_dirs: &[&str],
default_src_extensions: &[&str],
default_exclude_dirs: &[&str],
) -> StandardConfig {
let table = value.as_table();
let toml_array = |key: &str| -> Option<Vec<String>> {
table
.and_then(|t| t.get(key))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
};
let mut cfg = StandardConfig {
src_dirs: toml_array("src_dirs"),
src_extensions: toml_array("src_extensions"),
src_exclude_dirs: toml_array("src_exclude_dirs"),
src_exclude_files: toml_array("src_exclude_files"),
src_exclude_paths: toml_array("src_exclude_paths"),
src_files: toml_array("src_files"),
..StandardConfig::default()
};
if cfg.src_dirs.is_none() {
cfg.src_dirs = Some(
default_src_dirs
.iter()
.map(std::string::ToString::to_string)
.collect(),
);
}
if cfg.src_extensions.is_none() {
cfg.src_extensions = Some(
default_src_extensions
.iter()
.map(std::string::ToString::to_string)
.collect(),
);
}
if cfg.src_exclude_dirs.is_none() {
cfg.src_exclude_dirs = Some(
default_exclude_dirs
.iter()
.map(std::string::ToString::to_string)
.collect(),
);
}
if cfg.src_exclude_files.is_none() {
cfg.src_exclude_files = Some(Vec::new());
}
if cfg.src_exclude_paths.is_none() {
cfg.src_exclude_paths = Some(Vec::new());
}
if cfg.src_files.is_none() {
cfg.src_files = Some(Vec::new());
}
cfg
}
#[allow(clippy::unnecessary_wraps)] fn eatmydata_ci_default(config: &mut Config) -> anyhow::Result<()> {
if running_in_ci() {
config.dependencies.eatmydata = true;
}
Ok(())
}
pub fn running_in_ci() -> bool {
std::env::var("CI").is_ok_and(|v| v == "true")
}
inventory::submit! { crate::phases::PhaseHook {
name: "eatmydata_ci_default",
description: "When CI=true, enable eatmydata wrapping for apt/dnf/pacman installs",
function: concat!(module_path!(), "::eatmydata_ci_default"),
location: concat!(file!(), ":", line!()),
run: eatmydata_ci_default,
} }