config: add function to resolve conditional tables

This provides an inline version of Git's includeIf. The idea is that each config
layer has an optional key "--when" to enable the layer, and a layer may have
multiple sub-layer tables "--scope" to inline stuff. Layers can be nested, but
that wouldn't be useful in practice.

I choose "--" prefix to make these meta keys look special (and are placed
earlier in lexicographical order), but I don't have strong opinion about that.
We can use whatever names that are unlikely to conflict with the other config
sections.

resolve() isn't exported as a StackedConfig method because it doesn't make
sense to call resolve() on "resolved" StackedConfig. I'll add a newtype in
CLI layer to distinguish raw config from resolved one. Most consumers will
just see the "resolved" config.

#616
This commit is contained in:
Yuya Nishihara 2024-12-16 19:45:58 +09:00
parent 7d46207fa6
commit dc9caa5b0a
3 changed files with 450 additions and 0 deletions

View file

@ -33,6 +33,8 @@ use thiserror::Error;
use toml_edit::DocumentMut;
use toml_edit::ImDocument;
pub use crate::config_resolver::resolve;
pub use crate::config_resolver::ConfigResolutionContext;
use crate::file_util::IoResultExt as _;
use crate::file_util::PathError;

447
lib/src/config_resolver.rs Normal file
View file

