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