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