1use std::collections::HashMap;
18use std::path::Path;
19use std::path::PathBuf;
20use std::sync::Arc;
21
22use itertools::Itertools as _;
23use serde::Deserialize as _;
24use serde::de::IntoDeserializer as _;
25use thiserror::Error;
26use toml_edit::DocumentMut;
27
28use crate::config::ConfigGetError;
29use crate::config::ConfigLayer;
30use crate::config::ConfigNamePathBuf;
31use crate::config::ConfigSource;
32use crate::config::ConfigUpdateError;
33use crate::config::ConfigValue;
34use crate::config::StackedConfig;
35use crate::config::ToConfigNamePath;
36
37const SCOPE_CONDITION_KEY: &str = "--when";
40const SCOPE_TABLE_KEY: &str = "--scope";
41
42#[derive(Clone, Debug)]
44pub struct ConfigResolutionContext<'a> {
45 pub home_dir: Option<&'a Path>,
47 pub repo_path: Option<&'a Path>,
49 pub workspace_path: Option<&'a Path>,
51 pub command: Option<&'a str>,
54 pub hostname: &'a str,
56 pub environment: &'a HashMap<String, String>,
58}
59
60#[derive(Clone, Debug, Default, serde::Deserialize)]
68#[serde(default, rename_all = "kebab-case")]
69struct ScopeCondition {
70 pub repositories: Option<Vec<PathBuf>>,
72 pub workspaces: Option<Vec<PathBuf>>,
74 pub commands: Option<Vec<String>>,
79 pub platforms: Option<Vec<String>>,
82 pub hostnames: Option<Vec<String>>,
84 pub environments: Option<Vec<String>>,
88}
89
90impl ScopeCondition {
91 fn from_value(
92 value: ConfigValue,
93 context: &ConfigResolutionContext,
94 ) -> Result<Self, toml_edit::de::Error> {
95 Self::deserialize(value.into_deserializer())?
96 .expand_paths(context)
97 .map_err(serde::de::Error::custom)
98 }
99
100 fn expand_paths(mut self, context: &ConfigResolutionContext) -> Result<Self, &'static str> {
101 for path in self.repositories.as_mut().into_iter().flatten() {
105 if let Some(new_path) = expand_home(path, context.home_dir)? {
106 *path = new_path;
107 }
108 }
109 for path in self.workspaces.as_mut().into_iter().flatten() {
110 if let Some(new_path) = expand_home(path, context.home_dir)? {
111 *path = new_path;
112 }
113 }
114 Ok(self)
115 }
116
117 fn matches(&self, context: &ConfigResolutionContext) -> bool {
118 matches_path_prefix(self.repositories.as_deref(), context.repo_path)
119 && matches_path_prefix(self.workspaces.as_deref(), context.workspace_path)
120 && matches_platform(self.platforms.as_deref())
121 && matches_hostname(self.hostnames.as_deref(), context.hostname)
122 && matches_command(self.commands.as_deref(), context.command)
123 && matches_environments(self.environments.as_deref(), context.environment)
124 }
125}
126
127fn expand_home(path: &Path, home_dir: Option<&Path>) -> Result<Option<PathBuf>, &'static str> {
128 match path.strip_prefix("~") {
129 Ok(tail) => {
130 let home_dir = home_dir.ok_or("Cannot expand ~ (home directory is unknown)")?;
131 Ok(Some(home_dir.join(tail)))
132 }
133 Err(_) => Ok(None),
134 }
135}
136
137fn matches_path_prefix(candidates: Option<&[PathBuf]>, actual: Option<&Path>) -> bool {
138 match (candidates, actual) {
139 (Some(candidates), Some(actual)) => candidates.iter().any(|base| actual.starts_with(base)),
140 (Some(_), None) => false, (None, _) => true, }
143}
144
145fn matches_platform(candidates: Option<&[String]>) -> bool {
146 candidates.is_none_or(|candidates| {
147 candidates
148 .iter()
149 .any(|value| value == std::env::consts::FAMILY || value == std::env::consts::OS)
150 })
151}
152
153fn matches_hostname(candidates: Option<&[String]>, actual: &str) -> bool {
154 candidates.is_none_or(|candidates| candidates.iter().any(|candidate| actual == candidate))
155}
156
157fn matches_command(candidates: Option<&[String]>, actual: Option<&str>) -> bool {
158 match (candidates, actual) {
159 (Some(candidates), Some(actual)) => candidates.iter().any(|candidate| {
160 actual
161 .strip_prefix(candidate)
162 .is_some_and(|trailing| trailing.starts_with(' ') || trailing.is_empty())
163 }),
164 (Some(_), None) => false,
165 (None, _) => true,
166 }
167}
168
169fn matches_environments(
170 candidates: Option<&[String]>,
171 environment: &HashMap<String, String>,
172) -> bool {
173 candidates.is_none_or(|candidates| {
174 candidates.iter().any(|entry| {
175 if let Some((name, expected)) = entry.split_once('=') {
176 environment
178 .get(name)
179 .is_some_and(|actual| actual == expected)
180 } else {
181 environment.contains_key(entry.as_str())
183 }
184 })
185 })
186}
187
188pub fn resolve(
191 source_config: &StackedConfig,
192 context: &ConfigResolutionContext,
193) -> Result<StackedConfig, ConfigGetError> {
194 let mut source_layers_stack: Vec<Arc<ConfigLayer>> =
195 source_config.layers().iter().rev().cloned().collect();
196 let mut resolved_layers: Vec<Arc<ConfigLayer>> = Vec::new();
197 while let Some(mut source_layer) = source_layers_stack.pop() {
198 if !source_layer.data.contains_key(SCOPE_CONDITION_KEY)
199 && !source_layer.data.contains_key(SCOPE_TABLE_KEY)
200 {
201 resolved_layers.push(source_layer); continue;
203 }
204
205 let layer_mut = Arc::make_mut(&mut source_layer);
206 let condition = pop_scope_condition(layer_mut, context)?;
207 if !condition.matches(context) {
208 continue;
209 }
210 let tables = pop_scope_tables(layer_mut)?;
211 let frame = source_layers_stack.len();
214 for table in tables {
215 let layer = ConfigLayer {
216 source: source_layer.source,
217 path: source_layer.path.clone(),
218 data: DocumentMut::from(table),
219 };
220 source_layers_stack.push(Arc::new(layer));
221 }
222 source_layers_stack[frame..].reverse();
223 resolved_layers.push(source_layer);
224 }
225 let mut resolved_config = StackedConfig::empty();
226 resolved_config.extend_layers(resolved_layers);
227 Ok(resolved_config)
228}
229
230fn pop_scope_condition(
231 layer: &mut ConfigLayer,
232 context: &ConfigResolutionContext,
233) -> Result<ScopeCondition, ConfigGetError> {
234 let Some(item) = layer.data.remove(SCOPE_CONDITION_KEY) else {
235 return Ok(ScopeCondition::default());
236 };
237 let value = item
238 .clone()
239 .into_value()
240 .expect("Item::None should not exist in table");
241 ScopeCondition::from_value(value, context).map_err(|err| ConfigGetError::Type {
242 name: SCOPE_CONDITION_KEY.to_owned(),
243 error: err.into(),
244 source_path: layer.path.clone(),
245 })
246}
247
248fn pop_scope_tables(layer: &mut ConfigLayer) -> Result<toml_edit::ArrayOfTables, ConfigGetError> {
249 let Some(item) = layer.data.remove(SCOPE_TABLE_KEY) else {
250 return Ok(toml_edit::ArrayOfTables::new());
251 };
252 item.into_array_of_tables()
253 .map_err(|item| ConfigGetError::Type {
254 name: SCOPE_TABLE_KEY.to_owned(),
255 error: format!("Expected an array of tables, but is {}", item.type_name()).into(),
256 source_path: layer.path.clone(),
257 })
258}
259
260#[derive(Debug, Error)]
262#[error("Migration failed")]
263pub struct ConfigMigrateError {
264 #[source]
266 pub error: ConfigMigrateLayerError,
267 pub source_path: Option<PathBuf>,
269}
270
271#[derive(Debug, Error)]
273pub enum ConfigMigrateLayerError {
274 #[error(transparent)]
276 Update(#[from] ConfigUpdateError),
277 #[error("Invalid type or value for {name}")]
279 Type {
280 name: String,
282 #[source]
284 error: DynError,
285 },
286}
287
288impl ConfigMigrateLayerError {
289 fn with_source_path(self, source_path: Option<&Path>) -> ConfigMigrateError {
290 ConfigMigrateError {
291 error: self,
292 source_path: source_path.map(|path| path.to_owned()),
293 }
294 }
295}
296
297type DynError = Box<dyn std::error::Error + Send + Sync>;
298
299pub struct ConfigMigrationRule {
301 inner: MigrationRule,
302}
303
304enum MigrationRule {
305 RenameValue {
306 old_name: ConfigNamePathBuf,
307 new_name: ConfigNamePathBuf,
308 },
309 RenameUpdateValue {
310 old_name: ConfigNamePathBuf,
311 new_name: ConfigNamePathBuf,
312 #[expect(clippy::type_complexity)] new_value_fn: Box<dyn Fn(&ConfigValue) -> Result<ConfigValue, DynError>>,
314 },
315 Custom {
316 matches_fn: Box<dyn Fn(&ConfigLayer) -> bool>,
317 #[expect(clippy::type_complexity)] apply_fn: Box<dyn Fn(&mut ConfigLayer) -> Result<String, ConfigMigrateLayerError>>,
319 },
320}
321
322impl ConfigMigrationRule {
323 pub fn rename_value(old_name: impl ToConfigNamePath, new_name: impl ToConfigNamePath) -> Self {
325 let inner = MigrationRule::RenameValue {
326 old_name: old_name.into_name_path().into(),
327 new_name: new_name.into_name_path().into(),
328 };
329 Self { inner }
330 }
331
332 pub fn rename_update_value(
338 old_name: impl ToConfigNamePath,
339 new_name: impl ToConfigNamePath,
340 new_value_fn: impl Fn(&ConfigValue) -> Result<ConfigValue, DynError> + 'static,
341 ) -> Self {
342 let inner = MigrationRule::RenameUpdateValue {
343 old_name: old_name.into_name_path().into(),
344 new_name: new_name.into_name_path().into(),
345 new_value_fn: Box::new(new_value_fn),
346 };
347 Self { inner }
348 }
349
350 pub fn custom(
355 matches_fn: impl Fn(&ConfigLayer) -> bool + 'static,
356 apply_fn: impl Fn(&mut ConfigLayer) -> Result<String, ConfigMigrateLayerError> + 'static,
357 ) -> Self {
358 let inner = MigrationRule::Custom {
359 matches_fn: Box::new(matches_fn),
360 apply_fn: Box::new(apply_fn),
361 };
362 Self { inner }
363 }
364
365 fn matches(&self, layer: &ConfigLayer) -> bool {
367 match &self.inner {
368 MigrationRule::RenameValue { old_name, .. }
369 | MigrationRule::RenameUpdateValue { old_name, .. } => {
370 matches!(layer.look_up_item(old_name), Ok(Some(_)))
371 }
372 MigrationRule::Custom { matches_fn, .. } => matches_fn(layer),
373 }
374 }
375
376 fn apply(&self, layer: &mut ConfigLayer) -> Result<String, ConfigMigrateLayerError> {
378 match &self.inner {
379 MigrationRule::RenameValue { old_name, new_name } => {
380 rename_value(layer, old_name, new_name)
381 }
382 MigrationRule::RenameUpdateValue {
383 old_name,
384 new_name,
385 new_value_fn,
386 } => rename_update_value(layer, old_name, new_name, new_value_fn),
387 MigrationRule::Custom { apply_fn, .. } => apply_fn(layer),
388 }
389 }
390}
391
392fn rename_value(
393 layer: &mut ConfigLayer,
394 old_name: &ConfigNamePathBuf,
395 new_name: &ConfigNamePathBuf,
396) -> Result<String, ConfigMigrateLayerError> {
397 let value = layer.delete_value(old_name)?.expect("tested by matches()");
398 if matches!(layer.look_up_item(new_name), Ok(Some(_))) {
399 return Ok(format!("{old_name} is deleted (superseded by {new_name})"));
400 }
401 layer.set_value(new_name, value)?;
402 Ok(format!("{old_name} is renamed to {new_name}"))
403}
404
405fn rename_update_value(
406 layer: &mut ConfigLayer,
407 old_name: &ConfigNamePathBuf,
408 new_name: &ConfigNamePathBuf,
409 new_value_fn: impl FnOnce(&ConfigValue) -> Result<ConfigValue, DynError>,
410) -> Result<String, ConfigMigrateLayerError> {
411 let old_value = layer.delete_value(old_name)?.expect("tested by matches()");
412 if matches!(layer.look_up_item(new_name), Ok(Some(_))) {
413 return Ok(format!("{old_name} is deleted (superseded by {new_name})"));
414 }
415 let new_value = new_value_fn(&old_value).map_err(|error| ConfigMigrateLayerError::Type {
416 name: old_name.to_string(),
417 error,
418 })?;
419 layer.set_value(new_name, new_value.clone())?;
420 Ok(format!("{old_name} is updated to {new_name} = {new_value}"))
421}
422
423pub fn migrate(
426 config: &mut StackedConfig,
427 rules: &[ConfigMigrationRule],
428) -> Result<Vec<(ConfigSource, String)>, ConfigMigrateError> {
429 let mut descriptions = Vec::new();
430 for layer in config.layers_mut() {
431 migrate_layer(layer, rules, &mut descriptions)
432 .map_err(|err| err.with_source_path(layer.path.as_deref()))?;
433 }
434 Ok(descriptions)
435}
436
437fn migrate_layer(
438 layer: &mut Arc<ConfigLayer>,
439 rules: &[ConfigMigrationRule],
440 descriptions: &mut Vec<(ConfigSource, String)>,
441) -> Result<(), ConfigMigrateLayerError> {
442 let rules_to_apply = rules
443 .iter()
444 .filter(|rule| rule.matches(layer))
445 .collect_vec();
446 if rules_to_apply.is_empty() {
447 return Ok(());
448 }
449 let layer_mut = Arc::make_mut(layer);
450 for rule in rules_to_apply {
451 let desc = rule.apply(layer_mut)?;
452 descriptions.push((layer_mut.source, desc));
453 }
454 Ok(())
455}
456
457#[cfg(test)]
458mod tests {
459 use assert_matches::assert_matches;
460 use indoc::indoc;
461
462 use super::*;
463
464 #[test]
465 fn test_expand_home() {
466 let home_dir = Some(Path::new("/home/dir"));
467 assert_eq!(
468 expand_home("~".as_ref(), home_dir).unwrap(),
469 Some(PathBuf::from("/home/dir"))
470 );
471 assert_eq!(expand_home("~foo".as_ref(), home_dir).unwrap(), None);
472 assert_eq!(expand_home("/foo/~".as_ref(), home_dir).unwrap(), None);
473 assert_eq!(
474 expand_home("~/foo".as_ref(), home_dir).unwrap(),
475 Some(PathBuf::from("/home/dir/foo"))
476 );
477 assert!(expand_home("~/foo".as_ref(), None).is_err());
478 }
479
480 #[test]
481 fn test_condition_default() {
482 let condition = ScopeCondition::default();
483
484 let context = ConfigResolutionContext {
485 home_dir: None,
486 repo_path: None,
487 workspace_path: None,
488 command: None,
489 hostname: "",
490 environment: &HashMap::new(),
491 };
492 assert!(condition.matches(&context));
493 let context = ConfigResolutionContext {
494 home_dir: None,
495 repo_path: Some(Path::new("/foo")),
496 workspace_path: None,
497 command: None,
498 hostname: "",
499 environment: &HashMap::new(),
500 };
501 assert!(condition.matches(&context));
502 }
503
504 #[test]
505 fn test_condition_repo_path() {
506 let condition = ScopeCondition {
507 repositories: Some(["/foo", "/bar"].map(PathBuf::from).into()),
508 ..Default::default()
509 };
510
511 let context = ConfigResolutionContext {
512 home_dir: None,
513 repo_path: None,
514 workspace_path: None,
515 command: None,
516 hostname: "",
517 environment: &HashMap::new(),
518 };
519 assert!(!condition.matches(&context));
520 let context = ConfigResolutionContext {
521 home_dir: None,
522 repo_path: Some(Path::new("/foo")),
523 workspace_path: None,
524 command: None,
525 hostname: "",
526 environment: &HashMap::new(),
527 };
528 assert!(condition.matches(&context));
529 let context = ConfigResolutionContext {
530 home_dir: None,
531 repo_path: Some(Path::new("/fooo")),
532 workspace_path: None,
533 command: None,
534 hostname: "",
535 environment: &HashMap::new(),
536 };
537 assert!(!condition.matches(&context));
538 let context = ConfigResolutionContext {
539 home_dir: None,
540 repo_path: Some(Path::new("/foo/baz")),
541 workspace_path: None,
542 command: None,
543 hostname: "",
544 environment: &HashMap::new(),
545 };
546 assert!(condition.matches(&context));
547 let context = ConfigResolutionContext {
548 home_dir: None,
549 repo_path: Some(Path::new("/bar")),
550 workspace_path: None,
551 command: None,
552 hostname: "",
553 environment: &HashMap::new(),
554 };
555 assert!(condition.matches(&context));
556 }
557
558 #[test]
559 fn test_condition_repo_path_windows() {
560 let condition = ScopeCondition {
561 repositories: Some(["c:/foo", r"d:\bar/baz"].map(PathBuf::from).into()),
562 ..Default::default()
563 };
564
565 let context = ConfigResolutionContext {
566 home_dir: None,
567 repo_path: Some(Path::new(r"c:\foo")),
568 workspace_path: None,
569 command: None,
570 hostname: "",
571 environment: &HashMap::new(),
572 };
573 assert_eq!(condition.matches(&context), cfg!(windows));
574 let context = ConfigResolutionContext {
575 home_dir: None,
576 repo_path: Some(Path::new(r"c:\foo\baz")),
577 workspace_path: None,
578 command: None,
579 hostname: "",
580 environment: &HashMap::new(),
581 };
582 assert_eq!(condition.matches(&context), cfg!(windows));
583 let context = ConfigResolutionContext {
584 home_dir: None,
585 repo_path: Some(Path::new(r"d:\foo")),
586 workspace_path: None,
587 command: None,
588 hostname: "",
589 environment: &HashMap::new(),
590 };
591 assert!(!condition.matches(&context));
592 let context = ConfigResolutionContext {
593 home_dir: None,
594 repo_path: Some(Path::new(r"d:/bar\baz")),
595 workspace_path: None,
596 command: None,
597 hostname: "",
598 environment: &HashMap::new(),
599 };
600 assert_eq!(condition.matches(&context), cfg!(windows));
601 }
602
603 #[test]
604 fn test_condition_hostname() {
605 let condition = ScopeCondition {
606 hostnames: Some(["host-a", "host-b"].map(String::from).into()),
607 ..Default::default()
608 };
609
610 let context = ConfigResolutionContext {
611 home_dir: None,
612 repo_path: None,
613 workspace_path: None,
614 command: None,
615 hostname: "",
616 environment: &HashMap::new(),
617 };
618 assert!(!condition.matches(&context));
619 let context = ConfigResolutionContext {
620 home_dir: None,
621 repo_path: None,
622 workspace_path: None,
623 command: None,
624 hostname: "host-a",
625 environment: &HashMap::new(),
626 };
627 assert!(condition.matches(&context));
628 let context = ConfigResolutionContext {
629 home_dir: None,
630 repo_path: None,
631 workspace_path: None,
632 command: None,
633 hostname: "host-b",
634 environment: &HashMap::new(),
635 };
636 assert!(condition.matches(&context));
637 let context = ConfigResolutionContext {
638 home_dir: None,
639 repo_path: None,
640 workspace_path: None,
641 command: None,
642 hostname: "host-c",
643 environment: &HashMap::new(),
644 };
645 assert!(!condition.matches(&context));
646 }
647
648 #[test]
649 fn test_condition_environments() {
650 let environment = HashMap::from([
651 ("MY_ENV".into(), "hello".into()),
652 ("OTHER_ENV".into(), "world".into()),
653 ]);
654 let context = ConfigResolutionContext {
655 home_dir: None,
656 repo_path: None,
657 workspace_path: None,
658 command: None,
659 hostname: "",
660 environment: &environment,
661 };
662
663 let condition = ScopeCondition {
665 environments: Some(vec!["MY_ENV=hello".into()]),
666 ..Default::default()
667 };
668 assert!(condition.matches(&context));
669
670 let condition = ScopeCondition {
672 environments: Some(vec!["MY_ENV=wrong".into()]),
673 ..Default::default()
674 };
675 assert!(!condition.matches(&context));
676
677 let condition = ScopeCondition {
679 environments: Some(vec!["ABSENT_VAR=anything".into()]),
680 ..Default::default()
681 };
682 assert!(!condition.matches(&context));
683
684 let condition = ScopeCondition {
686 environments: Some(vec!["MY_ENV=hello".into(), "OTHER_ENV=world".into()]),
687 ..Default::default()
688 };
689 assert!(condition.matches(&context));
690
691 let condition = ScopeCondition {
693 environments: Some(vec!["MY_ENV=wrong".into(), "OTHER_ENV=world".into()]),
694 ..Default::default()
695 };
696 assert!(condition.matches(&context));
697
698 let condition = ScopeCondition {
700 environments: Some(vec!["MY_ENV=wrong".into(), "ABSENT_VAR=nope".into()]),
701 ..Default::default()
702 };
703 assert!(!condition.matches(&context));
704
705 let condition = ScopeCondition {
707 environments: Some(vec!["MY_ENV=".into()]),
708 ..Default::default()
709 };
710 assert!(!condition.matches(&context));
711
712 let condition = ScopeCondition {
714 environments: Some(vec!["ABSENT_VAR=".into()]),
715 ..Default::default()
716 };
717 assert!(!condition.matches(&context));
718
719 let condition = ScopeCondition {
721 environments: Some(vec![]),
722 ..Default::default()
723 };
724 assert!(!condition.matches(&context));
725
726 let environment = HashMap::from([("CONN".into(), "host=localhost:5432".into())]);
728 let context = ConfigResolutionContext {
729 home_dir: None,
730 repo_path: None,
731 workspace_path: None,
732 command: None,
733 hostname: "",
734 environment: &environment,
735 };
736 let condition = ScopeCondition {
737 environments: Some(vec!["CONN=host=localhost:5432".into()]),
738 ..Default::default()
739 };
740 assert!(condition.matches(&context));
741
742 let condition = ScopeCondition {
744 environments: Some(vec!["CONN".into()]),
745 ..Default::default()
746 };
747 assert!(condition.matches(&context));
748
749 let condition = ScopeCondition {
751 environments: Some(vec!["ABSENT_VAR".into()]),
752 ..Default::default()
753 };
754 assert!(!condition.matches(&context));
755
756 let condition = ScopeCondition {
758 environments: Some(vec!["CONN".into(), "OTHER=nope".into()]),
759 ..Default::default()
760 };
761 assert!(condition.matches(&context));
762
763 let condition = ScopeCondition {
765 environments: Some(vec!["ABSENT".into(), "CONN=host=localhost:5432".into()]),
766 ..Default::default()
767 };
768 assert!(condition.matches(&context));
769
770 let environment = HashMap::from([("EMPTY_VAR".into(), "".into())]);
772 let context = ConfigResolutionContext {
773 home_dir: None,
774 repo_path: None,
775 workspace_path: None,
776 command: None,
777 hostname: "",
778 environment: &environment,
779 };
780 let condition = ScopeCondition {
781 environments: Some(vec!["EMPTY_VAR".into()]),
782 ..Default::default()
783 };
784 assert!(condition.matches(&context));
785
786 let condition = ScopeCondition {
788 environments: None,
789 ..Default::default()
790 };
791 assert!(condition.matches(&context));
792 }
793
794 fn new_user_layer(text: &str) -> ConfigLayer {
795 ConfigLayer::parse(ConfigSource::User, text).unwrap()
796 }
797
798 #[test]
799 fn test_resolve_transparent() {
800 let mut source_config = StackedConfig::empty();
801 source_config.add_layer(ConfigLayer::empty(ConfigSource::Default));
802 source_config.add_layer(ConfigLayer::empty(ConfigSource::User));
803
804 let context = ConfigResolutionContext {
805 home_dir: None,
806 repo_path: None,
807 workspace_path: None,
808 command: None,
809 hostname: "",
810 environment: &HashMap::new(),
811 };
812 let resolved_config = resolve(&source_config, &context).unwrap();
813 assert_eq!(resolved_config.layers().len(), 2);
814 assert!(Arc::ptr_eq(
815 &source_config.layers()[0],
816 &resolved_config.layers()[0]
817 ));
818 assert!(Arc::ptr_eq(
819 &source_config.layers()[1],
820 &resolved_config.layers()[1]
821 ));
822 }
823
824 #[test]
825 fn test_resolve_table_order() {
826 let mut source_config = StackedConfig::empty();
827 source_config.add_layer(new_user_layer(indoc! {"
828 a = 'a #0'
829 [[--scope]]
830 a = 'a #0.0'
831 [[--scope]]
832 a = 'a #0.1'
833 [[--scope.--scope]]
834 a = 'a #0.1.0'
835 [[--scope]]
836 a = 'a #0.2'
837 "}));
838 source_config.add_layer(new_user_layer(indoc! {"
839 a = 'a #1'
840 [[--scope]]
841 a = 'a #1.0'
842 "}));
843
844 let context = ConfigResolutionContext {
845 home_dir: None,
846 repo_path: None,
847 workspace_path: None,
848 command: None,
849 hostname: "",
850 environment: &HashMap::new(),
851 };
852 let resolved_config = resolve(&source_config, &context).unwrap();
853 assert_eq!(resolved_config.layers().len(), 7);
854 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
855 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.0'");
856 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.1'");
857 insta::assert_snapshot!(resolved_config.layers()[3].data, @"a = 'a #0.1.0'");
858 insta::assert_snapshot!(resolved_config.layers()[4].data, @"a = 'a #0.2'");
859 insta::assert_snapshot!(resolved_config.layers()[5].data, @"a = 'a #1'");
860 insta::assert_snapshot!(resolved_config.layers()[6].data, @"a = 'a #1.0'");
861 }
862
863 #[test]
864 fn test_resolve_repo_path() {
865 let mut source_config = StackedConfig::empty();
866 source_config.add_layer(new_user_layer(indoc! {"
867 a = 'a #0'
868 [[--scope]]
869 --when.repositories = ['/foo']
870 a = 'a #0.1 foo'
871 [[--scope]]
872 --when.repositories = ['/foo', '/bar']
873 a = 'a #0.2 foo|bar'
874 [[--scope]]
875 --when.repositories = []
876 a = 'a #0.3 none'
877 "}));
878 source_config.add_layer(new_user_layer(indoc! {"
879 --when.repositories = ['~/baz']
880 a = 'a #1 baz'
881 [[--scope]]
882 --when.repositories = ['/foo'] # should never be enabled
883 a = 'a #1.1 baz&foo'
884 "}));
885
886 let context = ConfigResolutionContext {
887 home_dir: Some(Path::new("/home/dir")),
888 repo_path: None,
889 workspace_path: None,
890 command: None,
891 hostname: "",
892 environment: &HashMap::new(),
893 };
894 let resolved_config = resolve(&source_config, &context).unwrap();
895 assert_eq!(resolved_config.layers().len(), 1);
896 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
897
898 let context = ConfigResolutionContext {
899 home_dir: Some(Path::new("/home/dir")),
900 repo_path: Some(Path::new("/foo/.jj/repo")),
901 workspace_path: None,
902 command: None,
903 hostname: "",
904 environment: &HashMap::new(),
905 };
906 let resolved_config = resolve(&source_config, &context).unwrap();
907 assert_eq!(resolved_config.layers().len(), 3);
908 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
909 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 foo'");
910 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 foo|bar'");
911
912 let context = ConfigResolutionContext {
913 home_dir: Some(Path::new("/home/dir")),
914 repo_path: Some(Path::new("/bar/.jj/repo")),
915 workspace_path: None,
916 command: None,
917 hostname: "",
918 environment: &HashMap::new(),
919 };
920 let resolved_config = resolve(&source_config, &context).unwrap();
921 assert_eq!(resolved_config.layers().len(), 2);
922 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
923 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.2 foo|bar'");
924
925 let context = ConfigResolutionContext {
926 home_dir: Some(Path::new("/home/dir")),
927 repo_path: Some(Path::new("/home/dir/baz/.jj/repo")),
928 workspace_path: None,
929 command: None,
930 hostname: "",
931 environment: &HashMap::new(),
932 };
933 let resolved_config = resolve(&source_config, &context).unwrap();
934 assert_eq!(resolved_config.layers().len(), 2);
935 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
936 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #1 baz'");
937 }
938
939 #[test]
940 fn test_resolve_hostname() {
941 let mut source_config = StackedConfig::empty();
942 source_config.add_layer(new_user_layer(indoc! {"
943 a = 'a #0'
944 [[--scope]]
945 --when.hostnames = ['host-a']
946 a = 'a #0.1 host-a'
947 [[--scope]]
948 --when.hostnames = ['host-a', 'host-b']
949 a = 'a #0.2 host-a|host-b'
950 [[--scope]]
951 --when.hostnames = []
952 a = 'a #0.3 none'
953 "}));
954 source_config.add_layer(new_user_layer(indoc! {"
955 --when.hostnames = ['host-c']
956 a = 'a #1 host-c'
957 [[--scope]]
958 --when.hostnames = ['host-a'] # should never be enabled
959 a = 'a #1.1 host-c&host-a'
960 "}));
961
962 let context = ConfigResolutionContext {
963 home_dir: Some(Path::new("/home/dir")),
964 repo_path: None,
965 workspace_path: None,
966 command: None,
967 hostname: "",
968 environment: &HashMap::new(),
969 };
970 let resolved_config = resolve(&source_config, &context).unwrap();
971 assert_eq!(resolved_config.layers().len(), 1);
972 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
973
974 let context = ConfigResolutionContext {
975 home_dir: Some(Path::new("/home/dir")),
976 repo_path: None,
977 workspace_path: None,
978 command: None,
979 hostname: "host-a",
980 environment: &HashMap::new(),
981 };
982 let resolved_config = resolve(&source_config, &context).unwrap();
983 assert_eq!(resolved_config.layers().len(), 3);
984 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
985 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 host-a'");
986 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 host-a|host-b'");
987
988 let context = ConfigResolutionContext {
989 home_dir: Some(Path::new("/home/dir")),
990 repo_path: None,
991 workspace_path: None,
992 command: None,
993 hostname: "host-b",
994 environment: &HashMap::new(),
995 };
996 let resolved_config = resolve(&source_config, &context).unwrap();
997 assert_eq!(resolved_config.layers().len(), 2);
998 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
999 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.2 host-a|host-b'");
1000
1001 let context = ConfigResolutionContext {
1002 home_dir: Some(Path::new("/home/dir")),
1003 repo_path: None,
1004 workspace_path: None,
1005 command: None,
1006 hostname: "host-c",
1007 environment: &HashMap::new(),
1008 };
1009 let resolved_config = resolve(&source_config, &context).unwrap();
1010 assert_eq!(resolved_config.layers().len(), 2);
1011 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1012 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #1 host-c'");
1013 }
1014
1015 #[test]
1016 fn test_resolve_workspace_path() {
1017 let mut source_config = StackedConfig::empty();
1018 source_config.add_layer(new_user_layer(indoc! {"
1019 a = 'a #0'
1020 [[--scope]]
1021 --when.workspaces = ['/foo']
1022 a = 'a #0.1 foo'
1023 [[--scope]]
1024 --when.workspaces = ['/foo', '/bar']
1025 a = 'a #0.2 foo|bar'
1026 [[--scope]]
1027 --when.workspaces = []
1028 a = 'a #0.3 none'
1029 "}));
1030 source_config.add_layer(new_user_layer(indoc! {"
1031 --when.workspaces = ['~/baz']
1032 a = 'a #1 baz'
1033 [[--scope]]
1034 --when.workspaces = ['/foo'] # should never be enabled
1035 a = 'a #1.1 baz&foo'
1036 "}));
1037
1038 let context = ConfigResolutionContext {
1039 home_dir: Some(Path::new("/home/dir")),
1040 repo_path: None,
1041 workspace_path: None,
1042 command: None,
1043 hostname: "",
1044 environment: &HashMap::new(),
1045 };
1046 let resolved_config = resolve(&source_config, &context).unwrap();
1047 assert_eq!(resolved_config.layers().len(), 1);
1048 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1049
1050 let context = ConfigResolutionContext {
1051 home_dir: Some(Path::new("/home/dir")),
1052 repo_path: None,
1053 workspace_path: Some(Path::new("/foo")),
1054 command: None,
1055 hostname: "",
1056 environment: &HashMap::new(),
1057 };
1058 let resolved_config = resolve(&source_config, &context).unwrap();
1059 assert_eq!(resolved_config.layers().len(), 3);
1060 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1061 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 foo'");
1062 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 foo|bar'");
1063
1064 let context = ConfigResolutionContext {
1065 home_dir: Some(Path::new("/home/dir")),
1066 repo_path: None,
1067 workspace_path: Some(Path::new("/bar")),
1068 command: None,
1069 hostname: "",
1070 environment: &HashMap::new(),
1071 };
1072 let resolved_config = resolve(&source_config, &context).unwrap();
1073 assert_eq!(resolved_config.layers().len(), 2);
1074 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1075 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.2 foo|bar'");
1076
1077 let context = ConfigResolutionContext {
1078 home_dir: Some(Path::new("/home/dir")),
1079 repo_path: None,
1080 workspace_path: Some(Path::new("/home/dir/baz")),
1081 command: None,
1082 hostname: "",
1083 environment: &HashMap::new(),
1084 };
1085 let resolved_config = resolve(&source_config, &context).unwrap();
1086 assert_eq!(resolved_config.layers().len(), 2);
1087 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1088 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #1 baz'");
1089 }
1090
1091 #[test]
1092 fn test_resolve_command() {
1093 let mut source_config = StackedConfig::empty();
1094 source_config.add_layer(new_user_layer(indoc! {"
1095 a = 'a #0'
1096 [[--scope]]
1097 --when.commands = ['foo']
1098 a = 'a #0.1 foo'
1099 [[--scope]]
1100 --when.commands = ['foo', 'bar']
1101 a = 'a #0.2 foo|bar'
1102 [[--scope]]
1103 --when.commands = ['foo baz']
1104 a = 'a #0.3 foo baz'
1105 [[--scope]]
1106 --when.commands = []
1107 a = 'a #0.4 none'
1108 "}));
1109
1110 let context = ConfigResolutionContext {
1111 home_dir: None,
1112 repo_path: None,
1113 workspace_path: None,
1114 command: None,
1115 hostname: "",
1116 environment: &HashMap::new(),
1117 };
1118 let resolved_config = resolve(&source_config, &context).unwrap();
1119 assert_eq!(resolved_config.layers().len(), 1);
1120 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1121
1122 let context = ConfigResolutionContext {
1123 home_dir: None,
1124 repo_path: None,
1125 workspace_path: None,
1126 command: Some("foo"),
1127 hostname: "",
1128 environment: &HashMap::new(),
1129 };
1130 let resolved_config = resolve(&source_config, &context).unwrap();
1131 assert_eq!(resolved_config.layers().len(), 3);
1132 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1133 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 foo'");
1134 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 foo|bar'");
1135
1136 let context = ConfigResolutionContext {
1137 home_dir: None,
1138 repo_path: None,
1139 workspace_path: None,
1140 command: Some("bar"),
1141 hostname: "",
1142 environment: &HashMap::new(),
1143 };
1144 let resolved_config = resolve(&source_config, &context).unwrap();
1145 assert_eq!(resolved_config.layers().len(), 2);
1146 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1147 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.2 foo|bar'");
1148
1149 let context = ConfigResolutionContext {
1150 home_dir: None,
1151 repo_path: None,
1152 workspace_path: None,
1153 command: Some("foo baz"),
1154 hostname: "",
1155 environment: &HashMap::new(),
1156 };
1157 let resolved_config = resolve(&source_config, &context).unwrap();
1158 assert_eq!(resolved_config.layers().len(), 4);
1159 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1160 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 foo'");
1161 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 foo|bar'");
1162 insta::assert_snapshot!(resolved_config.layers()[3].data, @"a = 'a #0.3 foo baz'");
1163
1164 let context = ConfigResolutionContext {
1166 home_dir: None,
1167 repo_path: None,
1168 workspace_path: None,
1169 command: Some("fooqux"),
1170 hostname: "",
1171 environment: &HashMap::new(),
1172 };
1173 let resolved_config = resolve(&source_config, &context).unwrap();
1174 assert_eq!(resolved_config.layers().len(), 1);
1175 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1176 }
1177
1178 #[test]
1179 fn test_resolve_os() {
1180 let mut source_config = StackedConfig::empty();
1181 source_config.add_layer(new_user_layer(indoc! {"
1182 a = 'a none'
1183 b = 'b none'
1184 [[--scope]]
1185 --when.platforms = ['linux']
1186 a = 'a linux'
1187 [[--scope]]
1188 --when.platforms = ['macos']
1189 a = 'a macos'
1190 [[--scope]]
1191 --when.platforms = ['windows']
1192 a = 'a windows'
1193 [[--scope]]
1194 --when.platforms = ['unix']
1195 b = 'b unix'
1196 "}));
1197
1198 let context = ConfigResolutionContext {
1199 home_dir: Some(Path::new("/home/dir")),
1200 repo_path: None,
1201 workspace_path: None,
1202 command: None,
1203 hostname: "",
1204 environment: &HashMap::new(),
1205 };
1206 let resolved_config = resolve(&source_config, &context).unwrap();
1207 insta::assert_snapshot!(resolved_config.layers()[0].data, @"
1208 a = 'a none'
1209 b = 'b none'
1210 ");
1211 if cfg!(target_os = "linux") {
1212 assert_eq!(resolved_config.layers().len(), 3);
1213 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a linux'");
1214 insta::assert_snapshot!(resolved_config.layers()[2].data, @"b = 'b unix'");
1215 } else if cfg!(target_os = "macos") {
1216 assert_eq!(resolved_config.layers().len(), 3);
1217 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a macos'");
1218 insta::assert_snapshot!(resolved_config.layers()[2].data, @"b = 'b unix'");
1219 } else if cfg!(target_os = "windows") {
1220 assert_eq!(resolved_config.layers().len(), 2);
1221 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a windows'");
1222 } else if cfg!(target_family = "unix") {
1223 assert_eq!(resolved_config.layers().len(), 2);
1224 insta::assert_snapshot!(resolved_config.layers()[1].data, @"b = 'b unix'");
1225 } else {
1226 assert_eq!(resolved_config.layers().len(), 1);
1227 }
1228 }
1229
1230 #[test]
1231 fn test_resolve_repo_path_and_command() {
1232 let mut source_config = StackedConfig::empty();
1233 source_config.add_layer(new_user_layer(indoc! {"
1234 a = 'a #0'
1235 [[--scope]]
1236 --when.repositories = ['/foo', '/bar']
1237 --when.commands = ['ABC', 'DEF']
1238 a = 'a #0.1'
1239 "}));
1240
1241 let context = ConfigResolutionContext {
1242 home_dir: Some(Path::new("/home/dir")),
1243 repo_path: None,
1244 workspace_path: None,
1245 command: None,
1246 hostname: "",
1247 environment: &HashMap::new(),
1248 };
1249 let resolved_config = resolve(&source_config, &context).unwrap();
1250 assert_eq!(resolved_config.layers().len(), 1);
1251 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1252
1253 let context = ConfigResolutionContext {
1255 home_dir: Some(Path::new("/home/dir")),
1256 repo_path: Some(Path::new("/foo")),
1257 workspace_path: None,
1258 command: Some("other"),
1259 hostname: "",
1260 environment: &HashMap::new(),
1261 };
1262 let resolved_config = resolve(&source_config, &context).unwrap();
1263 assert_eq!(resolved_config.layers().len(), 1);
1264 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1265
1266 let context = ConfigResolutionContext {
1268 home_dir: Some(Path::new("/home/dir")),
1269 repo_path: Some(Path::new("/qux")),
1270 workspace_path: None,
1271 command: Some("ABC"),
1272 hostname: "",
1273 environment: &HashMap::new(),
1274 };
1275 let resolved_config = resolve(&source_config, &context).unwrap();
1276 assert_eq!(resolved_config.layers().len(), 1);
1277 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1278
1279 let context = ConfigResolutionContext {
1281 home_dir: Some(Path::new("/home/dir")),
1282 repo_path: Some(Path::new("/bar")),
1283 workspace_path: None,
1284 command: Some("DEF"),
1285 hostname: "",
1286 environment: &HashMap::new(),
1287 };
1288 let resolved_config = resolve(&source_config, &context).unwrap();
1289 assert_eq!(resolved_config.layers().len(), 2);
1290 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1291 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1'");
1292 }
1293
1294 #[test]
1295 fn test_resolve_environments() {
1296 let mut source_config = StackedConfig::empty();
1297 source_config.add_layer(new_user_layer(indoc! {"
1298 a = 'a #0'
1299 [[--scope]]
1300 --when.environments = ['MY_ENV=yes']
1301 a = 'a #0.1 env-yes'
1302 [[--scope]]
1303 --when.environments = ['MY_ENV=yes', 'MY_ENV=no']
1304 a = 'a #0.2 env-yes|env-no'
1305 [[--scope]]
1306 --when.environments = []
1307 a = 'a #0.3 none'
1308 [[--scope]]
1309 --when.environments = ['MY_ENV']
1310 a = 'a #0.4 env-exists'
1311 [[--scope]]
1312 --when.environments = ['ABSENT_VAR']
1313 a = 'a #0.5 absent-exists'
1314 "}));
1315 source_config.add_layer(new_user_layer(indoc! {"
1316 --when.environments = ['MY_ENV=yes']
1317 a = 'a #1 env-yes'
1318 [[--scope]]
1319 --when.environments = ['MY_ENV=no'] # can never match: layer requires MY_ENV=yes
1320 a = 'a #1.1 env-yes&env-no'
1321 "}));
1322
1323 let environment = HashMap::new();
1325 let context = ConfigResolutionContext {
1326 home_dir: Some(Path::new("/home/dir")),
1327 repo_path: None,
1328 workspace_path: None,
1329 command: None,
1330 hostname: "",
1331 environment: &environment,
1332 };
1333 let resolved_config = resolve(&source_config, &context).unwrap();
1334 assert_eq!(resolved_config.layers().len(), 1);
1335 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1336
1337 let environment = HashMap::from([("MY_ENV".into(), "yes".into())]);
1340 let context = ConfigResolutionContext {
1341 home_dir: Some(Path::new("/home/dir")),
1342 repo_path: None,
1343 workspace_path: None,
1344 command: None,
1345 hostname: "",
1346 environment: &environment,
1347 };
1348 let resolved_config = resolve(&source_config, &context).unwrap();
1349 assert_eq!(resolved_config.layers().len(), 5);
1350 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1351 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 env-yes'");
1352 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 env-yes|env-no'");
1353 insta::assert_snapshot!(resolved_config.layers()[3].data, @"a = 'a #0.4 env-exists'");
1354 insta::assert_snapshot!(resolved_config.layers()[4].data, @"a = 'a #1 env-yes'");
1355
1356 let environment = HashMap::from([("MY_ENV".into(), "no".into())]);
1358 let context = ConfigResolutionContext {
1359 home_dir: Some(Path::new("/home/dir")),
1360 repo_path: None,
1361 workspace_path: None,
1362 command: None,
1363 hostname: "",
1364 environment: &environment,
1365 };
1366 let resolved_config = resolve(&source_config, &context).unwrap();
1367 assert_eq!(resolved_config.layers().len(), 3);
1368 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1369 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.2 env-yes|env-no'");
1370 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.4 env-exists'");
1371 }
1372
1373 #[test]
1374 fn test_resolve_invalid_condition() {
1375 let new_config = |text: &str| {
1376 let mut config = StackedConfig::empty();
1377 config.add_layer(new_user_layer(text));
1378 config
1379 };
1380 let context = ConfigResolutionContext {
1381 home_dir: Some(Path::new("/home/dir")),
1382 repo_path: Some(Path::new("/foo/.jj/repo")),
1383 workspace_path: None,
1384 command: None,
1385 hostname: "",
1386 environment: &HashMap::new(),
1387 };
1388 assert_matches!(
1389 resolve(&new_config("--when.repositories = 0"), &context),
1390 Err(ConfigGetError::Type { .. })
1391 );
1392 }
1393
1394 #[test]
1395 fn test_resolve_invalid_scoped_tables() {
1396 let new_config = |text: &str| {
1397 let mut config = StackedConfig::empty();
1398 config.add_layer(new_user_layer(text));
1399 config
1400 };
1401 let context = ConfigResolutionContext {
1402 home_dir: Some(Path::new("/home/dir")),
1403 repo_path: Some(Path::new("/foo/.jj/repo")),
1404 workspace_path: None,
1405 command: None,
1406 hostname: "",
1407 environment: &HashMap::new(),
1408 };
1409 assert_matches!(
1410 resolve(&new_config("[--scope]"), &context),
1411 Err(ConfigGetError::Type { .. })
1412 );
1413 }
1414
1415 #[test]
1416 fn test_migrate_noop() {
1417 let mut config = StackedConfig::empty();
1418 config.add_layer(new_user_layer(indoc! {"
1419 foo = 'foo'
1420 "}));
1421 config.add_layer(new_user_layer(indoc! {"
1422 bar = 'bar'
1423 "}));
1424
1425 let old_layers = config.layers().to_vec();
1426 let rules = [ConfigMigrationRule::rename_value("baz", "foo")];
1427 let descriptions = migrate(&mut config, &rules).unwrap();
1428 assert!(descriptions.is_empty());
1429 assert!(Arc::ptr_eq(&config.layers()[0], &old_layers[0]));
1430 assert!(Arc::ptr_eq(&config.layers()[1], &old_layers[1]));
1431 }
1432
1433 #[test]
1434 fn test_migrate_error() {
1435 let mut config = StackedConfig::empty();
1436 let mut layer = new_user_layer(indoc! {"
1437 foo.bar = 'baz'
1438 "});
1439 layer.path = Some("source.toml".into());
1440 config.add_layer(layer);
1441
1442 let rules = [ConfigMigrationRule::rename_value("foo", "bar")];
1443 insta::assert_debug_snapshot!(migrate(&mut config, &rules).unwrap_err(), @r#"
1444 ConfigMigrateError {
1445 error: Update(
1446 WouldDeleteTable {
1447 name: "foo",
1448 },
1449 ),
1450 source_path: Some(
1451 "source.toml",
1452 ),
1453 }
1454 "#);
1455 }
1456
1457 #[test]
1458 fn test_migrate_rename_value() {
1459 let mut config = StackedConfig::empty();
1460 config.add_layer(new_user_layer(indoc! {"
1461 [foo]
1462 old = 'foo.old #0'
1463 [bar]
1464 old = 'bar.old #0'
1465 [baz]
1466 new = 'baz.new #0'
1467 "}));
1468 config.add_layer(new_user_layer(indoc! {"
1469 [bar]
1470 old = 'bar.old #1'
1471 "}));
1472
1473 let rules = [
1474 ConfigMigrationRule::rename_value("foo.old", "foo.new"),
1475 ConfigMigrationRule::rename_value("bar.old", "baz.new"),
1476 ];
1477 let descriptions = migrate(&mut config, &rules).unwrap();
1478 insta::assert_debug_snapshot!(descriptions, @r#"
1479 [
1480 (
1481 User,
1482 "foo.old is renamed to foo.new",
1483 ),
1484 (
1485 User,
1486 "bar.old is deleted (superseded by baz.new)",
1487 ),
1488 (
1489 User,
1490 "bar.old is renamed to baz.new",
1491 ),
1492 ]
1493 "#);
1494 insta::assert_snapshot!(config.layers()[0].data, @"
1495 [foo]
1496 new = 'foo.old #0'
1497 [bar]
1498 [baz]
1499 new = 'baz.new #0'
1500 ");
1501 insta::assert_snapshot!(config.layers()[1].data, @"
1502 [bar]
1503
1504 [baz]
1505 new = 'bar.old #1'
1506 ");
1507 }
1508
1509 #[test]
1510 fn test_migrate_rename_update_value() {
1511 let mut config = StackedConfig::empty();
1512 config.add_layer(new_user_layer(indoc! {"
1513 [foo]
1514 old = 'foo.old #0'
1515 [bar]
1516 old = 'bar.old #0'
1517 [baz]
1518 new = 'baz.new #0'
1519 "}));
1520 config.add_layer(new_user_layer(indoc! {"
1521 [bar]
1522 old = 'bar.old #1'
1523 "}));
1524
1525 let rules = [
1526 ConfigMigrationRule::rename_update_value("foo.old", "foo.new", |old_value| {
1528 let val = old_value.clone().decorated("", "");
1529 Ok(ConfigValue::from_iter([val]))
1530 }),
1531 ConfigMigrationRule::rename_update_value("bar.old", "baz.new", |old_value| {
1533 let s = old_value.as_str().ok_or("not a string")?;
1534 Ok(format!("{s} updated").into())
1535 }),
1536 ];
1537 let descriptions = migrate(&mut config, &rules).unwrap();
1538 insta::assert_debug_snapshot!(descriptions, @r#"
1539 [
1540 (
1541 User,
1542 "foo.old is updated to foo.new = ['foo.old #0']",
1543 ),
1544 (
1545 User,
1546 "bar.old is deleted (superseded by baz.new)",
1547 ),
1548 (
1549 User,
1550 "bar.old is updated to baz.new = \"bar.old #1 updated\"",
1551 ),
1552 ]
1553 "#);
1554 insta::assert_snapshot!(config.layers()[0].data, @"
1555 [foo]
1556 new = ['foo.old #0']
1557 [bar]
1558 [baz]
1559 new = 'baz.new #0'
1560 ");
1561 insta::assert_snapshot!(config.layers()[1].data, @r#"
1562 [bar]
1563
1564 [baz]
1565 new = "bar.old #1 updated"
1566 "#);
1567
1568 config.add_layer(new_user_layer(indoc! {"
1569 [bar]
1570 old = false # not a string
1571 "}));
1572 insta::assert_debug_snapshot!(migrate(&mut config, &rules).unwrap_err(), @r#"
1573 ConfigMigrateError {
1574 error: Type {
1575 name: "bar.old",
1576 error: "not a string",
1577 },
1578 source_path: None,
1579 }
1580 "#);
1581 }
1582}