1use std::path::Path;
18use std::path::PathBuf;
19use std::sync::Arc;
20
21use itertools::Itertools as _;
22use serde::Deserialize as _;
23use serde::de::IntoDeserializer as _;
24use thiserror::Error;
25use toml_edit::DocumentMut;
26
27use crate::config::ConfigGetError;
28use crate::config::ConfigLayer;
29use crate::config::ConfigNamePathBuf;
30use crate::config::ConfigSource;
31use crate::config::ConfigUpdateError;
32use crate::config::ConfigValue;
33use crate::config::StackedConfig;
34use crate::config::ToConfigNamePath;
35
36const SCOPE_CONDITION_KEY: &str = "--when";
39const SCOPE_TABLE_KEY: &str = "--scope";
40
41#[derive(Clone, Debug)]
43pub struct ConfigResolutionContext<'a> {
44 pub home_dir: Option<&'a Path>,
46 pub repo_path: Option<&'a Path>,
48 pub workspace_path: Option<&'a Path>,
50 pub command: Option<&'a str>,
53 pub hostname: &'a str,
55}
56
57#[derive(Clone, Debug, Default, serde::Deserialize)]
65#[serde(default, rename_all = "kebab-case")]
66struct ScopeCondition {
67 pub repositories: Option<Vec<PathBuf>>,
69 pub workspaces: Option<Vec<PathBuf>>,
71 pub commands: Option<Vec<String>>,
76 pub platforms: Option<Vec<String>>,
79 pub hostnames: Option<Vec<String>>,
81}
82
83impl ScopeCondition {
84 fn from_value(
85 value: ConfigValue,
86 context: &ConfigResolutionContext,
87 ) -> Result<Self, toml_edit::de::Error> {
88 Self::deserialize(value.into_deserializer())?
89 .expand_paths(context)
90 .map_err(serde::de::Error::custom)
91 }
92
93 fn expand_paths(mut self, context: &ConfigResolutionContext) -> Result<Self, &'static str> {
94 for path in self.repositories.as_mut().into_iter().flatten() {
98 if let Some(new_path) = expand_home(path, context.home_dir)? {
99 *path = new_path;
100 }
101 }
102 for path in self.workspaces.as_mut().into_iter().flatten() {
103 if let Some(new_path) = expand_home(path, context.home_dir)? {
104 *path = new_path;
105 }
106 }
107 Ok(self)
108 }
109
110 fn matches(&self, context: &ConfigResolutionContext) -> bool {
111 matches_path_prefix(self.repositories.as_deref(), context.repo_path)
112 && matches_path_prefix(self.workspaces.as_deref(), context.workspace_path)
113 && matches_platform(self.platforms.as_deref())
114 && matches_hostname(self.hostnames.as_deref(), context.hostname)
115 && matches_command(self.commands.as_deref(), context.command)
116 }
117}
118
119fn expand_home(path: &Path, home_dir: Option<&Path>) -> Result<Option<PathBuf>, &'static str> {
120 match path.strip_prefix("~") {
121 Ok(tail) => {
122 let home_dir = home_dir.ok_or("Cannot expand ~ (home directory is unknown)")?;
123 Ok(Some(home_dir.join(tail)))
124 }
125 Err(_) => Ok(None),
126 }
127}
128
129fn matches_path_prefix(candidates: Option<&[PathBuf]>, actual: Option<&Path>) -> bool {
130 match (candidates, actual) {
131 (Some(candidates), Some(actual)) => candidates.iter().any(|base| actual.starts_with(base)),
132 (Some(_), None) => false, (None, _) => true, }
135}
136
137fn matches_platform(candidates: Option<&[String]>) -> bool {
138 candidates.is_none_or(|candidates| {
139 candidates
140 .iter()
141 .any(|value| value == std::env::consts::FAMILY || value == std::env::consts::OS)
142 })
143}
144
145fn matches_hostname(candidates: Option<&[String]>, actual: &str) -> bool {
146 candidates.is_none_or(|candidates| candidates.iter().any(|candidate| actual == candidate))
147}
148
149fn matches_command(candidates: Option<&[String]>, actual: Option<&str>) -> bool {
150 match (candidates, actual) {
151 (Some(candidates), Some(actual)) => candidates.iter().any(|candidate| {
152 actual
153 .strip_prefix(candidate)
154 .is_some_and(|trailing| trailing.starts_with(' ') || trailing.is_empty())
155 }),
156 (Some(_), None) => false,
157 (None, _) => true,
158 }
159}
160
161pub fn resolve(
164 source_config: &StackedConfig,
165 context: &ConfigResolutionContext,
166) -> Result<StackedConfig, ConfigGetError> {
167 let mut source_layers_stack: Vec<Arc<ConfigLayer>> =
168 source_config.layers().iter().rev().cloned().collect();
169 let mut resolved_layers: Vec<Arc<ConfigLayer>> = Vec::new();
170 while let Some(mut source_layer) = source_layers_stack.pop() {
171 if !source_layer.data.contains_key(SCOPE_CONDITION_KEY)
172 && !source_layer.data.contains_key(SCOPE_TABLE_KEY)
173 {
174 resolved_layers.push(source_layer); continue;
176 }
177
178 let layer_mut = Arc::make_mut(&mut source_layer);
179 let condition = pop_scope_condition(layer_mut, context)?;
180 if !condition.matches(context) {
181 continue;
182 }
183 let tables = pop_scope_tables(layer_mut)?;
184 let frame = source_layers_stack.len();
187 for table in tables {
188 let layer = ConfigLayer {
189 source: source_layer.source,
190 path: source_layer.path.clone(),
191 data: DocumentMut::from(table),
192 };
193 source_layers_stack.push(Arc::new(layer));
194 }
195 source_layers_stack[frame..].reverse();
196 resolved_layers.push(source_layer);
197 }
198 let mut resolved_config = StackedConfig::empty();
199 resolved_config.extend_layers(resolved_layers);
200 Ok(resolved_config)
201}
202
203fn pop_scope_condition(
204 layer: &mut ConfigLayer,
205 context: &ConfigResolutionContext,
206) -> Result<ScopeCondition, ConfigGetError> {
207 let Some(item) = layer.data.remove(SCOPE_CONDITION_KEY) else {
208 return Ok(ScopeCondition::default());
209 };
210 let value = item
211 .clone()
212 .into_value()
213 .expect("Item::None should not exist in table");
214 ScopeCondition::from_value(value, context).map_err(|err| ConfigGetError::Type {
215 name: SCOPE_CONDITION_KEY.to_owned(),
216 error: err.into(),
217 source_path: layer.path.clone(),
218 })
219}
220
221fn pop_scope_tables(layer: &mut ConfigLayer) -> Result<toml_edit::ArrayOfTables, ConfigGetError> {
222 let Some(item) = layer.data.remove(SCOPE_TABLE_KEY) else {
223 return Ok(toml_edit::ArrayOfTables::new());
224 };
225 item.into_array_of_tables()
226 .map_err(|item| ConfigGetError::Type {
227 name: SCOPE_TABLE_KEY.to_owned(),
228 error: format!("Expected an array of tables, but is {}", item.type_name()).into(),
229 source_path: layer.path.clone(),
230 })
231}
232
233#[derive(Debug, Error)]
235#[error("Migration failed")]
236pub struct ConfigMigrateError {
237 #[source]
239 pub error: ConfigMigrateLayerError,
240 pub source_path: Option<PathBuf>,
242}
243
244#[derive(Debug, Error)]
246pub enum ConfigMigrateLayerError {
247 #[error(transparent)]
249 Update(#[from] ConfigUpdateError),
250 #[error("Invalid type or value for {name}")]
252 Type {
253 name: String,
255 #[source]
257 error: DynError,
258 },
259}
260
261impl ConfigMigrateLayerError {
262 fn with_source_path(self, source_path: Option<&Path>) -> ConfigMigrateError {
263 ConfigMigrateError {
264 error: self,
265 source_path: source_path.map(|path| path.to_owned()),
266 }
267 }
268}
269
270type DynError = Box<dyn std::error::Error + Send + Sync>;
271
272pub struct ConfigMigrationRule {
274 inner: MigrationRule,
275}
276
277enum MigrationRule {
278 RenameValue {
279 old_name: ConfigNamePathBuf,
280 new_name: ConfigNamePathBuf,
281 },
282 RenameUpdateValue {
283 old_name: ConfigNamePathBuf,
284 new_name: ConfigNamePathBuf,
285 #[expect(clippy::type_complexity)] new_value_fn: Box<dyn Fn(&ConfigValue) -> Result<ConfigValue, DynError>>,
287 },
288 Custom {
289 matches_fn: Box<dyn Fn(&ConfigLayer) -> bool>,
290 #[expect(clippy::type_complexity)] apply_fn: Box<dyn Fn(&mut ConfigLayer) -> Result<String, ConfigMigrateLayerError>>,
292 },
293}
294
295impl ConfigMigrationRule {
296 pub fn rename_value(old_name: impl ToConfigNamePath, new_name: impl ToConfigNamePath) -> Self {
298 let inner = MigrationRule::RenameValue {
299 old_name: old_name.into_name_path().into(),
300 new_name: new_name.into_name_path().into(),
301 };
302 Self { inner }
303 }
304
305 pub fn rename_update_value(
311 old_name: impl ToConfigNamePath,
312 new_name: impl ToConfigNamePath,
313 new_value_fn: impl Fn(&ConfigValue) -> Result<ConfigValue, DynError> + 'static,
314 ) -> Self {
315 let inner = MigrationRule::RenameUpdateValue {
316 old_name: old_name.into_name_path().into(),
317 new_name: new_name.into_name_path().into(),
318 new_value_fn: Box::new(new_value_fn),
319 };
320 Self { inner }
321 }
322
323 pub fn custom(
328 matches_fn: impl Fn(&ConfigLayer) -> bool + 'static,
329 apply_fn: impl Fn(&mut ConfigLayer) -> Result<String, ConfigMigrateLayerError> + 'static,
330 ) -> Self {
331 let inner = MigrationRule::Custom {
332 matches_fn: Box::new(matches_fn),
333 apply_fn: Box::new(apply_fn),
334 };
335 Self { inner }
336 }
337
338 fn matches(&self, layer: &ConfigLayer) -> bool {
340 match &self.inner {
341 MigrationRule::RenameValue { old_name, .. }
342 | MigrationRule::RenameUpdateValue { old_name, .. } => {
343 matches!(layer.look_up_item(old_name), Ok(Some(_)))
344 }
345 MigrationRule::Custom { matches_fn, .. } => matches_fn(layer),
346 }
347 }
348
349 fn apply(&self, layer: &mut ConfigLayer) -> Result<String, ConfigMigrateLayerError> {
351 match &self.inner {
352 MigrationRule::RenameValue { old_name, new_name } => {
353 rename_value(layer, old_name, new_name)
354 }
355 MigrationRule::RenameUpdateValue {
356 old_name,
357 new_name,
358 new_value_fn,
359 } => rename_update_value(layer, old_name, new_name, new_value_fn),
360 MigrationRule::Custom { apply_fn, .. } => apply_fn(layer),
361 }
362 }
363}
364
365fn rename_value(
366 layer: &mut ConfigLayer,
367 old_name: &ConfigNamePathBuf,
368 new_name: &ConfigNamePathBuf,
369) -> Result<String, ConfigMigrateLayerError> {
370 let value = layer.delete_value(old_name)?.expect("tested by matches()");
371 if matches!(layer.look_up_item(new_name), Ok(Some(_))) {
372 return Ok(format!("{old_name} is deleted (superseded by {new_name})"));
373 }
374 layer.set_value(new_name, value)?;
375 Ok(format!("{old_name} is renamed to {new_name}"))
376}
377
378fn rename_update_value(
379 layer: &mut ConfigLayer,
380 old_name: &ConfigNamePathBuf,
381 new_name: &ConfigNamePathBuf,
382 new_value_fn: impl FnOnce(&ConfigValue) -> Result<ConfigValue, DynError>,
383) -> Result<String, ConfigMigrateLayerError> {
384 let old_value = layer.delete_value(old_name)?.expect("tested by matches()");
385 if matches!(layer.look_up_item(new_name), Ok(Some(_))) {
386 return Ok(format!("{old_name} is deleted (superseded by {new_name})"));
387 }
388 let new_value = new_value_fn(&old_value).map_err(|error| ConfigMigrateLayerError::Type {
389 name: old_name.to_string(),
390 error,
391 })?;
392 layer.set_value(new_name, new_value.clone())?;
393 Ok(format!("{old_name} is updated to {new_name} = {new_value}"))
394}
395
396pub fn migrate(
399 config: &mut StackedConfig,
400 rules: &[ConfigMigrationRule],
401) -> Result<Vec<(ConfigSource, String)>, ConfigMigrateError> {
402 let mut descriptions = Vec::new();
403 for layer in config.layers_mut() {
404 migrate_layer(layer, rules, &mut descriptions)
405 .map_err(|err| err.with_source_path(layer.path.as_deref()))?;
406 }
407 Ok(descriptions)
408}
409
410fn migrate_layer(
411 layer: &mut Arc<ConfigLayer>,
412 rules: &[ConfigMigrationRule],
413 descriptions: &mut Vec<(ConfigSource, String)>,
414) -> Result<(), ConfigMigrateLayerError> {
415 let rules_to_apply = rules
416 .iter()
417 .filter(|rule| rule.matches(layer))
418 .collect_vec();
419 if rules_to_apply.is_empty() {
420 return Ok(());
421 }
422 let layer_mut = Arc::make_mut(layer);
423 for rule in rules_to_apply {
424 let desc = rule.apply(layer_mut)?;
425 descriptions.push((layer_mut.source, desc));
426 }
427 Ok(())
428}
429
430#[cfg(test)]
431mod tests {
432 use assert_matches::assert_matches;
433 use indoc::indoc;
434
435 use super::*;
436
437 #[test]
438 fn test_expand_home() {
439 let home_dir = Some(Path::new("/home/dir"));
440 assert_eq!(
441 expand_home("~".as_ref(), home_dir).unwrap(),
442 Some(PathBuf::from("/home/dir"))
443 );
444 assert_eq!(expand_home("~foo".as_ref(), home_dir).unwrap(), None);
445 assert_eq!(expand_home("/foo/~".as_ref(), home_dir).unwrap(), None);
446 assert_eq!(
447 expand_home("~/foo".as_ref(), home_dir).unwrap(),
448 Some(PathBuf::from("/home/dir/foo"))
449 );
450 assert!(expand_home("~/foo".as_ref(), None).is_err());
451 }
452
453 #[test]
454 fn test_condition_default() {
455 let condition = ScopeCondition::default();
456
457 let context = ConfigResolutionContext {
458 home_dir: None,
459 repo_path: None,
460 workspace_path: None,
461 command: None,
462 hostname: "",
463 };
464 assert!(condition.matches(&context));
465 let context = ConfigResolutionContext {
466 home_dir: None,
467 repo_path: Some(Path::new("/foo")),
468 workspace_path: None,
469 command: None,
470 hostname: "",
471 };
472 assert!(condition.matches(&context));
473 }
474
475 #[test]
476 fn test_condition_repo_path() {
477 let condition = ScopeCondition {
478 repositories: Some(["/foo", "/bar"].map(PathBuf::from).into()),
479 workspaces: None,
480 commands: None,
481 platforms: None,
482 hostnames: None,
483 };
484
485 let context = ConfigResolutionContext {
486 home_dir: None,
487 repo_path: None,
488 workspace_path: None,
489 command: None,
490 hostname: "",
491 };
492 assert!(!condition.matches(&context));
493 let context = ConfigResolutionContext {
494 home_dir: None,
495 repo_path: Some(Path::new("/foo")),
496 workspace_path: None,
497 command: None,
498 hostname: "",
499 };
500 assert!(condition.matches(&context));
501 let context = ConfigResolutionContext {
502 home_dir: None,
503 repo_path: Some(Path::new("/fooo")),
504 workspace_path: None,
505 command: None,
506 hostname: "",
507 };
508 assert!(!condition.matches(&context));
509 let context = ConfigResolutionContext {
510 home_dir: None,
511 repo_path: Some(Path::new("/foo/baz")),
512 workspace_path: None,
513 command: None,
514 hostname: "",
515 };
516 assert!(condition.matches(&context));
517 let context = ConfigResolutionContext {
518 home_dir: None,
519 repo_path: Some(Path::new("/bar")),
520 workspace_path: None,
521 command: None,
522 hostname: "",
523 };
524 assert!(condition.matches(&context));
525 }
526
527 #[test]
528 fn test_condition_repo_path_windows() {
529 let condition = ScopeCondition {
530 repositories: Some(["c:/foo", r"d:\bar/baz"].map(PathBuf::from).into()),
531 workspaces: None,
532 commands: None,
533 platforms: None,
534 hostnames: None,
535 };
536
537 let context = ConfigResolutionContext {
538 home_dir: None,
539 repo_path: Some(Path::new(r"c:\foo")),
540 workspace_path: None,
541 command: None,
542 hostname: "",
543 };
544 assert_eq!(condition.matches(&context), cfg!(windows));
545 let context = ConfigResolutionContext {
546 home_dir: None,
547 repo_path: Some(Path::new(r"c:\foo\baz")),
548 workspace_path: None,
549 command: None,
550 hostname: "",
551 };
552 assert_eq!(condition.matches(&context), cfg!(windows));
553 let context = ConfigResolutionContext {
554 home_dir: None,
555 repo_path: Some(Path::new(r"d:\foo")),
556 workspace_path: None,
557 command: None,
558 hostname: "",
559 };
560 assert!(!condition.matches(&context));
561 let context = ConfigResolutionContext {
562 home_dir: None,
563 repo_path: Some(Path::new(r"d:/bar\baz")),
564 workspace_path: None,
565 command: None,
566 hostname: "",
567 };
568 assert_eq!(condition.matches(&context), cfg!(windows));
569 }
570
571 #[test]
572 fn test_condition_hostname() {
573 let condition = ScopeCondition {
574 repositories: None,
575 hostnames: Some(["host-a", "host-b"].map(String::from).into()),
576 workspaces: None,
577 commands: None,
578 platforms: None,
579 };
580
581 let context = ConfigResolutionContext {
582 home_dir: None,
583 repo_path: None,
584 workspace_path: None,
585 command: None,
586 hostname: "",
587 };
588 assert!(!condition.matches(&context));
589 let context = ConfigResolutionContext {
590 home_dir: None,
591 repo_path: None,
592 workspace_path: None,
593 command: None,
594 hostname: "host-a",
595 };
596 assert!(condition.matches(&context));
597 let context = ConfigResolutionContext {
598 home_dir: None,
599 repo_path: None,
600 workspace_path: None,
601 command: None,
602 hostname: "host-b",
603 };
604 assert!(condition.matches(&context));
605 let context = ConfigResolutionContext {
606 home_dir: None,
607 repo_path: None,
608 workspace_path: None,
609 command: None,
610 hostname: "host-c",
611 };
612 assert!(!condition.matches(&context));
613 }
614
615 fn new_user_layer(text: &str) -> ConfigLayer {
616 ConfigLayer::parse(ConfigSource::User, text).unwrap()
617 }
618
619 #[test]
620 fn test_resolve_transparent() {
621 let mut source_config = StackedConfig::empty();
622 source_config.add_layer(ConfigLayer::empty(ConfigSource::Default));
623 source_config.add_layer(ConfigLayer::empty(ConfigSource::User));
624
625 let context = ConfigResolutionContext {
626 home_dir: None,
627 repo_path: None,
628 workspace_path: None,
629 command: None,
630 hostname: "",
631 };
632 let resolved_config = resolve(&source_config, &context).unwrap();
633 assert_eq!(resolved_config.layers().len(), 2);
634 assert!(Arc::ptr_eq(
635 &source_config.layers()[0],
636 &resolved_config.layers()[0]
637 ));
638 assert!(Arc::ptr_eq(
639 &source_config.layers()[1],
640 &resolved_config.layers()[1]
641 ));
642 }
643
644 #[test]
645 fn test_resolve_table_order() {
646 let mut source_config = StackedConfig::empty();
647 source_config.add_layer(new_user_layer(indoc! {"
648 a = 'a #0'
649 [[--scope]]
650 a = 'a #0.0'
651 [[--scope]]
652 a = 'a #0.1'
653 [[--scope.--scope]]
654 a = 'a #0.1.0'
655 [[--scope]]
656 a = 'a #0.2'
657 "}));
658 source_config.add_layer(new_user_layer(indoc! {"
659 a = 'a #1'
660 [[--scope]]
661 a = 'a #1.0'
662 "}));
663
664 let context = ConfigResolutionContext {
665 home_dir: None,
666 repo_path: None,
667 workspace_path: None,
668 command: None,
669 hostname: "",
670 };
671 let resolved_config = resolve(&source_config, &context).unwrap();
672 assert_eq!(resolved_config.layers().len(), 7);
673 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
674 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.0'");
675 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.1'");
676 insta::assert_snapshot!(resolved_config.layers()[3].data, @"a = 'a #0.1.0'");
677 insta::assert_snapshot!(resolved_config.layers()[4].data, @"a = 'a #0.2'");
678 insta::assert_snapshot!(resolved_config.layers()[5].data, @"a = 'a #1'");
679 insta::assert_snapshot!(resolved_config.layers()[6].data, @"a = 'a #1.0'");
680 }
681
682 #[test]
683 fn test_resolve_repo_path() {
684 let mut source_config = StackedConfig::empty();
685 source_config.add_layer(new_user_layer(indoc! {"
686 a = 'a #0'
687 [[--scope]]
688 --when.repositories = ['/foo']
689 a = 'a #0.1 foo'
690 [[--scope]]
691 --when.repositories = ['/foo', '/bar']
692 a = 'a #0.2 foo|bar'
693 [[--scope]]
694 --when.repositories = []
695 a = 'a #0.3 none'
696 "}));
697 source_config.add_layer(new_user_layer(indoc! {"
698 --when.repositories = ['~/baz']
699 a = 'a #1 baz'
700 [[--scope]]
701 --when.repositories = ['/foo'] # should never be enabled
702 a = 'a #1.1 baz&foo'
703 "}));
704
705 let context = ConfigResolutionContext {
706 home_dir: Some(Path::new("/home/dir")),
707 repo_path: None,
708 workspace_path: None,
709 command: None,
710 hostname: "",
711 };
712 let resolved_config = resolve(&source_config, &context).unwrap();
713 assert_eq!(resolved_config.layers().len(), 1);
714 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
715
716 let context = ConfigResolutionContext {
717 home_dir: Some(Path::new("/home/dir")),
718 repo_path: Some(Path::new("/foo/.jj/repo")),
719 workspace_path: None,
720 command: None,
721 hostname: "",
722 };
723 let resolved_config = resolve(&source_config, &context).unwrap();
724 assert_eq!(resolved_config.layers().len(), 3);
725 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
726 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 foo'");
727 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 foo|bar'");
728
729 let context = ConfigResolutionContext {
730 home_dir: Some(Path::new("/home/dir")),
731 repo_path: Some(Path::new("/bar/.jj/repo")),
732 workspace_path: None,
733 command: None,
734 hostname: "",
735 };
736 let resolved_config = resolve(&source_config, &context).unwrap();
737 assert_eq!(resolved_config.layers().len(), 2);
738 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
739 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.2 foo|bar'");
740
741 let context = ConfigResolutionContext {
742 home_dir: Some(Path::new("/home/dir")),
743 repo_path: Some(Path::new("/home/dir/baz/.jj/repo")),
744 workspace_path: None,
745 command: None,
746 hostname: "",
747 };
748 let resolved_config = resolve(&source_config, &context).unwrap();
749 assert_eq!(resolved_config.layers().len(), 2);
750 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
751 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #1 baz'");
752 }
753
754 #[test]
755 fn test_resolve_hostname() {
756 let mut source_config = StackedConfig::empty();
757 source_config.add_layer(new_user_layer(indoc! {"
758 a = 'a #0'
759 [[--scope]]
760 --when.hostnames = ['host-a']
761 a = 'a #0.1 host-a'
762 [[--scope]]
763 --when.hostnames = ['host-a', 'host-b']
764 a = 'a #0.2 host-a|host-b'
765 [[--scope]]
766 --when.hostnames = []
767 a = 'a #0.3 none'
768 "}));
769 source_config.add_layer(new_user_layer(indoc! {"
770 --when.hostnames = ['host-c']
771 a = 'a #1 host-c'
772 [[--scope]]
773 --when.hostnames = ['host-a'] # should never be enabled
774 a = 'a #1.1 host-c&host-a'
775 "}));
776
777 let context = ConfigResolutionContext {
778 home_dir: Some(Path::new("/home/dir")),
779 repo_path: None,
780 workspace_path: None,
781 command: None,
782 hostname: "",
783 };
784 let resolved_config = resolve(&source_config, &context).unwrap();
785 assert_eq!(resolved_config.layers().len(), 1);
786 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
787
788 let context = ConfigResolutionContext {
789 home_dir: Some(Path::new("/home/dir")),
790 repo_path: None,
791 workspace_path: None,
792 command: None,
793 hostname: "host-a",
794 };
795 let resolved_config = resolve(&source_config, &context).unwrap();
796 assert_eq!(resolved_config.layers().len(), 3);
797 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
798 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 host-a'");
799 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 host-a|host-b'");
800
801 let context = ConfigResolutionContext {
802 home_dir: Some(Path::new("/home/dir")),
803 repo_path: None,
804 workspace_path: None,
805 command: None,
806 hostname: "host-b",
807 };
808 let resolved_config = resolve(&source_config, &context).unwrap();
809 assert_eq!(resolved_config.layers().len(), 2);
810 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
811 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.2 host-a|host-b'");
812
813 let context = ConfigResolutionContext {
814 home_dir: Some(Path::new("/home/dir")),
815 repo_path: None,
816 workspace_path: None,
817 command: None,
818 hostname: "host-c",
819 };
820 let resolved_config = resolve(&source_config, &context).unwrap();
821 assert_eq!(resolved_config.layers().len(), 2);
822 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
823 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #1 host-c'");
824 }
825
826 #[test]
827 fn test_resolve_workspace_path() {
828 let mut source_config = StackedConfig::empty();
829 source_config.add_layer(new_user_layer(indoc! {"
830 a = 'a #0'
831 [[--scope]]
832 --when.workspaces = ['/foo']
833 a = 'a #0.1 foo'
834 [[--scope]]
835 --when.workspaces = ['/foo', '/bar']
836 a = 'a #0.2 foo|bar'
837 [[--scope]]
838 --when.workspaces = []
839 a = 'a #0.3 none'
840 "}));
841 source_config.add_layer(new_user_layer(indoc! {"
842 --when.workspaces = ['~/baz']
843 a = 'a #1 baz'
844 [[--scope]]
845 --when.workspaces = ['/foo'] # should never be enabled
846 a = 'a #1.1 baz&foo'
847 "}));
848
849 let context = ConfigResolutionContext {
850 home_dir: Some(Path::new("/home/dir")),
851 repo_path: None,
852 workspace_path: None,
853 command: None,
854 hostname: "",
855 };
856 let resolved_config = resolve(&source_config, &context).unwrap();
857 assert_eq!(resolved_config.layers().len(), 1);
858 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
859
860 let context = ConfigResolutionContext {
861 home_dir: Some(Path::new("/home/dir")),
862 repo_path: None,
863 workspace_path: Some(Path::new("/foo")),
864 command: None,
865 hostname: "",
866 };
867 let resolved_config = resolve(&source_config, &context).unwrap();
868 assert_eq!(resolved_config.layers().len(), 3);
869 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
870 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 foo'");
871 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 foo|bar'");
872
873 let context = ConfigResolutionContext {
874 home_dir: Some(Path::new("/home/dir")),
875 repo_path: None,
876 workspace_path: Some(Path::new("/bar")),
877 command: None,
878 hostname: "",
879 };
880 let resolved_config = resolve(&source_config, &context).unwrap();
881 assert_eq!(resolved_config.layers().len(), 2);
882 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
883 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.2 foo|bar'");
884
885 let context = ConfigResolutionContext {
886 home_dir: Some(Path::new("/home/dir")),
887 repo_path: None,
888 workspace_path: Some(Path::new("/home/dir/baz")),
889 command: None,
890 hostname: "",
891 };
892 let resolved_config = resolve(&source_config, &context).unwrap();
893 assert_eq!(resolved_config.layers().len(), 2);
894 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
895 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #1 baz'");
896 }
897
898 #[test]
899 fn test_resolve_command() {
900 let mut source_config = StackedConfig::empty();
901 source_config.add_layer(new_user_layer(indoc! {"
902 a = 'a #0'
903 [[--scope]]
904 --when.commands = ['foo']
905 a = 'a #0.1 foo'
906 [[--scope]]
907 --when.commands = ['foo', 'bar']
908 a = 'a #0.2 foo|bar'
909 [[--scope]]
910 --when.commands = ['foo baz']
911 a = 'a #0.3 foo baz'
912 [[--scope]]
913 --when.commands = []
914 a = 'a #0.4 none'
915 "}));
916
917 let context = ConfigResolutionContext {
918 home_dir: None,
919 repo_path: None,
920 workspace_path: None,
921 command: None,
922 hostname: "",
923 };
924 let resolved_config = resolve(&source_config, &context).unwrap();
925 assert_eq!(resolved_config.layers().len(), 1);
926 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
927
928 let context = ConfigResolutionContext {
929 home_dir: None,
930 repo_path: None,
931 workspace_path: None,
932 command: Some("foo"),
933 hostname: "",
934 };
935 let resolved_config = resolve(&source_config, &context).unwrap();
936 assert_eq!(resolved_config.layers().len(), 3);
937 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
938 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 foo'");
939 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 foo|bar'");
940
941 let context = ConfigResolutionContext {
942 home_dir: None,
943 repo_path: None,
944 workspace_path: None,
945 command: Some("bar"),
946 hostname: "",
947 };
948 let resolved_config = resolve(&source_config, &context).unwrap();
949 assert_eq!(resolved_config.layers().len(), 2);
950 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
951 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.2 foo|bar'");
952
953 let context = ConfigResolutionContext {
954 home_dir: None,
955 repo_path: None,
956 workspace_path: None,
957 command: Some("foo baz"),
958 hostname: "",
959 };
960 let resolved_config = resolve(&source_config, &context).unwrap();
961 assert_eq!(resolved_config.layers().len(), 4);
962 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
963 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 foo'");
964 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 foo|bar'");
965 insta::assert_snapshot!(resolved_config.layers()[3].data, @"a = 'a #0.3 foo baz'");
966
967 let context = ConfigResolutionContext {
969 home_dir: None,
970 repo_path: None,
971 workspace_path: None,
972 command: Some("fooqux"),
973 hostname: "",
974 };
975 let resolved_config = resolve(&source_config, &context).unwrap();
976 assert_eq!(resolved_config.layers().len(), 1);
977 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
978 }
979
980 #[test]
981 fn test_resolve_os() {
982 let mut source_config = StackedConfig::empty();
983 source_config.add_layer(new_user_layer(indoc! {"
984 a = 'a none'
985 b = 'b none'
986 [[--scope]]
987 --when.platforms = ['linux']
988 a = 'a linux'
989 [[--scope]]
990 --when.platforms = ['macos']
991 a = 'a macos'
992 [[--scope]]
993 --when.platforms = ['windows']
994 a = 'a windows'
995 [[--scope]]
996 --when.platforms = ['unix']
997 b = 'b unix'
998 "}));
999
1000 let context = ConfigResolutionContext {
1001 home_dir: Some(Path::new("/home/dir")),
1002 repo_path: None,
1003 workspace_path: None,
1004 command: None,
1005 hostname: "",
1006 };
1007 let resolved_config = resolve(&source_config, &context).unwrap();
1008 insta::assert_snapshot!(resolved_config.layers()[0].data, @r"
1009 a = 'a none'
1010 b = 'b none'
1011 ");
1012 if cfg!(target_os = "linux") {
1013 assert_eq!(resolved_config.layers().len(), 3);
1014 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a linux'");
1015 insta::assert_snapshot!(resolved_config.layers()[2].data, @"b = 'b unix'");
1016 } else if cfg!(target_os = "macos") {
1017 assert_eq!(resolved_config.layers().len(), 3);
1018 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a macos'");
1019 insta::assert_snapshot!(resolved_config.layers()[2].data, @"b = 'b unix'");
1020 } else if cfg!(target_os = "windows") {
1021 assert_eq!(resolved_config.layers().len(), 2);
1022 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a windows'");
1023 } else if cfg!(target_family = "unix") {
1024 assert_eq!(resolved_config.layers().len(), 2);
1025 insta::assert_snapshot!(resolved_config.layers()[1].data, @"b = 'b unix'");
1026 } else {
1027 assert_eq!(resolved_config.layers().len(), 1);
1028 }
1029 }
1030
1031 #[test]
1032 fn test_resolve_repo_path_and_command() {
1033 let mut source_config = StackedConfig::empty();
1034 source_config.add_layer(new_user_layer(indoc! {"
1035 a = 'a #0'
1036 [[--scope]]
1037 --when.repositories = ['/foo', '/bar']
1038 --when.commands = ['ABC', 'DEF']
1039 a = 'a #0.1'
1040 "}));
1041
1042 let context = ConfigResolutionContext {
1043 home_dir: Some(Path::new("/home/dir")),
1044 repo_path: None,
1045 workspace_path: None,
1046 command: None,
1047 hostname: "",
1048 };
1049 let resolved_config = resolve(&source_config, &context).unwrap();
1050 assert_eq!(resolved_config.layers().len(), 1);
1051 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1052
1053 let context = ConfigResolutionContext {
1055 home_dir: Some(Path::new("/home/dir")),
1056 repo_path: Some(Path::new("/foo")),
1057 workspace_path: None,
1058 command: Some("other"),
1059 hostname: "",
1060 };
1061 let resolved_config = resolve(&source_config, &context).unwrap();
1062 assert_eq!(resolved_config.layers().len(), 1);
1063 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1064
1065 let context = ConfigResolutionContext {
1067 home_dir: Some(Path::new("/home/dir")),
1068 repo_path: Some(Path::new("/qux")),
1069 workspace_path: None,
1070 command: Some("ABC"),
1071 hostname: "",
1072 };
1073 let resolved_config = resolve(&source_config, &context).unwrap();
1074 assert_eq!(resolved_config.layers().len(), 1);
1075 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1076
1077 let context = ConfigResolutionContext {
1079 home_dir: Some(Path::new("/home/dir")),
1080 repo_path: Some(Path::new("/bar")),
1081 workspace_path: None,
1082 command: Some("DEF"),
1083 hostname: "",
1084 };
1085 let resolved_config = resolve(&source_config, &context).unwrap();
1086 assert_eq!(resolved_config.layers().len(), 2);
1087 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1088 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1'");
1089 }
1090
1091 #[test]
1092 fn test_resolve_invalid_condition() {
1093 let new_config = |text: &str| {
1094 let mut config = StackedConfig::empty();
1095 config.add_layer(new_user_layer(text));
1096 config
1097 };
1098 let context = ConfigResolutionContext {
1099 home_dir: Some(Path::new("/home/dir")),
1100 repo_path: Some(Path::new("/foo/.jj/repo")),
1101 workspace_path: None,
1102 command: None,
1103 hostname: "",
1104 };
1105 assert_matches!(
1106 resolve(&new_config("--when.repositories = 0"), &context),
1107 Err(ConfigGetError::Type { .. })
1108 );
1109 }
1110
1111 #[test]
1112 fn test_resolve_invalid_scoped_tables() {
1113 let new_config = |text: &str| {
1114 let mut config = StackedConfig::empty();
1115 config.add_layer(new_user_layer(text));
1116 config
1117 };
1118 let context = ConfigResolutionContext {
1119 home_dir: Some(Path::new("/home/dir")),
1120 repo_path: Some(Path::new("/foo/.jj/repo")),
1121 workspace_path: None,
1122 command: None,
1123 hostname: "",
1124 };
1125 assert_matches!(
1126 resolve(&new_config("[--scope]"), &context),
1127 Err(ConfigGetError::Type { .. })
1128 );
1129 }
1130
1131 #[test]
1132 fn test_migrate_noop() {
1133 let mut config = StackedConfig::empty();
1134 config.add_layer(new_user_layer(indoc! {"
1135 foo = 'foo'
1136 "}));
1137 config.add_layer(new_user_layer(indoc! {"
1138 bar = 'bar'
1139 "}));
1140
1141 let old_layers = config.layers().to_vec();
1142 let rules = [ConfigMigrationRule::rename_value("baz", "foo")];
1143 let descriptions = migrate(&mut config, &rules).unwrap();
1144 assert!(descriptions.is_empty());
1145 assert!(Arc::ptr_eq(&config.layers()[0], &old_layers[0]));
1146 assert!(Arc::ptr_eq(&config.layers()[1], &old_layers[1]));
1147 }
1148
1149 #[test]
1150 fn test_migrate_error() {
1151 let mut config = StackedConfig::empty();
1152 let mut layer = new_user_layer(indoc! {"
1153 foo.bar = 'baz'
1154 "});
1155 layer.path = Some("source.toml".into());
1156 config.add_layer(layer);
1157
1158 let rules = [ConfigMigrationRule::rename_value("foo", "bar")];
1159 insta::assert_debug_snapshot!(migrate(&mut config, &rules).unwrap_err(), @r#"
1160 ConfigMigrateError {
1161 error: Update(
1162 WouldDeleteTable {
1163 name: "foo",
1164 },
1165 ),
1166 source_path: Some(
1167 "source.toml",
1168 ),
1169 }
1170 "#);
1171 }
1172
1173 #[test]
1174 fn test_migrate_rename_value() {
1175 let mut config = StackedConfig::empty();
1176 config.add_layer(new_user_layer(indoc! {"
1177 [foo]
1178 old = 'foo.old #0'
1179 [bar]
1180 old = 'bar.old #0'
1181 [baz]
1182 new = 'baz.new #0'
1183 "}));
1184 config.add_layer(new_user_layer(indoc! {"
1185 [bar]
1186 old = 'bar.old #1'
1187 "}));
1188
1189 let rules = [
1190 ConfigMigrationRule::rename_value("foo.old", "foo.new"),
1191 ConfigMigrationRule::rename_value("bar.old", "baz.new"),
1192 ];
1193 let descriptions = migrate(&mut config, &rules).unwrap();
1194 insta::assert_debug_snapshot!(descriptions, @r#"
1195 [
1196 (
1197 User,
1198 "foo.old is renamed to foo.new",
1199 ),
1200 (
1201 User,
1202 "bar.old is deleted (superseded by baz.new)",
1203 ),
1204 (
1205 User,
1206 "bar.old is renamed to baz.new",
1207 ),
1208 ]
1209 "#);
1210 insta::assert_snapshot!(config.layers()[0].data, @r"
1211 [foo]
1212 new = 'foo.old #0'
1213 [bar]
1214 [baz]
1215 new = 'baz.new #0'
1216 ");
1217 insta::assert_snapshot!(config.layers()[1].data, @r"
1218 [bar]
1219
1220 [baz]
1221 new = 'bar.old #1'
1222 ");
1223 }
1224
1225 #[test]
1226 fn test_migrate_rename_update_value() {
1227 let mut config = StackedConfig::empty();
1228 config.add_layer(new_user_layer(indoc! {"
1229 [foo]
1230 old = 'foo.old #0'
1231 [bar]
1232 old = 'bar.old #0'
1233 [baz]
1234 new = 'baz.new #0'
1235 "}));
1236 config.add_layer(new_user_layer(indoc! {"
1237 [bar]
1238 old = 'bar.old #1'
1239 "}));
1240
1241 let rules = [
1242 ConfigMigrationRule::rename_update_value("foo.old", "foo.new", |old_value| {
1244 let val = old_value.clone().decorated("", "");
1245 Ok(ConfigValue::from_iter([val]))
1246 }),
1247 ConfigMigrationRule::rename_update_value("bar.old", "baz.new", |old_value| {
1249 let s = old_value.as_str().ok_or("not a string")?;
1250 Ok(format!("{s} updated").into())
1251 }),
1252 ];
1253 let descriptions = migrate(&mut config, &rules).unwrap();
1254 insta::assert_debug_snapshot!(descriptions, @r#"
1255 [
1256 (
1257 User,
1258 "foo.old is updated to foo.new = ['foo.old #0']",
1259 ),
1260 (
1261 User,
1262 "bar.old is deleted (superseded by baz.new)",
1263 ),
1264 (
1265 User,
1266 "bar.old is updated to baz.new = \"bar.old #1 updated\"",
1267 ),
1268 ]
1269 "#);
1270 insta::assert_snapshot!(config.layers()[0].data, @r"
1271 [foo]
1272 new = ['foo.old #0']
1273 [bar]
1274 [baz]
1275 new = 'baz.new #0'
1276 ");
1277 insta::assert_snapshot!(config.layers()[1].data, @r#"
1278 [bar]
1279
1280 [baz]
1281 new = "bar.old #1 updated"
1282 "#);
1283
1284 config.add_layer(new_user_layer(indoc! {"
1285 [bar]
1286 old = false # not a string
1287 "}));
1288 insta::assert_debug_snapshot!(migrate(&mut config, &rules).unwrap_err(), @r#"
1289 ConfigMigrateError {
1290 error: Type {
1291 name: "bar.old",
1292 error: "not a string",
1293 },
1294 source_path: None,
1295 }
1296 "#);
1297 }
1298}