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 use crate::config::ConfigSource;
437
438 #[test]
439 fn test_expand_home() {
440 let home_dir = Some(Path::new("/home/dir"));
441 assert_eq!(
442 expand_home("~".as_ref(), home_dir).unwrap(),
443 Some(PathBuf::from("/home/dir"))
444 );
445 assert_eq!(expand_home("~foo".as_ref(), home_dir).unwrap(), None);
446 assert_eq!(expand_home("/foo/~".as_ref(), home_dir).unwrap(), None);
447 assert_eq!(
448 expand_home("~/foo".as_ref(), home_dir).unwrap(),
449 Some(PathBuf::from("/home/dir/foo"))
450 );
451 assert!(expand_home("~/foo".as_ref(), None).is_err());
452 }
453
454 #[test]
455 fn test_condition_default() {
456 let condition = ScopeCondition::default();
457
458 let context = ConfigResolutionContext {
459 home_dir: None,
460 repo_path: None,
461 workspace_path: None,
462 command: None,
463 hostname: "",
464 };
465 assert!(condition.matches(&context));
466 let context = ConfigResolutionContext {
467 home_dir: None,
468 repo_path: Some(Path::new("/foo")),
469 workspace_path: None,
470 command: None,
471 hostname: "",
472 };
473 assert!(condition.matches(&context));
474 }
475
476 #[test]
477 fn test_condition_repo_path() {
478 let condition = ScopeCondition {
479 repositories: Some(["/foo", "/bar"].map(PathBuf::from).into()),
480 workspaces: None,
481 commands: None,
482 platforms: None,
483 hostnames: None,
484 };
485
486 let context = ConfigResolutionContext {
487 home_dir: None,
488 repo_path: None,
489 workspace_path: None,
490 command: None,
491 hostname: "",
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 };
501 assert!(condition.matches(&context));
502 let context = ConfigResolutionContext {
503 home_dir: None,
504 repo_path: Some(Path::new("/fooo")),
505 workspace_path: None,
506 command: None,
507 hostname: "",
508 };
509 assert!(!condition.matches(&context));
510 let context = ConfigResolutionContext {
511 home_dir: None,
512 repo_path: Some(Path::new("/foo/baz")),
513 workspace_path: None,
514 command: None,
515 hostname: "",
516 };
517 assert!(condition.matches(&context));
518 let context = ConfigResolutionContext {
519 home_dir: None,
520 repo_path: Some(Path::new("/bar")),
521 workspace_path: None,
522 command: None,
523 hostname: "",
524 };
525 assert!(condition.matches(&context));
526 }
527
528 #[test]
529 fn test_condition_repo_path_windows() {
530 let condition = ScopeCondition {
531 repositories: Some(["c:/foo", r"d:\bar/baz"].map(PathBuf::from).into()),
532 workspaces: None,
533 commands: None,
534 platforms: None,
535 hostnames: None,
536 };
537
538 let context = ConfigResolutionContext {
539 home_dir: None,
540 repo_path: Some(Path::new(r"c:\foo")),
541 workspace_path: None,
542 command: None,
543 hostname: "",
544 };
545 assert_eq!(condition.matches(&context), cfg!(windows));
546 let context = ConfigResolutionContext {
547 home_dir: None,
548 repo_path: Some(Path::new(r"c:\foo\baz")),
549 workspace_path: None,
550 command: None,
551 hostname: "",
552 };
553 assert_eq!(condition.matches(&context), cfg!(windows));
554 let context = ConfigResolutionContext {
555 home_dir: None,
556 repo_path: Some(Path::new(r"d:\foo")),
557 workspace_path: None,
558 command: None,
559 hostname: "",
560 };
561 assert!(!condition.matches(&context));
562 let context = ConfigResolutionContext {
563 home_dir: None,
564 repo_path: Some(Path::new(r"d:/bar\baz")),
565 workspace_path: None,
566 command: None,
567 hostname: "",
568 };
569 assert_eq!(condition.matches(&context), cfg!(windows));
570 }
571
572 #[test]
573 fn test_condition_hostname() {
574 let condition = ScopeCondition {
575 repositories: None,
576 hostnames: Some(["host-a", "host-b"].map(String::from).into()),
577 workspaces: None,
578 commands: None,
579 platforms: None,
580 };
581
582 let context = ConfigResolutionContext {
583 home_dir: None,
584 repo_path: None,
585 workspace_path: None,
586 command: None,
587 hostname: "",
588 };
589 assert!(!condition.matches(&context));
590 let context = ConfigResolutionContext {
591 home_dir: None,
592 repo_path: None,
593 workspace_path: None,
594 command: None,
595 hostname: "host-a",
596 };
597 assert!(condition.matches(&context));
598 let context = ConfigResolutionContext {
599 home_dir: None,
600 repo_path: None,
601 workspace_path: None,
602 command: None,
603 hostname: "host-b",
604 };
605 assert!(condition.matches(&context));
606 let context = ConfigResolutionContext {
607 home_dir: None,
608 repo_path: None,
609 workspace_path: None,
610 command: None,
611 hostname: "host-c",
612 };
613 assert!(!condition.matches(&context));
614 }
615
616 fn new_user_layer(text: &str) -> ConfigLayer {
617 ConfigLayer::parse(ConfigSource::User, text).unwrap()
618 }
619
620 #[test]
621 fn test_resolve_transparent() {
622 let mut source_config = StackedConfig::empty();
623 source_config.add_layer(ConfigLayer::empty(ConfigSource::Default));
624 source_config.add_layer(ConfigLayer::empty(ConfigSource::User));
625
626 let context = ConfigResolutionContext {
627 home_dir: None,
628 repo_path: None,
629 workspace_path: None,
630 command: None,
631 hostname: "",
632 };
633 let resolved_config = resolve(&source_config, &context).unwrap();
634 assert_eq!(resolved_config.layers().len(), 2);
635 assert!(Arc::ptr_eq(
636 &source_config.layers()[0],
637 &resolved_config.layers()[0]
638 ));
639 assert!(Arc::ptr_eq(
640 &source_config.layers()[1],
641 &resolved_config.layers()[1]
642 ));
643 }
644
645 #[test]
646 fn test_resolve_table_order() {
647 let mut source_config = StackedConfig::empty();
648 source_config.add_layer(new_user_layer(indoc! {"
649 a = 'a #0'
650 [[--scope]]
651 a = 'a #0.0'
652 [[--scope]]
653 a = 'a #0.1'
654 [[--scope.--scope]]
655 a = 'a #0.1.0'
656 [[--scope]]
657 a = 'a #0.2'
658 "}));
659 source_config.add_layer(new_user_layer(indoc! {"
660 a = 'a #1'
661 [[--scope]]
662 a = 'a #1.0'
663 "}));
664
665 let context = ConfigResolutionContext {
666 home_dir: None,
667 repo_path: None,
668 workspace_path: None,
669 command: None,
670 hostname: "",
671 };
672 let resolved_config = resolve(&source_config, &context).unwrap();
673 assert_eq!(resolved_config.layers().len(), 7);
674 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
675 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.0'");
676 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.1'");
677 insta::assert_snapshot!(resolved_config.layers()[3].data, @"a = 'a #0.1.0'");
678 insta::assert_snapshot!(resolved_config.layers()[4].data, @"a = 'a #0.2'");
679 insta::assert_snapshot!(resolved_config.layers()[5].data, @"a = 'a #1'");
680 insta::assert_snapshot!(resolved_config.layers()[6].data, @"a = 'a #1.0'");
681 }
682
683 #[test]
684 fn test_resolve_repo_path() {
685 let mut source_config = StackedConfig::empty();
686 source_config.add_layer(new_user_layer(indoc! {"
687 a = 'a #0'
688 [[--scope]]
689 --when.repositories = ['/foo']
690 a = 'a #0.1 foo'
691 [[--scope]]
692 --when.repositories = ['/foo', '/bar']
693 a = 'a #0.2 foo|bar'
694 [[--scope]]
695 --when.repositories = []
696 a = 'a #0.3 none'
697 "}));
698 source_config.add_layer(new_user_layer(indoc! {"
699 --when.repositories = ['~/baz']
700 a = 'a #1 baz'
701 [[--scope]]
702 --when.repositories = ['/foo'] # should never be enabled
703 a = 'a #1.1 baz&foo'
704 "}));
705
706 let context = ConfigResolutionContext {
707 home_dir: Some(Path::new("/home/dir")),
708 repo_path: None,
709 workspace_path: None,
710 command: None,
711 hostname: "",
712 };
713 let resolved_config = resolve(&source_config, &context).unwrap();
714 assert_eq!(resolved_config.layers().len(), 1);
715 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
716
717 let context = ConfigResolutionContext {
718 home_dir: Some(Path::new("/home/dir")),
719 repo_path: Some(Path::new("/foo/.jj/repo")),
720 workspace_path: None,
721 command: None,
722 hostname: "",
723 };
724 let resolved_config = resolve(&source_config, &context).unwrap();
725 assert_eq!(resolved_config.layers().len(), 3);
726 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
727 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 foo'");
728 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 foo|bar'");
729
730 let context = ConfigResolutionContext {
731 home_dir: Some(Path::new("/home/dir")),
732 repo_path: Some(Path::new("/bar/.jj/repo")),
733 workspace_path: None,
734 command: None,
735 hostname: "",
736 };
737 let resolved_config = resolve(&source_config, &context).unwrap();
738 assert_eq!(resolved_config.layers().len(), 2);
739 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
740 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.2 foo|bar'");
741
742 let context = ConfigResolutionContext {
743 home_dir: Some(Path::new("/home/dir")),
744 repo_path: Some(Path::new("/home/dir/baz/.jj/repo")),
745 workspace_path: None,
746 command: None,
747 hostname: "",
748 };
749 let resolved_config = resolve(&source_config, &context).unwrap();
750 assert_eq!(resolved_config.layers().len(), 2);
751 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
752 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #1 baz'");
753 }
754
755 #[test]
756 fn test_resolve_hostname() {
757 let mut source_config = StackedConfig::empty();
758 source_config.add_layer(new_user_layer(indoc! {"
759 a = 'a #0'
760 [[--scope]]
761 --when.hostnames = ['host-a']
762 a = 'a #0.1 host-a'
763 [[--scope]]
764 --when.hostnames = ['host-a', 'host-b']
765 a = 'a #0.2 host-a|host-b'
766 [[--scope]]
767 --when.hostnames = []
768 a = 'a #0.3 none'
769 "}));
770 source_config.add_layer(new_user_layer(indoc! {"
771 --when.hostnames = ['host-c']
772 a = 'a #1 host-c'
773 [[--scope]]
774 --when.hostnames = ['host-a'] # should never be enabled
775 a = 'a #1.1 host-c&host-a'
776 "}));
777
778 let context = ConfigResolutionContext {
779 home_dir: Some(Path::new("/home/dir")),
780 repo_path: None,
781 workspace_path: None,
782 command: None,
783 hostname: "",
784 };
785 let resolved_config = resolve(&source_config, &context).unwrap();
786 assert_eq!(resolved_config.layers().len(), 1);
787 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
788
789 let context = ConfigResolutionContext {
790 home_dir: Some(Path::new("/home/dir")),
791 repo_path: None,
792 workspace_path: None,
793 command: None,
794 hostname: "host-a",
795 };
796 let resolved_config = resolve(&source_config, &context).unwrap();
797 assert_eq!(resolved_config.layers().len(), 3);
798 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
799 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 host-a'");
800 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 host-a|host-b'");
801
802 let context = ConfigResolutionContext {
803 home_dir: Some(Path::new("/home/dir")),
804 repo_path: None,
805 workspace_path: None,
806 command: None,
807 hostname: "host-b",
808 };
809 let resolved_config = resolve(&source_config, &context).unwrap();
810 assert_eq!(resolved_config.layers().len(), 2);
811 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
812 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.2 host-a|host-b'");
813
814 let context = ConfigResolutionContext {
815 home_dir: Some(Path::new("/home/dir")),
816 repo_path: None,
817 workspace_path: None,
818 command: None,
819 hostname: "host-c",
820 };
821 let resolved_config = resolve(&source_config, &context).unwrap();
822 assert_eq!(resolved_config.layers().len(), 2);
823 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
824 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #1 host-c'");
825 }
826
827 #[test]
828 fn test_resolve_workspace_path() {
829 let mut source_config = StackedConfig::empty();
830 source_config.add_layer(new_user_layer(indoc! {"
831 a = 'a #0'
832 [[--scope]]
833 --when.workspaces = ['/foo']
834 a = 'a #0.1 foo'
835 [[--scope]]
836 --when.workspaces = ['/foo', '/bar']
837 a = 'a #0.2 foo|bar'
838 [[--scope]]
839 --when.workspaces = []
840 a = 'a #0.3 none'
841 "}));
842 source_config.add_layer(new_user_layer(indoc! {"
843 --when.workspaces = ['~/baz']
844 a = 'a #1 baz'
845 [[--scope]]
846 --when.workspaces = ['/foo'] # should never be enabled
847 a = 'a #1.1 baz&foo'
848 "}));
849
850 let context = ConfigResolutionContext {
851 home_dir: Some(Path::new("/home/dir")),
852 repo_path: None,
853 workspace_path: None,
854 command: None,
855 hostname: "",
856 };
857 let resolved_config = resolve(&source_config, &context).unwrap();
858 assert_eq!(resolved_config.layers().len(), 1);
859 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
860
861 let context = ConfigResolutionContext {
862 home_dir: Some(Path::new("/home/dir")),
863 repo_path: None,
864 workspace_path: Some(Path::new("/foo")),
865 command: None,
866 hostname: "",
867 };
868 let resolved_config = resolve(&source_config, &context).unwrap();
869 assert_eq!(resolved_config.layers().len(), 3);
870 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
871 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 foo'");
872 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 foo|bar'");
873
874 let context = ConfigResolutionContext {
875 home_dir: Some(Path::new("/home/dir")),
876 repo_path: None,
877 workspace_path: Some(Path::new("/bar")),
878 command: None,
879 hostname: "",
880 };
881 let resolved_config = resolve(&source_config, &context).unwrap();
882 assert_eq!(resolved_config.layers().len(), 2);
883 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
884 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.2 foo|bar'");
885
886 let context = ConfigResolutionContext {
887 home_dir: Some(Path::new("/home/dir")),
888 repo_path: None,
889 workspace_path: Some(Path::new("/home/dir/baz")),
890 command: None,
891 hostname: "",
892 };
893 let resolved_config = resolve(&source_config, &context).unwrap();
894 assert_eq!(resolved_config.layers().len(), 2);
895 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
896 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #1 baz'");
897 }
898
899 #[test]
900 fn test_resolve_command() {
901 let mut source_config = StackedConfig::empty();
902 source_config.add_layer(new_user_layer(indoc! {"
903 a = 'a #0'
904 [[--scope]]
905 --when.commands = ['foo']
906 a = 'a #0.1 foo'
907 [[--scope]]
908 --when.commands = ['foo', 'bar']
909 a = 'a #0.2 foo|bar'
910 [[--scope]]
911 --when.commands = ['foo baz']
912 a = 'a #0.3 foo baz'
913 [[--scope]]
914 --when.commands = []
915 a = 'a #0.4 none'
916 "}));
917
918 let context = ConfigResolutionContext {
919 home_dir: None,
920 repo_path: None,
921 workspace_path: None,
922 command: None,
923 hostname: "",
924 };
925 let resolved_config = resolve(&source_config, &context).unwrap();
926 assert_eq!(resolved_config.layers().len(), 1);
927 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
928
929 let context = ConfigResolutionContext {
930 home_dir: None,
931 repo_path: None,
932 workspace_path: None,
933 command: Some("foo"),
934 hostname: "",
935 };
936 let resolved_config = resolve(&source_config, &context).unwrap();
937 assert_eq!(resolved_config.layers().len(), 3);
938 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
939 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 foo'");
940 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 foo|bar'");
941
942 let context = ConfigResolutionContext {
943 home_dir: None,
944 repo_path: None,
945 workspace_path: None,
946 command: Some("bar"),
947 hostname: "",
948 };
949 let resolved_config = resolve(&source_config, &context).unwrap();
950 assert_eq!(resolved_config.layers().len(), 2);
951 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
952 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.2 foo|bar'");
953
954 let context = ConfigResolutionContext {
955 home_dir: None,
956 repo_path: None,
957 workspace_path: None,
958 command: Some("foo baz"),
959 hostname: "",
960 };
961 let resolved_config = resolve(&source_config, &context).unwrap();
962 assert_eq!(resolved_config.layers().len(), 4);
963 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
964 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 foo'");
965 insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 foo|bar'");
966 insta::assert_snapshot!(resolved_config.layers()[3].data, @"a = 'a #0.3 foo baz'");
967
968 let context = ConfigResolutionContext {
970 home_dir: None,
971 repo_path: None,
972 workspace_path: None,
973 command: Some("fooqux"),
974 hostname: "",
975 };
976 let resolved_config = resolve(&source_config, &context).unwrap();
977 assert_eq!(resolved_config.layers().len(), 1);
978 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
979 }
980
981 #[test]
982 fn test_resolve_os() {
983 let mut source_config = StackedConfig::empty();
984 source_config.add_layer(new_user_layer(indoc! {"
985 a = 'a none'
986 b = 'b none'
987 [[--scope]]
988 --when.platforms = ['linux']
989 a = 'a linux'
990 [[--scope]]
991 --when.platforms = ['macos']
992 a = 'a macos'
993 [[--scope]]
994 --when.platforms = ['windows']
995 a = 'a windows'
996 [[--scope]]
997 --when.platforms = ['unix']
998 b = 'b unix'
999 "}));
1000
1001 let context = ConfigResolutionContext {
1002 home_dir: Some(Path::new("/home/dir")),
1003 repo_path: None,
1004 workspace_path: None,
1005 command: None,
1006 hostname: "",
1007 };
1008 let resolved_config = resolve(&source_config, &context).unwrap();
1009 insta::assert_snapshot!(resolved_config.layers()[0].data, @r#"
1010 a = 'a none'
1011 b = 'b none'
1012 "#);
1013 if cfg!(target_os = "linux") {
1014 assert_eq!(resolved_config.layers().len(), 3);
1015 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a linux'");
1016 insta::assert_snapshot!(resolved_config.layers()[2].data, @"b = 'b unix'");
1017 } else if cfg!(target_os = "macos") {
1018 assert_eq!(resolved_config.layers().len(), 3);
1019 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a macos'");
1020 insta::assert_snapshot!(resolved_config.layers()[2].data, @"b = 'b unix'");
1021 } else if cfg!(target_os = "windows") {
1022 assert_eq!(resolved_config.layers().len(), 2);
1023 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a windows'");
1024 } else if cfg!(target_family = "unix") {
1025 assert_eq!(resolved_config.layers().len(), 2);
1026 insta::assert_snapshot!(resolved_config.layers()[1].data, @"b = 'b unix'");
1027 } else {
1028 assert_eq!(resolved_config.layers().len(), 1);
1029 }
1030 }
1031
1032 #[test]
1033 fn test_resolve_repo_path_and_command() {
1034 let mut source_config = StackedConfig::empty();
1035 source_config.add_layer(new_user_layer(indoc! {"
1036 a = 'a #0'
1037 [[--scope]]
1038 --when.repositories = ['/foo', '/bar']
1039 --when.commands = ['ABC', 'DEF']
1040 a = 'a #0.1'
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 };
1050 let resolved_config = resolve(&source_config, &context).unwrap();
1051 assert_eq!(resolved_config.layers().len(), 1);
1052 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1053
1054 let context = ConfigResolutionContext {
1056 home_dir: Some(Path::new("/home/dir")),
1057 repo_path: Some(Path::new("/foo")),
1058 workspace_path: None,
1059 command: Some("other"),
1060 hostname: "",
1061 };
1062 let resolved_config = resolve(&source_config, &context).unwrap();
1063 assert_eq!(resolved_config.layers().len(), 1);
1064 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1065
1066 let context = ConfigResolutionContext {
1068 home_dir: Some(Path::new("/home/dir")),
1069 repo_path: Some(Path::new("/qux")),
1070 workspace_path: None,
1071 command: Some("ABC"),
1072 hostname: "",
1073 };
1074 let resolved_config = resolve(&source_config, &context).unwrap();
1075 assert_eq!(resolved_config.layers().len(), 1);
1076 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1077
1078 let context = ConfigResolutionContext {
1080 home_dir: Some(Path::new("/home/dir")),
1081 repo_path: Some(Path::new("/bar")),
1082 workspace_path: None,
1083 command: Some("DEF"),
1084 hostname: "",
1085 };
1086 let resolved_config = resolve(&source_config, &context).unwrap();
1087 assert_eq!(resolved_config.layers().len(), 2);
1088 insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1089 insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1'");
1090 }
1091
1092 #[test]
1093 fn test_resolve_invalid_condition() {
1094 let new_config = |text: &str| {
1095 let mut config = StackedConfig::empty();
1096 config.add_layer(new_user_layer(text));
1097 config
1098 };
1099 let context = ConfigResolutionContext {
1100 home_dir: Some(Path::new("/home/dir")),
1101 repo_path: Some(Path::new("/foo/.jj/repo")),
1102 workspace_path: None,
1103 command: None,
1104 hostname: "",
1105 };
1106 assert_matches!(
1107 resolve(&new_config("--when.repositories = 0"), &context),
1108 Err(ConfigGetError::Type { .. })
1109 );
1110 }
1111
1112 #[test]
1113 fn test_resolve_invalid_scoped_tables() {
1114 let new_config = |text: &str| {
1115 let mut config = StackedConfig::empty();
1116 config.add_layer(new_user_layer(text));
1117 config
1118 };
1119 let context = ConfigResolutionContext {
1120 home_dir: Some(Path::new("/home/dir")),
1121 repo_path: Some(Path::new("/foo/.jj/repo")),
1122 workspace_path: None,
1123 command: None,
1124 hostname: "",
1125 };
1126 assert_matches!(
1127 resolve(&new_config("[--scope]"), &context),
1128 Err(ConfigGetError::Type { .. })
1129 );
1130 }
1131
1132 #[test]
1133 fn test_migrate_noop() {
1134 let mut config = StackedConfig::empty();
1135 config.add_layer(new_user_layer(indoc! {"
1136 foo = 'foo'
1137 "}));
1138 config.add_layer(new_user_layer(indoc! {"
1139 bar = 'bar'
1140 "}));
1141
1142 let old_layers = config.layers().to_vec();
1143 let rules = [ConfigMigrationRule::rename_value("baz", "foo")];
1144 let descriptions = migrate(&mut config, &rules).unwrap();
1145 assert!(descriptions.is_empty());
1146 assert!(Arc::ptr_eq(&config.layers()[0], &old_layers[0]));
1147 assert!(Arc::ptr_eq(&config.layers()[1], &old_layers[1]));
1148 }
1149
1150 #[test]
1151 fn test_migrate_error() {
1152 let mut config = StackedConfig::empty();
1153 let mut layer = new_user_layer(indoc! {"
1154 foo.bar = 'baz'
1155 "});
1156 layer.path = Some("source.toml".into());
1157 config.add_layer(layer);
1158
1159 let rules = [ConfigMigrationRule::rename_value("foo", "bar")];
1160 insta::assert_debug_snapshot!(migrate(&mut config, &rules).unwrap_err(), @r#"
1161 ConfigMigrateError {
1162 error: Update(
1163 WouldDeleteTable {
1164 name: "foo",
1165 },
1166 ),
1167 source_path: Some(
1168 "source.toml",
1169 ),
1170 }
1171 "#);
1172 }
1173
1174 #[test]
1175 fn test_migrate_rename_value() {
1176 let mut config = StackedConfig::empty();
1177 config.add_layer(new_user_layer(indoc! {"
1178 [foo]
1179 old = 'foo.old #0'
1180 [bar]
1181 old = 'bar.old #0'
1182 [baz]
1183 new = 'baz.new #0'
1184 "}));
1185 config.add_layer(new_user_layer(indoc! {"
1186 [bar]
1187 old = 'bar.old #1'
1188 "}));
1189
1190 let rules = [
1191 ConfigMigrationRule::rename_value("foo.old", "foo.new"),
1192 ConfigMigrationRule::rename_value("bar.old", "baz.new"),
1193 ];
1194 let descriptions = migrate(&mut config, &rules).unwrap();
1195 insta::assert_debug_snapshot!(descriptions, @r#"
1196 [
1197 (
1198 User,
1199 "foo.old is renamed to foo.new",
1200 ),
1201 (
1202 User,
1203 "bar.old is deleted (superseded by baz.new)",
1204 ),
1205 (
1206 User,
1207 "bar.old is renamed to baz.new",
1208 ),
1209 ]
1210 "#);
1211 insta::assert_snapshot!(config.layers()[0].data, @r"
1212 [foo]
1213 new = 'foo.old #0'
1214 [bar]
1215 [baz]
1216 new = 'baz.new #0'
1217 ");
1218 insta::assert_snapshot!(config.layers()[1].data, @r"
1219 [bar]
1220
1221 [baz]
1222 new = 'bar.old #1'
1223 ");
1224 }
1225
1226 #[test]
1227 fn test_migrate_rename_update_value() {
1228 let mut config = StackedConfig::empty();
1229 config.add_layer(new_user_layer(indoc! {"
1230 [foo]
1231 old = 'foo.old #0'
1232 [bar]
1233 old = 'bar.old #0'
1234 [baz]
1235 new = 'baz.new #0'
1236 "}));
1237 config.add_layer(new_user_layer(indoc! {"
1238 [bar]
1239 old = 'bar.old #1'
1240 "}));
1241
1242 let rules = [
1243 ConfigMigrationRule::rename_update_value("foo.old", "foo.new", |old_value| {
1245 let val = old_value.clone().decorated("", "");
1246 Ok(ConfigValue::from_iter([val]))
1247 }),
1248 ConfigMigrationRule::rename_update_value("bar.old", "baz.new", |old_value| {
1250 let s = old_value.as_str().ok_or("not a string")?;
1251 Ok(format!("{s} updated").into())
1252 }),
1253 ];
1254 let descriptions = migrate(&mut config, &rules).unwrap();
1255 insta::assert_debug_snapshot!(descriptions, @r#"
1256 [
1257 (
1258 User,
1259 "foo.old is updated to foo.new = ['foo.old #0']",
1260 ),
1261 (
1262 User,
1263 "bar.old is deleted (superseded by baz.new)",
1264 ),
1265 (
1266 User,
1267 "bar.old is updated to baz.new = \"bar.old #1 updated\"",
1268 ),
1269 ]
1270 "#);
1271 insta::assert_snapshot!(config.layers()[0].data, @r"
1272 [foo]
1273 new = ['foo.old #0']
1274 [bar]
1275 [baz]
1276 new = 'baz.new #0'
1277 ");
1278 insta::assert_snapshot!(config.layers()[1].data, @r#"
1279 [bar]
1280
1281 [baz]
1282 new = "bar.old #1 updated"
1283 "#);
1284
1285 config.add_layer(new_user_layer(indoc! {"
1286 [bar]
1287 old = false # not a string
1288 "}));
1289 insta::assert_debug_snapshot!(migrate(&mut config, &rules).unwrap_err(), @r#"
1290 ConfigMigrateError {
1291 error: Type {
1292 name: "bar.old",
1293 error: "not a string",
1294 },
1295 source_path: None,
1296 }
1297 "#);
1298 }
1299}