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