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 assert_matches::assert_matches;
460    use indoc::indoc;
461
462    use super::*;
463    use crate::tests::TestResult;
464
465    #[test]
466    fn test_expand_home() {
467        let home_dir = Some(Path::new("/home/dir"));
468        assert_eq!(
469            expand_home("~".as_ref(), home_dir).unwrap(),
470            Some(PathBuf::from("/home/dir"))
471        );
472        assert_eq!(expand_home("~foo".as_ref(), home_dir).unwrap(), None);
473        assert_eq!(expand_home("/foo/~".as_ref(), home_dir).unwrap(), None);
474        assert_eq!(
475            expand_home("~/foo".as_ref(), home_dir).unwrap(),
476            Some(PathBuf::from("/home/dir/foo"))
477        );
478        assert!(expand_home("~/foo".as_ref(), None).is_err());
479    }
480
481    #[test]
482    fn test_condition_default() {
483        let condition = ScopeCondition::default();
484
485        let context = ConfigResolutionContext {
486            home_dir: None,
487            repo_path: None,
488            workspace_path: None,
489            command: None,
490            hostname: "",
491            environment: &HashMap::new(),
492        };
493        assert!(condition.matches(&context));
494        let context = ConfigResolutionContext {
495            home_dir: None,
496            repo_path: Some(Path::new("/foo")),
497            workspace_path: None,
498            command: None,
499            hostname: "",
500            environment: &HashMap::new(),
501        };
502        assert!(condition.matches(&context));
503    }
504
505    #[test]
506    fn test_condition_repo_path() {
507        let condition = ScopeCondition {
508            repositories: Some(["/foo", "/bar"].map(PathBuf::from).into()),
509            ..Default::default()
510        };
511
512        let context = ConfigResolutionContext {
513            home_dir: None,
514            repo_path: None,
515            workspace_path: None,
516            command: None,
517            hostname: "",
518            environment: &HashMap::new(),
519        };
520        assert!(!condition.matches(&context));
521        let context = ConfigResolutionContext {
522            home_dir: None,
523            repo_path: Some(Path::new("/foo")),
524            workspace_path: None,
525            command: None,
526            hostname: "",
527            environment: &HashMap::new(),
528        };
529        assert!(condition.matches(&context));
530        let context = ConfigResolutionContext {
531            home_dir: None,
532            repo_path: Some(Path::new("/fooo")),
533            workspace_path: None,
534            command: None,
535            hostname: "",
536            environment: &HashMap::new(),
537        };
538        assert!(!condition.matches(&context));
539        let context = ConfigResolutionContext {
540            home_dir: None,
541            repo_path: Some(Path::new("/foo/baz")),
542            workspace_path: None,
543            command: None,
544            hostname: "",
545            environment: &HashMap::new(),
546        };
547        assert!(condition.matches(&context));
548        let context = ConfigResolutionContext {
549            home_dir: None,
550            repo_path: Some(Path::new("/bar")),
551            workspace_path: None,
552            command: None,
553            hostname: "",
554            environment: &HashMap::new(),
555        };
556        assert!(condition.matches(&context));
557    }
558
559    #[test]
560    fn test_condition_repo_path_windows() {
561        let condition = ScopeCondition {
562            repositories: Some(["c:/foo", r"d:\bar/baz"].map(PathBuf::from).into()),
563            ..Default::default()
564        };
565
566        let context = ConfigResolutionContext {
567            home_dir: None,
568            repo_path: Some(Path::new(r"c:\foo")),
569            workspace_path: None,
570            command: None,
571            hostname: "",
572            environment: &HashMap::new(),
573        };
574        assert_eq!(condition.matches(&context), cfg!(windows));
575        let context = ConfigResolutionContext {
576            home_dir: None,
577            repo_path: Some(Path::new(r"c:\foo\baz")),
578            workspace_path: None,
579            command: None,
580            hostname: "",
581            environment: &HashMap::new(),
582        };
583        assert_eq!(condition.matches(&context), cfg!(windows));
584        let context = ConfigResolutionContext {
585            home_dir: None,
586            repo_path: Some(Path::new(r"d:\foo")),
587            workspace_path: None,
588            command: None,
589            hostname: "",
590            environment: &HashMap::new(),
591        };
592        assert!(!condition.matches(&context));
593        let context = ConfigResolutionContext {
594            home_dir: None,
595            repo_path: Some(Path::new(r"d:/bar\baz")),
596            workspace_path: None,
597            command: None,
598            hostname: "",
599            environment: &HashMap::new(),
600        };
601        assert_eq!(condition.matches(&context), cfg!(windows));
602    }
603
604    #[test]
605    fn test_condition_hostname() {
606        let condition = ScopeCondition {
607            hostnames: Some(["host-a", "host-b"].map(String::from).into()),
608            ..Default::default()
609        };
610
611        let context = ConfigResolutionContext {
612            home_dir: None,
613            repo_path: None,
614            workspace_path: None,
615            command: None,
616            hostname: "",
617            environment: &HashMap::new(),
618        };
619        assert!(!condition.matches(&context));
620        let context = ConfigResolutionContext {
621            home_dir: None,
622            repo_path: None,
623            workspace_path: None,
624            command: None,
625            hostname: "host-a",
626            environment: &HashMap::new(),
627        };
628        assert!(condition.matches(&context));
629        let context = ConfigResolutionContext {
630            home_dir: None,
631            repo_path: None,
632            workspace_path: None,
633            command: None,
634            hostname: "host-b",
635            environment: &HashMap::new(),
636        };
637        assert!(condition.matches(&context));
638        let context = ConfigResolutionContext {
639            home_dir: None,
640            repo_path: None,
641            workspace_path: None,
642            command: None,
643            hostname: "host-c",
644            environment: &HashMap::new(),
645        };
646        assert!(!condition.matches(&context));
647    }
648
649    #[test]
650    fn test_condition_environments() {
651        let environment = HashMap::from([
652            ("MY_ENV".into(), "hello".into()),
653            ("OTHER_ENV".into(), "world".into()),
654        ]);
655        let context = ConfigResolutionContext {
656            home_dir: None,
657            repo_path: None,
658            workspace_path: None,
659            command: None,
660            hostname: "",
661            environment: &environment,
662        };
663
664        // Exact match
665        let condition = ScopeCondition {
666            environments: Some(vec!["MY_ENV=hello".into()]),
667            ..Default::default()
668        };
669        assert!(condition.matches(&context));
670
671        // Wrong value
672        let condition = ScopeCondition {
673            environments: Some(vec!["MY_ENV=wrong".into()]),
674            ..Default::default()
675        };
676        assert!(!condition.matches(&context));
677
678        // Absent var
679        let condition = ScopeCondition {
680            environments: Some(vec!["ABSENT_VAR=anything".into()]),
681            ..Default::default()
682        };
683        assert!(!condition.matches(&context));
684
685        // OR semantics: one right, one wrong
686        let condition = ScopeCondition {
687            environments: Some(vec!["MY_ENV=hello".into(), "OTHER_ENV=world".into()]),
688            ..Default::default()
689        };
690        assert!(condition.matches(&context));
691
692        // OR semantics: one wrong, one right
693        let condition = ScopeCondition {
694            environments: Some(vec!["MY_ENV=wrong".into(), "OTHER_ENV=world".into()]),
695            ..Default::default()
696        };
697        assert!(condition.matches(&context));
698
699        // OR semantics: neither matches
700        let condition = ScopeCondition {
701            environments: Some(vec!["MY_ENV=wrong".into(), "ABSENT_VAR=nope".into()]),
702            ..Default::default()
703        };
704        assert!(!condition.matches(&context));
705
706        // Empty value doesn't match non-empty var
707        let condition = ScopeCondition {
708            environments: Some(vec!["MY_ENV=".into()]),
709            ..Default::default()
710        };
711        assert!(!condition.matches(&context));
712
713        // Empty value doesn't match absent var
714        let condition = ScopeCondition {
715            environments: Some(vec!["ABSENT_VAR=".into()]),
716            ..Default::default()
717        };
718        assert!(!condition.matches(&context));
719
720        // Empty list never matches
721        let condition = ScopeCondition {
722            environments: Some(vec![]),
723            ..Default::default()
724        };
725        assert!(!condition.matches(&context));
726
727        // Value containing '=' is matched correctly (split on first '=')
728        let environment = HashMap::from([("CONN".into(), "host=localhost:5432".into())]);
729        let context = ConfigResolutionContext {
730            home_dir: None,
731            repo_path: None,
732            workspace_path: None,
733            command: None,
734            hostname: "",
735            environment: &environment,
736        };
737        let condition = ScopeCondition {
738            environments: Some(vec!["CONN=host=localhost:5432".into()]),
739            ..Default::default()
740        };
741        assert!(condition.matches(&context));
742
743        // Key-exists: variable is set
744        let condition = ScopeCondition {
745            environments: Some(vec!["CONN".into()]),
746            ..Default::default()
747        };
748        assert!(condition.matches(&context));
749
750        // Key-exists: variable is not set
751        let condition = ScopeCondition {
752            environments: Some(vec!["ABSENT_VAR".into()]),
753            ..Default::default()
754        };
755        assert!(!condition.matches(&context));
756
757        // Key-exists OR key=value: first matches
758        let condition = ScopeCondition {
759            environments: Some(vec!["CONN".into(), "OTHER=nope".into()]),
760            ..Default::default()
761        };
762        assert!(condition.matches(&context));
763
764        // Key-exists OR key=value: second matches
765        let condition = ScopeCondition {
766            environments: Some(vec!["ABSENT".into(), "CONN=host=localhost:5432".into()]),
767            ..Default::default()
768        };
769        assert!(condition.matches(&context));
770
771        // Key-exists with empty value variable
772        let environment = HashMap::from([("EMPTY_VAR".into(), "".into())]);
773        let context = ConfigResolutionContext {
774            home_dir: None,
775            repo_path: None,
776            workspace_path: None,
777            command: None,
778            hostname: "",
779            environment: &environment,
780        };
781        let condition = ScopeCondition {
782            environments: Some(vec!["EMPTY_VAR".into()]),
783            ..Default::default()
784        };
785        assert!(condition.matches(&context));
786
787        // None (no constraint) always matches
788        let condition = ScopeCondition {
789            environments: None,
790            ..Default::default()
791        };
792        assert!(condition.matches(&context));
793    }
794
795    fn new_user_layer(text: &str) -> ConfigLayer {
796        ConfigLayer::parse(ConfigSource::User, text).unwrap()
797    }
798
799    #[test]
800    fn test_resolve_transparent() -> TestResult {
801        let mut source_config = StackedConfig::empty();
802        source_config.add_layer(ConfigLayer::empty(ConfigSource::Default));
803        source_config.add_layer(ConfigLayer::empty(ConfigSource::User));
804
805        let context = ConfigResolutionContext {
806            home_dir: None,
807            repo_path: None,
808            workspace_path: None,
809            command: None,
810            hostname: "",
811            environment: &HashMap::new(),
812        };
813        let resolved_config = resolve(&source_config, &context)?;
814        assert_eq!(resolved_config.layers().len(), 2);
815        assert!(Arc::ptr_eq(
816            &source_config.layers()[0],
817            &resolved_config.layers()[0]
818        ));
819        assert!(Arc::ptr_eq(
820            &source_config.layers()[1],
821            &resolved_config.layers()[1]
822        ));
823        Ok(())
824    }
825
826    #[test]
827    fn test_resolve_table_order() -> TestResult {
828        let mut source_config = StackedConfig::empty();
829        source_config.add_layer(new_user_layer(indoc! {"
830            a = 'a #0'
831            [[--scope]]
832            a = 'a #0.0'
833            [[--scope]]
834            a = 'a #0.1'
835            [[--scope.--scope]]
836            a = 'a #0.1.0'
837            [[--scope]]
838            a = 'a #0.2'
839        "}));
840        source_config.add_layer(new_user_layer(indoc! {"
841            a = 'a #1'
842            [[--scope]]
843            a = 'a #1.0'
844        "}));
845
846        let context = ConfigResolutionContext {
847            home_dir: None,
848            repo_path: None,
849            workspace_path: None,
850            command: None,
851            hostname: "",
852            environment: &HashMap::new(),
853        };
854        let resolved_config = resolve(&source_config, &context)?;
855        assert_eq!(resolved_config.layers().len(), 7);
856        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
857        insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.0'");
858        insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.1'");
859        insta::assert_snapshot!(resolved_config.layers()[3].data, @"a = 'a #0.1.0'");
860        insta::assert_snapshot!(resolved_config.layers()[4].data, @"a = 'a #0.2'");
861        insta::assert_snapshot!(resolved_config.layers()[5].data, @"a = 'a #1'");
862        insta::assert_snapshot!(resolved_config.layers()[6].data, @"a = 'a #1.0'");
863        Ok(())
864    }
865
866    #[test]
867    fn test_resolve_repo_path() -> TestResult {
868        let mut source_config = StackedConfig::empty();
869        source_config.add_layer(new_user_layer(indoc! {"
870            a = 'a #0'
871            [[--scope]]
872            --when.repositories = ['/foo']
873            a = 'a #0.1 foo'
874            [[--scope]]
875            --when.repositories = ['/foo', '/bar']
876            a = 'a #0.2 foo|bar'
877            [[--scope]]
878            --when.repositories = []
879            a = 'a #0.3 none'
880        "}));
881        source_config.add_layer(new_user_layer(indoc! {"
882            --when.repositories = ['~/baz']
883            a = 'a #1 baz'
884            [[--scope]]
885            --when.repositories = ['/foo']  # should never be enabled
886            a = 'a #1.1 baz&foo'
887        "}));
888
889        let context = ConfigResolutionContext {
890            home_dir: Some(Path::new("/home/dir")),
891            repo_path: None,
892            workspace_path: None,
893            command: None,
894            hostname: "",
895            environment: &HashMap::new(),
896        };
897        let resolved_config = resolve(&source_config, &context)?;
898        assert_eq!(resolved_config.layers().len(), 1);
899        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
900
901        let context = ConfigResolutionContext {
902            home_dir: Some(Path::new("/home/dir")),
903            repo_path: Some(Path::new("/foo/.jj/repo")),
904            workspace_path: None,
905            command: None,
906            hostname: "",
907            environment: &HashMap::new(),
908        };
909        let resolved_config = resolve(&source_config, &context)?;
910        assert_eq!(resolved_config.layers().len(), 3);
911        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
912        insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 foo'");
913        insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 foo|bar'");
914
915        let context = ConfigResolutionContext {
916            home_dir: Some(Path::new("/home/dir")),
917            repo_path: Some(Path::new("/bar/.jj/repo")),
918            workspace_path: None,
919            command: None,
920            hostname: "",
921            environment: &HashMap::new(),
922        };
923        let resolved_config = resolve(&source_config, &context)?;
924        assert_eq!(resolved_config.layers().len(), 2);
925        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
926        insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.2 foo|bar'");
927
928        let context = ConfigResolutionContext {
929            home_dir: Some(Path::new("/home/dir")),
930            repo_path: Some(Path::new("/home/dir/baz/.jj/repo")),
931            workspace_path: None,
932            command: None,
933            hostname: "",
934            environment: &HashMap::new(),
935        };
936        let resolved_config = resolve(&source_config, &context)?;
937        assert_eq!(resolved_config.layers().len(), 2);
938        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
939        insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #1 baz'");
940        Ok(())
941    }
942
943    #[test]
944    fn test_resolve_hostname() -> TestResult {
945        let mut source_config = StackedConfig::empty();
946        source_config.add_layer(new_user_layer(indoc! {"
947            a = 'a #0'
948            [[--scope]]
949            --when.hostnames = ['host-a']
950            a = 'a #0.1 host-a'
951            [[--scope]]
952            --when.hostnames = ['host-a', 'host-b']
953            a = 'a #0.2 host-a|host-b'
954            [[--scope]]
955            --when.hostnames = []
956            a = 'a #0.3 none'
957        "}));
958        source_config.add_layer(new_user_layer(indoc! {"
959            --when.hostnames = ['host-c']
960            a = 'a #1 host-c'
961            [[--scope]]
962            --when.hostnames = ['host-a']  # should never be enabled
963            a = 'a #1.1 host-c&host-a'
964        "}));
965
966        let context = ConfigResolutionContext {
967            home_dir: Some(Path::new("/home/dir")),
968            repo_path: None,
969            workspace_path: None,
970            command: None,
971            hostname: "",
972            environment: &HashMap::new(),
973        };
974        let resolved_config = resolve(&source_config, &context)?;
975        assert_eq!(resolved_config.layers().len(), 1);
976        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
977
978        let context = ConfigResolutionContext {
979            home_dir: Some(Path::new("/home/dir")),
980            repo_path: None,
981            workspace_path: None,
982            command: None,
983            hostname: "host-a",
984            environment: &HashMap::new(),
985        };
986        let resolved_config = resolve(&source_config, &context)?;
987        assert_eq!(resolved_config.layers().len(), 3);
988        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
989        insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 host-a'");
990        insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 host-a|host-b'");
991
992        let context = ConfigResolutionContext {
993            home_dir: Some(Path::new("/home/dir")),
994            repo_path: None,
995            workspace_path: None,
996            command: None,
997            hostname: "host-b",
998            environment: &HashMap::new(),
999        };
1000        let resolved_config = resolve(&source_config, &context)?;
1001        assert_eq!(resolved_config.layers().len(), 2);
1002        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1003        insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.2 host-a|host-b'");
1004
1005        let context = ConfigResolutionContext {
1006            home_dir: Some(Path::new("/home/dir")),
1007            repo_path: None,
1008            workspace_path: None,
1009            command: None,
1010            hostname: "host-c",
1011            environment: &HashMap::new(),
1012        };
1013        let resolved_config = resolve(&source_config, &context)?;
1014        assert_eq!(resolved_config.layers().len(), 2);
1015        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1016        insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #1 host-c'");
1017        Ok(())
1018    }
1019
1020    #[test]
1021    fn test_resolve_workspace_path() -> TestResult {
1022        let mut source_config = StackedConfig::empty();
1023        source_config.add_layer(new_user_layer(indoc! {"
1024            a = 'a #0'
1025            [[--scope]]
1026            --when.workspaces = ['/foo']
1027            a = 'a #0.1 foo'
1028            [[--scope]]
1029            --when.workspaces = ['/foo', '/bar']
1030            a = 'a #0.2 foo|bar'
1031            [[--scope]]
1032            --when.workspaces = []
1033            a = 'a #0.3 none'
1034        "}));
1035        source_config.add_layer(new_user_layer(indoc! {"
1036            --when.workspaces = ['~/baz']
1037            a = 'a #1 baz'
1038            [[--scope]]
1039            --when.workspaces = ['/foo']  # should never be enabled
1040            a = 'a #1.1 baz&foo'
1041        "}));
1042
1043        let context = ConfigResolutionContext {
1044            home_dir: Some(Path::new("/home/dir")),
1045            repo_path: None,
1046            workspace_path: None,
1047            command: None,
1048            hostname: "",
1049            environment: &HashMap::new(),
1050        };
1051        let resolved_config = resolve(&source_config, &context)?;
1052        assert_eq!(resolved_config.layers().len(), 1);
1053        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1054
1055        let context = ConfigResolutionContext {
1056            home_dir: Some(Path::new("/home/dir")),
1057            repo_path: None,
1058            workspace_path: Some(Path::new("/foo")),
1059            command: None,
1060            hostname: "",
1061            environment: &HashMap::new(),
1062        };
1063        let resolved_config = resolve(&source_config, &context)?;
1064        assert_eq!(resolved_config.layers().len(), 3);
1065        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1066        insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 foo'");
1067        insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 foo|bar'");
1068
1069        let context = ConfigResolutionContext {
1070            home_dir: Some(Path::new("/home/dir")),
1071            repo_path: None,
1072            workspace_path: Some(Path::new("/bar")),
1073            command: None,
1074            hostname: "",
1075            environment: &HashMap::new(),
1076        };
1077        let resolved_config = resolve(&source_config, &context)?;
1078        assert_eq!(resolved_config.layers().len(), 2);
1079        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1080        insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.2 foo|bar'");
1081
1082        let context = ConfigResolutionContext {
1083            home_dir: Some(Path::new("/home/dir")),
1084            repo_path: None,
1085            workspace_path: Some(Path::new("/home/dir/baz")),
1086            command: None,
1087            hostname: "",
1088            environment: &HashMap::new(),
1089        };
1090        let resolved_config = resolve(&source_config, &context)?;
1091        assert_eq!(resolved_config.layers().len(), 2);
1092        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1093        insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #1 baz'");
1094        Ok(())
1095    }
1096
1097    #[test]
1098    fn test_resolve_command() -> TestResult {
1099        let mut source_config = StackedConfig::empty();
1100        source_config.add_layer(new_user_layer(indoc! {"
1101            a = 'a #0'
1102            [[--scope]]
1103            --when.commands = ['foo']
1104            a = 'a #0.1 foo'
1105            [[--scope]]
1106            --when.commands = ['foo', 'bar']
1107            a = 'a #0.2 foo|bar'
1108            [[--scope]]
1109            --when.commands = ['foo baz']
1110            a = 'a #0.3 foo baz'
1111            [[--scope]]
1112            --when.commands = []
1113            a = 'a #0.4 none'
1114        "}));
1115
1116        let context = ConfigResolutionContext {
1117            home_dir: None,
1118            repo_path: None,
1119            workspace_path: None,
1120            command: None,
1121            hostname: "",
1122            environment: &HashMap::new(),
1123        };
1124        let resolved_config = resolve(&source_config, &context)?;
1125        assert_eq!(resolved_config.layers().len(), 1);
1126        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1127
1128        let context = ConfigResolutionContext {
1129            home_dir: None,
1130            repo_path: None,
1131            workspace_path: None,
1132            command: Some("foo"),
1133            hostname: "",
1134            environment: &HashMap::new(),
1135        };
1136        let resolved_config = resolve(&source_config, &context)?;
1137        assert_eq!(resolved_config.layers().len(), 3);
1138        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1139        insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 foo'");
1140        insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 foo|bar'");
1141
1142        let context = ConfigResolutionContext {
1143            home_dir: None,
1144            repo_path: None,
1145            workspace_path: None,
1146            command: Some("bar"),
1147            hostname: "",
1148            environment: &HashMap::new(),
1149        };
1150        let resolved_config = resolve(&source_config, &context)?;
1151        assert_eq!(resolved_config.layers().len(), 2);
1152        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1153        insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.2 foo|bar'");
1154
1155        let context = ConfigResolutionContext {
1156            home_dir: None,
1157            repo_path: None,
1158            workspace_path: None,
1159            command: Some("foo baz"),
1160            hostname: "",
1161            environment: &HashMap::new(),
1162        };
1163        let resolved_config = resolve(&source_config, &context)?;
1164        assert_eq!(resolved_config.layers().len(), 4);
1165        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1166        insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 foo'");
1167        insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 foo|bar'");
1168        insta::assert_snapshot!(resolved_config.layers()[3].data, @"a = 'a #0.3 foo baz'");
1169
1170        // "fooqux" shares "foo" prefix, but should *not* match
1171        let context = ConfigResolutionContext {
1172            home_dir: None,
1173            repo_path: None,
1174            workspace_path: None,
1175            command: Some("fooqux"),
1176            hostname: "",
1177            environment: &HashMap::new(),
1178        };
1179        let resolved_config = resolve(&source_config, &context)?;
1180        assert_eq!(resolved_config.layers().len(), 1);
1181        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1182        Ok(())
1183    }
1184
1185    #[test]
1186    fn test_resolve_os() -> TestResult {
1187        let mut source_config = StackedConfig::empty();
1188        source_config.add_layer(new_user_layer(indoc! {"
1189            a = 'a none'
1190            b = 'b none'
1191            [[--scope]]
1192            --when.platforms = ['linux']
1193            a = 'a linux'
1194            [[--scope]]
1195            --when.platforms = ['macos']
1196            a = 'a macos'
1197            [[--scope]]
1198            --when.platforms = ['windows']
1199            a = 'a windows'
1200            [[--scope]]
1201            --when.platforms = ['unix']
1202            b = 'b unix'
1203        "}));
1204
1205        let context = ConfigResolutionContext {
1206            home_dir: Some(Path::new("/home/dir")),
1207            repo_path: None,
1208            workspace_path: None,
1209            command: None,
1210            hostname: "",
1211            environment: &HashMap::new(),
1212        };
1213        let resolved_config = resolve(&source_config, &context)?;
1214        insta::assert_snapshot!(resolved_config.layers()[0].data, @"
1215        a = 'a none'
1216        b = 'b none'
1217        ");
1218        if cfg!(target_os = "linux") {
1219            assert_eq!(resolved_config.layers().len(), 3);
1220            insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a linux'");
1221            insta::assert_snapshot!(resolved_config.layers()[2].data, @"b = 'b unix'");
1222        } else if cfg!(target_os = "macos") {
1223            assert_eq!(resolved_config.layers().len(), 3);
1224            insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a macos'");
1225            insta::assert_snapshot!(resolved_config.layers()[2].data, @"b = 'b unix'");
1226        } else if cfg!(target_os = "windows") {
1227            assert_eq!(resolved_config.layers().len(), 2);
1228            insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a windows'");
1229        } else if cfg!(target_family = "unix") {
1230            assert_eq!(resolved_config.layers().len(), 2);
1231            insta::assert_snapshot!(resolved_config.layers()[1].data, @"b = 'b unix'");
1232        } else {
1233            assert_eq!(resolved_config.layers().len(), 1);
1234        }
1235        Ok(())
1236    }
1237
1238    #[test]
1239    fn test_resolve_repo_path_and_command() -> TestResult {
1240        let mut source_config = StackedConfig::empty();
1241        source_config.add_layer(new_user_layer(indoc! {"
1242            a = 'a #0'
1243            [[--scope]]
1244            --when.repositories = ['/foo', '/bar']
1245            --when.commands = ['ABC', 'DEF']
1246            a = 'a #0.1'
1247        "}));
1248
1249        let context = ConfigResolutionContext {
1250            home_dir: Some(Path::new("/home/dir")),
1251            repo_path: None,
1252            workspace_path: None,
1253            command: None,
1254            hostname: "",
1255            environment: &HashMap::new(),
1256        };
1257        let resolved_config = resolve(&source_config, &context)?;
1258        assert_eq!(resolved_config.layers().len(), 1);
1259        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1260
1261        // only repo matches
1262        let context = ConfigResolutionContext {
1263            home_dir: Some(Path::new("/home/dir")),
1264            repo_path: Some(Path::new("/foo")),
1265            workspace_path: None,
1266            command: Some("other"),
1267            hostname: "",
1268            environment: &HashMap::new(),
1269        };
1270        let resolved_config = resolve(&source_config, &context)?;
1271        assert_eq!(resolved_config.layers().len(), 1);
1272        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1273
1274        // only command matches
1275        let context = ConfigResolutionContext {
1276            home_dir: Some(Path::new("/home/dir")),
1277            repo_path: Some(Path::new("/qux")),
1278            workspace_path: None,
1279            command: Some("ABC"),
1280            hostname: "",
1281            environment: &HashMap::new(),
1282        };
1283        let resolved_config = resolve(&source_config, &context)?;
1284        assert_eq!(resolved_config.layers().len(), 1);
1285        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1286
1287        // both match
1288        let context = ConfigResolutionContext {
1289            home_dir: Some(Path::new("/home/dir")),
1290            repo_path: Some(Path::new("/bar")),
1291            workspace_path: None,
1292            command: Some("DEF"),
1293            hostname: "",
1294            environment: &HashMap::new(),
1295        };
1296        let resolved_config = resolve(&source_config, &context)?;
1297        assert_eq!(resolved_config.layers().len(), 2);
1298        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1299        insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1'");
1300        Ok(())
1301    }
1302
1303    #[test]
1304    fn test_resolve_environments() -> TestResult {
1305        let mut source_config = StackedConfig::empty();
1306        source_config.add_layer(new_user_layer(indoc! {"
1307            a = 'a #0'
1308            [[--scope]]
1309            --when.environments = ['MY_ENV=yes']
1310            a = 'a #0.1 env-yes'
1311            [[--scope]]
1312            --when.environments = ['MY_ENV=yes', 'MY_ENV=no']
1313            a = 'a #0.2 env-yes|env-no'
1314            [[--scope]]
1315            --when.environments = []
1316            a = 'a #0.3 none'
1317            [[--scope]]
1318            --when.environments = ['MY_ENV']
1319            a = 'a #0.4 env-exists'
1320            [[--scope]]
1321            --when.environments = ['ABSENT_VAR']
1322            a = 'a #0.5 absent-exists'
1323        "}));
1324        source_config.add_layer(new_user_layer(indoc! {"
1325            --when.environments = ['MY_ENV=yes']
1326            a = 'a #1 env-yes'
1327            [[--scope]]
1328            --when.environments = ['MY_ENV=no']  # can never match: layer requires MY_ENV=yes
1329            a = 'a #1.1 env-yes&env-no'
1330        "}));
1331
1332        // no env vars set
1333        let environment = HashMap::new();
1334        let context = ConfigResolutionContext {
1335            home_dir: Some(Path::new("/home/dir")),
1336            repo_path: None,
1337            workspace_path: None,
1338            command: None,
1339            hostname: "",
1340            environment: &environment,
1341        };
1342        let resolved_config = resolve(&source_config, &context)?;
1343        assert_eq!(resolved_config.layers().len(), 1);
1344        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1345
1346        // MY_ENV=yes matches first scope, OR scope, key-exists scope, and second
1347        // layer (but not nested scope or absent-exists scope)
1348        let environment = HashMap::from([("MY_ENV".into(), "yes".into())]);
1349        let context = ConfigResolutionContext {
1350            home_dir: Some(Path::new("/home/dir")),
1351            repo_path: None,
1352            workspace_path: None,
1353            command: None,
1354            hostname: "",
1355            environment: &environment,
1356        };
1357        let resolved_config = resolve(&source_config, &context)?;
1358        assert_eq!(resolved_config.layers().len(), 5);
1359        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1360        insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 env-yes'");
1361        insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 env-yes|env-no'");
1362        insta::assert_snapshot!(resolved_config.layers()[3].data, @"a = 'a #0.4 env-exists'");
1363        insta::assert_snapshot!(resolved_config.layers()[4].data, @"a = 'a #1 env-yes'");
1364
1365        // MY_ENV=no matches the OR scope and key-exists scope
1366        let environment = HashMap::from([("MY_ENV".into(), "no".into())]);
1367        let context = ConfigResolutionContext {
1368            home_dir: Some(Path::new("/home/dir")),
1369            repo_path: None,
1370            workspace_path: None,
1371            command: None,
1372            hostname: "",
1373            environment: &environment,
1374        };
1375        let resolved_config = resolve(&source_config, &context)?;
1376        assert_eq!(resolved_config.layers().len(), 3);
1377        insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'");
1378        insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.2 env-yes|env-no'");
1379        insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.4 env-exists'");
1380        Ok(())
1381    }
1382
1383    #[test]
1384    fn test_resolve_invalid_condition() {
1385        let new_config = |text: &str| {
1386            let mut config = StackedConfig::empty();
1387            config.add_layer(new_user_layer(text));
1388            config
1389        };
1390        let context = ConfigResolutionContext {
1391            home_dir: Some(Path::new("/home/dir")),
1392            repo_path: Some(Path::new("/foo/.jj/repo")),
1393            workspace_path: None,
1394            command: None,
1395            hostname: "",
1396            environment: &HashMap::new(),
1397        };
1398        assert_matches!(
1399            resolve(&new_config("--when.repositories = 0"), &context),
1400            Err(ConfigGetError::Type { .. })
1401        );
1402    }
1403
1404    #[test]
1405    fn test_resolve_invalid_scoped_tables() {
1406        let new_config = |text: &str| {
1407            let mut config = StackedConfig::empty();
1408            config.add_layer(new_user_layer(text));
1409            config
1410        };
1411        let context = ConfigResolutionContext {
1412            home_dir: Some(Path::new("/home/dir")),
1413            repo_path: Some(Path::new("/foo/.jj/repo")),
1414            workspace_path: None,
1415            command: None,
1416            hostname: "",
1417            environment: &HashMap::new(),
1418        };
1419        assert_matches!(
1420            resolve(&new_config("[--scope]"), &context),
1421            Err(ConfigGetError::Type { .. })
1422        );
1423    }
1424
1425    #[test]
1426    fn test_migrate_noop() -> TestResult {
1427        let mut config = StackedConfig::empty();
1428        config.add_layer(new_user_layer(indoc! {"
1429            foo = 'foo'
1430        "}));
1431        config.add_layer(new_user_layer(indoc! {"
1432            bar = 'bar'
1433        "}));
1434
1435        let old_layers = config.layers().to_vec();
1436        let rules = [ConfigMigrationRule::rename_value("baz", "foo")];
1437        let descriptions = migrate(&mut config, &rules)?;
1438        assert!(descriptions.is_empty());
1439        assert!(Arc::ptr_eq(&config.layers()[0], &old_layers[0]));
1440        assert!(Arc::ptr_eq(&config.layers()[1], &old_layers[1]));
1441        Ok(())
1442    }
1443
1444    #[test]
1445    fn test_migrate_error() {
1446        let mut config = StackedConfig::empty();
1447        let mut layer = new_user_layer(indoc! {"
1448            foo.bar = 'baz'
1449        "});
1450        layer.path = Some("source.toml".into());
1451        config.add_layer(layer);
1452
1453        let rules = [ConfigMigrationRule::rename_value("foo", "bar")];
1454        insta::assert_debug_snapshot!(migrate(&mut config, &rules).unwrap_err(), @r#"
1455        ConfigMigrateError {
1456            error: Update(
1457                WouldDeleteTable {
1458                    name: "foo",
1459                },
1460            ),
1461            source_path: Some(
1462                "source.toml",
1463            ),
1464        }
1465        "#);
1466    }
1467
1468    #[test]
1469    fn test_migrate_rename_value() -> TestResult {
1470        let mut config = StackedConfig::empty();
1471        config.add_layer(new_user_layer(indoc! {"
1472            [foo]
1473            old = 'foo.old #0'
1474            [bar]
1475            old = 'bar.old #0'
1476            [baz]
1477            new = 'baz.new #0'
1478        "}));
1479        config.add_layer(new_user_layer(indoc! {"
1480            [bar]
1481            old = 'bar.old #1'
1482        "}));
1483
1484        let rules = [
1485            ConfigMigrationRule::rename_value("foo.old", "foo.new"),
1486            ConfigMigrationRule::rename_value("bar.old", "baz.new"),
1487        ];
1488        let descriptions = migrate(&mut config, &rules)?;
1489        insta::assert_debug_snapshot!(descriptions, @r#"
1490        [
1491            (
1492                User,
1493                "foo.old is renamed to foo.new",
1494            ),
1495            (
1496                User,
1497                "bar.old is deleted (superseded by baz.new)",
1498            ),
1499            (
1500                User,
1501                "bar.old is renamed to baz.new",
1502            ),
1503        ]
1504        "#);
1505        insta::assert_snapshot!(config.layers()[0].data, @"
1506        [foo]
1507        new = 'foo.old #0'
1508        [bar]
1509        [baz]
1510        new = 'baz.new #0'
1511        ");
1512        insta::assert_snapshot!(config.layers()[1].data, @"
1513        [bar]
1514
1515        [baz]
1516        new = 'bar.old #1'
1517        ");
1518        Ok(())
1519    }
1520
1521    #[test]
1522    fn test_migrate_rename_update_value() -> TestResult {
1523        let mut config = StackedConfig::empty();
1524        config.add_layer(new_user_layer(indoc! {"
1525            [foo]
1526            old = 'foo.old #0'
1527            [bar]
1528            old = 'bar.old #0'
1529            [baz]
1530            new = 'baz.new #0'
1531        "}));
1532        config.add_layer(new_user_layer(indoc! {"
1533            [bar]
1534            old = 'bar.old #1'
1535        "}));
1536
1537        let rules = [
1538            // to array
1539            ConfigMigrationRule::rename_update_value("foo.old", "foo.new", |old_value| {
1540                let val = old_value.clone().decorated("", "");
1541                Ok(ConfigValue::from_iter([val]))
1542            }),
1543            // update string or error
1544            ConfigMigrationRule::rename_update_value("bar.old", "baz.new", |old_value| {
1545                let s = old_value.as_str().ok_or("not a string")?;
1546                Ok(format!("{s} updated").into())
1547            }),
1548        ];
1549        let descriptions = migrate(&mut config, &rules)?;
1550        insta::assert_debug_snapshot!(descriptions, @r#"
1551        [
1552            (
1553                User,
1554                "foo.old is updated to foo.new = ['foo.old #0']",
1555            ),
1556            (
1557                User,
1558                "bar.old is deleted (superseded by baz.new)",
1559            ),
1560            (
1561                User,
1562                "bar.old is updated to baz.new = \"bar.old #1 updated\"",
1563            ),
1564        ]
1565        "#);
1566        insta::assert_snapshot!(config.layers()[0].data, @"
1567        [foo]
1568        new = ['foo.old #0']
1569        [bar]
1570        [baz]
1571        new = 'baz.new #0'
1572        ");
1573        insta::assert_snapshot!(config.layers()[1].data, @r#"
1574        [bar]
1575
1576        [baz]
1577        new = "bar.old #1 updated"
1578        "#);
1579
1580        config.add_layer(new_user_layer(indoc! {"
1581            [bar]
1582            old = false  # not a string
1583        "}));
1584        insta::assert_debug_snapshot!(migrate(&mut config, &rules).unwrap_err(), @r#"
1585        ConfigMigrateError {
1586            error: Type {
1587                name: "bar.old",
1588                error: "not a string",
1589            },
1590            source_path: None,
1591        }
1592        "#);
1593        Ok(())
1594    }
1595}