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