@ -0,0 +1,447 @@
// Copyright 2024 The Jujutsu Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Post-processing functions for [`StackedConfig`].
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use serde::de::IntoDeserializer as _;
use serde::Deserialize as _;
use toml_edit::DocumentMut;
use crate::config::ConfigGetError;
use crate::config::ConfigItem;
use crate::config::ConfigLayer;
use crate::config::ConfigValue;
use crate::config::StackedConfig;
// Prefixed by "--" so these keys look unusual. It's also nice that "-" is
// placed earlier than the other keys in lexicographical order.
const SCOPE_CONDITION_KEY: &str = "--when";
const SCOPE_TABLE_KEY: &str = "--scope";
/// Parameters to enable scoped config tables conditionally.
#[derive(Clone, Debug)]
pub struct ConfigResolutionContext<'a> {
/// Home directory. `~` will be substituted with this path.
pub home_dir: Option<&'a Path>,
/// Repository path, which is usually `<workspace_root>/.jj/repo`.
pub repo_path: Option<&'a Path>,
}
/// Conditions to enable the parent table.
///
/// - Each predicate is tested separately, and the results are intersected.
/// - `None` means there are no constraints. (i.e. always `true`)
// TODO: introduce fileset-like DSL?
// TODO: add support for fileset-like pattern prefixes? it might be a bit tricky
// if path canonicalization is involved.
#[derive(Clone, Debug, Default, serde::Deserialize)]
#[serde(default, rename_all = "kebab-case")]
struct ScopeCondition {
/// Paths to match the repository path prefix.
pub repositories: Option<Vec<PathBuf>>,
// TODO: maybe add "workspaces"?
}
impl ScopeCondition {
fn from_value(
value: ConfigValue,
context: &ConfigResolutionContext,
) -> Result<Self, toml_edit::de::Error> {
Self::deserialize(value.into_deserializer())?
.expand_paths(context)
.map_err(serde::de::Error::custom)
}
fn expand_paths(mut self, context: &ConfigResolutionContext) -> Result<Self, &'static str> {
for path in self.repositories.as_mut().into_iter().flatten() {
// TODO: might be better to compare paths in canonicalized form?
if let Some(new_path) = expand_home(path, context.home_dir)? {
*path = new_path;
}
// TODO: better to skip relative paths instead of an error? The rule
// differs across platforms. "/" is relative path on Windows.
// Another option is to add "platforms = [..]" predicate and do path
// validation lazily.
if path.is_relative() {
return Err("Relative path in --when.repositories");
}
}
Ok(self)
}
fn matches(&self, context: &ConfigResolutionContext) -> bool {
matches_path_prefix(self.repositories.as_deref(), context.repo_path)
}
}
fn expand_home(path: &Path, home_dir: Option<&Path>) -> Result<Option<PathBuf>, &'static str> {
match path.strip_prefix("~") {
Ok(tail) => {
let home_dir = home_dir.ok_or("Cannot expand ~ (home directory is unknown)")?;
Ok(Some(home_dir.join(tail)))
}
Err(_) => Ok(None),
}
}
fn matches_path_prefix(candidates: Option<&[PathBuf]>, actual: Option<&Path>) -> bool {
match (candidates, actual) {
(Some(candidates), Some(actual)) => candidates.iter().any(|base| actual.starts_with(base)),
(Some(_), None) => false, // actual path not known (e.g. not in workspace)
(None, _) => true, // no constraints
}
}
/// Evaluates condition for each layer and scope, flattens scoped tables.
/// Returns new config that only contains enabled layers and tables.
pub fn resolve(
source_config: &StackedConfig,
context: &ConfigResolutionContext,
) -> Result<StackedConfig, ConfigGetError> {
let mut source_layers_stack: Vec<Arc<ConfigLayer>> =
source_config.layers().iter().rev().cloned().collect();
let mut resolved_layers: Vec<Arc<ConfigLayer>> = Vec::new();
while let Some(mut source_layer) = source_layers_stack.pop() {
if !source_layer.data.contains_key(SCOPE_CONDITION_KEY)
&& !source_layer.data.contains_key(SCOPE_TABLE_KEY)
{
resolved_layers.push(source_layer); // reuse original table
continue;
}
let layer_mut = Arc::make_mut(&mut source_layer);
let condition = pop_scope_condition(layer_mut, context)?;
if !condition.matches(context) {
continue;
}
let tables = pop_scope_tables(layer_mut)?;
// tables.iter() does not implement DoubleEndedIterator as of toml_edit
// 0.22.22.
let frame = source_layers_stack.len();
for table in tables {
let layer = ConfigLayer {
source: source_layer.source,
path: source_layer.path.clone(),
data: DocumentMut::from(table),
};
source_layers_stack.push(Arc::new(layer));
}
source_layers_stack[frame..].reverse();
resolved_layers.push(source_layer);
}
let mut resolved_config = StackedConfig::empty();
resolved_config.extend_layers(resolved_layers);
Ok(resolved_config)
}
fn pop_scope_condition(
layer: &mut ConfigLayer,
context: &ConfigResolutionContext,
) -> Result<ScopeCondition, ConfigGetError> {
let Some(item) = layer.data.remove(SCOPE_CONDITION_KEY) else {
return Ok(ScopeCondition::default());
};
let value = item
.clone()
.into_value()
.expect("Item::None should not exist in table");
ScopeCondition::from_value(value, context).map_err(|err| ConfigGetError::Type {
name: SCOPE_CONDITION_KEY.to_owned(),
error: err.into(),
source_path: layer.path.clone(),
})
}
fn pop_scope_tables(layer: &mut ConfigLayer) -> Result<toml_edit::ArrayOfTables, ConfigGetError> {
let Some(item) = layer.data.remove(SCOPE_TABLE_KEY) else {
return Ok(toml_edit::ArrayOfTables::new());
};
match item {
ConfigItem::ArrayOfTables(tables) => Ok(tables),
_ => Err(ConfigGetError::Type {
name: SCOPE_TABLE_KEY.to_owned(),
error: format!("Expected an array of tables, but is {}", item.type_name()).into(),
source_path: layer.path.clone(),
}),
}
}
#[cfg(test)]
mod tests {
use assert_matches::assert_matches;
use indoc::indoc;
use super::*;
use crate::config::ConfigSource;
#[test]
fn test_expand_home() {
let home_dir = Some(Path::new("/home/dir"));
assert_eq!(
expand_home("~".as_ref(), home_dir).unwrap(),
Some(PathBuf::from("/home/dir"))
);
assert_eq!(expand_home("~foo".as_ref(), home_dir).unwrap(), None);
assert_eq!(expand_home("/foo/~".as_ref(), home_dir).unwrap(), None);
assert_eq!(
expand_home("~/foo".as_ref(), home_dir).unwrap(),
Some(PathBuf::from("/home/dir/foo"))
);
assert!(expand_home("~/foo".as_ref(), None).is_err());
}
#[test]
fn test_condition_default() {
let condition = ScopeCondition::default();
let context = ConfigResolutionContext {
home_dir: None,
repo_path: None,
};
assert!(condition.matches(&context));
let context = ConfigResolutionContext {
home_dir: None,
repo_path: Some(Path::new("/foo")),
};
assert!(condition.matches(&context));
}
#[test]
fn test_condition_repo_path() {
let condition = ScopeCondition {
repositories: Some(["/foo", "/bar"].map(PathBuf::from).into()),
};
let context = ConfigResolutionContext {
home_dir: None,
repo_path: None,
};
assert!(!condition.matches(&context));
let context = ConfigResolutionContext {
home_dir: None,
repo_path: Some(Path::new("/foo")),
};
assert!(condition.matches(&context));
let context = ConfigResolutionContext {
home_dir: None,
repo_path: Some(Path::new("/fooo")),
};
assert!(!condition.matches(&context));
let context = ConfigResolutionContext {
home_dir: None,
repo_path: Some(Path::new("/foo/baz")),
};
assert!(condition.matches(&context));
let context = ConfigResolutionContext {
home_dir: None,
repo_path: Some(Path::new("/bar")),
};
assert!(condition.matches(&context));
}
#[test]
fn test_condition_repo_path_windows() {
let condition = ScopeCondition {
repositories: Some(["c:/foo", r"d:\bar/baz"].map(PathBuf::from).into()),
};
let context = ConfigResolutionContext {
home_dir: None,
repo_path: Some(Path::new(r"c:\foo")),
};
assert_eq!(condition.matches(&context), cfg!(windows));
let context = ConfigResolutionContext {
home_dir: None,
repo_path: Some(Path::new(r"c:\foo\baz")),
};
assert_eq!(condition.matches(&context), cfg!(windows));
let context = ConfigResolutionContext {
home_dir: None,
repo_path: Some(Path::new(r"d:\foo")),
};
assert!(!condition.matches(&context));
let context = ConfigResolutionContext {
home_dir: None,
repo_path: Some(Path::new(r"d:/bar\baz")),
};
assert_eq!(condition.matches(&context), cfg!(windows));
}
fn new_user_layer(text: &str) -> ConfigLayer {
ConfigLayer::parse(ConfigSource::User, text).unwrap()
}
#[test]
fn test_resolve_transparent() {
let mut source_config = StackedConfig::empty();
source_config.add_layer(ConfigLayer::empty(ConfigSource::Default));
source_config.add_layer(ConfigLayer::empty(ConfigSource::User));
let context = ConfigResolutionContext {
home_dir: None,
repo_path: None,
};
let resolved_config = resolve(&source_config, &context).unwrap();
assert_eq!(resolved_config.layers().len(), 2);
assert!(Arc::ptr_eq(
&source_config.layers()[0],
&resolved_config.layers()[0]
));
assert!(Arc::ptr_eq(
&source_config.layers()[1],
&resolved_config.layers()[1]
));
}
#[test]
fn test_resolve_table_order() {
let mut source_config = StackedConfig::empty();
source_config.add_layer(new_user_layer(indoc! {"
a = 'a #0'
[[--scope]]
a = 'a #0.0'
[[--scope]]
a = 'a #0.1'
[[--scope.--scope]]
a = 'a #0.1.0'
[[--scope]]
a = 'a #0.2'
"}));
source_config.add_layer(new_user_layer(indoc! {"
a = 'a #1'
[[--scope]]
a = 'a #1.0'
"}));
let context = ConfigResolutionContext {
home_dir: None,
repo_path: None,
};
let resolved_config = resolve(&source_config, &context).unwrap();
assert_eq!(resolved_config.layers().len(), 7);
insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.0'");
insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.1'");
insta::assert_snapshot!(resolved_config.layers()[3].data, @"a = 'a #0.1.0'");
insta::assert_snapshot!(resolved_config.layers()[4].data, @"a = 'a #0.2'");
insta::assert_snapshot!(resolved_config.layers()[5].data, @"a = 'a #1'");
insta::assert_snapshot!(resolved_config.layers()[6].data, @"a = 'a #1.0'");
}
#[test]
#[cfg(not(windows))] // '/' is not absolute path on Windows
fn test_resolve_repo_path() {
let mut source_config = StackedConfig::empty();
source_config.add_layer(new_user_layer(indoc! {"
a = 'a #0'
[[--scope]]
--when.repositories = ['/foo']
a = 'a #0.1 foo'
[[--scope]]
--when.repositories = ['/foo', '/bar']
a = 'a #0.2 foo|bar'
[[--scope]]
--when.repositories = []
a = 'a #0.3 none'
"}));
source_config.add_layer(new_user_layer(indoc! {"
--when.repositories = ['~/baz']
a = 'a #1 baz'
[[--scope]]
--when.repositories = ['/foo'] # should never be enabled
a = 'a #1.1 baz&foo'
"}));
let context = ConfigResolutionContext {
home_dir: Some(Path::new("/home/dir")),
repo_path: None,
};
let resolved_config = resolve(&source_config, &context).unwrap();
assert_eq!(resolved_config.layers().len(), 1);
insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
let context = ConfigResolutionContext {
home_dir: Some(Path::new("/home/dir")),
repo_path: Some(Path::new("/foo/.jj/repo")),
};
let resolved_config = resolve(&source_config, &context).unwrap();
assert_eq!(resolved_config.layers().len(), 3);
insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 foo'");
insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 foo|bar'");
let context = ConfigResolutionContext {
home_dir: Some(Path::new("/home/dir")),
repo_path: Some(Path::new("/bar/.jj/repo")),
};
let resolved_config = resolve(&source_config, &context).unwrap();
assert_eq!(resolved_config.layers().len(), 2);
insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.2 foo|bar'");
let context = ConfigResolutionContext {
home_dir: Some(Path::new("/home/dir")),
repo_path: Some(Path::new("/home/dir/baz/.jj/repo")),
};
let resolved_config = resolve(&source_config, &context).unwrap();
assert_eq!(resolved_config.layers().len(), 2);
insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #1 baz'");
}
#[test]
fn test_resolve_invalid_condition() {
let new_config = |text: &str| {
let mut config = StackedConfig::empty();
config.add_layer(new_user_layer(text));
config
};
let context = ConfigResolutionContext {
home_dir: Some(Path::new("/home/dir")),
repo_path: Some(Path::new("/foo/.jj/repo")),
};
assert_matches!(
resolve(&new_config("--when.repositories = 0"), &context),
Err(ConfigGetError::Type { .. })
);
assert_matches!(
resolve(
&new_config("--when.repositories = ['relative/path']"),
&context
),
Err(ConfigGetError::Type { .. })
);
}
#[test]
fn test_resolve_invalid_scoped_tables() {
let new_config = |text: &str| {
let mut config = StackedConfig::empty();
config.add_layer(new_user_layer(text));
config
};
let context = ConfigResolutionContext {
home_dir: Some(Path::new("/home/dir")),
repo_path: Some(Path::new("/foo/.jj/repo")),
};
assert_matches!(
resolve(&new_config("[--scope]"), &context),
Err(ConfigGetError::Type { .. })
);
}
}

View file

@ -34,6 +34,7 @@ pub mod backend;
pub mod commit;
pub mod commit_builder;
pub mod config;
mod config_resolver;
pub mod conflicts;
pub mod copies;
pub mod dag_walk;