just playing with tangled
at ig/vimdiffwarn 949 lines 34 kB view raw
1// Copyright 2024 The Jujutsu Authors 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// https://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15//! Post-processing functions for [`StackedConfig`]. 16 17use std::path::Path; 18use std::path::PathBuf; 19use std::sync::Arc; 20 21use itertools::Itertools as _; 22use serde::de::IntoDeserializer as _; 23use serde::Deserialize as _; 24use thiserror::Error; 25use toml_edit::DocumentMut; 26 27use crate::config::ConfigGetError; 28use crate::config::ConfigLayer; 29use crate::config::ConfigNamePathBuf; 30use crate::config::ConfigUpdateError; 31use crate::config::ConfigValue; 32use crate::config::StackedConfig; 33use crate::config::ToConfigNamePath; 34 35// Prefixed by "--" so these keys look unusual. It's also nice that "-" is 36// placed earlier than the other keys in lexicographical order. 37const SCOPE_CONDITION_KEY: &str = "--when"; 38const SCOPE_TABLE_KEY: &str = "--scope"; 39 40/// Parameters to enable scoped config tables conditionally. 41#[derive(Clone, Debug)] 42pub struct ConfigResolutionContext<'a> { 43 /// Home directory. `~` will be substituted with this path. 44 pub home_dir: Option<&'a Path>, 45 /// Repository path, which is usually `<workspace_root>/.jj/repo`. 46 pub repo_path: Option<&'a Path>, 47 /// Space-separated subcommand. `jj file show ...` should result in `"file 48 /// show"`. 49 pub command: Option<&'a str>, 50} 51 52/// Conditions to enable the parent table. 53/// 54/// - Each predicate is tested separately, and the results are intersected. 55/// - `None` means there are no constraints. (i.e. always `true`) 56// TODO: introduce fileset-like DSL? 57// TODO: add support for fileset-like pattern prefixes? it might be a bit tricky 58// if path canonicalization is involved. 59#[derive(Clone, Debug, Default, serde::Deserialize)] 60#[serde(default, rename_all = "kebab-case")] 61struct ScopeCondition { 62 /// Paths to match the repository path prefix. 63 pub repositories: Option<Vec<PathBuf>>, 64 /// Commands to match. Subcommands are matched space-separated. 65 /// - `--when.commands = ["foo"]` -> matches "foo", "foo bar", "foo bar baz" 66 /// - `--when.commands = ["foo bar"]` -> matches "foo bar", "foo bar baz", 67 /// NOT "foo" 68 pub commands: Option<Vec<String>>, 69 // TODO: maybe add "workspaces"? 70} 71 72impl ScopeCondition { 73 fn from_value( 74 value: ConfigValue, 75 context: &ConfigResolutionContext, 76 ) -> Result<Self, toml_edit::de::Error> { 77 Self::deserialize(value.into_deserializer())? 78 .expand_paths(context) 79 .map_err(serde::de::Error::custom) 80 } 81 82 fn expand_paths(mut self, context: &ConfigResolutionContext) -> Result<Self, &'static str> { 83 // It might make some sense to compare paths in canonicalized form, but 84 // be careful to not resolve relative path patterns against cwd, which 85 // wouldn't be what the user would expect. 86 for path in self.repositories.as_mut().into_iter().flatten() { 87 if let Some(new_path) = expand_home(path, context.home_dir)? { 88 *path = new_path; 89 } 90 } 91 Ok(self) 92 } 93 94 fn matches(&self, context: &ConfigResolutionContext) -> bool { 95 matches_path_prefix(self.repositories.as_deref(), context.repo_path) 96 && matches_command(self.commands.as_deref(), context.command) 97 } 98} 99 100fn expand_home(path: &Path, home_dir: Option<&Path>) -> Result<Option<PathBuf>, &'static str> { 101 match path.strip_prefix("~") { 102 Ok(tail) => { 103 let home_dir = home_dir.ok_or("Cannot expand ~ (home directory is unknown)")?; 104 Ok(Some(home_dir.join(tail))) 105 } 106 Err(_) => Ok(None), 107 } 108} 109 110fn matches_path_prefix(candidates: Option<&[PathBuf]>, actual: Option<&Path>) -> bool { 111 match (candidates, actual) { 112 (Some(candidates), Some(actual)) => candidates.iter().any(|base| actual.starts_with(base)), 113 (Some(_), None) => false, // actual path not known (e.g. not in workspace) 114 (None, _) => true, // no constraints 115 } 116} 117 118fn matches_command(candidates: Option<&[String]>, actual: Option<&str>) -> bool { 119 match (candidates, actual) { 120 (Some(candidates), Some(actual)) => candidates.iter().any(|candidate| { 121 actual 122 .strip_prefix(candidate) 123 .is_some_and(|trailing| trailing.starts_with(' ') || trailing.is_empty()) 124 }), 125 (Some(_), None) => false, 126 (None, _) => true, 127 } 128} 129 130/// Evaluates condition for each layer and scope, flattens scoped tables. 131/// Returns new config that only contains enabled layers and tables. 132pub fn resolve( 133 source_config: &StackedConfig, 134 context: &ConfigResolutionContext, 135) -> Result<StackedConfig, ConfigGetError> { 136 let mut source_layers_stack: Vec<Arc<ConfigLayer>> = 137 source_config.layers().iter().rev().cloned().collect(); 138 let mut resolved_layers: Vec<Arc<ConfigLayer>> = Vec::new(); 139 while let Some(mut source_layer) = source_layers_stack.pop() { 140 if !source_layer.data.contains_key(SCOPE_CONDITION_KEY) 141 && !source_layer.data.contains_key(SCOPE_TABLE_KEY) 142 { 143 resolved_layers.push(source_layer); // reuse original table 144 continue; 145 } 146 147 let layer_mut = Arc::make_mut(&mut source_layer); 148 let condition = pop_scope_condition(layer_mut, context)?; 149 if !condition.matches(context) { 150 continue; 151 } 152 let tables = pop_scope_tables(layer_mut)?; 153 // tables.iter() does not implement DoubleEndedIterator as of toml_edit 154 // 0.22.22. 155 let frame = source_layers_stack.len(); 156 for table in tables { 157 let layer = ConfigLayer { 158 source: source_layer.source, 159 path: source_layer.path.clone(), 160 data: DocumentMut::from(table), 161 }; 162 source_layers_stack.push(Arc::new(layer)); 163 } 164 source_layers_stack[frame..].reverse(); 165 resolved_layers.push(source_layer); 166 } 167 let mut resolved_config = StackedConfig::empty(); 168 resolved_config.extend_layers(resolved_layers); 169 Ok(resolved_config) 170} 171 172fn pop_scope_condition( 173 layer: &mut ConfigLayer, 174 context: &ConfigResolutionContext, 175) -> Result<ScopeCondition, ConfigGetError> { 176 let Some(item) = layer.data.remove(SCOPE_CONDITION_KEY) else { 177 return Ok(ScopeCondition::default()); 178 }; 179 let value = item 180 .clone() 181 .into_value() 182 .expect("Item::None should not exist in table"); 183 ScopeCondition::from_value(value, context).map_err(|err| ConfigGetError::Type { 184 name: SCOPE_CONDITION_KEY.to_owned(), 185 error: err.into(), 186 source_path: layer.path.clone(), 187 }) 188} 189 190fn pop_scope_tables(layer: &mut ConfigLayer) -> Result<toml_edit::ArrayOfTables, ConfigGetError> { 191 let Some(item) = layer.data.remove(SCOPE_TABLE_KEY) else { 192 return Ok(toml_edit::ArrayOfTables::new()); 193 }; 194 item.into_array_of_tables() 195 .map_err(|item| ConfigGetError::Type { 196 name: SCOPE_TABLE_KEY.to_owned(), 197 error: format!("Expected an array of tables, but is {}", item.type_name()).into(), 198 source_path: layer.path.clone(), 199 }) 200} 201 202/// Error that can occur when migrating config variables. 203#[derive(Debug, Error)] 204#[error("Migration failed")] 205pub struct ConfigMigrateError { 206 /// Source error. 207 #[source] 208 pub error: ConfigMigrateLayerError, 209 /// Source file path where the value is defined. 210 pub source_path: Option<PathBuf>, 211} 212 213/// Inner error of [`ConfigMigrateError`]. 214#[derive(Debug, Error)] 215pub enum ConfigMigrateLayerError { 216 /// Cannot delete old value or set new value. 217 #[error(transparent)] 218 Update(#[from] ConfigUpdateError), 219 /// Old config value cannot be converted. 220 #[error("Invalid type or value for {name}")] 221 Type { 222 /// Dotted config name path. 223 name: String, 224 /// Source error. 225 #[source] 226 error: DynError, 227 }, 228} 229 230impl ConfigMigrateLayerError { 231 fn with_source_path(self, source_path: Option<&Path>) -> ConfigMigrateError { 232 ConfigMigrateError { 233 error: self, 234 source_path: source_path.map(|path| path.to_owned()), 235 } 236 } 237} 238 239type DynError = Box<dyn std::error::Error + Send + Sync>; 240 241/// Rule to migrate deprecated config variables. 242pub struct ConfigMigrationRule { 243 inner: MigrationRule, 244} 245 246enum MigrationRule { 247 RenameValue { 248 old_name: ConfigNamePathBuf, 249 new_name: ConfigNamePathBuf, 250 }, 251 RenameUpdateValue { 252 old_name: ConfigNamePathBuf, 253 new_name: ConfigNamePathBuf, 254 #[expect(clippy::type_complexity)] // type alias wouldn't help readability 255 new_value_fn: Box<dyn Fn(&ConfigValue) -> Result<ConfigValue, DynError>>, 256 }, 257 Custom { 258 matches_fn: Box<dyn Fn(&ConfigLayer) -> bool>, 259 #[expect(clippy::type_complexity)] // type alias wouldn't help readability 260 apply_fn: Box<dyn Fn(&mut ConfigLayer) -> Result<String, ConfigMigrateLayerError>>, 261 }, 262} 263 264impl ConfigMigrationRule { 265 /// Creates rule that moves value from `old_name` to `new_name`. 266 pub fn rename_value(old_name: impl ToConfigNamePath, new_name: impl ToConfigNamePath) -> Self { 267 let inner = MigrationRule::RenameValue { 268 old_name: old_name.into_name_path().into(), 269 new_name: new_name.into_name_path().into(), 270 }; 271 ConfigMigrationRule { inner } 272 } 273 274 /// Creates rule that moves value from `old_name` to `new_name`, and updates 275 /// the value. 276 /// 277 /// If `new_value_fn(&old_value)` returned an error, the whole migration 278 /// process would fail. 279 pub fn rename_update_value( 280 old_name: impl ToConfigNamePath, 281 new_name: impl ToConfigNamePath, 282 new_value_fn: impl Fn(&ConfigValue) -> Result<ConfigValue, DynError> + 'static, 283 ) -> Self { 284 let inner = MigrationRule::RenameUpdateValue { 285 old_name: old_name.into_name_path().into(), 286 new_name: new_name.into_name_path().into(), 287 new_value_fn: Box::new(new_value_fn), 288 }; 289 ConfigMigrationRule { inner } 290 } 291 292 // TODO: update value, etc. 293 294 /// Creates rule that updates config layer by `apply_fn`. `match_fn` should 295 /// return true if the layer contains items to be updated. 296 pub fn custom( 297 matches_fn: impl Fn(&ConfigLayer) -> bool + 'static, 298 apply_fn: impl Fn(&mut ConfigLayer) -> Result<String, ConfigMigrateLayerError> + 'static, 299 ) -> Self { 300 let inner = MigrationRule::Custom { 301 matches_fn: Box::new(matches_fn), 302 apply_fn: Box::new(apply_fn), 303 }; 304 ConfigMigrationRule { inner } 305 } 306 307 /// Returns true if `layer` contains an item to be migrated. 308 fn matches(&self, layer: &ConfigLayer) -> bool { 309 match &self.inner { 310 MigrationRule::RenameValue { old_name, .. } 311 | MigrationRule::RenameUpdateValue { old_name, .. } => { 312 matches!(layer.look_up_item(old_name), Ok(Some(_))) 313 } 314 MigrationRule::Custom { matches_fn, .. } => matches_fn(layer), 315 } 316 } 317 318 /// Migrates `layer` item. Returns a description of the applied migration. 319 fn apply(&self, layer: &mut ConfigLayer) -> Result<String, ConfigMigrateLayerError> { 320 match &self.inner { 321 MigrationRule::RenameValue { old_name, new_name } => { 322 rename_value(layer, old_name, new_name) 323 } 324 MigrationRule::RenameUpdateValue { 325 old_name, 326 new_name, 327 new_value_fn, 328 } => rename_update_value(layer, old_name, new_name, new_value_fn), 329 MigrationRule::Custom { apply_fn, .. } => apply_fn(layer), 330 } 331 } 332} 333 334fn rename_value( 335 layer: &mut ConfigLayer, 336 old_name: &ConfigNamePathBuf, 337 new_name: &ConfigNamePathBuf, 338) -> Result<String, ConfigMigrateLayerError> { 339 let value = layer.delete_value(old_name)?.expect("tested by matches()"); 340 if matches!(layer.look_up_item(new_name), Ok(Some(_))) { 341 return Ok(format!("{old_name} is deleted (superseded by {new_name})")); 342 } 343 layer.set_value(new_name, value)?; 344 Ok(format!("{old_name} is renamed to {new_name}")) 345} 346 347fn rename_update_value( 348 layer: &mut ConfigLayer, 349 old_name: &ConfigNamePathBuf, 350 new_name: &ConfigNamePathBuf, 351 new_value_fn: impl FnOnce(&ConfigValue) -> Result<ConfigValue, DynError>, 352) -> Result<String, ConfigMigrateLayerError> { 353 let old_value = layer.delete_value(old_name)?.expect("tested by matches()"); 354 if matches!(layer.look_up_item(new_name), Ok(Some(_))) { 355 return Ok(format!("{old_name} is deleted (superseded by {new_name})")); 356 } 357 let new_value = new_value_fn(&old_value).map_err(|error| ConfigMigrateLayerError::Type { 358 name: old_name.to_string(), 359 error, 360 })?; 361 layer.set_value(new_name, new_value.clone())?; 362 Ok(format!("{old_name} is updated to {new_name} = {new_value}")) 363} 364 365/// Applies migration `rules` to `config`. Returns descriptions of the applied 366/// migrations. 367pub fn migrate( 368 config: &mut StackedConfig, 369 rules: &[ConfigMigrationRule], 370) -> Result<Vec<String>, ConfigMigrateError> { 371 let mut descriptions = Vec::new(); 372 for layer in config.layers_mut() { 373 migrate_layer(layer, rules, &mut descriptions) 374 .map_err(|err| err.with_source_path(layer.path.as_deref()))?; 375 } 376 Ok(descriptions) 377} 378 379fn migrate_layer( 380 layer: &mut Arc<ConfigLayer>, 381 rules: &[ConfigMigrationRule], 382 descriptions: &mut Vec<String>, 383) -> Result<(), ConfigMigrateLayerError> { 384 let rules_to_apply = rules 385 .iter() 386 .filter(|rule| rule.matches(layer)) 387 .collect_vec(); 388 if rules_to_apply.is_empty() { 389 return Ok(()); 390 } 391 let layer_mut = Arc::make_mut(layer); 392 for rule in rules_to_apply { 393 let desc = rule.apply(layer_mut)?; 394 descriptions.push(desc); 395 } 396 Ok(()) 397} 398 399#[cfg(test)] 400mod tests { 401 use assert_matches::assert_matches; 402 use indoc::indoc; 403 404 use super::*; 405 use crate::config::ConfigSource; 406 407 #[test] 408 fn test_expand_home() { 409 let home_dir = Some(Path::new("/home/dir")); 410 assert_eq!( 411 expand_home("~".as_ref(), home_dir).unwrap(), 412 Some(PathBuf::from("/home/dir")) 413 ); 414 assert_eq!(expand_home("~foo".as_ref(), home_dir).unwrap(), None); 415 assert_eq!(expand_home("/foo/~".as_ref(), home_dir).unwrap(), None); 416 assert_eq!( 417 expand_home("~/foo".as_ref(), home_dir).unwrap(), 418 Some(PathBuf::from("/home/dir/foo")) 419 ); 420 assert!(expand_home("~/foo".as_ref(), None).is_err()); 421 } 422 423 #[test] 424 fn test_condition_default() { 425 let condition = ScopeCondition::default(); 426 427 let context = ConfigResolutionContext { 428 home_dir: None, 429 repo_path: None, 430 command: None, 431 }; 432 assert!(condition.matches(&context)); 433 let context = ConfigResolutionContext { 434 home_dir: None, 435 repo_path: Some(Path::new("/foo")), 436 command: None, 437 }; 438 assert!(condition.matches(&context)); 439 } 440 441 #[test] 442 fn test_condition_repo_path() { 443 let condition = ScopeCondition { 444 repositories: Some(["/foo", "/bar"].map(PathBuf::from).into()), 445 commands: None, 446 }; 447 448 let context = ConfigResolutionContext { 449 home_dir: None, 450 repo_path: None, 451 command: None, 452 }; 453 assert!(!condition.matches(&context)); 454 let context = ConfigResolutionContext { 455 home_dir: None, 456 repo_path: Some(Path::new("/foo")), 457 command: None, 458 }; 459 assert!(condition.matches(&context)); 460 let context = ConfigResolutionContext { 461 home_dir: None, 462 repo_path: Some(Path::new("/fooo")), 463 command: None, 464 }; 465 assert!(!condition.matches(&context)); 466 let context = ConfigResolutionContext { 467 home_dir: None, 468 repo_path: Some(Path::new("/foo/baz")), 469 command: None, 470 }; 471 assert!(condition.matches(&context)); 472 let context = ConfigResolutionContext { 473 home_dir: None, 474 repo_path: Some(Path::new("/bar")), 475 command: None, 476 }; 477 assert!(condition.matches(&context)); 478 } 479 480 #[test] 481 fn test_condition_repo_path_windows() { 482 let condition = ScopeCondition { 483 repositories: Some(["c:/foo", r"d:\bar/baz"].map(PathBuf::from).into()), 484 commands: None, 485 }; 486 487 let context = ConfigResolutionContext { 488 home_dir: None, 489 repo_path: Some(Path::new(r"c:\foo")), 490 command: None, 491 }; 492 assert_eq!(condition.matches(&context), cfg!(windows)); 493 let context = ConfigResolutionContext { 494 home_dir: None, 495 repo_path: Some(Path::new(r"c:\foo\baz")), 496 command: None, 497 }; 498 assert_eq!(condition.matches(&context), cfg!(windows)); 499 let context = ConfigResolutionContext { 500 home_dir: None, 501 repo_path: Some(Path::new(r"d:\foo")), 502 command: None, 503 }; 504 assert!(!condition.matches(&context)); 505 let context = ConfigResolutionContext { 506 home_dir: None, 507 repo_path: Some(Path::new(r"d:/bar\baz")), 508 command: None, 509 }; 510 assert_eq!(condition.matches(&context), cfg!(windows)); 511 } 512 513 fn new_user_layer(text: &str) -> ConfigLayer { 514 ConfigLayer::parse(ConfigSource::User, text).unwrap() 515 } 516 517 #[test] 518 fn test_resolve_transparent() { 519 let mut source_config = StackedConfig::empty(); 520 source_config.add_layer(ConfigLayer::empty(ConfigSource::Default)); 521 source_config.add_layer(ConfigLayer::empty(ConfigSource::User)); 522 523 let context = ConfigResolutionContext { 524 home_dir: None, 525 repo_path: None, 526 command: None, 527 }; 528 let resolved_config = resolve(&source_config, &context).unwrap(); 529 assert_eq!(resolved_config.layers().len(), 2); 530 assert!(Arc::ptr_eq( 531 &source_config.layers()[0], 532 &resolved_config.layers()[0] 533 )); 534 assert!(Arc::ptr_eq( 535 &source_config.layers()[1], 536 &resolved_config.layers()[1] 537 )); 538 } 539 540 #[test] 541 fn test_resolve_table_order() { 542 let mut source_config = StackedConfig::empty(); 543 source_config.add_layer(new_user_layer(indoc! {" 544 a = 'a #0' 545 [[--scope]] 546 a = 'a #0.0' 547 [[--scope]] 548 a = 'a #0.1' 549 [[--scope.--scope]] 550 a = 'a #0.1.0' 551 [[--scope]] 552 a = 'a #0.2' 553 "})); 554 source_config.add_layer(new_user_layer(indoc! {" 555 a = 'a #1' 556 [[--scope]] 557 a = 'a #1.0' 558 "})); 559 560 let context = ConfigResolutionContext { 561 home_dir: None, 562 repo_path: None, 563 command: None, 564 }; 565 let resolved_config = resolve(&source_config, &context).unwrap(); 566 assert_eq!(resolved_config.layers().len(), 7); 567 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'"); 568 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.0'"); 569 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.1'"); 570 insta::assert_snapshot!(resolved_config.layers()[3].data, @"a = 'a #0.1.0'"); 571 insta::assert_snapshot!(resolved_config.layers()[4].data, @"a = 'a #0.2'"); 572 insta::assert_snapshot!(resolved_config.layers()[5].data, @"a = 'a #1'"); 573 insta::assert_snapshot!(resolved_config.layers()[6].data, @"a = 'a #1.0'"); 574 } 575 576 #[test] 577 fn test_resolve_repo_path() { 578 let mut source_config = StackedConfig::empty(); 579 source_config.add_layer(new_user_layer(indoc! {" 580 a = 'a #0' 581 [[--scope]] 582 --when.repositories = ['/foo'] 583 a = 'a #0.1 foo' 584 [[--scope]] 585 --when.repositories = ['/foo', '/bar'] 586 a = 'a #0.2 foo|bar' 587 [[--scope]] 588 --when.repositories = [] 589 a = 'a #0.3 none' 590 "})); 591 source_config.add_layer(new_user_layer(indoc! {" 592 --when.repositories = ['~/baz'] 593 a = 'a #1 baz' 594 [[--scope]] 595 --when.repositories = ['/foo'] # should never be enabled 596 a = 'a #1.1 baz&foo' 597 "})); 598 599 let context = ConfigResolutionContext { 600 home_dir: Some(Path::new("/home/dir")), 601 repo_path: None, 602 command: None, 603 }; 604 let resolved_config = resolve(&source_config, &context).unwrap(); 605 assert_eq!(resolved_config.layers().len(), 1); 606 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'"); 607 608 let context = ConfigResolutionContext { 609 home_dir: Some(Path::new("/home/dir")), 610 repo_path: Some(Path::new("/foo/.jj/repo")), 611 command: None, 612 }; 613 let resolved_config = resolve(&source_config, &context).unwrap(); 614 assert_eq!(resolved_config.layers().len(), 3); 615 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'"); 616 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 foo'"); 617 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 foo|bar'"); 618 619 let context = ConfigResolutionContext { 620 home_dir: Some(Path::new("/home/dir")), 621 repo_path: Some(Path::new("/bar/.jj/repo")), 622 command: None, 623 }; 624 let resolved_config = resolve(&source_config, &context).unwrap(); 625 assert_eq!(resolved_config.layers().len(), 2); 626 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'"); 627 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.2 foo|bar'"); 628 629 let context = ConfigResolutionContext { 630 home_dir: Some(Path::new("/home/dir")), 631 repo_path: Some(Path::new("/home/dir/baz/.jj/repo")), 632 command: None, 633 }; 634 let resolved_config = resolve(&source_config, &context).unwrap(); 635 assert_eq!(resolved_config.layers().len(), 2); 636 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'"); 637 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #1 baz'"); 638 } 639 640 #[test] 641 fn test_resolve_command() { 642 let mut source_config = StackedConfig::empty(); 643 source_config.add_layer(new_user_layer(indoc! {" 644 a = 'a #0' 645 [[--scope]] 646 --when.commands = ['foo'] 647 a = 'a #0.1 foo' 648 [[--scope]] 649 --when.commands = ['foo', 'bar'] 650 a = 'a #0.2 foo|bar' 651 [[--scope]] 652 --when.commands = ['foo baz'] 653 a = 'a #0.3 foo baz' 654 [[--scope]] 655 --when.commands = [] 656 a = 'a #0.4 none' 657 "})); 658 659 let context = ConfigResolutionContext { 660 home_dir: None, 661 repo_path: None, 662 command: None, 663 }; 664 let resolved_config = resolve(&source_config, &context).unwrap(); 665 assert_eq!(resolved_config.layers().len(), 1); 666 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'"); 667 668 let context = ConfigResolutionContext { 669 home_dir: None, 670 repo_path: None, 671 command: Some("foo"), 672 }; 673 let resolved_config = resolve(&source_config, &context).unwrap(); 674 assert_eq!(resolved_config.layers().len(), 3); 675 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'"); 676 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 foo'"); 677 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 foo|bar'"); 678 679 let context = ConfigResolutionContext { 680 home_dir: None, 681 repo_path: None, 682 command: Some("bar"), 683 }; 684 let resolved_config = resolve(&source_config, &context).unwrap(); 685 assert_eq!(resolved_config.layers().len(), 2); 686 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'"); 687 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.2 foo|bar'"); 688 689 let context = ConfigResolutionContext { 690 home_dir: None, 691 repo_path: None, 692 command: Some("foo baz"), 693 }; 694 let resolved_config = resolve(&source_config, &context).unwrap(); 695 assert_eq!(resolved_config.layers().len(), 4); 696 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'"); 697 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 foo'"); 698 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 foo|bar'"); 699 insta::assert_snapshot!(resolved_config.layers()[3].data, @"a = 'a #0.3 foo baz'"); 700 701 // "fooqux" shares "foo" prefix, but should *not* match 702 let context = ConfigResolutionContext { 703 home_dir: None, 704 repo_path: None, 705 command: Some("fooqux"), 706 }; 707 let resolved_config = resolve(&source_config, &context).unwrap(); 708 assert_eq!(resolved_config.layers().len(), 1); 709 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'"); 710 } 711 712 #[test] 713 fn test_resolve_repo_path_and_command() { 714 let mut source_config = StackedConfig::empty(); 715 source_config.add_layer(new_user_layer(indoc! {" 716 a = 'a #0' 717 [[--scope]] 718 --when.repositories = ['/foo', '/bar'] 719 --when.commands = ['ABC', 'DEF'] 720 a = 'a #0.1' 721 "})); 722 723 let context = ConfigResolutionContext { 724 home_dir: Some(Path::new("/home/dir")), 725 repo_path: None, 726 command: None, 727 }; 728 let resolved_config = resolve(&source_config, &context).unwrap(); 729 assert_eq!(resolved_config.layers().len(), 1); 730 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'"); 731 732 // only repo matches 733 let context = ConfigResolutionContext { 734 home_dir: Some(Path::new("/home/dir")), 735 repo_path: Some(Path::new("/foo")), 736 command: Some("other"), 737 }; 738 let resolved_config = resolve(&source_config, &context).unwrap(); 739 assert_eq!(resolved_config.layers().len(), 1); 740 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'"); 741 742 // only command matches 743 let context = ConfigResolutionContext { 744 home_dir: Some(Path::new("/home/dir")), 745 repo_path: Some(Path::new("/qux")), 746 command: Some("ABC"), 747 }; 748 let resolved_config = resolve(&source_config, &context).unwrap(); 749 assert_eq!(resolved_config.layers().len(), 1); 750 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'"); 751 752 // both match 753 let context = ConfigResolutionContext { 754 home_dir: Some(Path::new("/home/dir")), 755 repo_path: Some(Path::new("/bar")), 756 command: Some("DEF"), 757 }; 758 let resolved_config = resolve(&source_config, &context).unwrap(); 759 assert_eq!(resolved_config.layers().len(), 2); 760 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'"); 761 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1'"); 762 } 763 764 #[test] 765 fn test_resolve_invalid_condition() { 766 let new_config = |text: &str| { 767 let mut config = StackedConfig::empty(); 768 config.add_layer(new_user_layer(text)); 769 config 770 }; 771 let context = ConfigResolutionContext { 772 home_dir: Some(Path::new("/home/dir")), 773 repo_path: Some(Path::new("/foo/.jj/repo")), 774 command: None, 775 }; 776 assert_matches!( 777 resolve(&new_config("--when.repositories = 0"), &context), 778 Err(ConfigGetError::Type { .. }) 779 ); 780 } 781 782 #[test] 783 fn test_resolve_invalid_scoped_tables() { 784 let new_config = |text: &str| { 785 let mut config = StackedConfig::empty(); 786 config.add_layer(new_user_layer(text)); 787 config 788 }; 789 let context = ConfigResolutionContext { 790 home_dir: Some(Path::new("/home/dir")), 791 repo_path: Some(Path::new("/foo/.jj/repo")), 792 command: None, 793 }; 794 assert_matches!( 795 resolve(&new_config("[--scope]"), &context), 796 Err(ConfigGetError::Type { .. }) 797 ); 798 } 799 800 #[test] 801 fn test_migrate_noop() { 802 let mut config = StackedConfig::empty(); 803 config.add_layer(new_user_layer(indoc! {" 804 foo = 'foo' 805 "})); 806 config.add_layer(new_user_layer(indoc! {" 807 bar = 'bar' 808 "})); 809 810 let old_layers = config.layers().to_vec(); 811 let rules = [ConfigMigrationRule::rename_value("baz", "foo")]; 812 let descriptions = migrate(&mut config, &rules).unwrap(); 813 assert!(descriptions.is_empty()); 814 assert!(Arc::ptr_eq(&config.layers()[0], &old_layers[0])); 815 assert!(Arc::ptr_eq(&config.layers()[1], &old_layers[1])); 816 } 817 818 #[test] 819 fn test_migrate_error() { 820 let mut config = StackedConfig::empty(); 821 let mut layer = new_user_layer(indoc! {" 822 foo.bar = 'baz' 823 "}); 824 layer.path = Some("source.toml".into()); 825 config.add_layer(layer); 826 827 let rules = [ConfigMigrationRule::rename_value("foo", "bar")]; 828 insta::assert_debug_snapshot!(migrate(&mut config, &rules).unwrap_err(), @r#" 829 ConfigMigrateError { 830 error: Update( 831 WouldDeleteTable { 832 name: "foo", 833 }, 834 ), 835 source_path: Some( 836 "source.toml", 837 ), 838 } 839 "#); 840 } 841 842 #[test] 843 fn test_migrate_rename_value() { 844 let mut config = StackedConfig::empty(); 845 config.add_layer(new_user_layer(indoc! {" 846 [foo] 847 old = 'foo.old #0' 848 [bar] 849 old = 'bar.old #0' 850 [baz] 851 new = 'baz.new #0' 852 "})); 853 config.add_layer(new_user_layer(indoc! {" 854 [bar] 855 old = 'bar.old #1' 856 "})); 857 858 let rules = [ 859 ConfigMigrationRule::rename_value("foo.old", "foo.new"), 860 ConfigMigrationRule::rename_value("bar.old", "baz.new"), 861 ]; 862 let descriptions = migrate(&mut config, &rules).unwrap(); 863 insta::assert_debug_snapshot!(descriptions, @r#" 864 [ 865 "foo.old is renamed to foo.new", 866 "bar.old is deleted (superseded by baz.new)", 867 "bar.old is renamed to baz.new", 868 ] 869 "#); 870 insta::assert_snapshot!(config.layers()[0].data, @r" 871 [foo] 872 new = 'foo.old #0' 873 [bar] 874 [baz] 875 new = 'baz.new #0' 876 "); 877 insta::assert_snapshot!(config.layers()[1].data, @r" 878 [bar] 879 880 [baz] 881 new = 'bar.old #1' 882 "); 883 } 884 885 #[test] 886 fn test_migrate_rename_update_value() { 887 let mut config = StackedConfig::empty(); 888 config.add_layer(new_user_layer(indoc! {" 889 [foo] 890 old = 'foo.old #0' 891 [bar] 892 old = 'bar.old #0' 893 [baz] 894 new = 'baz.new #0' 895 "})); 896 config.add_layer(new_user_layer(indoc! {" 897 [bar] 898 old = 'bar.old #1' 899 "})); 900 901 let rules = [ 902 // to array 903 ConfigMigrationRule::rename_update_value("foo.old", "foo.new", |old_value| { 904 let val = old_value.clone().decorated("", ""); 905 Ok(ConfigValue::from_iter([val])) 906 }), 907 // update string or error 908 ConfigMigrationRule::rename_update_value("bar.old", "baz.new", |old_value| { 909 let s = old_value.as_str().ok_or("not a string")?; 910 Ok(format!("{s} updated").into()) 911 }), 912 ]; 913 let descriptions = migrate(&mut config, &rules).unwrap(); 914 insta::assert_debug_snapshot!(descriptions, @r#" 915 [ 916 "foo.old is updated to foo.new = ['foo.old #0']", 917 "bar.old is deleted (superseded by baz.new)", 918 "bar.old is updated to baz.new = \"bar.old #1 updated\"", 919 ] 920 "#); 921 insta::assert_snapshot!(config.layers()[0].data, @r" 922 [foo] 923 new = ['foo.old #0'] 924 [bar] 925 [baz] 926 new = 'baz.new #0' 927 "); 928 insta::assert_snapshot!(config.layers()[1].data, @r#" 929 [bar] 930 931 [baz] 932 new = "bar.old #1 updated" 933 "#); 934 935 config.add_layer(new_user_layer(indoc! {" 936 [bar] 937 old = false # not a string 938 "})); 939 insta::assert_debug_snapshot!(migrate(&mut config, &rules).unwrap_err(), @r#" 940 ConfigMigrateError { 941 error: Type { 942 name: "bar.old", 943 error: "not a string", 944 }, 945 source_path: None, 946 } 947 "#); 948 } 949}