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