Skip to main content

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