mod code;
mod comms;
pub mod daemon;
mod documents;
mod documents_validate;
pub(crate) mod layered;
mod overrides;
mod resources;
pub mod root_guard;
pub mod rules;
mod shells;
mod source;
pub mod trust;
mod v1;
mod validate;
use std::path::{Path, PathBuf};
use thiserror::Error;
pub use code::CodeSearchConfig;
pub use comms::CommsConfig;
pub use documents::{
ApiKey, DocLanguageConfig, DocumentsConfig, KeywordAlgorithm, KeywordsConfig, LlmConfig, NerBackend, NerConfig,
OcrBackend, OcrConfig, OutputConfig, OutputFormat, RerankerConfig, SecretString, SummarizationConfig,
SummarizationStrategy,
};
pub use layered::{ConfigLayers, LoadedConfig, defaults_only, merge_layers};
pub use overrides::DocumentsCliOverrides;
pub use resources::{DocumentModelProfile, FootprintKeyword, MaxFootprint, OnnxProvider, ResourcesConfig};
pub use shells::{ShellsConfig, TerminalChoice, VisualMode};
pub use source::{ConfigSource, ProvenanceMap};
pub use v1::{CodeIntelConfig, ConfigV1, CrawlConfig, LanguageConfig};
pub type Config = ConfigV1;
pub const CONFIG_FILE_NAME: &str = "basemind.toml";
pub const BASEMIND_DIR: &str = ".basemind";
pub const CONFIG_CONVENTION_DIR: &str = ".config";
pub const CONFIG_CONVENTION_SUBDIR: &str = "basemind";
pub const CONFIG_NESTED_FILE_NAME: &str = "config.toml";
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("config file not found at {0}")]
NotFound(PathBuf),
#[error("failed to read {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("invalid TOML in {path}: {source}")]
Toml {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("config is missing required \"$schema\" field — add `\"$schema\" = \"v1\"`")]
MissingSchema,
#[error("unknown schema version {0:?} — supported: v1")]
UnknownSchema(String),
#[error("schema validation failed:\n{0}")]
SchemaValidation(String),
#[error("config does not match v1 shape after validation: {0}")]
Deserialize(#[source] serde_json::Error),
}
pub fn load(root: &Path) -> Result<Config, ConfigError> {
let path = resolve_config_path(root);
if !path.exists() {
return Err(ConfigError::NotFound(path));
}
let io_error = |source| ConfigError::Io {
path: path.clone(),
source,
};
let resolved = path.canonicalize().map_err(io_error)?;
let workspace = root.canonicalize().map_err(io_error)?;
if !resolved.starts_with(&workspace) {
return Err(io_error(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"config file resolves outside the workspace root",
)));
}
let raw = std::fs::read_to_string(&resolved).map_err(io_error)?;
let mut config = parse_str(&raw).map_err(|e| annotate_path(e, &path))?;
trust::sanitize_repo_config(&mut config, trust::Grants::from_env());
trust::warn_inert_fields(&config);
Ok(config)
}
pub fn load_with_overrides(
root: &Path,
env_overrides: Option<DocumentsCliOverrides>,
cli_overrides: Option<DocumentsCliOverrides>,
) -> Result<LoadedConfig, ConfigError> {
let toml_file = match load(root) {
Ok(cfg) => Some(cfg),
Err(ConfigError::NotFound(_)) => None,
Err(e) => return Err(e),
};
let loaded = merge_layers(
ConfigV1::with_defaults(),
ConfigLayers {
toml_file,
env: env_overrides,
cli: cli_overrides,
},
);
validate_merged(&loaded.config)?;
Ok(loaded)
}
pub fn validate_merged(config: &Config) -> Result<(), ConfigError> {
config
.documents
.validate()
.and_then(|()| config.code_search.validate())
.map_err(ConfigError::SchemaValidation)
}
pub fn load_logged(root: &Path) -> Option<Config> {
match load(root) {
Ok(config) => Some(config),
Err(ConfigError::NotFound(_)) => None,
Err(error) => {
tracing::warn!(root = %root.display(), %error, "ignoring unreadable basemind.toml");
None
}
}
}
pub fn load_or_default_logged(root: &Path) -> Config {
load_logged(root).unwrap_or_else(|| default_for_root(root))
}
pub fn discover_root_with_basemind(start: &Path) -> PathBuf {
let git_root = crate::git::Repo::discover(start).ok().map(|repo| {
repo.workdir()
.canonicalize()
.unwrap_or_else(|_| repo.workdir().to_path_buf())
});
let mut current = start;
loop {
if has_config_marker(current) {
return current.to_path_buf();
}
if git_root.as_deref() == Some(current) {
break;
}
match current.parent() {
Some(parent) if parent != current => current = parent,
_ => break,
}
}
git_root.unwrap_or_else(|| start.to_path_buf())
}
pub fn init_root(start: &Path) -> PathBuf {
match crate::git::Repo::discover(start) {
Ok(repo) => repo.workdir().to_path_buf(),
Err(_) => start.to_path_buf(),
}
}
pub fn config_path(root: &Path) -> PathBuf {
root.join(CONFIG_FILE_NAME)
}
pub fn legacy_config_path(root: &Path) -> PathBuf {
root.join(BASEMIND_DIR).join(CONFIG_FILE_NAME)
}
pub fn convention_flat_config_path(root: &Path) -> PathBuf {
root.join(CONFIG_CONVENTION_DIR).join(CONFIG_FILE_NAME)
}
pub fn convention_nested_config_path(root: &Path) -> PathBuf {
root.join(CONFIG_CONVENTION_DIR)
.join(CONFIG_CONVENTION_SUBDIR)
.join(CONFIG_NESTED_FILE_NAME)
}
pub fn config_candidate_paths(root: &Path) -> Vec<PathBuf> {
vec![
config_path(root),
legacy_config_path(root),
convention_flat_config_path(root),
convention_nested_config_path(root),
]
}
pub fn has_config_marker(root: &Path) -> bool {
config_candidate_paths(root).iter().any(|path| path.is_file())
}
pub fn resolve_config_path(root: &Path) -> PathBuf {
config_candidate_paths(root)
.into_iter()
.find(|path| path.exists())
.unwrap_or_else(|| config_path(root))
}
pub fn parse_str(raw: &str) -> Result<Config, ConfigError> {
let config = parse_v1(raw)?;
config.documents.validate().map_err(ConfigError::SchemaValidation)?;
Ok(config)
}
fn parse_v1(raw: &str) -> Result<Config, ConfigError> {
let toml_value: toml::Value = toml::from_str(raw).map_err(|source| ConfigError::Toml {
path: PathBuf::new(),
source,
})?;
let json_value: serde_json::Value =
serde_json::to_value(&toml_value).expect("toml::Value → serde_json::Value never fails");
let schema_tag = json_value
.as_object()
.and_then(|o| o.get("$schema"))
.and_then(|v| v.as_str())
.ok_or(ConfigError::MissingSchema)?;
match schema_tag {
"v1" | "https://basemind.dev/schema/v1.json" => {
validate::validate_v1(&json_value)?;
let config = serde_json::from_value::<ConfigV1>(json_value).map_err(ConfigError::Deserialize)?;
rules::validate(&config).map_err(ConfigError::SchemaValidation)?;
Ok(config)
}
other => Err(ConfigError::UnknownSchema(other.to_string())),
}
}
pub fn default_for_root(_root: &Path) -> Config {
ConfigV1::with_defaults()
}
fn annotate_path(err: ConfigError, path: &Path) -> ConfigError {
match err {
ConfigError::Toml { source, .. } => ConfigError::Toml {
path: path.to_path_buf(),
source,
},
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn a_config_symlinked_outside_the_root_is_refused_without_echoing_it() {
let outside = tempfile::tempdir().expect("tempdir");
let secret = outside.path().join("credentials");
std::fs::write(&secret, "aws_secret_access_key = AKIASECRETVALUE\n").expect("write");
let root = tempfile::tempdir().expect("tempdir");
std::os::unix::fs::symlink(&secret, root.path().join(CONFIG_FILE_NAME)).expect("symlink");
let error = load(root.path()).expect_err("must refuse").to_string();
assert!(!error.contains("AKIASECRETVALUE"), "{error}");
assert!(error.contains("outside the workspace"), "{error}");
}
#[test]
fn parse_extra_roots_through_schema_validation() {
let raw = "\"$schema\" = \"v1\"\n[scan]\nextra_roots = [\"/opt/ext\", \"/var/cache/bazel\"]\n";
let cfg = parse_str(raw).expect("extra_roots is a valid, schema-accepted scan field");
assert_eq!(
cfg.scan.extra_roots,
vec![PathBuf::from("/opt/ext"), PathBuf::from("/var/cache/bazel"),]
);
}
#[test]
fn extra_roots_defaults_to_empty() {
let cfg = parse_str("\"$schema\" = \"v1\"\n").unwrap();
assert!(cfg.scan.extra_roots.is_empty());
}
#[test]
fn code_intel_precise_resolution_defaults_true_and_parses_override() {
let default_cfg = parse_str("\"$schema\" = \"v1\"\n").unwrap();
assert!(
default_cfg.code_intel.precise_resolution,
"precise_resolution defaults to true when [code_intel] is absent"
);
let overridden = parse_str("\"$schema\" = \"v1\"\n[code_intel]\nprecise_resolution = false\n")
.expect("[code_intel] precise_resolution is a valid, schema-accepted field");
assert!(
!overridden.code_intel.precise_resolution,
"an explicit precise_resolution = false is honored"
);
}
#[test]
fn resources_section_parses_embed_batch_size_and_defaults_when_absent() {
let default_cfg = parse_str("\"$schema\" = \"v1\"\n").unwrap();
assert_eq!(
default_cfg.resources.embed_batch_size, 32,
"embed_batch_size defaults to 32 when [resources] is absent"
);
assert_eq!(default_cfg.resources.scan_threads, 0);
assert_eq!(
default_cfg.resources.document_models,
crate::config::DocumentModelProfile::Full
);
let overridden = parse_str("\"$schema\" = \"v1\"\n[resources]\nembed_batch_size = 8\n")
.expect("[resources] embed_batch_size is a valid, schema-accepted field");
assert_eq!(
overridden.resources.embed_batch_size, 8,
"an explicit embed_batch_size = 8 is honored"
);
}
#[test]
fn discover_root_walks_up_to_ancestor_config_marker() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().canonicalize().expect("canonicalize");
std::fs::write(root.join(CONFIG_FILE_NAME), "\"$schema\" = \"v1\"\n").expect("write basemind.toml");
let sub = root.join("a").join("b");
std::fs::create_dir_all(&sub).expect("mkdir sub");
assert_eq!(discover_root_with_basemind(&sub), root);
}
#[test]
fn discover_root_returns_start_when_no_basemind_or_git() {
let tmp = tempfile::tempdir().expect("tempdir");
let start = tmp.path().canonicalize().expect("canonicalize");
assert_eq!(discover_root_with_basemind(&start), start);
}
#[test]
fn resolve_reads_flat_config_convention_when_root_absent() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
let path = convention_flat_config_path(root);
std::fs::create_dir_all(path.parent().expect("convention dir")).expect("mkdir .config");
std::fs::write(&path, "\"$schema\" = \"v1\"\n[scan]\nmax_file_bytes = 1234\n")
.expect("write convention config");
assert_eq!(resolve_config_path(root), path);
assert_eq!(load(root).expect("convention config loads").scan.max_file_bytes, 1234);
}
#[test]
fn resolve_reads_nested_config_convention_when_root_absent() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
let path = convention_nested_config_path(root);
std::fs::create_dir_all(path.parent().expect("convention dir")).expect("mkdir .config/basemind");
std::fs::write(&path, "\"$schema\" = \"v1\"\n[scan]\nmax_file_bytes = 4321\n")
.expect("write nested convention");
assert_eq!(resolve_config_path(root), path);
assert_eq!(load(root).expect("nested config loads").scan.max_file_bytes, 4321);
}
#[test]
fn root_config_wins_over_the_convention() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
let flat = convention_flat_config_path(root);
std::fs::create_dir_all(flat.parent().expect("convention dir")).expect("mkdir .config");
std::fs::write(
config_path(root),
"\"$schema\" = \"v1\"\n[scan]\nmax_file_bytes = 4096\n",
)
.expect("root");
std::fs::write(&flat, "\"$schema\" = \"v1\"\n[scan]\nmax_file_bytes = 9999\n").expect("convention");
assert_eq!(resolve_config_path(root), config_path(root));
assert_eq!(load(root).expect("load").scan.max_file_bytes, 4096);
}
#[test]
fn flat_convention_wins_over_nested_convention() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
let flat = convention_flat_config_path(root);
let nested = convention_nested_config_path(root);
std::fs::create_dir_all(flat.parent().expect("convention dir")).expect("mkdir .config");
std::fs::create_dir_all(nested.parent().expect("nested dir")).expect("mkdir .config/basemind");
std::fs::write(&flat, "\"$schema\" = \"v1\"\n[scan]\nmax_file_bytes = 1111\n").expect("flat");
std::fs::write(&nested, "\"$schema\" = \"v1\"\n[scan]\nmax_file_bytes = 2222\n").expect("nested");
assert_eq!(resolve_config_path(root), flat);
}
#[test]
fn discover_root_walks_up_to_the_convention_marker() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().canonicalize().expect("canonicalize");
let path = convention_nested_config_path(&root);
std::fs::create_dir_all(path.parent().expect("convention dir")).expect("mkdir");
std::fs::write(&path, "\"$schema\" = \"v1\"\n").expect("write marker");
let sub = root.join("crates").join("inner");
std::fs::create_dir_all(&sub).expect("mkdir sub");
assert_eq!(
discover_root_with_basemind(&sub),
root,
"the .config marker anchors the walk"
);
}
#[test]
fn has_config_marker_recognizes_every_candidate_location() {
let bare = tempfile::tempdir().expect("tempdir");
assert!(!has_config_marker(bare.path()), "a bare directory is not a marker");
for candidate in ["root", "legacy", "flat", "nested"] {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
let path = match candidate {
"root" => config_path(root),
"legacy" => legacy_config_path(root),
"flat" => convention_flat_config_path(root),
_ => convention_nested_config_path(root),
};
std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
std::fs::write(&path, "\"$schema\" = \"v1\"\n").expect("write");
assert!(has_config_marker(root), "{candidate} config is a marker");
}
}
}