mod analyzer_configs;
mod dependencies;
mod overrides;
mod processor_configs;
mod processors;
mod provenance;
mod settings;
#[cfg(test)]
mod tests;
mod validation;
mod variables;
pub use analyzer_configs::*;
pub use dependencies::*;
use overrides::{apply_override_to_instances, parse_override_entry};
pub use processor_configs::*;
pub use processors::*;
pub use provenance::{FieldProvenance, ProvenanceMap, Section, SpanMap};
pub use settings::*;
pub use validation::FieldType;
use validation::{
expected_field_type, validate_analyzer_fields_raw, validate_build_config,
validate_dep_auto_exist, validate_no_dot_src_dirs, validate_processor_fields_raw,
validate_single_processor,
};
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 crate::registries as registry;
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", OUTPUT_DEPENDS_ON_INPUT_NAME];
pub const OUTPUT_DEPENDS_ON_INPUT_NAME: &str = "output_depends_on_input_name";
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>,
pub output_depends_on_input_name: bool,
}
impl ProcessorDefaults {
pub const EMPTY: Self = Self {
command: "",
dep_auto: &[],
output_dir: "",
formats: &[],
args: &[],
batch: None,
output_depends_on_input_name: false,
};
}
#[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",
),
(
OUTPUT_DEPENDS_ON_INPUT_NAME,
"The result depends on input paths, not only content: a renamed or identical file is rebuilt, not restored from cache",
),
];
pub fn checksum_fields_of(name: &str) -> Vec<&'static str> {
ProcessorConfig::checksum_fields_for(®istry::pname_of(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)
}
#[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 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>,
}
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 analyzer 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,
}
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.pname == 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.pname, §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 });
}
}
}
}
#[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,
} }