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