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 std::assert_matches;
460
461 use indoc::indoc;
462
463 use super::*;
464 use crate::tests::TestResult;
465
466 #[test]
467 fn test_expand_home() {
468 let home_dir = Some(Path::new("/home/dir"));
469 assert_eq!(
470 expand_home("~".as_ref(), home_dir).unwrap(),
471 Some(PathBuf::from("/home/dir"))
472 );
473 assert_eq!(expand_home("~foo".as_ref(), home_dir).unwrap(), None);
474 assert_eq!(expand_home("/foo/~".as_ref(), home_dir).unwrap(), None);
475 assert_eq!(
476 expand_home("~/foo".as_ref(), home_dir).unwrap(),
477 Some(PathBuf::from("/home/dir/foo"))
478 );
479 assert!(expand_home("~/foo".as_ref(), None).is_err());
480 }
481
482 #[test]
483 fn test_condition_default() {
484 let condition = ScopeCondition::default();
485
486 let context = ConfigResolutionContext {
487 home_dir: None,
488 repo_path: None,
489 workspace_path: None,
490 command: None,
491 hostname: "",
492 environment: &HashMap::new(),
493 };
494 assert!(condition.matches(&context));
495 let context = ConfigResolutionContext {
496 home_dir: None,
497 repo_path: Some(Path::new("/foo")),
498 workspace_path: None,
499 command: None,
500 hostname: "",
501 environment: &HashMap::new(),
502 };
503 assert!(condition.matches(&context));
504 }
505
506 #[test]
507 fn test_condition_repo_path() {
508 let condition = ScopeCondition {
509 repositories: Some(["/foo", "/bar"].map(PathBuf::from).into()),
510 ..Default::default()
511 };
512
513 let context = ConfigResolutionContext {
514 home_dir: None,
515 repo_path: None,
516 workspace_path: None,
517 command: None,
518 hostname: "",
519 environment: &HashMap::new(),
520 };
521 assert!(!condition.matches(&context));
522 let context = ConfigResolutionContext {
523 home_dir: None,
524 repo_path: Some(Path::new("/foo")),
525 workspace_path: None,
526 command: None,
527 hostname: "",
528 environment: &HashMap::new(),
529 };
530 assert!(condition.matches(&context));
531 let context = ConfigResolutionContext {
532 home_dir: None,
533 repo_path: Some(Path::new("/fooo")),
534 workspace_path: None,
535 command: None,
536 hostname: "",
537 environment: &HashMap::new(),
538 };
539 assert!(!condition.matches(&context));
540 let context = ConfigResolutionContext {
541 home_dir: None,
542 repo_path: Some(Path::new("/foo/baz")),
543 workspace_path: None,
544 command: None,
545 hostname: "",
546 environment: &HashMap::new(),
547 };
548 assert!(condition.matches(&context));
549 let context = ConfigResolutionContext {
550 home_dir: None,
551 repo_path: Some(Path::new("/bar")),
552 workspace_path: None,
553 command: None,
554 hostname: "",
555 environment: &HashMap::new(),
556 };
557 assert!(condition.matches(&context));
558 }
559
560 #[test]
561 fn test_condition_repo_path_windows() {
562 let condition = ScopeCondition {
563 repositories: Some(["c:/foo", r"d:\bar/baz"].map(PathBuf::from).into()),
564 ..Default::default()
565 };
566
567 let context = ConfigResolutionContext {
568 home_dir: None,
569 repo_path: Some(Path::new(r"c:\foo")),
570 workspace_path: None,
571 command: None,
572 hostname: "",
573 environment: &HashMap::new(),
574 };
575 assert_eq!(condition.matches(&context), cfg!(windows));
576 let context = ConfigResolutionContext {
577 home_dir: None,
578 repo_path: Some(Path::new(r"c:\foo\baz")),
579 workspace_path: None,
580 command: None,
581 hostname: "",
582 environment: &HashMap::new(),
583 };
584 assert_eq!(condition.matches(&context), cfg!(windows));
585 let context = ConfigResolutionContext {
586 home_dir: None,
587 repo_path: Some(Path::new(r"d:\foo")),
588 workspace_path: None,
589 command: None,
590 hostname: "",
591 environment: &HashMap::new(),
592 };
593 assert!(!condition.matches(&context));
594 let context = ConfigResolutionContext {
595 home_dir: None,
596 repo_path: Some(Path::new(r"d:/bar\baz")),
597 workspace_path: None,
598 command: None,
599 hostname: "",
600 environment: &HashMap::new(),
601 };
602 assert_eq!(condition.matches(&context), cfg!(windows));
603 }
604
605 #[test]
606 fn test_condition_hostname() {
607 let condition = ScopeCondition {
608 hostnames: Some(["host-a", "host-b"].map(String::from).into()),
609 ..Default::default()
610 };
611
612 let context = ConfigResolutionContext {
613 home_dir: None,
614 repo_path: None,
615 workspace_path: None,
616 command: None,
617 hostname: "",
618 environment: &HashMap::new(),
619 };
620 assert!(!condition.matches(&context));
621 let context = ConfigResolutionContext {
622 home_dir: None,
623 repo_path: None,
624 workspace_path: None,
625 command: None,
626 hostname: "host-a",
627 environment: &HashMap::new(),
628 };
629 assert!(condition.matches(&context));
630 let context = ConfigResolutionContext {
631 home_dir: None,
632 repo_path: None,
633 workspace_path: None,
634 command: None,
635 hostname: "host-b",
636 environment: &HashMap::new(),
637 };
638 assert!(condition.matches(&context));
639 let context = ConfigResolutionContext {
640 home_dir: None,
641 repo_path: None,
642 workspace_path: None,
643 command: None,
644 hostname: "host-c",
645 environment: &HashMap::new(),
646 };
647 assert!(!condition.matches(&context));
648 }
649
650 #[test]
651 fn test_condition_environments() {
652 let environment = HashMap::from([
653 ("MY_ENV".into(), "hello".into()),
654 ("OTHER_ENV".into(), "world".into()),
655 ]);
656 let context = ConfigResolutionContext {
657 home_dir: None,
658 repo_path: None,
659 workspace_path: None,
660 command: None,
661 hostname: "",
662 environment: &environment,
663 };
664
665 let condition = ScopeCondition {
667 environments: Some(vec!["MY_ENV=hello".into()]),
668 ..Default::default()
669 };
670 assert!(condition.matches(&context));
671
672 let condition = ScopeCondition {
674 environments: Some(vec!["MY_ENV=wrong".into()]),
675 ..Default::default()
676 };
677 assert!(!condition.matches(&context));
678
679 let condition = ScopeCondition {
681 environments: Some(vec!["ABSENT_VAR=anything".into()]),
682 ..Default::default()
683 };
684 assert!(!condition.matches(&context));
685
686 let condition = ScopeCondition {
688 environments: Some(vec!["MY_ENV=hello".into(), "OTHER_ENV=world".into()]),
689 ..Default::default()
690 };
691 assert!(condition.matches(&context));
692
693 let condition = ScopeCondition {
695 environments: Some(vec!["MY_ENV=wrong".into(), "OTHER_ENV=world".into()]),
696 ..Default::default()
697 };
698 assert!(condition.matches(&context));
699
700 let condition = ScopeCondition {
702 environments: Some(vec!["MY_ENV=wrong".into(), "ABSENT_VAR=nope".into()]),
703 ..Default::default()
704 };
705 assert!(!condition.matches(&context));
706
707 let condition = ScopeCondition {
709 environments: Some(vec!["MY_ENV=".into()]),
710 ..Default::default()
711 };
712 assert!(!condition.matches(&context));
713
714 let condition = ScopeCondition {
716 environments: Some(vec!["ABSENT_VAR=".into()]),
717 ..Default::default()
718 };
719 assert!(!condition.matches(&context));
720
721 let condition = ScopeCondition {
723 environments: Some(vec![]),
724 ..Default::default()
725 };
726 assert!(!condition.matches(&context));
727
728 let environment = HashMap::from([("CONN".into(), "host=localhost:5432".into())]);
730 let context = ConfigResolutionContext {
731 home_dir: None,
732 repo_path: None,
733 workspace_path: None,
734 command: None,
735 hostname: "",
736 environment: &environment,
737 };
738 let condition = ScopeCondition {
739 environments: Some(vec!["CONN=host=localhost:5432".into()]),
740 ..Default::default()
741 };
742 assert!(condition.matches(&context));
743
744 let condition = ScopeCondition {
746 environments: Some(vec!["CONN".into()]),
747 ..Default::default()
748 };
749 assert!(condition.matches(&context));
750
751 let condition = ScopeCondition {
753 environments: Some(vec!["ABSENT_VAR".into()]),
754 ..Default::default()
755 };
756 assert!(!condition.matches(&context));
757
758 let condition = ScopeCondition {
760 environments: Some(vec!["CONN".into(), "OTHER=nope".into()]),
761 ..Default::default()
762 };
763 assert!(condition.matches(&context));
764
765 let condition = ScopeCondition {
767 environments: Some(vec!["ABSENT".into(), "CONN=host=localhost:5432".into()]),
768 ..Default::default()
769 };
770 assert!(condition.matches(&context));
771
772 let environment = HashMap::from([("EMPTY_VAR".into(), "".into())]);
774 let context = ConfigResolutionContext {
775 home_dir: None,
776 repo_path: None,
777 workspace_path: None,
778 command: None,
779 hostname: "",
780 environment: &environment,
781 };
782 let condition = ScopeCondition {
783 environments: Some(vec!["EMPTY_VAR".into()]),
784 ..Default::default()
785 };
786 assert!(condition.matches(&context));
787
788 let condition = ScopeCondition {
790 environments: None,
791 ..Default::default()
792 };
793 assert!(condition.matches(&context));
794 }
795
796 fn new_user_layer(text: &str) -> ConfigLayer {
797 ConfigLayer::parse(ConfigSource::User, text).unwrap()
798 }
799
800 #[test]
801 fn test_resolve_transparent() -> TestResult {
802 let mut source_config = StackedConfig::empty();
803 source_config.add_layer(ConfigLayer::empty(ConfigSource::Default));
804 source_config.add_layer(ConfigLayer::empty(ConfigSource::User));
805
806 let context = ConfigResolutionContext {
807 home_dir: None,
808 repo_path: None,
809 workspace_path: None,
810 command: None,
811 hostname: "",
812 environment: &HashMap::new(),
813 };
814 let resolved_config = resolve(&source_config, &context)?;
815 assert_eq!(resolved_config.layers().len(), 2);
816 assert!(Arc::ptr_eq(
817 &source_config.layers()[0],
818 &resolved_config.layers()[0]
819 ));
820 assert!(Arc::ptr_eq(
821 &source_config.layers()[1],
822 &resolved_config.layers()[1]
823 ));
824 Ok(())
825 }
826
827 #[test]
828 fn test_resolve_table_order() -> TestResult {
829 let mut source_config = StackedConfig::empty();
830 source_config.add_layer(new_user_layer(indoc! {"
831 a = 'a #0'
832 [[--scope]]
833 a = 'a #0.0'
834 [[--scope]]
835 a = 'a #0.1'
836 [[--scope.--scope]]
837 a = 'a #0.1.0'
838 [[--scope]]
839 a = 'a #0.2'
840 "}));
841 source_config.add_layer(new_user_layer(indoc! {"
842 a = 'a #1'
843 [[--scope]]
844 a = 'a #1.0'
845 "}));
846
847 let context = ConfigResolutionContext {
848 home_dir: None,
849 repo_path: None,
850 workspace_path: None,
851 command: None,
852 hostname: "",
853 environment: &HashMap::new(),
854 };
855 let resolved_config = resolve(&source_config, &context)?;
856 assert_eq!(resolved_config.layers().len(), 7);
857 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
858 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.0'");
859 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.1'");
860 insta::assert_snapshot!(resolved_config.layers()[3].data, @"a = 'a #0.1.0'");
861 insta::assert_snapshot!(resolved_config.layers()[4].data, @"a = 'a #0.2'");
862 insta::assert_snapshot!(resolved_config.layers()[5].data, @"a = 'a #1'");
863 insta::assert_snapshot!(resolved_config.layers()[6].data, @"a = 'a #1.0'");
864 Ok(())
865 }
866
867 #[test]
868 fn test_resolve_repo_path() -> TestResult {
869 let mut source_config = StackedConfig::empty();
870 source_config.add_layer(new_user_layer(indoc! {"
871 a = 'a #0'
872 [[--scope]]
873 --when.repositories = ['/foo']
874 a = 'a #0.1 foo'
875 [[--scope]]
876 --when.repositories = ['/foo', '/bar']
877 a = 'a #0.2 foo|bar'
878 [[--scope]]
879 --when.repositories = []
880 a = 'a #0.3 none'
881 "}));
882 source_config.add_layer(new_user_layer(indoc! {"
883 --when.repositories = ['~/baz']
884 a = 'a #1 baz'
885 [[--scope]]
886 --when.repositories = ['/foo'] # should never be enabled
887 a = 'a #1.1 baz&foo'
888 "}));
889
890 let context = ConfigResolutionContext {
891 home_dir: Some(Path::new("/home/dir")),
892 repo_path: None,
893 workspace_path: None,
894 command: None,
895 hostname: "",
896 environment: &HashMap::new(),
897 };
898 let resolved_config = resolve(&source_config, &context)?;
899 assert_eq!(resolved_config.layers().len(), 1);
900 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
901
902 let context = ConfigResolutionContext {
903 home_dir: Some(Path::new("/home/dir")),
904 repo_path: Some(Path::new("/foo/.jj/repo")),
905 workspace_path: None,
906 command: None,
907 hostname: "",
908 environment: &HashMap::new(),
909 };
910 let resolved_config = resolve(&source_config, &context)?;
911 assert_eq!(resolved_config.layers().len(), 3);
912 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
913 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 foo'");
914 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 foo|bar'");
915
916 let context = ConfigResolutionContext {
917 home_dir: Some(Path::new("/home/dir")),
918 repo_path: Some(Path::new("/bar/.jj/repo")),
919 workspace_path: None,
920 command: None,
921 hostname: "",
922 environment: &HashMap::new(),
923 };
924 let resolved_config = resolve(&source_config, &context)?;
925 assert_eq!(resolved_config.layers().len(), 2);
926 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
927 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.2 foo|bar'");
928
929 let context = ConfigResolutionContext {
930 home_dir: Some(Path::new("/home/dir")),
931 repo_path: Some(Path::new("/home/dir/baz/.jj/repo")),
932 workspace_path: None,
933 command: None,
934 hostname: "",
935 environment: &HashMap::new(),
936 };
937 let resolved_config = resolve(&source_config, &context)?;
938 assert_eq!(resolved_config.layers().len(), 2);
939 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
940 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #1 baz'");
941 Ok(())
942 }
943
944 #[test]
945 fn test_resolve_hostname() -> TestResult {
946 let mut source_config = StackedConfig::empty();
947 source_config.add_layer(new_user_layer(indoc! {"
948 a = 'a #0'
949 [[--scope]]
950 --when.hostnames = ['host-a']
951 a = 'a #0.1 host-a'
952 [[--scope]]
953 --when.hostnames = ['host-a', 'host-b']
954 a = 'a #0.2 host-a|host-b'
955 [[--scope]]
956 --when.hostnames = []
957 a = 'a #0.3 none'
958 "}));
959 source_config.add_layer(new_user_layer(indoc! {"
960 --when.hostnames = ['host-c']
961 a = 'a #1 host-c'
962 [[--scope]]
963 --when.hostnames = ['host-a'] # should never be enabled
964 a = 'a #1.1 host-c&host-a'
965 "}));
966
967 let context = ConfigResolutionContext {
968 home_dir: Some(Path::new("/home/dir")),
969 repo_path: None,
970 workspace_path: None,
971 command: None,
972 hostname: "",
973 environment: &HashMap::new(),
974 };
975 let resolved_config = resolve(&source_config, &context)?;
976 assert_eq!(resolved_config.layers().len(), 1);
977 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
978
979 let context = ConfigResolutionContext {
980 home_dir: Some(Path::new("/home/dir")),
981 repo_path: None,
982 workspace_path: None,
983 command: None,
984 hostname: "host-a",
985 environment: &HashMap::new(),
986 };
987 let resolved_config = resolve(&source_config, &context)?;
988 assert_eq!(resolved_config.layers().len(), 3);
989 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
990 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 host-a'");
991 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 host-a|host-b'");
992
993 let context = ConfigResolutionContext {
994 home_dir: Some(Path::new("/home/dir")),
995 repo_path: None,
996 workspace_path: None,
997 command: None,
998 hostname: "host-b",
999 environment: &HashMap::new(),
1000 };
1001 let resolved_config = resolve(&source_config, &context)?;
1002 assert_eq!(resolved_config.layers().len(), 2);
1003 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1004 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.2 host-a|host-b'");
1005
1006 let context = ConfigResolutionContext {
1007 home_dir: Some(Path::new("/home/dir")),
1008 repo_path: None,
1009 workspace_path: None,
1010 command: None,
1011 hostname: "host-c",
1012 environment: &HashMap::new(),
1013 };
1014 let resolved_config = resolve(&source_config, &context)?;
1015 assert_eq!(resolved_config.layers().len(), 2);
1016 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1017 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #1 host-c'");
1018 Ok(())
1019 }
1020
1021 #[test]
1022 fn test_resolve_workspace_path() -> TestResult {
1023 let mut source_config = StackedConfig::empty();
1024 source_config.add_layer(new_user_layer(indoc! {"
1025 a = 'a #0'
1026 [[--scope]]
1027 --when.workspaces = ['/foo']
1028 a = 'a #0.1 foo'
1029 [[--scope]]
1030 --when.workspaces = ['/foo', '/bar']
1031 a = 'a #0.2 foo|bar'
1032 [[--scope]]
1033 --when.workspaces = []
1034 a = 'a #0.3 none'
1035 "}));
1036 source_config.add_layer(new_user_layer(indoc! {"
1037 --when.workspaces = ['~/baz']
1038 a = 'a #1 baz'
1039 [[--scope]]
1040 --when.workspaces = ['/foo'] # should never be enabled
1041 a = 'a #1.1 baz&foo'
1042 "}));
1043
1044 let context = ConfigResolutionContext {
1045 home_dir: Some(Path::new("/home/dir")),
1046 repo_path: None,
1047 workspace_path: None,
1048 command: None,
1049 hostname: "",
1050 environment: &HashMap::new(),
1051 };
1052 let resolved_config = resolve(&source_config, &context)?;
1053 assert_eq!(resolved_config.layers().len(), 1);
1054 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1055
1056 let context = ConfigResolutionContext {
1057 home_dir: Some(Path::new("/home/dir")),
1058 repo_path: None,
1059 workspace_path: Some(Path::new("/foo")),
1060 command: None,
1061 hostname: "",
1062 environment: &HashMap::new(),
1063 };
1064 let resolved_config = resolve(&source_config, &context)?;
1065 assert_eq!(resolved_config.layers().len(), 3);
1066 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1067 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 foo'");
1068 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 foo|bar'");
1069
1070 let context = ConfigResolutionContext {
1071 home_dir: Some(Path::new("/home/dir")),
1072 repo_path: None,
1073 workspace_path: Some(Path::new("/bar")),
1074 command: None,
1075 hostname: "",
1076 environment: &HashMap::new(),
1077 };
1078 let resolved_config = resolve(&source_config, &context)?;
1079 assert_eq!(resolved_config.layers().len(), 2);
1080 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1081 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.2 foo|bar'");
1082
1083 let context = ConfigResolutionContext {
1084 home_dir: Some(Path::new("/home/dir")),
1085 repo_path: None,
1086 workspace_path: Some(Path::new("/home/dir/baz")),
1087 command: None,
1088 hostname: "",
1089 environment: &HashMap::new(),
1090 };
1091 let resolved_config = resolve(&source_config, &context)?;
1092 assert_eq!(resolved_config.layers().len(), 2);
1093 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1094 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #1 baz'");
1095 Ok(())
1096 }
1097
1098 #[test]
1099 fn test_resolve_command() -> TestResult {
1100 let mut source_config = StackedConfig::empty();
1101 source_config.add_layer(new_user_layer(indoc! {"
1102 a = 'a #0'
1103 [[--scope]]
1104 --when.commands = ['foo']
1105 a = 'a #0.1 foo'
1106 [[--scope]]
1107 --when.commands = ['foo', 'bar']
1108 a = 'a #0.2 foo|bar'
1109 [[--scope]]
1110 --when.commands = ['foo baz']
1111 a = 'a #0.3 foo baz'
1112 [[--scope]]
1113 --when.commands = []
1114 a = 'a #0.4 none'
1115 "}));
1116
1117 let context = ConfigResolutionContext {
1118 home_dir: None,
1119 repo_path: None,
1120 workspace_path: None,
1121 command: None,
1122 hostname: "",
1123 environment: &HashMap::new(),
1124 };
1125 let resolved_config = resolve(&source_config, &context)?;
1126 assert_eq!(resolved_config.layers().len(), 1);
1127 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1128
1129 let context = ConfigResolutionContext {
1130 home_dir: None,
1131 repo_path: None,
1132 workspace_path: None,
1133 command: Some("foo"),
1134 hostname: "",
1135 environment: &HashMap::new(),
1136 };
1137 let resolved_config = resolve(&source_config, &context)?;
1138 assert_eq!(resolved_config.layers().len(), 3);
1139 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1140 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 foo'");
1141 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 foo|bar'");
1142
1143 let context = ConfigResolutionContext {
1144 home_dir: None,
1145 repo_path: None,
1146 workspace_path: None,
1147 command: Some("bar"),
1148 hostname: "",
1149 environment: &HashMap::new(),
1150 };
1151 let resolved_config = resolve(&source_config, &context)?;
1152 assert_eq!(resolved_config.layers().len(), 2);
1153 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1154 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.2 foo|bar'");
1155
1156 let context = ConfigResolutionContext {
1157 home_dir: None,
1158 repo_path: None,
1159 workspace_path: None,
1160 command: Some("foo baz"),
1161 hostname: "",
1162 environment: &HashMap::new(),
1163 };
1164 let resolved_config = resolve(&source_config, &context)?;
1165 assert_eq!(resolved_config.layers().len(), 4);
1166 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1167 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 foo'");
1168 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 foo|bar'");
1169 insta::assert_snapshot!(resolved_config.layers()[3].data, @"a = 'a #0.3 foo baz'");
1170
1171 let context = ConfigResolutionContext {
1173 home_dir: None,
1174 repo_path: None,
1175 workspace_path: None,
1176 command: Some("fooqux"),
1177 hostname: "",
1178 environment: &HashMap::new(),
1179 };
1180 let resolved_config = resolve(&source_config, &context)?;
1181 assert_eq!(resolved_config.layers().len(), 1);
1182 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1183 Ok(())
1184 }
1185
1186 #[test]
1187 fn test_resolve_os() -> TestResult {
1188 let mut source_config = StackedConfig::empty();
1189 source_config.add_layer(new_user_layer(indoc! {"
1190 a = 'a none'
1191 b = 'b none'
1192 [[--scope]]
1193 --when.platforms = ['linux']
1194 a = 'a linux'
1195 [[--scope]]
1196 --when.platforms = ['macos']
1197 a = 'a macos'
1198 [[--scope]]
1199 --when.platforms = ['windows']
1200 a = 'a windows'
1201 [[--scope]]
1202 --when.platforms = ['unix']
1203 b = 'b unix'
1204 "}));
1205
1206 let context = ConfigResolutionContext {
1207 home_dir: Some(Path::new("/home/dir")),
1208 repo_path: None,
1209 workspace_path: None,
1210 command: None,
1211 hostname: "",
1212 environment: &HashMap::new(),
1213 };
1214 let resolved_config = resolve(&source_config, &context)?;
1215 insta::assert_snapshot!(resolved_config.layers()[0].data, @"
1216 a = 'a none'
1217 b = 'b none'
1218 ");
1219 if cfg!(target_os = "linux") {
1220 assert_eq!(resolved_config.layers().len(), 3);
1221 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a linux'");
1222 insta::assert_snapshot!(resolved_config.layers()[2].data, @"b = 'b unix'");
1223 } else if cfg!(target_os = "macos") {
1224 assert_eq!(resolved_config.layers().len(), 3);
1225 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a macos'");
1226 insta::assert_snapshot!(resolved_config.layers()[2].data, @"b = 'b unix'");
1227 } else if cfg!(target_os = "windows") {
1228 assert_eq!(resolved_config.layers().len(), 2);
1229 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a windows'");
1230 } else if cfg!(target_family = "unix") {
1231 assert_eq!(resolved_config.layers().len(), 2);
1232 insta::assert_snapshot!(resolved_config.layers()[1].data, @"b = 'b unix'");
1233 } else {
1234 assert_eq!(resolved_config.layers().len(), 1);
1235 }
1236 Ok(())
1237 }
1238
1239 #[test]
1240 fn test_resolve_repo_path_and_command() -> TestResult {
1241 let mut source_config = StackedConfig::empty();
1242 source_config.add_layer(new_user_layer(indoc! {"
1243 a = 'a #0'
1244 [[--scope]]
1245 --when.repositories = ['/foo', '/bar']
1246 --when.commands = ['ABC', 'DEF']
1247 a = 'a #0.1'
1248 "}));
1249
1250 let context = ConfigResolutionContext {
1251 home_dir: Some(Path::new("/home/dir")),
1252 repo_path: None,
1253 workspace_path: None,
1254 command: None,
1255 hostname: "",
1256 environment: &HashMap::new(),
1257 };
1258 let resolved_config = resolve(&source_config, &context)?;
1259 assert_eq!(resolved_config.layers().len(), 1);
1260 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1261
1262 let context = ConfigResolutionContext {
1264 home_dir: Some(Path::new("/home/dir")),
1265 repo_path: Some(Path::new("/foo")),
1266 workspace_path: None,
1267 command: Some("other"),
1268 hostname: "",
1269 environment: &HashMap::new(),
1270 };
1271 let resolved_config = resolve(&source_config, &context)?;
1272 assert_eq!(resolved_config.layers().len(), 1);
1273 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1274
1275 let context = ConfigResolutionContext {
1277 home_dir: Some(Path::new("/home/dir")),
1278 repo_path: Some(Path::new("/qux")),
1279 workspace_path: None,
1280 command: Some("ABC"),
1281 hostname: "",
1282 environment: &HashMap::new(),
1283 };
1284 let resolved_config = resolve(&source_config, &context)?;
1285 assert_eq!(resolved_config.layers().len(), 1);
1286 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1287
1288 let context = ConfigResolutionContext {
1290 home_dir: Some(Path::new("/home/dir")),
1291 repo_path: Some(Path::new("/bar")),
1292 workspace_path: None,
1293 command: Some("DEF"),
1294 hostname: "",
1295 environment: &HashMap::new(),
1296 };
1297 let resolved_config = resolve(&source_config, &context)?;
1298 assert_eq!(resolved_config.layers().len(), 2);
1299 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1300 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1'");
1301 Ok(())
1302 }
1303
1304 #[test]
1305 fn test_resolve_environments() -> TestResult {
1306 let mut source_config = StackedConfig::empty();
1307 source_config.add_layer(new_user_layer(indoc! {"
1308 a = 'a #0'
1309 [[--scope]]
1310 --when.environments = ['MY_ENV=yes']
1311 a = 'a #0.1 env-yes'
1312 [[--scope]]
1313 --when.environments = ['MY_ENV=yes', 'MY_ENV=no']
1314 a = 'a #0.2 env-yes|env-no'
1315 [[--scope]]
1316 --when.environments = []
1317 a = 'a #0.3 none'
1318 [[--scope]]
1319 --when.environments = ['MY_ENV']
1320 a = 'a #0.4 env-exists'
1321 [[--scope]]
1322 --when.environments = ['ABSENT_VAR']
1323 a = 'a #0.5 absent-exists'
1324 "}));
1325 source_config.add_layer(new_user_layer(indoc! {"
1326 --when.environments = ['MY_ENV=yes']
1327 a = 'a #1 env-yes'
1328 [[--scope]]
1329 --when.environments = ['MY_ENV=no'] # can never match: layer requires MY_ENV=yes
1330 a = 'a #1.1 env-yes&env-no'
1331 "}));
1332
1333 let environment = HashMap::new();
1335 let context = ConfigResolutionContext {
1336 home_dir: Some(Path::new("/home/dir")),
1337 repo_path: None,
1338 workspace_path: None,
1339 command: None,
1340 hostname: "",
1341 environment: &environment,
1342 };
1343 let resolved_config = resolve(&source_config, &context)?;
1344 assert_eq!(resolved_config.layers().len(), 1);
1345 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1346
1347 let environment = HashMap::from([("MY_ENV".into(), "yes".into())]);
1350 let context = ConfigResolutionContext {
1351 home_dir: Some(Path::new("/home/dir")),
1352 repo_path: None,
1353 workspace_path: None,
1354 command: None,
1355 hostname: "",
1356 environment: &environment,
1357 };
1358 let resolved_config = resolve(&source_config, &context)?;
1359 assert_eq!(resolved_config.layers().len(), 5);
1360 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1361 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 env-yes'");
1362 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 env-yes|env-no'");
1363 insta::assert_snapshot!(resolved_config.layers()[3].data, @"a = 'a #0.4 env-exists'");
1364 insta::assert_snapshot!(resolved_config.layers()[4].data, @"a = 'a #1 env-yes'");
1365
1366 let environment = HashMap::from([("MY_ENV".into(), "no".into())]);
1368 let context = ConfigResolutionContext {
1369 home_dir: Some(Path::new("/home/dir")),
1370 repo_path: None,
1371 workspace_path: None,
1372 command: None,
1373 hostname: "",
1374 environment: &environment,
1375 };
1376 let resolved_config = resolve(&source_config, &context)?;
1377 assert_eq!(resolved_config.layers().len(), 3);
1378 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1379 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.2 env-yes|env-no'");
1380 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.4 env-exists'");
1381 Ok(())
1382 }
1383
1384 #[test]
1385 fn test_resolve_invalid_condition() {
1386 let new_config = |text: &str| {
1387 let mut config = StackedConfig::empty();
1388 config.add_layer(new_user_layer(text));
1389 config
1390 };
1391 let context = ConfigResolutionContext {
1392 home_dir: Some(Path::new("/home/dir")),
1393 repo_path: Some(Path::new("/foo/.jj/repo")),
1394 workspace_path: None,
1395 command: None,
1396 hostname: "",
1397 environment: &HashMap::new(),
1398 };
1399 assert_matches!(
1400 resolve(&new_config("--when.repositories = 0"), &context),
1401 Err(ConfigGetError::Type { .. })
1402 );
1403 }
1404
1405 #[test]
1406 fn test_resolve_invalid_scoped_tables() {
1407 let new_config = |text: &str| {
1408 let mut config = StackedConfig::empty();
1409 config.add_layer(new_user_layer(text));
1410 config
1411 };
1412 let context = ConfigResolutionContext {
1413 home_dir: Some(Path::new("/home/dir")),
1414 repo_path: Some(Path::new("/foo/.jj/repo")),
1415 workspace_path: None,
1416 command: None,
1417 hostname: "",
1418 environment: &HashMap::new(),
1419 };
1420 assert_matches!(
1421 resolve(&new_config("[--scope]"), &context),
1422 Err(ConfigGetError::Type { .. })
1423 );
1424 }
1425
1426 #[test]
1427 fn test_migrate_noop() -> TestResult {
1428 let mut config = StackedConfig::empty();
1429 config.add_layer(new_user_layer(indoc! {"
1430 foo = 'foo'
1431 "}));
1432 config.add_layer(new_user_layer(indoc! {"
1433 bar = 'bar'
1434 "}));
1435
1436 let old_layers = config.layers().to_vec();
1437 let rules = [ConfigMigrationRule::rename_value("baz", "foo")];
1438 let descriptions = migrate(&mut config, &rules)?;
1439 assert!(descriptions.is_empty());
1440 assert!(Arc::ptr_eq(&config.layers()[0], &old_layers[0]));
1441 assert!(Arc::ptr_eq(&config.layers()[1], &old_layers[1]));
1442 Ok(())
1443 }
1444
1445 #[test]
1446 fn test_migrate_error() {
1447 let mut config = StackedConfig::empty();
1448 let mut layer = new_user_layer(indoc! {"
1449 foo.bar = 'baz'
1450 "});
1451 layer.path = Some("source.toml".into());
1452 config.add_layer(layer);
1453
1454 let rules = [ConfigMigrationRule::rename_value("foo", "bar")];
1455 insta::assert_debug_snapshot!(migrate(&mut config, &rules).unwrap_err(), @r#"
1456 ConfigMigrateError {
1457 error: Update(
1458 WouldDeleteTable {
1459 name: "foo",
1460 },
1461 ),
1462 source_path: Some(
1463 "source.toml",
1464 ),
1465 }
1466 "#);
1467 }
1468
1469 #[test]
1470 fn test_migrate_rename_value() -> TestResult {
1471 let mut config = StackedConfig::empty();
1472 config.add_layer(new_user_layer(indoc! {"
1473 [foo]
1474 old = 'foo.old #0'
1475 [bar]
1476 old = 'bar.old #0'
1477 [baz]
1478 new = 'baz.new #0'
1479 "}));
1480 config.add_layer(new_user_layer(indoc! {"
1481 [bar]
1482 old = 'bar.old #1'
1483 "}));
1484
1485 let rules = [
1486 ConfigMigrationRule::rename_value("foo.old", "foo.new"),
1487 ConfigMigrationRule::rename_value("bar.old", "baz.new"),
1488 ];
1489 let descriptions = migrate(&mut config, &rules)?;
1490 insta::assert_debug_snapshot!(descriptions, @r#"
1491 [
1492 (
1493 User,
1494 "foo.old is renamed to foo.new",
1495 ),
1496 (
1497 User,
1498 "bar.old is deleted (superseded by baz.new)",
1499 ),
1500 (
1501 User,
1502 "bar.old is renamed to baz.new",
1503 ),
1504 ]
1505 "#);
1506 insta::assert_snapshot!(config.layers()[0].data, @"
1507 [foo]
1508 new = 'foo.old #0'
1509 [bar]
1510 [baz]
1511 new = 'baz.new #0'
1512 ");
1513 insta::assert_snapshot!(config.layers()[1].data, @"
1514 [bar]
1515
1516 [baz]
1517 new = 'bar.old #1'
1518 ");
1519 Ok(())
1520 }
1521
1522 #[test]
1523 fn test_migrate_rename_update_value() -> TestResult {
1524 let mut config = StackedConfig::empty();
1525 config.add_layer(new_user_layer(indoc! {"
1526 [foo]
1527 old = 'foo.old #0'
1528 [bar]
1529 old = 'bar.old #0'
1530 [baz]
1531 new = 'baz.new #0'
1532 "}));
1533 config.add_layer(new_user_layer(indoc! {"
1534 [bar]
1535 old = 'bar.old #1'
1536 "}));
1537
1538 let rules = [
1539 ConfigMigrationRule::rename_update_value("foo.old", "foo.new", |old_value| {
1541 let val = old_value.clone().decorated("", "");
1542 Ok(ConfigValue::from_iter([val]))
1543 }),
1544 ConfigMigrationRule::rename_update_value("bar.old", "baz.new", |old_value| {
1546 let s = old_value.as_str().ok_or("not a string")?;
1547 Ok(format!("{s} updated").into())
1548 }),
1549 ];
1550 let descriptions = migrate(&mut config, &rules)?;
1551 insta::assert_debug_snapshot!(descriptions, @r#"
1552 [
1553 (
1554 User,
1555 "foo.old is updated to foo.new = ['foo.old #0']",
1556 ),
1557 (
1558 User,
1559 "bar.old is deleted (superseded by baz.new)",
1560 ),
1561 (
1562 User,
1563 "bar.old is updated to baz.new = \"bar.old #1 updated\"",
1564 ),
1565 ]
1566 "#);
1567 insta::assert_snapshot!(config.layers()[0].data, @"
1568 [foo]
1569 new = ['foo.old #0']
1570 [bar]
1571 [baz]
1572 new = 'baz.new #0'
1573 ");
1574 insta::assert_snapshot!(config.layers()[1].data, @r#"
1575 [bar]
1576
1577 [baz]
1578 new = "bar.old #1 updated"
1579 "#);
1580
1581 config.add_layer(new_user_layer(indoc! {"
1582 [bar]
1583 old = false # not a string
1584 "}));
1585 insta::assert_debug_snapshot!(migrate(&mut config, &rules).unwrap_err(), @r#"
1586 ConfigMigrateError {
1587 error: Type {
1588 name: "bar.old",
1589 error: "not a string",
1590 },
1591 source_path: None,
1592 }
1593 "#);
1594 Ok(())
1595 }
1596}