use std::fs;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::config::{Config, ContextName, DomainName, InvalidName, ModelName, SkillName};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContextSource {
CommandLine,
OverrideEnvironment,
ClankerFile,
Environment,
Hostname,
Fallback,
}
impl ContextSource {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::CommandLine => "--context",
Self::OverrideEnvironment => "CONTEXT_OVERRIDE",
Self::ClankerFile => ".clanker",
Self::Environment => "CONTEXT",
Self::Hostname => "hostname",
Self::Fallback => "fallback",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedContext {
pub name: ContextName,
pub source: ContextSource,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DirectoryConfig {
pub context: Option<ContextName>,
pub domain: Option<DomainName>,
pub model: Option<ModelName>,
pub bake: Option<BakeConfig>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BakeConfig {
pub skills: Option<Vec<SkillName>>,
}
#[derive(Debug, thiserror::Error)]
pub enum ContextError {
#[error("invalid context from {origin}: {error}")]
InvalidName {
origin: &'static str,
error: InvalidName,
},
#[error("failed to read context file {path}: {source}")]
Read {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to parse directory config {path}: {source}")]
Parse {
path: PathBuf,
source: toml::de::Error,
},
#[error(".clanker directory is missing required config file {path}")]
MissingDirectoryConfig {
path: PathBuf,
},
#[error(
"[bake] is only supported in directory-form .clanker/config.toml, not flat file {path}"
)]
BakeInFlatFile {
path: PathBuf,
},
#[error(
"unknown context `{name}` from {origin}; add it to defaults.contexts or fix the selection"
)]
UnknownContext {
name: ContextName,
origin: &'static str,
},
}
pub fn resolve_context(
current_dir: &Path,
command_line: Option<&ContextName>,
context_override: Option<&str>,
context_environment: Option<&str>,
hostname: &str,
config: &Config,
) -> Result<ResolvedContext, ContextError> {
let directory_config = read_directory_config(current_dir)?;
resolve_context_with_directory(
command_line,
context_override,
context_environment,
hostname,
config,
directory_config.as_ref(),
)
}
pub fn resolve_context_with_directory(
command_line: Option<&ContextName>,
context_override: Option<&str>,
context_environment: Option<&str>,
hostname: &str,
config: &Config,
directory_config: Option<&DirectoryConfig>,
) -> Result<ResolvedContext, ContextError> {
let resolved = if let Some(context) = command_line {
ResolvedContext {
name: context.clone(),
source: ContextSource::CommandLine,
}
} else if let Some(context) = parse_optional_context(context_override, "CONTEXT_OVERRIDE")? {
ResolvedContext {
name: context,
source: ContextSource::OverrideEnvironment,
}
} else if let Some(context) = directory_config.and_then(|directory| directory.context.clone()) {
ResolvedContext {
name: context,
source: ContextSource::ClankerFile,
}
} else if let Some(context) = parse_optional_context(context_environment, "CONTEXT")? {
ResolvedContext {
name: context,
source: ContextSource::Environment,
}
} else if let Some(context) = config.defaults.context_by_hostname.get(hostname) {
ResolvedContext {
name: context.clone(),
source: ContextSource::Hostname,
}
} else {
ResolvedContext {
name: config.defaults.context_fallback.clone(),
source: ContextSource::Fallback,
}
};
if config.defaults.contexts.contains(&resolved.name) {
Ok(resolved)
} else {
Err(ContextError::UnknownContext {
name: resolved.name,
origin: resolved.source.label(),
})
}
}
pub fn read_directory_config(current_dir: &Path) -> Result<Option<DirectoryConfig>, ContextError> {
for directory in current_dir.ancestors() {
let path = directory.join(".clanker");
let metadata = match fs::metadata(&path) {
Ok(metadata) => metadata,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => continue,
Err(source) => return Err(ContextError::Read { path, source }),
};
if metadata.is_dir() {
let config_path = path.join("config.toml");
if !config_path.exists() {
return Err(ContextError::MissingDirectoryConfig { path: config_path });
}
return parse_directory_config_file(&config_path, DirectoryConfigForm::Directory)
.map(Some);
}
return parse_directory_config_file(&path, DirectoryConfigForm::Flat).map(Some);
}
Ok(None)
}
#[derive(Debug, Clone, Copy)]
enum DirectoryConfigForm {
Flat,
Directory,
}
fn parse_directory_config_file(
path: &Path,
form: DirectoryConfigForm,
) -> Result<DirectoryConfig, ContextError> {
let text = fs::read_to_string(path).map_err(|source| ContextError::Read {
path: path.to_path_buf(),
source,
})?;
let config: DirectoryConfig = toml::from_str(&text).map_err(|source| ContextError::Parse {
path: path.to_path_buf(),
source,
})?;
if matches!(form, DirectoryConfigForm::Flat) && config.bake.is_some() {
return Err(ContextError::BakeInFlatFile {
path: path.to_path_buf(),
});
}
Ok(config)
}
fn parse_optional_context(
value: Option<&str>,
origin: &'static str,
) -> Result<Option<ContextName>, ContextError> {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
ContextName::new(value)
.map(Some)
.map_err(|error| ContextError::InvalidName { origin, error })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::load_config_with_overlay;
use tempfile::TempDir;
fn fixture_config(directory: &Path) -> Config {
let path = directory.join("config.toml");
fs::write(
&path,
r#"
[defaults]
domain = "eng"
contexts = ["personal", "work"]
context_fallback = "personal"
context_by_hostname = { workstation = "work" }
shim_path = "~/.local/clankers/bin"
sandbox_wrapper = "~/.config/sandbox-exec/run-sandboxed.sh"
prompter_bundle = "~/.local/prompter"
skills_bundle = "~/.local/clanker/skills"
prompt_cache = "~/.cache/clanker/prompts"
prompt_cache_ttl_seconds = 86400
[harness.claude]
bin = "claude"
family = "claude"
default_args = []
[harness.claude.injection]
kind = "arg-text"
args = ["--append-system-prompt", "{text}"]
[model]
[domain.eng]
profiles = ["core.base", "domain.eng"]
skills = []
env = {}
"#,
)
.unwrap();
load_config_with_overlay(&path, None).unwrap().config
}
#[test]
fn clanker_context_beats_environment() {
let temp = TempDir::new().unwrap();
let config = fixture_config(temp.path());
fs::write(temp.path().join(".clanker"), "context = \"work\"\n").unwrap();
let resolved =
resolve_context(temp.path(), None, None, Some("personal"), "other", &config).unwrap();
assert_eq!(resolved.name.as_str(), "work");
assert_eq!(resolved.source, ContextSource::ClankerFile);
}
#[test]
fn nearest_ancestor_clanker_wins() {
let temp = TempDir::new().unwrap();
let config = fixture_config(temp.path());
let parent = temp.path().join("work");
let current = parent.join("repo/src");
fs::create_dir_all(¤t).unwrap();
fs::write(parent.join(".clanker"), "context = \"work\"\n").unwrap();
let inherited =
resolve_context(¤t, None, None, Some("personal"), "other", &config).unwrap();
assert_eq!(inherited.name.as_str(), "work");
assert_eq!(inherited.source, ContextSource::ClankerFile);
fs::write(current.join(".clanker"), "context = \"personal\"\n").unwrap();
let nearest =
resolve_context(¤t, None, None, Some("work"), "other", &config).unwrap();
assert_eq!(nearest.name.as_str(), "personal");
assert_eq!(nearest.source, ContextSource::ClankerFile);
}
#[test]
fn directory_form_config_is_read() {
let temp = TempDir::new().unwrap();
let config = fixture_config(temp.path());
let clanker = temp.path().join(".clanker");
fs::create_dir_all(&clanker).unwrap();
fs::write(clanker.join("config.toml"), "context = \"work\"\n").unwrap();
let resolved =
resolve_context(temp.path(), None, None, Some("personal"), "other", &config).unwrap();
assert_eq!(resolved.name.as_str(), "work");
assert_eq!(resolved.source, ContextSource::ClankerFile);
}
#[test]
fn child_directory_form_beats_parent_flat_file() {
let temp = TempDir::new().unwrap();
let config = fixture_config(temp.path());
let parent = temp.path().join("work");
let current = parent.join("repo");
let child_clanker = current.join(".clanker");
fs::create_dir_all(&child_clanker).unwrap();
fs::write(parent.join(".clanker"), "context = \"work\"\n").unwrap();
fs::write(
child_clanker.join("config.toml"),
"context = \"personal\"\n",
)
.unwrap();
let resolved =
resolve_context(¤t, None, None, Some("work"), "other", &config).unwrap();
assert_eq!(resolved.name.as_str(), "personal");
assert_eq!(resolved.source, ContextSource::ClankerFile);
}
#[test]
fn directory_form_without_config_toml_fails_closed() {
let temp = TempDir::new().unwrap();
fs::create_dir_all(temp.path().join(".clanker")).unwrap();
let error = read_directory_config(temp.path()).unwrap_err();
let message = error.to_string();
assert!(message.contains(".clanker/config.toml"));
assert!(message.contains("missing required config file"));
}
#[test]
fn bake_section_is_rejected_in_flat_file() {
let temp = TempDir::new().unwrap();
fs::write(temp.path().join(".clanker"), "[bake]\nskills = []\n").unwrap();
let error = read_directory_config(temp.path()).unwrap_err();
let message = error.to_string();
assert!(message.contains("[bake]"));
assert!(message.contains(".clanker/config.toml"));
}
#[test]
fn directory_bake_skills_preserve_absent_and_empty_semantics() {
let temp = TempDir::new().unwrap();
let clanker = temp.path().join(".clanker");
fs::create_dir_all(&clanker).unwrap();
fs::write(clanker.join("config.toml"), "context = \"work\"\n").unwrap();
let absent = read_directory_config(temp.path()).unwrap().unwrap();
assert!(absent.bake.is_none());
fs::write(clanker.join("config.toml"), "[bake]\nskills = []\n").unwrap();
let empty = read_directory_config(temp.path()).unwrap().unwrap();
assert!(empty.bake.unwrap().skills.unwrap().is_empty());
}
#[test]
fn unregistered_contexts_are_rejected_per_tier() {
let temp = TempDir::new().unwrap();
let config = fixture_config(temp.path());
let command_line = ContextName::new("staging").unwrap();
let error = resolve_context(temp.path(), Some(&command_line), None, None, "h", &config)
.unwrap_err();
assert!(error.to_string().contains("unknown context `staging`"));
assert!(error.to_string().contains("--context"));
let error =
resolve_context(temp.path(), None, Some("staging"), None, "h", &config).unwrap_err();
assert!(error.to_string().contains("CONTEXT_OVERRIDE"));
fs::write(temp.path().join(".clanker"), "context = \"staging\"\n").unwrap();
let error = resolve_context(temp.path(), None, None, None, "h", &config).unwrap_err();
assert!(error.to_string().contains(".clanker"));
}
#[test]
fn hostname_then_fallback_complete_the_chain() {
let temp = TempDir::new().unwrap();
let config = fixture_config(temp.path());
let host = resolve_context(temp.path(), None, None, None, "workstation", &config).unwrap();
assert_eq!(host.name.as_str(), "work");
assert_eq!(host.source, ContextSource::Hostname);
let fallback = resolve_context(temp.path(), None, None, None, "other", &config).unwrap();
assert_eq!(fallback.name.as_str(), "personal");
assert_eq!(fallback.source, ContextSource::Fallback);
}
}