From b213d0f1e2dd325fe0ff97422d3a4a0da24e3278 Mon Sep 17 00:00:00 2001
From: Yuya Nishihara <yuya@tcha.org>
Date: Wed, 11 Dec 2024 14:56:46 +0900
Subject: [PATCH 1/2] cli: set global args to config table without re-parsing
 as TOML

This should be safer than constructing a parsable TOML form.
---
 cli/src/cli_util.rs | 21 +++++++++++++++------
 lib/src/config.rs   |  5 +++++
 2 files changed, 20 insertions(+), 6 deletions(-)

diff --git a/cli/src/cli_util.rs b/cli/src/cli_util.rs
index a202c681cd..b03aa5f29e 100644
--- a/cli/src/cli_util.rs
+++ b/cli/src/cli_util.rs
@@ -3276,19 +3276,28 @@ fn handle_early_args(
         )
         .ignore_errors(true)
         .try_get_matches_from(args)?;
-    let mut args: EarlyArgs = EarlyArgs::from_arg_matches(&early_matches).unwrap();
+    let args = EarlyArgs::from_arg_matches(&early_matches).unwrap();
 
+    let old_layers_len = config.layers().len();
+    config.extend_layers(parse_config_args(&args.config_toml)?);
+
+    // Command arguments overrides any other configuration including the
+    // variables loaded from --config* arguments.
+    let mut layer = ConfigLayer::empty(ConfigSource::CommandArg);
     if let Some(choice) = args.color {
-        args.config_toml.push(format!(r#"ui.color="{choice}""#));
+        layer.set_value("ui.color", choice.to_string()).unwrap();
     }
     if args.quiet.unwrap_or_default() {
-        args.config_toml.push(r#"ui.quiet=true"#.to_string());
+        layer.set_value("ui.quiet", true).unwrap();
     }
     if args.no_pager.unwrap_or_default() {
-        args.config_toml.push(r#"ui.paginate="never""#.to_owned());
+        layer.set_value("ui.paginate", "never").unwrap();
+    }
+    if !layer.is_empty() {
+        config.add_layer(layer);
     }
-    if !args.config_toml.is_empty() {
-        config.extend_layers(parse_config_args(&args.config_toml)?);
+
+    if config.layers().len() != old_layers_len {
         ui.reset(config)?;
     }
     Ok(())
diff --git a/lib/src/config.rs b/lib/src/config.rs
index d6722a8a35..0d3b73e0fe 100644
--- a/lib/src/config.rs
+++ b/lib/src/config.rs
@@ -327,6 +327,11 @@ impl ConfigLayer {
             .try_collect()
     }
 
+    /// Returns true if the table has no configuration variables.
+    pub fn is_empty(&self) -> bool {
+        self.data.is_empty()
+    }
+
     // Add .get_value(name) if needed. look_up_*() are low-level API.
 
     /// Looks up sub non-inline table by the `name` path. Returns `Some(table)`

From 9293da88fdab37791e930bbe808b8522123d5de9 Mon Sep 17 00:00:00 2001
From: Yuya Nishihara <yuya@tcha.org>
Date: Wed, 11 Dec 2024 12:33:59 +0900
Subject: [PATCH 2/2] cli: add --config-file=PATH argument

This would be useful for scripting purpose. Maybe we can also replace the
current --config-toml=<TOML> use cases by --config-file=<PATH> and simpler
--config=<KEY>=<VALUE>.

https://github.com/martinvonz/jj/issues/4926#issuecomment-2506672165

If we want to add more source variants (such as fd number), it might be better
to add --config-from=<type>:<path|fd|..>. In any case, we'll probably want
--config=<KEY>=<VALUE>, and therefore, we'll need to merge more than one
--config* arguments.
---
 CHANGELOG.md                     |  3 ++
 cli/src/cli_util.rs              | 88 +++++++++++++++++++++++++++++++-
 cli/src/config.rs                | 19 +++++--
 cli/tests/cli-reference@.md.snap |  1 +
 cli/tests/test_completion.rs     |  1 +
 cli/tests/test_global_opts.rs    | 69 +++++++++++++++++++++++++
 docs/config.md                   | 12 +++--
 lib/src/config.rs                |  3 +-
 8 files changed, 188 insertions(+), 8 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6dbcb57d60..37bfed7bba 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -35,6 +35,9 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
   `snapshot.max-new-file-size` config option. It will print a warning and large
   files will be left untracked.
 
+* New command option `--config-file=PATH` to load additional configuration from
+  files.
+
 * Templates now support the `>=`, `>`, `<=`, and `<` relational operators for
   `Integer` types.
 
diff --git a/cli/src/cli_util.rs b/cli/src/cli_util.rs
index b03aa5f29e..7caf3395bc 100644
--- a/cli/src/cli_util.rs
+++ b/cli/src/cli_util.rs
@@ -157,6 +157,7 @@ use crate::complete;
 use crate::config::config_from_environment;
 use crate::config::parse_config_args;
 use crate::config::CommandNameAndArgs;
+use crate::config::ConfigArg;
 use crate::config::ConfigEnv;
 use crate::diff_util;
 use crate::diff_util::DiffFormat;
@@ -3102,6 +3103,26 @@ pub struct EarlyArgs {
     //  cases, designed so that `--config ui.color=auto` works
     #[arg(long, value_name = "TOML", global = true)]
     pub config_toml: Vec<String>,
+    /// Additional configuration files (can be repeated)
+    #[arg(long, value_name = "PATH", global = true, value_hint = clap::ValueHint::FilePath)]
+    pub config_file: Vec<String>,
+}
+
+impl EarlyArgs {
+    fn merged_config_args(&self, matches: &ArgMatches) -> Vec<ConfigArg> {
+        merge_args_with(
+            matches,
+            &[
+                ("config_toml", &self.config_toml),
+                ("config_file", &self.config_file),
+            ],
+            |id, value| match id {
+                "config_toml" => ConfigArg::Toml(value.clone()),
+                "config_file" => ConfigArg::File(value.clone()),
+                _ => unreachable!("unexpected id {id:?}"),
+            },
+        )
+    }
 }
 
 /// Wrapper around revset expression argument.
@@ -3143,6 +3164,29 @@ impl ValueParserFactory for RevisionArg {
     }
 }
 
+/// Merges multiple clap args in order of appearance.
+///
+/// The `id_values` is a list of `(id, values)` pairs, where `id` is the name of
+/// the clap `Arg`, and `values` are the parsed values for that arg. The
+/// `convert` function transforms each `(id, value)` pair to e.g. an enum.
+///
+/// This is a workaround for <https://github.com/clap-rs/clap/issues/3146>.
+pub fn merge_args_with<'k, 'v, T, U>(
+    matches: &ArgMatches,
+    id_values: &[(&'k str, &'v [T])],
+    mut convert: impl FnMut(&'k str, &'v T) -> U,
+) -> Vec<U> {
+    let mut pos_values: Vec<(usize, U)> = Vec::new();
+    for (id, values) in id_values {
+        pos_values.extend(itertools::zip_eq(
+            matches.indices_of(id).into_iter().flatten(),
+            values.iter().map(|v| convert(id, v)),
+        ));
+    }
+    pos_values.sort_unstable_by_key(|&(pos, _)| pos);
+    pos_values.into_iter().map(|(_, value)| value).collect()
+}
+
 fn get_string_or_array(
     config: &StackedConfig,
     key: &'static str,
@@ -3279,7 +3323,7 @@ fn handle_early_args(
     let args = EarlyArgs::from_arg_matches(&early_matches).unwrap();
 
     let old_layers_len = config.layers().len();
-    config.extend_layers(parse_config_args(&args.config_toml)?);
+    config.extend_layers(parse_config_args(&args.merged_config_args(&early_matches))?);
 
     // Command arguments overrides any other configuration including the
     // variables loaded from --config* arguments.
@@ -3711,3 +3755,45 @@ fn format_template_aliases_hint(template_aliases: &TemplateAliasesMap) -> String
     );
     hint
 }
+
+#[cfg(test)]
+mod tests {
+    use clap::CommandFactory as _;
+
+    use super::*;
+
+    #[derive(clap::Parser, Clone, Debug)]
+    pub struct TestArgs {
+        #[arg(long)]
+        pub foo: Vec<u32>,
+        #[arg(long)]
+        pub bar: Vec<u32>,
+        #[arg(long)]
+        pub baz: bool,
+    }
+
+    #[test]
+    fn test_merge_args_with() {
+        let command = TestArgs::command();
+        let parse = |args: &[&str]| -> Vec<(&'static str, u32)> {
+            let matches = command.clone().try_get_matches_from(args).unwrap();
+            let args = TestArgs::from_arg_matches(&matches).unwrap();
+            merge_args_with(
+                &matches,
+                &[("foo", &args.foo), ("bar", &args.bar)],
+                |id, value| (id, *value),
+            )
+        };
+
+        assert_eq!(parse(&["jj"]), vec![]);
+        assert_eq!(parse(&["jj", "--foo=1"]), vec![("foo", 1)]);
+        assert_eq!(
+            parse(&["jj", "--foo=1", "--bar=2"]),
+            vec![("foo", 1), ("bar", 2)]
+        );
+        assert_eq!(
+            parse(&["jj", "--foo=1", "--baz", "--bar=2", "--foo", "3"]),
+            vec![("foo", 1), ("bar", 2), ("foo", 3)]
+        );
+    }
+}
diff --git a/cli/src/config.rs b/cli/src/config.rs
index 9ca8da9862..cacadcdcc1 100644
--- a/cli/src/config.rs
+++ b/cli/src/config.rs
@@ -319,7 +319,7 @@ impl ConfigEnv {
 /// 4. Repo config `.jj/repo/config.toml`
 /// 5. TODO: Workspace config `.jj/config.toml`
 /// 6. Override environment variables
-/// 7. Command-line arguments `--config-toml`
+/// 7. Command-line arguments `--config-toml`, `--config-file`
 ///
 /// This function sets up 1, 2, and 6.
 pub fn config_from_environment(
@@ -401,15 +401,28 @@ fn env_overrides_layer() -> ConfigLayer {
     layer
 }
 
+/// Configuration source/data provided as command-line argument.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum ConfigArg {
+    /// `--config-toml=TOML`
+    Toml(String),
+    /// `--config-file=PATH`
+    File(String),
+}
+
 /// Parses `--config-toml` arguments.
-pub fn parse_config_args(toml_strs: &[String]) -> Result<Vec<ConfigLayer>, ConfigLoadError> {
+pub fn parse_config_args(toml_strs: &[ConfigArg]) -> Result<Vec<ConfigLayer>, ConfigLoadError> {
     // It might look silly that a layer is constructed per argument, but
     // --config-toml argument can contain a full TOML document, and it makes
     // sense to preserve line numbers within the doc. If we add
     // --config=KEY=VALUE, multiple values might be loaded into one layer.
+    let source = ConfigSource::CommandArg;
     toml_strs
         .iter()
-        .map(|text| ConfigLayer::parse(ConfigSource::CommandArg, text))
+        .map(|arg| match arg {
+            ConfigArg::Toml(text) => ConfigLayer::parse(source, text),
+            ConfigArg::File(path) => ConfigLayer::load_from_file(source, path.into()),
+        })
         .try_collect()
 }
 
diff --git a/cli/tests/cli-reference@.md.snap b/cli/tests/cli-reference@.md.snap
index b3083eb360..d666a72bee 100644
--- a/cli/tests/cli-reference@.md.snap
+++ b/cli/tests/cli-reference@.md.snap
@@ -196,6 +196,7 @@ To get started, see the tutorial at https://martinvonz.github.io/jj/latest/tutor
    Warnings and errors will still be printed.
 * `--no-pager` — Disable the pager
 * `--config-toml <TOML>` — Additional configuration options (can be repeated)
+* `--config-file <PATH>` — Additional configuration files (can be repeated)
 
 
 
diff --git a/cli/tests/test_completion.rs b/cli/tests/test_completion.rs
index 778a729ea3..9cec9a0927 100644
--- a/cli/tests/test_completion.rs
+++ b/cli/tests/test_completion.rs
@@ -83,6 +83,7 @@ fn test_bookmark_names() {
     --quiet	Silence non-primary command output
     --no-pager	Disable the pager
     --config-toml	Additional configuration options (can be repeated)
+    --config-file	Additional configuration files (can be repeated)
     --help	Print help (see more with '--help')
     ");
 
diff --git a/cli/tests/test_global_opts.rs b/cli/tests/test_global_opts.rs
index 503bf49541..06e3393815 100644
--- a/cli/tests/test_global_opts.rs
+++ b/cli/tests/test_global_opts.rs
@@ -14,6 +14,8 @@
 
 use std::ffi::OsString;
 
+use indoc::indoc;
+
 use crate::common::get_stderr_string;
 use crate::common::strip_last_line;
 use crate::common::TestEnvironment;
@@ -595,6 +597,72 @@ fn test_early_args() {
     );
 }
 
+#[test]
+fn test_config_args() {
+    let test_env = TestEnvironment::default();
+    let list_config = |args: &[&str]| {
+        test_env.jj_cmd_success(
+            test_env.env_root(),
+            &[&["config", "list", "--include-overridden", "test"], args].concat(),
+        )
+    };
+
+    std::fs::write(
+        test_env.env_root().join("file1.toml"),
+        indoc! {"
+            test.key1 = 'file1'
+            test.key2 = 'file1'
+        "},
+    )
+    .unwrap();
+    std::fs::write(
+        test_env.env_root().join("file2.toml"),
+        indoc! {"
+            test.key3 = 'file2'
+        "},
+    )
+    .unwrap();
+
+    let stdout = list_config(&["--config-toml=test.key1='arg1'"]);
+    insta::assert_snapshot!(stdout, @"test.key1 = 'arg1'");
+    let stdout = list_config(&["--config-file=file1.toml"]);
+    insta::assert_snapshot!(stdout, @r"
+    test.key1 = 'file1'
+    test.key2 = 'file1'
+    ");
+
+    // --config* arguments are processed in order of appearance
+    let stdout = list_config(&[
+        "--config-toml=test.key1='arg1'",
+        "--config-file=file1.toml",
+        "--config-toml=test.key2='arg3'",
+        "--config-file=file2.toml",
+    ]);
+    insta::assert_snapshot!(stdout, @r"
+    # test.key1 = 'arg1'
+    test.key1 = 'file1'
+    # test.key2 = 'file1'
+    test.key2 = 'arg3'
+    test.key3 = 'file2'
+    ");
+
+    let stderr = test_env.jj_cmd_failure(
+        test_env.env_root(),
+        &["config", "list", "--config-file=unknown.toml"],
+    );
+    insta::with_settings!({
+        filters => [("(?m)^([2-9]): .*", "$1: <redacted>")],
+    }, {
+        insta::assert_snapshot!(stderr, @r"
+        Config error: Failed to read configuration file
+        Caused by:
+        1: Cannot access unknown.toml
+        2: <redacted>
+        For help, see https://martinvonz.github.io/jj/latest/config/.
+        ");
+    });
+}
+
 #[test]
 fn test_invalid_config() {
     // Test that we get a reasonable error if the config is invalid (#55)
@@ -708,6 +776,7 @@ fn test_help() {
           --quiet                        Silence non-primary command output
           --no-pager                     Disable the pager
           --config-toml <TOML>           Additional configuration options (can be repeated)
+          --config-file <PATH>           Additional configuration files (can be repeated)
     ");
 }
 
diff --git a/docs/config.md b/docs/config.md
index 3004eb8e93..a232986813 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -1203,9 +1203,9 @@ env JJ_CONFIG=/dev/null jj log       # Ignores any settings specified in the con
 
 ### Specifying config on the command-line
 
-You can use one or more `--config-toml` options on the command line to specify
-additional configuration settings. This overrides settings defined in config
-files or environment variables. For example,
+You can use one or more `--config-toml`/`--config-file` options on the command
+line to specify additional configuration settings. This overrides settings
+defined in config files or environment variables. For example,
 
 ```shell
 jj --config-toml='ui.color="always"' --config-toml='ui.diff-editor="kdiff3"' split
@@ -1221,3 +1221,9 @@ files with the config specified in `.jjconfig.toml`:
 ```shell
 jj --config-toml="$(cat extra-config.toml)" log
 ```
+
+This is equivalent to
+
+```shell
+jj --config-file=extra-config.toml log
+```
diff --git a/lib/src/config.rs b/lib/src/config.rs
index 0d3b73e0fe..7557feba61 100644
--- a/lib/src/config.rs
+++ b/lib/src/config.rs
@@ -292,7 +292,8 @@ impl ConfigLayer {
         Ok(Self::with_data(source, data.into_mut()))
     }
 
-    fn load_from_file(source: ConfigSource, path: PathBuf) -> Result<Self, ConfigLoadError> {
+    /// Loads TOML file from the specified `path`.
+    pub fn load_from_file(source: ConfigSource, path: PathBuf) -> Result<Self, ConfigLoadError> {
         let text = fs::read_to_string(&path)
             .context(&path)
             .map_err(ConfigLoadError::Read)?;