jj_lib/
config_resolver.rs

1// Copyright 2024 The Jujutsu Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Post-processing functions for [`StackedConfig`].
16
17use std::path::Path;
18use std::path::PathBuf;
19use std::sync::Arc;
20
21use itertools::Itertools as _;
22use serde::de::IntoDeserializer as _;
23use serde::Deserialize 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
36// Prefixed by "--" so these keys look unusual. It's also nice that "-" is
37// placed earlier than the other keys in lexicographical order.
38const SCOPE_CONDITION_KEY: &str = "--when";
39const SCOPE_TABLE_KEY: &str = "--scope";
40
41/// Parameters to enable scoped config tables conditionally.
42#[derive(Clone, Debug)]
43pub struct ConfigResolutionContext<'a> {
44    /// Home directory. `~` will be substituted with this path.
45    pub home_dir: Option<&'a Path>,
46    /// Repository path, which is usually `<workspace_root>/.jj/repo`.
47    pub repo_path: Option<&'a Path>,
48    /// Space-separated subcommand. `jj file show ...` should result in `"file
49    /// show"`.
50    pub command: Option<&'a str>,
51}
52
53/// Conditions to enable the parent table.
54///
55/// - Each predicate is tested separately, and the results are intersected.
56/// - `None` means there are no constraints. (i.e. always `true`)
57// TODO: introduce fileset-like DSL?
58// TODO: add support for fileset-like pattern prefixes? it might be a bit tricky
59// if path canonicalization is involved.
60#[derive(Clone, Debug, Default, serde::Deserialize)]
61#[serde(default, rename_all = "kebab-case")]
62struct ScopeCondition {
63    /// Paths to match the repository path prefix.
64    pub repositories: Option<Vec<PathBuf>>,
65    /// Commands to match. Subcommands are matched space-separated.
66    /// - `--when.commands = ["foo"]` -> matches "foo", "foo bar", "foo bar baz"
67    /// - `--when.commands = ["foo bar"]` -> matches "foo bar", "foo bar baz",
68    ///   NOT "foo"
69    pub commands: Option<Vec<String>>,
70    // TODO: maybe add "workspaces"?
71}
72
73impl ScopeCondition {
74    fn from_value(
75        value: ConfigValue,
76        context: &ConfigResolutionContext,
77    ) -> Result<Self, toml_edit::de::Error> {
78        Self::deserialize(value.into_deserializer())?
79            .expand_paths(context)
80            .map_err(serde::de::Error::custom)
81    }
82
83    fn expand_paths(mut self, context: &ConfigResolutionContext) -> Result<Self, &'static str> {
84        // It might make some sense to compare paths in canonicalized form, but
85        // be careful to not resolve relative path patterns against cwd, which
86        // wouldn't be what the user would expect.
87        for path in self.repositories.as_mut().into_iter().flatten() {
88            if let Some(new_path) = expand_home(path, context.home_dir)? {
89                *path = new_path;
90            }
91        }
92        Ok(self)
93    }
94
95    fn matches(&self, context: &ConfigResolutionContext) -> bool {
96        matches_path_prefix(self.repositories.as_deref(), context.repo_path)
97            && matches_command(self.commands.as_deref(), context.command)
98    }
99}
100
101fn expand_home(path: &Path, home_dir: Option<&Path>) -> Result<Option<PathBuf>, &'static str> {
102    match path.strip_prefix("~") {
103        Ok(tail) => {
104            let home_dir = home_dir.ok_or("Cannot expand ~ (home directory is unknown)")?;
105            Ok(Some(home_dir.join(tail)))
106        }
107        Err(_) => Ok(None),
108    }
109}
110
111fn matches_path_prefix(candidates: Option<&[PathBuf]>, actual: Option<&Path>) -> bool {
112    match (candidates, actual) {
113        (Some(candidates), Some(actual)) => candidates.iter().any(|base| actual.starts_with(base)),
114        (Some(_), None) => false, // actual path not known (e.g. not in workspace)
115        (None, _) => true,        // no constraints
116    }
117}
118
119fn matches_command(candidates: Option<&[String]>, actual: Option<&str>) -> bool {
120    match (candidates, actual) {
121        (Some(candidates), Some(actual)) => candidates.iter().any(|candidate| {
122            actual
123                .strip_prefix(candidate)
124                .is_some_and(|trailing| trailing.starts_with(' ') || trailing.is_empty())
125        }),
126        (Some(_), None) => false,
127        (None, _) => true,
128    }
129}
130
131/// Evaluates condition for each layer and scope, flattens scoped tables.
132/// Returns new config that only contains enabled layers and tables.
133pub fn resolve(
134    source_config: &StackedConfig,
135    context: &ConfigResolutionContext,
136) -> Result<StackedConfig, ConfigGetError> {
137    let mut source_layers_stack: Vec<Arc<ConfigLayer>> =
138        source_config.layers().iter().rev().cloned().collect();
139    let mut resolved_layers: Vec<Arc<ConfigLayer>> = Vec::new();
140    while let Some(mut source_layer) = source_layers_stack.pop() {
141        if !source_layer.data.contains_key(SCOPE_CONDITION_KEY)
142            && !source_layer.data.contains_key(SCOPE_TABLE_KEY)
143        {
144            resolved_layers.push(source_layer); // reuse original table
145            continue;
146        }
147
148        let layer_mut = Arc::make_mut(&mut source_layer);
149        let condition = pop_scope_condition(layer_mut, context)?;
150        if !condition.matches(context) {
151            continue;
152        }
153        let tables = pop_scope_tables(layer_mut)?;
154        // tables.iter() does not implement DoubleEndedIterator as of toml_edit
155        // 0.22.22.
156        let frame = source_layers_stack.len();
157        for table in tables {
158            let layer = ConfigLayer {
159                source: source_layer.source,
160                path: source_layer.path.clone(),
161                data: DocumentMut::from(table),
162            };
163            source_layers_stack.push(Arc::new(layer));
164        }
165        source_layers_stack[frame..].reverse();
166        resolved_layers.push(source_layer);
167    }
168    let mut resolved_config = StackedConfig::empty();
169    resolved_config.extend_layers(resolved_layers);
170    Ok(resolved_config)
171}
172
173fn pop_scope_condition(
174    layer: &mut ConfigLayer,
175    context: &ConfigResolutionContext,
176) -> Result<ScopeCondition, ConfigGetError> {
177    let Some(item) = layer.data.remove(SCOPE_CONDITION_KEY) else {
178        return Ok(ScopeCondition::default());
179    };
180    let value = item
181        .clone()
182        .into_value()
183        .expect("Item::None should not exist in table");
184    ScopeCondition::from_value(value, context).map_err(|err| ConfigGetError::Type {
185        name: SCOPE_CONDITION_KEY.to_owned(),
186        error: err.into(),
187        source_path: layer.path.clone(),
188    })
189}
190
191fn pop_scope_tables(layer: &mut ConfigLayer) -> Result<toml_edit::ArrayOfTables, ConfigGetError> {
192    let Some(item) = layer.data.remove(SCOPE_TABLE_KEY) else {
193        return Ok(toml_edit::ArrayOfTables::new());
194    };
195    item.into_array_of_tables()
196        .map_err(|item| ConfigGetError::Type {
197            name: SCOPE_TABLE_KEY.to_owned(),
198            error: format!("Expected an array of tables, but is {}", item.type_name()).into(),
199            source_path: layer.path.clone(),
200        })
201}
202
203/// Error that can occur when migrating config variables.
204#[derive(Debug, Error)]
205#[error("Migration failed")]
206pub struct ConfigMigrateError {
207    /// Source error.
208    #[source]
209    pub error: ConfigMigrateLayerError,
210    /// Source file path where the value is defined.
211    pub source_path: Option<PathBuf>,
212}
213
214/// Inner error of [`ConfigMigrateError`].
215#[derive(Debug, Error)]
216pub enum ConfigMigrateLayerError {
217    /// Cannot delete old value or set new value.
218    #[error(transparent)]
219    Update(#[from] ConfigUpdateError),
220    /// Old config value cannot be converted.
221    #[error("Invalid type or value for {name}")]
222    Type {
223        /// Dotted config name path.
224        name: String,
225        /// Source error.
226        #[source]
227        error: DynError,
228    },
229}
230
231impl ConfigMigrateLayerError {
232    fn with_source_path(self, source_path: Option<&Path>) -> ConfigMigrateError {
233        ConfigMigrateError {
234            error: self,
235            source_path: source_path.map(|path| path.to_owned()),
236        }
237    }
238}
239
240type DynError = Box<dyn std::error::Error + Send + Sync>;
241
242/// Rule to migrate deprecated config variables.
243pub struct ConfigMigrationRule {
244    inner: MigrationRule,
245}
246
247enum MigrationRule {
248    RenameValue {
249        old_name: ConfigNamePathBuf,
250        new_name: ConfigNamePathBuf,
251    },
252    RenameUpdateValue {
253        old_name: ConfigNamePathBuf,
254        new_name: ConfigNamePathBuf,
255        #[expect(clippy::type_complexity)] // type alias wouldn't help readability
256        new_value_fn: Box<dyn Fn(&ConfigValue) -> Result<ConfigValue, DynError>>,
257    },
258    Custom {
259        matches_fn: Box<dyn Fn(&ConfigLayer) -> bool>,
260        #[expect(clippy::type_complexity)] // type alias wouldn't help readability
261        apply_fn: Box<dyn Fn(&mut ConfigLayer) -> Result<String, ConfigMigrateLayerError>>,
262    },
263}
264
265impl ConfigMigrationRule {
266    /// Creates rule that moves value from `old_name` to `new_name`.
267    pub fn rename_value(old_name: impl ToConfigNamePath, new_name: impl ToConfigNamePath) -> Self {
268        let inner = MigrationRule::RenameValue {
269            old_name: old_name.into_name_path().into(),
270            new_name: new_name.into_name_path().into(),
271        };
272        ConfigMigrationRule { inner }
273    }
274
275    /// Creates rule that moves value from `old_name` to `new_name`, and updates
276    /// the value.
277    ///
278    /// If `new_value_fn(&old_value)` returned an error, the whole migration
279    /// process would fail.
280    pub fn rename_update_value(
281        old_name: impl ToConfigNamePath,
282        new_name: impl ToConfigNamePath,
283        new_value_fn: impl Fn(&ConfigValue) -> Result<ConfigValue, DynError> + 'static,
284    ) -> Self {
285        let inner = MigrationRule::RenameUpdateValue {
286            old_name: old_name.into_name_path().into(),
287            new_name: new_name.into_name_path().into(),
288            new_value_fn: Box::new(new_value_fn),
289        };
290        ConfigMigrationRule { inner }
291    }
292
293    // TODO: update value, etc.
294
295    /// Creates rule that updates config layer by `apply_fn`. `match_fn` should
296    /// return true if the layer contains items to be updated.
297    pub fn custom(
298        matches_fn: impl Fn(&ConfigLayer) -> bool + 'static,
299        apply_fn: impl Fn(&mut ConfigLayer) -> Result<String, ConfigMigrateLayerError> + 'static,
300    ) -> Self {
301        let inner = MigrationRule::Custom {
302            matches_fn: Box::new(matches_fn),
303            apply_fn: Box::new(apply_fn),
304        };
305        ConfigMigrationRule { inner }
306    }
307
308    /// Returns true if `layer` contains an item to be migrated.
309    fn matches(&self, layer: &ConfigLayer) -> bool {
310        match &self.inner {
311            MigrationRule::RenameValue { old_name, .. }
312            | MigrationRule::RenameUpdateValue { old_name, .. } => {
313                matches!(layer.look_up_item(old_name), Ok(Some(_)))
314            }
315            MigrationRule::Custom { matches_fn, .. } => matches_fn(layer),
316        }
317    }
318
319    /// Migrates `layer` item. Returns a description of the applied migration.
320    fn apply(&self, layer: &mut ConfigLayer) -> Result<String, ConfigMigrateLayerError> {
321        match &self.inner {
322            MigrationRule::RenameValue { old_name, new_name } => {
323                rename_value(layer, old_name, new_name)
324            }
325            MigrationRule::RenameUpdateValue {
326                old_name,
327                new_name,
328                new_value_fn,
329            } => rename_update_value(layer, old_name, new_name, new_value_fn),
330            MigrationRule::Custom { apply_fn, .. } => apply_fn(layer),
331        }
332    }
333}
334
335fn rename_value(
336    layer: &mut ConfigLayer,
337    old_name: &ConfigNamePathBuf,
338    new_name: &ConfigNamePathBuf,
339) -> Result<String, ConfigMigrateLayerError> {
340    let value = layer.delete_value(old_name)?.expect("tested by matches()");
341    if matches!(layer.look_up_item(new_name), Ok(Some(_))) {
342        return Ok(format!("{old_name} is deleted (superseded by {new_name})"));
343    }
344    layer.set_value(new_name, value)?;
345    Ok(format!("{old_name} is renamed to {new_name}"))
346}
347
348fn rename_update_value(
349    layer: &mut ConfigLayer,
350    old_name: &ConfigNamePathBuf,
351    new_name: &ConfigNamePathBuf,
352    new_value_fn: impl FnOnce(&ConfigValue) -> Result<ConfigValue, DynError>,
353) -> Result<String, ConfigMigrateLayerError> {
354    let old_value = layer.delete_value(old_name)?.expect("tested by matches()");
355    if matches!(layer.look_up_item(new_name), Ok(Some(_))) {
356        return Ok(format!("{old_name} is deleted (superseded by {new_name})"));
357    }
358    let new_value = new_value_fn(&old_value).map_err(|error| ConfigMigrateLayerError::Type {
359        name: old_name.to_string(),
360        error,
361    })?;
362    layer.set_value(new_name, new_value.clone())?;
363    Ok(format!("{old_name} is updated to {new_name} = {new_value}"))
364}
365
366/// Applies migration `rules` to `config`. Returns descriptions of the applied
367/// migrations.
368pub fn migrate(
369    config: &mut StackedConfig,
370    rules: &[ConfigMigrationRule],
371) -> Result<Vec<(ConfigSource, String)>, ConfigMigrateError> {
372    let mut descriptions = Vec::new();
373    for layer in config.layers_mut() {
374        migrate_layer(layer, rules, &mut descriptions)
375            .map_err(|err| err.with_source_path(layer.path.as_deref()))?;
376    }
377    Ok(descriptions)
378}
379
380fn migrate_layer(
381    layer: &mut Arc<ConfigLayer>,
382    rules: &[ConfigMigrationRule],
383    descriptions: &mut Vec<(ConfigSource, String)>,
384) -> Result<(), ConfigMigrateLayerError> {
385    let rules_to_apply = rules
386        .iter()
387        .filter(|rule| rule.matches(layer))
388        .collect_vec();
389    if rules_to_apply.is_empty() {
390        return Ok(());
391    }
392    let layer_mut = Arc::make_mut(layer);
393    for rule in rules_to_apply {
394        let desc = rule.apply(layer_mut)?;
395        descriptions.push((layer_mut.source, desc));
396    }
397    Ok(())
398}
399
400#[cfg(test)]
401mod tests {
402    use assert_matches::assert_matches;
403    use indoc::indoc;
404
405    use super::*;
406    use crate::config::ConfigSource;
407
408    #[test]
409    fn test_expand_home() {
410        let home_dir = Some(Path::new("/home/dir"));
411        assert_eq!(
412            expand_home("~".as_ref(), home_dir).unwrap(),
413            Some(PathBuf::from("/home/dir"))
414        );
415        assert_eq!(expand_home("~foo".as_ref(), home_dir).unwrap(), None);
416        assert_eq!(expand_home("/foo/~".as_ref(), home_dir).unwrap(), None);
417        assert_eq!(
418            expand_home("~/foo".as_ref(), home_dir).unwrap(),
419            Some(PathBuf::from("/home/dir/foo"))
420        );
421        assert!(expand_home("~/foo".as_ref(), None).is_err());
422    }
423
424    #[test]
425    fn test_condition_default() {
426        let condition = ScopeCondition::default();
427
428        let context = ConfigResolutionContext {
429            home_dir: None,
430            repo_path: None,
431            command: None,
432        };
433        assert!(condition.matches(&context));
434        let context = ConfigResolutionContext {
435            home_dir: None,
436            repo_path: Some(Path::new("/foo")),
437            command: None,
438        };
439        assert!(condition.matches(&context));
440    }
441
442    #[test]
443    fn test_condition_repo_path() {
444        let condition = ScopeCondition {
445            repositories: Some(["/foo", "/bar"].map(PathBuf::from).into()),
446            commands: None,
447        };
448
449        let context = ConfigResolutionContext {
450            home_dir: None,
451            repo_path: None,
452            command: None,
453        };
454        assert!(!condition.matches(&context));
455        let context = ConfigResolutionContext {
456            home_dir: None,
457            repo_path: Some(Path::new("/foo")),
458            command: None,
459        };
460        assert!(condition.matches(&context));
461        let context = ConfigResolutionContext {
462            home_dir: None,
463            repo_path: Some(Path::new("/fooo")),
464            command: None,
465        };
466        assert!(!condition.matches(&context));
467        let context = ConfigResolutionContext {
468            home_dir: None,
469            repo_path: Some(Path::new("/foo/baz")),
470            command: None,
471        };
472        assert!(condition.matches(&context));
473        let context = ConfigResolutionContext {
474            home_dir: None,
475            repo_path: Some(Path::new("/bar")),
476            command: None,
477        };
478        assert!(condition.matches(&context));
479    }
480
481    #[test]
482    fn test_condition_repo_path_windows() {
483        let condition = ScopeCondition {
484            repositories: Some(["c:/foo", r"d:\bar/baz"].map(PathBuf::from).into()),
485            commands: None,
486        };
487
488        let context = ConfigResolutionContext {
489            home_dir: None,
490            repo_path: Some(Path::new(r"c:\foo")),
491            command: None,
492        };
493        assert_eq!(condition.matches(&context), cfg!(windows));
494        let context = ConfigResolutionContext {
495            home_dir: None,
496            repo_path: Some(Path::new(r"c:\foo\baz")),
497            command: None,
498        };
499        assert_eq!(condition.matches(&context), cfg!(windows));
500        let context = ConfigResolutionContext {
501            home_dir: None,
502            repo_path: Some(Path::new(r"d:\foo")),
503            command: None,
504        };
505        assert!(!condition.matches(&context));
506        let context = ConfigResolutionContext {
507            home_dir: None,
508            repo_path: Some(Path::new(r"d:/bar\baz")),
509            command: None,
510        };
511        assert_eq!(condition.matches(&context), cfg!(windows));
512    }
513
514    fn new_user_layer(text: &str) -> ConfigLayer {
515        ConfigLayer::parse(ConfigSource::User, text).unwrap()
516    }
517
518    #[test]
519    fn test_resolve_transparent() {
520        let mut source_config = StackedConfig::empty();
521        source_config.add_layer(ConfigLayer::empty(ConfigSource::Default));
522        source_config.add_layer(ConfigLayer::empty(ConfigSource::User));
523
524        let context = ConfigResolutionContext {
525            home_dir: None,
526            repo_path: None,
527            command: None,
528        };
529        let resolved_config = resolve(&source_config, &context).unwrap();
530        assert_eq!(resolved_config.layers().len(), 2);
531        assert!(Arc::ptr_eq(
532            &source_config.layers()[0],
533            &resolved_config.layers()[0]
534        ));
535        assert!(Arc::ptr_eq(
536            &source_config.layers()[1],
537            &resolved_config.layers()[1]
538        ));
539    }
540
541    #[test]
542    fn test_resolve_table_order() {
543        let mut source_config = StackedConfig::empty();
544        source_config.add_layer(new_user_layer(indoc! {"
545            a = 'a #0'
546            [[--scope]]
547            a = 'a #0.0'
548            [[--scope]]
549            a = 'a #0.1'
550            [[--scope.--scope]]
551            a = 'a #0.1.0'
552            [[--scope]]
553            a = 'a #0.2'
554        "}));
555        source_config.add_layer(new_user_layer(indoc! {"
556            a = 'a #1'
557            [[--scope]]
558            a = 'a #1.0'
559        "}));
560
561        let context = ConfigResolutionContext {
562            home_dir: None,
563            repo_path: None,
564            command: None,
565        };
566        let resolved_config = resolve(&source_config, &context).unwrap();
567        assert_eq!(resolved_config.layers().len(), 7);
568        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
569        insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.0'");
570        insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.1'");
571        insta::assert_snapshot!(resolved_config.layers()[3].data, @"a = 'a #0.1.0'");
572        insta::assert_snapshot!(resolved_config.layers()[4].data, @"a = 'a #0.2'");
573        insta::assert_snapshot!(resolved_config.layers()[5].data, @"a = 'a #1'");
574        insta::assert_snapshot!(resolved_config.layers()[6].data, @"a = 'a #1.0'");
575    }
576
577    #[test]
578    fn test_resolve_repo_path() {
579        let mut source_config = StackedConfig::empty();
580        source_config.add_layer(new_user_layer(indoc! {"
581            a = 'a #0'
582            [[--scope]]
583            --when.repositories = ['/foo']
584            a = 'a #0.1 foo'
585            [[--scope]]
586            --when.repositories = ['/foo', '/bar']
587            a = 'a #0.2 foo|bar'
588            [[--scope]]
589            --when.repositories = []
590            a = 'a #0.3 none'
591        "}));
592        source_config.add_layer(new_user_layer(indoc! {"
593            --when.repositories = ['~/baz']
594            a = 'a #1 baz'
595            [[--scope]]
596            --when.repositories = ['/foo']  # should never be enabled
597            a = 'a #1.1 baz&foo'
598        "}));
599
600        let context = ConfigResolutionContext {
601            home_dir: Some(Path::new("/home/dir")),
602            repo_path: None,
603            command: None,
604        };
605        let resolved_config = resolve(&source_config, &context).unwrap();
606        assert_eq!(resolved_config.layers().len(), 1);
607        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
608
609        let context = ConfigResolutionContext {
610            home_dir: Some(Path::new("/home/dir")),
611            repo_path: Some(Path::new("/foo/.jj/repo")),
612            command: None,
613        };
614        let resolved_config = resolve(&source_config, &context).unwrap();
615        assert_eq!(resolved_config.layers().len(), 3);
616        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
617        insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 foo'");
618        insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 foo|bar'");
619
620        let context = ConfigResolutionContext {
621            home_dir: Some(Path::new("/home/dir")),
622            repo_path: Some(Path::new("/bar/.jj/repo")),
623            command: None,
624        };
625        let resolved_config = resolve(&source_config, &context).unwrap();
626        assert_eq!(resolved_config.layers().len(), 2);
627        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
628        insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.2 foo|bar'");
629
630        let context = ConfigResolutionContext {
631            home_dir: Some(Path::new("/home/dir")),
632            repo_path: Some(Path::new("/home/dir/baz/.jj/repo")),
633            command: None,
634        };
635        let resolved_config = resolve(&source_config, &context).unwrap();
636        assert_eq!(resolved_config.layers().len(), 2);
637        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
638        insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #1 baz'");
639    }
640
641    #[test]
642    fn test_resolve_command() {
643        let mut source_config = StackedConfig::empty();
644        source_config.add_layer(new_user_layer(indoc! {"
645            a = 'a #0'
646            [[--scope]]
647            --when.commands = ['foo']
648            a = 'a #0.1 foo'
649            [[--scope]]
650            --when.commands = ['foo', 'bar']
651            a = 'a #0.2 foo|bar'
652            [[--scope]]
653            --when.commands = ['foo baz']
654            a = 'a #0.3 foo baz'
655            [[--scope]]
656            --when.commands = []
657            a = 'a #0.4 none'
658        "}));
659
660        let context = ConfigResolutionContext {
661            home_dir: None,
662            repo_path: None,
663            command: None,
664        };
665        let resolved_config = resolve(&source_config, &context).unwrap();
666        assert_eq!(resolved_config.layers().len(), 1);
667        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
668
669        let context = ConfigResolutionContext {
670            home_dir: None,
671            repo_path: None,
672            command: Some("foo"),
673        };
674        let resolved_config = resolve(&source_config, &context).unwrap();
675        assert_eq!(resolved_config.layers().len(), 3);
676        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
677        insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 foo'");
678        insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 foo|bar'");
679
680        let context = ConfigResolutionContext {
681            home_dir: None,
682            repo_path: None,
683            command: Some("bar"),
684        };
685        let resolved_config = resolve(&source_config, &context).unwrap();
686        assert_eq!(resolved_config.layers().len(), 2);
687        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
688        insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.2 foo|bar'");
689
690        let context = ConfigResolutionContext {
691            home_dir: None,
692            repo_path: None,
693            command: Some("foo baz"),
694        };
695        let resolved_config = resolve(&source_config, &context).unwrap();
696        assert_eq!(resolved_config.layers().len(), 4);
697        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
698        insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 foo'");
699        insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 foo|bar'");
700        insta::assert_snapshot!(resolved_config.layers()[3].data, @"a = 'a #0.3 foo baz'");
701
702        // "fooqux" shares "foo" prefix, but should *not* match
703        let context = ConfigResolutionContext {
704            home_dir: None,
705            repo_path: None,
706            command: Some("fooqux"),
707        };
708        let resolved_config = resolve(&source_config, &context).unwrap();
709        assert_eq!(resolved_config.layers().len(), 1);
710        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
711    }
712
713    #[test]
714    fn test_resolve_repo_path_and_command() {
715        let mut source_config = StackedConfig::empty();
716        source_config.add_layer(new_user_layer(indoc! {"
717            a = 'a #0'
718            [[--scope]]
719            --when.repositories = ['/foo', '/bar']
720            --when.commands = ['ABC', 'DEF']
721            a = 'a #0.1'
722        "}));
723
724        let context = ConfigResolutionContext {
725            home_dir: Some(Path::new("/home/dir")),
726            repo_path: None,
727            command: None,
728        };
729        let resolved_config = resolve(&source_config, &context).unwrap();
730        assert_eq!(resolved_config.layers().len(), 1);
731        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
732
733        // only repo matches
734        let context = ConfigResolutionContext {
735            home_dir: Some(Path::new("/home/dir")),
736            repo_path: Some(Path::new("/foo")),
737            command: Some("other"),
738        };
739        let resolved_config = resolve(&source_config, &context).unwrap();
740        assert_eq!(resolved_config.layers().len(), 1);
741        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
742
743        // only command matches
744        let context = ConfigResolutionContext {
745            home_dir: Some(Path::new("/home/dir")),
746            repo_path: Some(Path::new("/qux")),
747            command: Some("ABC"),
748        };
749        let resolved_config = resolve(&source_config, &context).unwrap();
750        assert_eq!(resolved_config.layers().len(), 1);
751        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
752
753        // both match
754        let context = ConfigResolutionContext {
755            home_dir: Some(Path::new("/home/dir")),
756            repo_path: Some(Path::new("/bar")),
757            command: Some("DEF"),
758        };
759        let resolved_config = resolve(&source_config, &context).unwrap();
760        assert_eq!(resolved_config.layers().len(), 2);
761        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
762        insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1'");
763    }
764
765    #[test]
766    fn test_resolve_invalid_condition() {
767        let new_config = |text: &str| {
768            let mut config = StackedConfig::empty();
769            config.add_layer(new_user_layer(text));
770            config
771        };
772        let context = ConfigResolutionContext {
773            home_dir: Some(Path::new("/home/dir")),
774            repo_path: Some(Path::new("/foo/.jj/repo")),
775            command: None,
776        };
777        assert_matches!(
778            resolve(&new_config("--when.repositories = 0"), &context),
779            Err(ConfigGetError::Type { .. })
780        );
781    }
782
783    #[test]
784    fn test_resolve_invalid_scoped_tables() {
785        let new_config = |text: &str| {
786            let mut config = StackedConfig::empty();
787            config.add_layer(new_user_layer(text));
788            config
789        };
790        let context = ConfigResolutionContext {
791            home_dir: Some(Path::new("/home/dir")),
792            repo_path: Some(Path::new("/foo/.jj/repo")),
793            command: None,
794        };
795        assert_matches!(
796            resolve(&new_config("[--scope]"), &context),
797            Err(ConfigGetError::Type { .. })
798        );
799    }
800
801    #[test]
802    fn test_migrate_noop() {
803        let mut config = StackedConfig::empty();
804        config.add_layer(new_user_layer(indoc! {"
805            foo = 'foo'
806        "}));
807        config.add_layer(new_user_layer(indoc! {"
808            bar = 'bar'
809        "}));
810
811        let old_layers = config.layers().to_vec();
812        let rules = [ConfigMigrationRule::rename_value("baz", "foo")];
813        let descriptions = migrate(&mut config, &rules).unwrap();
814        assert!(descriptions.is_empty());
815        assert!(Arc::ptr_eq(&config.layers()[0], &old_layers[0]));
816        assert!(Arc::ptr_eq(&config.layers()[1], &old_layers[1]));
817    }
818
819    #[test]
820    fn test_migrate_error() {
821        let mut config = StackedConfig::empty();
822        let mut layer = new_user_layer(indoc! {"
823            foo.bar = 'baz'
824        "});
825        layer.path = Some("source.toml".into());
826        config.add_layer(layer);
827
828        let rules = [ConfigMigrationRule::rename_value("foo", "bar")];
829        insta::assert_debug_snapshot!(migrate(&mut config, &rules).unwrap_err(), @r#"
830        ConfigMigrateError {
831            error: Update(
832                WouldDeleteTable {
833                    name: "foo",
834                },
835            ),
836            source_path: Some(
837                "source.toml",
838            ),
839        }
840        "#);
841    }
842
843    #[test]
844    fn test_migrate_rename_value() {
845        let mut config = StackedConfig::empty();
846        config.add_layer(new_user_layer(indoc! {"
847            [foo]
848            old = 'foo.old #0'
849            [bar]
850            old = 'bar.old #0'
851            [baz]
852            new = 'baz.new #0'
853        "}));
854        config.add_layer(new_user_layer(indoc! {"
855            [bar]
856            old = 'bar.old #1'
857        "}));
858
859        let rules = [
860            ConfigMigrationRule::rename_value("foo.old", "foo.new"),
861            ConfigMigrationRule::rename_value("bar.old", "baz.new"),
862        ];
863        let descriptions = migrate(&mut config, &rules).unwrap();
864        insta::assert_debug_snapshot!(descriptions, @r#"
865        [
866            (
867                User,
868                "foo.old is renamed to foo.new",
869            ),
870            (
871                User,
872                "bar.old is deleted (superseded by baz.new)",
873            ),
874            (
875                User,
876                "bar.old is renamed to baz.new",
877            ),
878        ]
879        "#);
880        insta::assert_snapshot!(config.layers()[0].data, @r"
881        [foo]
882        new = 'foo.old #0'
883        [bar]
884        [baz]
885        new = 'baz.new #0'
886        ");
887        insta::assert_snapshot!(config.layers()[1].data, @r"
888        [bar]
889
890        [baz]
891        new = 'bar.old #1'
892        ");
893    }
894
895    #[test]
896    fn test_migrate_rename_update_value() {
897        let mut config = StackedConfig::empty();
898        config.add_layer(new_user_layer(indoc! {"
899            [foo]
900            old = 'foo.old #0'
901            [bar]
902            old = 'bar.old #0'
903            [baz]
904            new = 'baz.new #0'
905        "}));
906        config.add_layer(new_user_layer(indoc! {"
907            [bar]
908            old = 'bar.old #1'
909        "}));
910
911        let rules = [
912            // to array
913            ConfigMigrationRule::rename_update_value("foo.old", "foo.new", |old_value| {
914                let val = old_value.clone().decorated("", "");
915                Ok(ConfigValue::from_iter([val]))
916            }),
917            // update string or error
918            ConfigMigrationRule::rename_update_value("bar.old", "baz.new", |old_value| {
919                let s = old_value.as_str().ok_or("not a string")?;
920                Ok(format!("{s} updated").into())
921            }),
922        ];
923        let descriptions = migrate(&mut config, &rules).unwrap();
924        insta::assert_debug_snapshot!(descriptions, @r#"
925        [
926            (
927                User,
928                "foo.old is updated to foo.new = ['foo.old #0']",
929            ),
930            (
931                User,
932                "bar.old is deleted (superseded by baz.new)",
933            ),
934            (
935                User,
936                "bar.old is updated to baz.new = \"bar.old #1 updated\"",
937            ),
938        ]
939        "#);
940        insta::assert_snapshot!(config.layers()[0].data, @r"
941        [foo]
942        new = ['foo.old #0']
943        [bar]
944        [baz]
945        new = 'baz.new #0'
946        ");
947        insta::assert_snapshot!(config.layers()[1].data, @r#"
948        [bar]
949
950        [baz]
951        new = "bar.old #1 updated"
952        "#);
953
954        config.add_layer(new_user_layer(indoc! {"
955            [bar]
956            old = false  # not a string
957        "}));
958        insta::assert_debug_snapshot!(migrate(&mut config, &rules).unwrap_err(), @r#"
959        ConfigMigrateError {
960            error: Type {
961                name: "bar.old",
962                error: "not a string",
963            },
964            source_path: None,
965        }
966        "#);
967    }
968}