// Copyright 2022 The Jujutsu Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

use std::env::join_paths;
use std::path::Path;
use std::path::PathBuf;

use indoc::indoc;
use itertools::Itertools as _;
use regex::Regex;
use testutils::TestResult;

use crate::common::TestEnvironment;
use crate::common::default_config_from_schema;
use crate::common::fake_editor_path;
use crate::common::force_interactive;
use crate::common::to_toml_value;

#[test]
fn test_config_list_single() {
    let test_env = TestEnvironment::default();
    test_env.add_config(
        r#"
    [test-table]
    somekey = "some value"
    "#,
    );

    let output = test_env.run_jj_in(".", ["config", "list", "test-table.somekey"]);
    insta::assert_snapshot!(output, @r#"
    test-table.somekey = "some value"
    [EOF]
    "#);

    let output = test_env.run_jj_in(
        ".",
        ["config", "list", r#"-Tname ++ "\n""#, "test-table.somekey"],
    );
    insta::assert_snapshot!(output, @"
    test-table.somekey
    [EOF]
    ");
}

#[test]
fn test_config_list_nonexistent() {
    let test_env = TestEnvironment::default();
    let output = test_env.run_jj_in(".", ["config", "list", "nonexistent-test-key"]);
    insta::assert_snapshot!(output, @r"
    ------- stderr -------
    Warning: No matching config key for: nonexistent-test-key
    [EOF]
    ");

    let output = test_env.run_jj_in(".", ["config", "list", "--repo"]);
    insta::assert_snapshot!(output, @r"
    ------- stderr -------
    Warning: No config to list.
    [EOF]
    ");
}

#[test]
fn test_config_list_table() {
    let test_env = TestEnvironment::default();
    test_env.add_config(
        r#"
    [test-table]
    x = true
    y.foo = "abc"
    y.bar = 123
    "z"."with space"."function()" = 5
    "#,
    );
    let output = test_env.run_jj_in(".", ["config", "list", "test-table"]);
    insta::assert_snapshot!(output, @r#"
    test-table.x = true
    test-table.y.foo = "abc"
    test-table.y.bar = 123
    test-table.z."with space"."function()" = 5
    [EOF]
    "#);
}

#[test]
fn test_config_list_inline_table() {
    let test_env = TestEnvironment::default();
    test_env.add_config(
        r#"
    test-table = { x = true, y = 1 }
    "#,
    );
    // Inline tables are expanded
    let output = test_env.run_jj_in(".", ["config", "list", "test-table"]);
    insta::assert_snapshot!(output, @"
    test-table.x = true
    test-table.y = 1
    [EOF]
    ");
    // Inner value can also be addressed by a dotted name path
    let output = test_env.run_jj_in(".", ["config", "list", "test-table.x"]);
    insta::assert_snapshot!(output, @"
    test-table.x = true
    [EOF]
    ");
}

#[test]
fn test_config_list_array() {
    let test_env = TestEnvironment::default();
    test_env.add_config(
        r#"
    test-array = [1, "b", 3.4]
    "#,
    );
    let output = test_env.run_jj_in(".", ["config", "list", "test-array"]);
    insta::assert_snapshot!(output, @r#"
    test-array = [1, "b", 3.4]
    [EOF]
    "#);
}

#[test]
fn test_config_list_array_of_tables() {
    let test_env = TestEnvironment::default();
    test_env.add_config(
        r#"
        [[test-table]]
        x = 1
        [[test-table]]
        y = ["z"]
        z."key=with whitespace" = []
    "#,
    );
    // Array is a value, so is array of tables
    let output = test_env.run_jj_in(".", ["config", "list", "test-table"]);
    insta::assert_snapshot!(output, @r#"
    test-table = [{ x = 1 }, { y = ["z"], z = { "key=with whitespace" = [] } }]
    [EOF]
    "#);
}

#[test]
fn test_config_list_all() {
    let test_env = TestEnvironment::default();
    test_env.add_config(
        r#"
    test-val = [1, 2, 3]
    [test-table]
    x = true
    y.foo = "abc"
    y.bar = 123
    "#,
    );

    let output = test_env.run_jj_in(".", ["config", "list"]);
    insta::assert_snapshot!(
        output.normalize_stdout_with(|s| find_stdout_lines(r"(test-val|test-table\b[^=]*)", &s)), @r#"
    test-val = [1, 2, 3]
    test-table.x = true
    test-table.y.foo = "abc"
    test-table.y.bar = 123
    [EOF]
    "#);
}

#[test]
fn test_config_list_multiline_string() {
    let test_env = TestEnvironment::default();
    test_env.add_config(
        r#"
    multiline = '''
foo
bar
'''
    "#,
    );

    let output = test_env.run_jj_in(".", ["config", "list", "multiline"]);
    insta::assert_snapshot!(output, @"
    multiline = '''
    foo
    bar
    '''
    [EOF]
    ");

    let output = test_env.run_jj_in(
        ".",
        [
            "config",
            "list",
            "multiline",
            "--include-overridden",
            "--config=multiline='single'",
        ],
    );
    insta::assert_snapshot!(output, @"
    # multiline = '''
    # foo
    # bar
    # '''
    multiline = 'single'
    [EOF]
    ");
}

#[test]
fn test_config_list_layer() {
    let mut test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    // Test with fresh new config file
    let user_config_path = test_env.config_path().join("config.toml");
    test_env.set_config_path(&user_config_path);
    let work_dir = test_env.work_dir("repo");

    // User
    work_dir
        .run_jj(["config", "set", "--user", "test-key", "test-val"])
        .success();

    work_dir
        .run_jj([
            "config",
            "set",
            "--user",
            "test-layered-key",
            "test-original-val",
        ])
        .success();

    let output = work_dir.run_jj(["config", "list", "--user"]);
    insta::assert_snapshot!(output, @r#"
    test-key = "test-val"
    test-layered-key = "test-original-val"
    [EOF]
    "#);

    // Repo
    work_dir
        .run_jj([
            "config",
            "set",
            "--repo",
            "test-layered-key",
            "test-layered-val",
        ])
        .success();

    let output = work_dir.run_jj(["config", "list", "--user"]);
    insta::assert_snapshot!(output, @r#"
    test-key = "test-val"
    [EOF]
    "#);

    let output = work_dir.run_jj(["config", "list", "--repo"]);
    insta::assert_snapshot!(output, @r#"
    test-layered-key = "test-layered-val"
    [EOF]
    "#);

    // Workspace (new scope takes precedence over repo)
    // Add a workspace-level setting
    work_dir
        .run_jj([
            "config",
            "set",
            "--workspace",
            "test-layered-wks-key",
            "ws-val",
        ])
        .success();

    // Listing user shouldn't include workspace
    let output = work_dir.run_jj(["config", "list", "--user"]);
    insta::assert_snapshot!(output, @r#"
    test-key = "test-val"
    [EOF]
    "#);

    // Workspace
    let output = work_dir.run_jj(["config", "list", "--workspace"]);
    insta::assert_snapshot!(output, @r#"
    test-layered-wks-key = "ws-val"
    [EOF]
    "#);
}

#[test]
fn test_config_list_origin() {
    let mut test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    // Test with fresh new config file
    let user_config_path = test_env.config_path().join("config.toml");
    test_env.set_config_path(&user_config_path);
    let work_dir = test_env.work_dir("repo");

    // User
    work_dir
        .run_jj(["config", "set", "--user", "test-key", "test-val"])
        .success();

    work_dir
        .run_jj([
            "config",
            "set",
            "--user",
            "test-layered-key",
            "test-original-val",
        ])
        .success();

    // Repo
    work_dir
        .run_jj([
            "config",
            "set",
            "--repo",
            "test-layered-key",
            "test-layered-val",
        ])
        .success();

    let output = work_dir.run_jj([
        "config",
        "list",
        "-Tbuiltin_config_list_detailed",
        "--config",
        "test-cli-key=test-cli-val",
    ]);
    insta::assert_snapshot!(output, @r#"
    test-key = "test-val" # user $TEST_ENV/config/config.toml
    test-layered-key = "test-layered-val" # repo $TEST_ENV/home/.config/jj/repos/0757f5ec8418b4f0983d/config.toml
    user.name = "Test User" # env
    user.email = "test.user@example.com" # env
    debug.commit-timestamp = "2001-02-03T04:05:11+07:00" # env
    debug.randomness-seed = 5 # env
    debug.operation-timestamp = "2001-02-03T04:05:11+07:00" # env
    operation.hostname = "host.example.com" # env
    operation.username = "test-username" # env
    test-cli-key = "test-cli-val" # cli
    [EOF]
    "#);

    let output = work_dir.run_jj([
        "config",
        "list",
        "-Tbuiltin_config_list_detailed",
        "--color=debug",
        "--include-defaults",
        "--include-overridden",
        "--config=test-key=test-cli-val",
        "test-key",
    ]);
    insta::assert_snapshot!(output, @r#"
    [38;5;8m<<config_list overridden name::# test-key>><<config_list overridden:: = >><<config_list overridden value::"test-val">><<config_list overridden:: # >><<config_list overridden source::user>><<config_list overridden:: >><<config_list overridden path::$TEST_ENV/config/config.toml>><<config_list overridden::>>[39m
    [38;5;2m<<config_list name::test-key>>[39m<<config_list:: = >>[38;5;3m<<config_list value::"test-cli-val">>[39m<<config_list:: # >>[38;5;4m<<config_list source::cli>>[39m<<config_list::>>
    [EOF]
    "#);

    let output = work_dir.run_jj([
        "config",
        "list",
        r#"-Tjson(self) ++ "\n""#,
        "--include-defaults",
        "--include-overridden",
        "--config=test-key=test-cli-val",
        "test-key",
    ]);
    insta::with_settings!({
        // Windows paths will be escaped in JSON syntax, which cannot be
        // normalized as $TEST_ENV.
        filters => [(r#""path":"[^"]*","#, r#""path":"<redacted>","#)],
    }, {
        insta::assert_snapshot!(output, @r#"
        {"name":"test-key","value":"test-val","source":"user","path":"<redacted>","is_overridden":true}
        {"name":"test-key","value":"test-cli-val","source":"cli","path":null,"is_overridden":false}
        [EOF]
        "#);
    });
}

#[test]
fn test_config_layer_override_default() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    let config_key = "merge-tools.vimdiff.program";

    // Default
    let output = work_dir.run_jj(["config", "list", config_key, "--include-defaults"]);
    insta::assert_snapshot!(output, @r#"
    merge-tools.vimdiff.program = "vim"
    [EOF]
    "#);

    // User
    test_env.add_config(format!(
        "{config_key} = {value}\n",
        value = to_toml_value("user")
    ));
    let output = work_dir.run_jj(["config", "list", config_key]);
    insta::assert_snapshot!(output, @r#"
    merge-tools.vimdiff.program = "user"
    [EOF]
    "#);

    // Repo
    work_dir
        .run_jj(["config", "set", "--repo", config_key, "repo"])
        .success();
    let output = work_dir.run_jj(["config", "list", config_key]);
    insta::assert_snapshot!(output, @r#"
    merge-tools.vimdiff.program = "repo"
    [EOF]
    "#);

    // Command argument
    let output = work_dir.run_jj([
        "config",
        "list",
        config_key,
        "--config",
        &format!("{config_key}={value}", value = to_toml_value("command-arg")),
    ]);
    insta::assert_snapshot!(output, @r#"
    merge-tools.vimdiff.program = "command-arg"
    [EOF]
    "#);

    // Allow printing overridden values
    let output = work_dir.run_jj([
        "config",
        "list",
        config_key,
        "--include-overridden",
        "--config",
        &format!("{config_key}={value}", value = to_toml_value("command-arg")),
    ]);
    insta::assert_snapshot!(output, @r#"
    # merge-tools.vimdiff.program = "user"
    # merge-tools.vimdiff.program = "repo"
    merge-tools.vimdiff.program = "command-arg"
    [EOF]
    "#);

    let output = work_dir.run_jj([
        "config",
        "list",
        "--color=always",
        config_key,
        "--include-overridden",
    ]);
    insta::assert_snapshot!(output, @r#"
    [38;5;8m# merge-tools.vimdiff.program = "user"[39m
    [38;5;2mmerge-tools.vimdiff.program[39m = [38;5;3m"repo"[39m
    [EOF]
    "#);
}

#[test]
fn test_config_layer_override_env() {
    let mut test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let config_key = "ui.editor";

    // Environment base
    test_env.add_env_var("EDITOR", "env-base");
    let work_dir = test_env.work_dir("repo");
    let output = work_dir.run_jj(["config", "list", config_key]);
    insta::assert_snapshot!(output, @r#"
    ui.editor = "env-base"
    [EOF]
    "#);

    // User
    test_env.add_config(format!(
        "{config_key} = {value}\n",
        value = to_toml_value("user")
    ));
    let output = work_dir.run_jj(["config", "list", config_key]);
    insta::assert_snapshot!(output, @r#"
    ui.editor = "user"
    [EOF]
    "#);

    // Repo
    work_dir
        .run_jj(["config", "set", "--repo", config_key, "repo"])
        .success();
    let output = work_dir.run_jj(["config", "list", config_key]);
    insta::assert_snapshot!(output, @r#"
    ui.editor = "repo"
    [EOF]
    "#);

    // Environment override
    test_env.add_env_var("JJ_EDITOR", "env-override");
    let work_dir = test_env.work_dir("repo");
    let output = work_dir.run_jj(["config", "list", config_key]);
    insta::assert_snapshot!(output, @r#"
    ui.editor = "env-override"
    [EOF]
    "#);

    // Command argument
    let output = work_dir.run_jj([
        "config",
        "list",
        config_key,
        "--config",
        &format!("{config_key}={value}", value = to_toml_value("command-arg")),
    ]);
    insta::assert_snapshot!(output, @r#"
    ui.editor = "command-arg"
    [EOF]
    "#);

    // Allow printing overridden values
    let output = work_dir.run_jj([
        "config",
        "list",
        config_key,
        "--include-overridden",
        "--config",
        &format!("{config_key}={value}", value = to_toml_value("command-arg")),
    ]);
    insta::assert_snapshot!(output, @r#"
    # ui.editor = "env-base"
    # ui.editor = "user"
    # ui.editor = "repo"
    # ui.editor = "env-override"
    ui.editor = "command-arg"
    [EOF]
    "#);
}

#[test]
fn test_config_layer_workspace() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "main"]).success();
    let main_dir = test_env.work_dir("main");
    let secondary_dir = test_env.work_dir("secondary");
    let config_key = "ui.editor";

    main_dir.write_file("file", "contents");
    main_dir.run_jj(["new"]).success();
    main_dir
        .run_jj(["workspace", "add", "--name", "second", "../secondary"])
        .success();

    // Repo
    main_dir
        .run_jj(["config", "set", "--repo", config_key, "main-repo"])
        .success();
    let output = main_dir.run_jj(["config", "list", config_key]);
    insta::assert_snapshot!(output, @r#"
    ui.editor = "main-repo"
    [EOF]
    "#);
    let output = secondary_dir.run_jj(["config", "list", config_key]);
    insta::assert_snapshot!(output, @r#"
    ui.editor = "main-repo"
    [EOF]
    "#);
}

#[test]
fn test_config_set_bad_opts() {
    let test_env = TestEnvironment::default();
    let output = test_env.run_jj_in(".", ["config", "set"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    error: the following required arguments were not provided:
      <--user|--repo|--workspace>
      <NAME>
      <VALUE>

    Usage: jj config set <--user|--repo|--workspace> <NAME> <VALUE>

    For more information, try '--help'.
    [EOF]
    [exit status: 2]
    ");

    let output = test_env.run_jj_in(".", ["config", "set", "--user", "", "x"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    error: invalid value '' for '<NAME>': TOML parse error at line 1, column 1
      |
    1 | 
      | ^
    unquoted keys cannot be empty, expected letters, numbers, `-`, `_`


    For more information, try '--help'.
    [EOF]
    [exit status: 2]
    ");

    let output = test_env.run_jj_in(".", ["config", "set", "--user", "x", "['typo'}"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    error: invalid value '['typo'}' for '<VALUE>': TOML parse error at line 1, column 8
      |
    1 | ['typo'}
      |        ^
    missing comma between array elements, expected `,`


    For more information, try '--help'.
    [EOF]
    [exit status: 2]
    ");
}

#[test]
fn test_config_set_for_user() {
    let mut test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    // Test with fresh new config file
    let user_config_path = test_env.config_path().join("config.toml");
    test_env.set_config_path(&user_config_path);
    let work_dir = test_env.work_dir("repo");

    work_dir
        .run_jj(["config", "set", "--user", "test-key", "test-val"])
        .success();
    work_dir
        .run_jj(["config", "set", "--user", "test-table.foo", "true"])
        .success();
    work_dir
        .run_jj(["config", "set", "--user", "test-table.'bar()'", "0"])
        .success();

    // Ensure test-key successfully written to user config.
    let user_config_toml = std::fs::read_to_string(&user_config_path)
        .unwrap_or_else(|_| panic!("Failed to read file {}", user_config_path.display()));
    insta::assert_snapshot!(user_config_toml, @r#"
    #:schema https://docs.jj-vcs.dev/latest/config-schema.json

    test-key = "test-val"

    [test-table]
    foo = true
    'bar()' = 0
    "#);
}

#[test]
fn test_config_set_for_user_directory() -> TestResult {
    let test_env = TestEnvironment::default();

    test_env
        .run_jj_in(".", ["config", "set", "--user", "test-key", "test-val"])
        .success();
    insta::assert_snapshot!(
        std::fs::read_to_string(test_env.last_config_file_path())?,
        @r#"
    test-key = "test-val"

    [template-aliases]
    'format_time_range(time_range)' = 'time_range.start() ++ " - " ++ time_range.end()'

    [git]
    colocate = false
    "#);

    // Add one more config file to the directory
    test_env.add_config("");
    let output = test_env.run_jj_in(
        ".",
        ["config", "set", "--user", "test-key", "test-other-val"],
    );
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    1: $TEST_ENV/config/config0001.toml
    2: $TEST_ENV/config/config0002.toml
    Choose a config file (default 1): 1
    [EOF]
    ");

    insta::assert_snapshot!(
        std::fs::read_to_string(test_env.first_config_file_path())?,
        @r#"
    test-key = "test-other-val"

    [template-aliases]
    'format_time_range(time_range)' = 'time_range.start() ++ " - " ++ time_range.end()'

    [git]
    colocate = false
    "#);

    insta::assert_snapshot!(
        std::fs::read_to_string(test_env.last_config_file_path())?,
        @"");
    Ok(())
}

#[test]
fn test_config_set_for_repo() -> TestResult {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    work_dir
        .run_jj(["config", "set", "--repo", "test-key", "test-val"])
        .success();
    work_dir
        .run_jj(["config", "set", "--repo", "test-table.foo", "true"])
        .success();
    // Ensure test-key successfully written to user config.
    let config_dir = test_env.work_dir("home/.config/jj/repos/8e4fac809cbb3b162c95");
    let repo_config_toml = config_dir.read_file("config.toml");
    insta::assert_snapshot!(repo_config_toml, @r#"
    #:schema https://docs.jj-vcs.dev/latest/config-schema.json

    test-key = "test-val"

    [test-table]
    foo = true
    "#);

    std::fs::remove_dir_all(config_dir.root())?;
    let output = work_dir.run_jj(["config", "path", "--repo"]);
    insta::assert_snapshot!(output, @"
    $TEST_ENV/home/.config/jj/repos/8e4fac809cbb3b162c95/config.toml
    [EOF]
    ------- stderr -------
    Warning: Per-repo config not found. Generating an empty one.
    Per-repo config is stored in the same directory as your user config for security reasons.
    If you work across multiple computers, you may want to keep your user config directory in sync.
    [EOF]
    ");

    // Check that it's regenerated the metadata.
    assert!(config_dir.root().join("metadata.binpb").is_file());
    // But not the config file itself.
    assert!(!config_dir.root().join("config.toml").is_file());
    Ok(())
}

#[test]
fn test_config_set_for_workspace() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    work_dir.run_jj(["new"]).success();
    work_dir
        .run_jj(["workspace", "add", "--name", "second", "../secondary"])
        .success();
    let work_dir = test_env.work_dir("secondary");

    // set in workspace
    work_dir
        .run_jj(["config", "set", "--workspace", "test-key", "ws-val"])
        .success();

    // Read workspace config
    let workspace_config = &test_env
        .work_dir("home/.config/jj/workspaces/0757f5ec8418b4f0983d")
        .read_file("config.toml");
    insta::assert_snapshot!(workspace_config, @r#"
    #:schema https://docs.jj-vcs.dev/latest/config-schema.json

    test-key = "ws-val"
    "#);
}

#[test]
fn test_config_set_toml_types() -> TestResult {
    let mut test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    // Test with fresh new config file
    let user_config_path = test_env.config_path().join("config.toml");
    test_env.set_config_path(&user_config_path);
    let work_dir = test_env.work_dir("repo");

    let set_value = |key, value| {
        work_dir
            .run_jj(["config", "set", "--user", key, value])
            .success();
    };
    set_value("test-table.integer", "42");
    set_value("test-table.float", "3.14");
    set_value("test-table.array", r#"["one", "two"]"#);
    set_value("test-table.boolean", "true");
    set_value("test-table.string", r#""foo""#);
    set_value("test-table.invalid", r"a + b");
    insta::assert_snapshot!(std::fs::read_to_string(&user_config_path)?, @r#"
    #:schema https://docs.jj-vcs.dev/latest/config-schema.json

    [test-table]
    integer = 42
    float = 3.14
    array = ["one", "two"]
    boolean = true
    string = "foo"
    invalid = "a + b"
    "#);
    Ok(())
}

#[test]
fn test_config_set_type_mismatch() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    work_dir
        .run_jj(["config", "set", "--user", "test-table.foo", "test-val"])
        .success();
    let output = work_dir.run_jj(["config", "set", "--user", "test-table", "not-a-table"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Error: Failed to set test-table
    Caused by: Would overwrite entire table test-table
    [EOF]
    [exit status: 1]
    ");

    // But it's fine to overwrite arrays and inline tables
    work_dir
        .run_jj(["config", "set", "--user", "test-table.array", "[1,2,3]"])
        .success();
    work_dir
        .run_jj(["config", "set", "--user", "test-table.array", "[4,5,6]"])
        .success();
    work_dir
        .run_jj(["config", "set", "--user", "test-table.inline", "{ x = 42}"])
        .success();
    work_dir
        .run_jj(["config", "set", "--user", "test-table.inline", "42"])
        .success();
}

#[test]
fn test_config_set_nontable_parent() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    work_dir
        .run_jj(["config", "set", "--user", "test-nontable", "test-val"])
        .success();
    let output = work_dir.run_jj(["config", "set", "--user", "test-nontable.foo", "test-val"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Error: Failed to set test-nontable.foo
    Caused by: Would overwrite non-table value with parent table test-nontable
    [EOF]
    [exit status: 1]
    ");
}

#[test]
fn test_config_unset_non_existent_key() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    let output = work_dir.run_jj(["config", "unset", "--user", "nonexistent"]);
    insta::assert_snapshot!(output, @r#"
    ------- stderr -------
    Error: "nonexistent" doesn't exist
    [EOF]
    [exit status: 1]
    "#);
}

#[test]
fn test_config_unset_inline_table_key() -> TestResult {
    let mut test_env = TestEnvironment::default();
    // Test with fresh new config file
    let user_config_path = test_env.config_path().join("config.toml");
    test_env.set_config_path(&user_config_path);
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    work_dir
        .run_jj(["config", "set", "--user", "inline-table", "{ foo = true }"])
        .success();
    work_dir
        .run_jj(["config", "unset", "--user", "inline-table.foo"])
        .success();
    let user_config_toml = std::fs::read_to_string(&user_config_path)?;
    insta::assert_snapshot!(user_config_toml, @"
    #:schema https://docs.jj-vcs.dev/latest/config-schema.json

    inline-table = {}
    ");
    Ok(())
}

#[test]
fn test_config_unset_table_like() -> TestResult {
    let mut test_env = TestEnvironment::default();
    // Test with fresh new config file
    let user_config_path = test_env.config_path().join("config.toml");
    test_env.set_config_path(&user_config_path);

    std::fs::write(
        &user_config_path,
        indoc! {b"
            inline-table = { foo = true }
            [non-inline-table]
            foo = true
        "},
    )?;

    // Inline table is syntactically a "value", so it can be deleted.
    test_env
        .run_jj_in(".", ["config", "unset", "--user", "inline-table"])
        .success();
    // Non-inline table cannot be deleted.
    let output = test_env.run_jj_in(".", ["config", "unset", "--user", "non-inline-table"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Error: Failed to unset non-inline-table
    Caused by: Would delete entire table non-inline-table
    [EOF]
    [exit status: 1]
    ");

    let user_config_toml = std::fs::read_to_string(&user_config_path)?;
    insta::assert_snapshot!(user_config_toml, @"
    [non-inline-table]
    foo = true
    ");
    Ok(())
}

#[test]
fn test_config_unset_for_user() -> TestResult {
    let mut test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    // Test with fresh new config file
    let user_config_path = test_env.config_path().join("config.toml");
    test_env.set_config_path(&user_config_path);
    let work_dir = test_env.work_dir("repo");

    work_dir
        .run_jj(["config", "set", "--user", "foo", "true"])
        .success();
    work_dir
        .run_jj(["config", "unset", "--user", "foo"])
        .success();

    work_dir
        .run_jj(["config", "set", "--user", "table.foo", "true"])
        .success();
    work_dir
        .run_jj(["config", "unset", "--user", "table.foo"])
        .success();

    work_dir
        .run_jj(["config", "set", "--user", "table.inline", "{ foo = true }"])
        .success();
    work_dir
        .run_jj(["config", "unset", "--user", "table.inline"])
        .success();

    let user_config_toml = std::fs::read_to_string(&user_config_path)?;
    insta::assert_snapshot!(user_config_toml, @"[table]");
    Ok(())
}

#[test]
fn test_config_unset_for_repo() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    work_dir
        .run_jj(["config", "set", "--repo", "test-key", "test-val"])
        .success();
    work_dir
        .run_jj(["config", "unset", "--repo", "test-key"])
        .success();

    let repo_config_toml = &test_env
        .work_dir("home/.config/jj/repos/8e4fac809cbb3b162c95")
        .read_file("config.toml");
    insta::assert_snapshot!(repo_config_toml, @"");
}

#[test]
fn test_config_unset_for_workspace() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    work_dir.run_jj(["new"]).success();
    work_dir
        .run_jj(["workspace", "add", "--name", "second", "../secondary"])
        .success();
    let work_dir = test_env.work_dir("secondary");

    // set then unset
    work_dir
        .run_jj(["config", "set", "--workspace", "foo", "bar"])
        .success();
    work_dir
        .run_jj(["config", "unset", "--workspace", "foo"])
        .success();

    let workspace_config = &test_env
        .work_dir("home/.config/jj/workspaces/0757f5ec8418b4f0983d")
        .read_file("config.toml");
    insta::assert_snapshot!(workspace_config, @"");
}

#[test]
fn test_config_edit_missing_opt() {
    let test_env = TestEnvironment::default();
    let output = test_env.run_jj_in(".", ["config", "edit"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    error: the following required arguments were not provided:
      <--user|--repo|--workspace>

    Usage: jj config edit <--user|--repo|--workspace>

    For more information, try '--help'.
    [EOF]
    [exit status: 2]
    ");
}

#[test]
fn test_config_edit_user() -> TestResult {
    let mut test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    // Remove one of the config file to disambiguate
    std::fs::remove_file(test_env.last_config_file_path())?;
    let edit_script = test_env.set_up_fake_editor();
    let work_dir = test_env.work_dir("repo");

    std::fs::write(edit_script, "dump-path path")?;
    work_dir.run_jj(["config", "edit", "--user"]).success();

    let edited_path = PathBuf::from(std::fs::read_to_string(test_env.env_root().join("path"))?);
    assert_eq!(
        edited_path,
        dunce::simplified(&test_env.last_config_file_path())
    );
    Ok(())
}

#[test]
fn test_config_edit_user_new_file() {
    let mut test_env = TestEnvironment::default();
    let user_config_path = test_env.config_path().join("config").join("file.toml");
    test_env.set_up_fake_editor(); // set $EDIT_SCRIPT, but added configuration is ignored
    test_env.add_env_var("EDITOR", fake_editor_path());
    test_env.set_config_path(&user_config_path);
    assert!(!user_config_path.exists());

    test_env
        .run_jj_in(".", ["config", "edit", "--user"])
        .success();
    assert!(
        user_config_path.exists(),
        "new file and directory should be created"
    );
}

#[test]
fn test_config_edit_repo() -> TestResult {
    let mut test_env = TestEnvironment::default();
    let edit_script = test_env.set_up_fake_editor();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let repo_config_dir = test_env.home_dir().join(".config/jj/repos");
    assert!(!repo_config_dir.is_dir());

    std::fs::write(edit_script, "dump-path path")?;
    let work_dir = test_env.work_dir("repo");
    work_dir.run_jj(["config", "edit", "--repo"]).success();

    let repo_config_path = test_env
        .work_dir("home/.config/jj/repos/8e4fac809cbb3b162c95")
        .root()
        .join("config.toml");

    let edited_path = PathBuf::from(std::fs::read_to_string(test_env.env_root().join("path"))?);
    assert!(repo_config_dir.is_dir());
    assert_eq!(edited_path, dunce::simplified(&repo_config_path));
    assert!(repo_config_path.exists(), "new file should be created");
    Ok(())
}

#[test]
fn test_config_edit_invalid_config() -> TestResult {
    let mut test_env = TestEnvironment::default();
    let edit_script = test_env.set_up_fake_editor();

    // Test re-edit
    std::fs::write(
        &edit_script,
        "write\ninvalid config here\0next invocation\n\0write\ntest=\"success\"",
    )?;
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    let output = work_dir.run_jj_with(|cmd| {
        force_interactive(cmd)
            .args(["config", "edit", "--repo"])
            .write_stdin("Y\n")
    });
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Editing file: $TEST_ENV/home/.config/jj/repos/8e4fac809cbb3b162c95/config.toml
    Warning: An error has been found inside the config:
    Caused by:
    1: Configuration cannot be parsed as TOML document
    2: TOML parse error at line 1, column 9
      |
    1 | invalid config here
      |         ^
    key with no value, expected `=`

    Do you want to keep editing the file? If not, previous config will be restored. (Yn): [EOF]
    ");

    let output = work_dir.run_jj(["config", "get", "test"]);
    insta::assert_snapshot!(output, @"
    success
    [EOF]
    "
    );

    // Test the restore previous config
    std::fs::write(&edit_script, "write\ninvalid config here")?;
    let work_dir = test_env.work_dir("repo");
    let output = work_dir.run_jj_with(|cmd| {
        force_interactive(cmd)
            .args(["config", "edit", "--repo"])
            .write_stdin("n\n")
    });
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Editing file: $TEST_ENV/home/.config/jj/repos/8e4fac809cbb3b162c95/config.toml
    Warning: An error has been found inside the config:
    Caused by:
    1: Configuration cannot be parsed as TOML document
    2: TOML parse error at line 1, column 9
      |
    1 | invalid config here
      |         ^
    key with no value, expected `=`

    Do you want to keep editing the file? If not, previous config will be restored. (Yn): [EOF]
    ");

    let output = work_dir.run_jj(["config", "get", "test"]);
    insta::assert_snapshot!(output, @"
    success
    [EOF]
    "
    );
    Ok(())
}

#[test]
fn test_config_path() {
    let mut test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    let user_config_path = test_env.env_root().join("config.toml");
    let repo_config_path = work_dir
        .root()
        .join(PathBuf::from_iter([".jj", "repo", "config.toml"]));
    let ws_config_path = work_dir
        .root()
        .join(PathBuf::from_iter([".jj", "workspace-config.toml"]));
    test_env.set_config_path(&user_config_path);
    let work_dir = test_env.work_dir("repo");

    insta::assert_snapshot!(work_dir.run_jj(["config", "path", "--user"]), @"
    $TEST_ENV/config.toml
    [EOF]
    ");
    assert!(
        !user_config_path.exists(),
        "jj config path shouldn't create new file"
    );

    insta::assert_snapshot!(work_dir.run_jj(["config", "path", "--repo"]), @"
    $TEST_ENV/home/.config/jj/repos/ffdaa62087a280bddc5e/config.toml
    [EOF]
    ");
    assert!(
        !repo_config_path.exists(),
        "jj config path shouldn't create new file"
    );

    insta::assert_snapshot!(test_env.run_jj_in(".", ["config", "path", "--repo"]), @"
    ------- stderr -------
    Error: No repo config path found
    [EOF]
    [exit status: 1]
    ");

    insta::assert_snapshot!(work_dir.run_jj(["config", "path", "--workspace"]), @"
    $TEST_ENV/home/.config/jj/workspaces/d043564ef93650b06a70/config.toml
    [EOF]
    ");
    assert!(
        !ws_config_path.exists(),
        "jj config path shouldn't create new file"
    );

    insta::assert_snapshot!(test_env.run_jj_in(".", ["config", "path", "--workspace"]), @"
    ------- stderr -------
    Error: No workspace config path found
    [EOF]
    [exit status: 1]
    ");
}

#[test]
fn test_config_path_multiple() -> TestResult {
    let mut test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let config_path = test_env.config_path().join("config.toml");
    let work_config_path = test_env.config_path().join("conf.d");
    let user_config_path = join_paths([config_path, work_config_path])?;
    test_env.set_config_path(&user_config_path);
    let work_dir = test_env.work_dir("repo");
    insta::assert_snapshot!(work_dir.run_jj(["config", "path", "--user"]), @"
    $TEST_ENV/config/config.toml
    $TEST_ENV/config/conf.d
    [EOF]
    ");
    Ok(())
}

#[test]
fn test_config_only_loads_toml_files() -> TestResult {
    let mut test_env = TestEnvironment::default();
    test_env.set_up_fake_editor();
    std::fs::File::create(test_env.config_path().join("is-not.loaded"))?;
    insta::assert_snapshot!(test_env.run_jj_in(".", ["config", "edit", "--user"]), @"
    ------- stderr -------
    1: $TEST_ENV/config/config0001.toml
    2: $TEST_ENV/config/config0002.toml
    Choose a config file (default 1): 1
    Editing file: $TEST_ENV/config/config0001.toml
    [EOF]
    ");
    Ok(())
}

#[test]
fn test_config_edit_repo_outside_repo() {
    let test_env = TestEnvironment::default();
    let output = test_env.run_jj_in(".", ["config", "edit", "--repo"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Error: No repo config path found to edit
    [EOF]
    [exit status: 1]
    ");
}

#[test]
fn test_config_get() {
    let test_env = TestEnvironment::default();
    test_env.add_config(
        r#"
    [table]
    string = "some value 1"
    int = 123
    list = ["list", "value"]
    overridden = "foo"
    "#,
    );
    test_env.add_config(
        r#"
    [table]
    overridden = "bar"
    "#,
    );

    let output = test_env.run_jj_in(".", ["config", "get", "nonexistent"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Config error: Value not found for nonexistent
    For help, see https://docs.jj-vcs.dev/latest/config/ or use `jj help -k config`.
    [EOF]
    [exit status: 1]
    ");

    let output = test_env.run_jj_in(".", ["config", "get", "table.string"]);
    insta::assert_snapshot!(output, @"
    some value 1
    [EOF]
    ");

    let output = test_env.run_jj_in(".", ["config", "get", "table.int"]);
    insta::assert_snapshot!(output, @"
    123
    [EOF]
    ");

    let output = test_env.run_jj_in(".", ["config", "get", "table.list"]);
    insta::assert_snapshot!(output, @r#"
    ["list", "value"]
    [EOF]
    "#);

    let output = test_env.run_jj_in(".", ["config", "get", "table"]);
    insta::assert_snapshot!(output, @r#"
    { string = "some value 1", int = 123, list = ["list", "value"], overridden = "bar" }
    [EOF]
    "#);

    let output = test_env.run_jj_in(".", ["config", "get", "table.overridden"]);
    insta::assert_snapshot!(output, @"
    bar
    [EOF]
    ");
}

#[test]
fn test_config_get_yields_values_consistent_with_schema_defaults() -> TestResult {
    let mut test_env = TestEnvironment::default();

    // The default test environment may already contain configuration that's
    // different from the true default, e.g. `git.colocate = false`. So we
    // explicitly set the config to an empty one in order to test the true
    // default config values.
    let config_dir = test_env.env_root().join("empty-config");
    std::fs::create_dir(&config_dir)?;
    test_env.set_config_path(&config_dir);

    let get_true_default = move |key: &str| {
        let output = test_env.run_jj_in(".", ["config", "get", key]).success();
        let output_doc = toml_edit::Document::parse(format!("test={}", output.stdout.normalized()))
            .unwrap_or_else(|_| {
                // Unfortunately for this test, `config get` is "lossy" and does not print
                // quoted strings. This means that e.g. `false` and `"false"` are not
                // distinguishable. If value couldn't be parsed, it's probably a string, so
                // let's parse its Debug string instead.
                toml_edit::Document::parse(format!("test={:?}", output.stdout.normalized().trim()))
                    .unwrap()
            });
        output_doc.get("test").unwrap().as_value().unwrap().clone()
    };

    let mut schema_defaults = toml_edit::ser::to_document(&default_config_from_schema())?;

    // Ensure that `get_values()` flattens the entire configuration.
    struct SetDotted;
    impl toml_edit::visit_mut::VisitMut for SetDotted {
        fn visit_table_like_mut(&mut self, node: &mut dyn toml_edit::TableLike) {
            node.set_dotted(true);
            toml_edit::visit_mut::visit_table_like_mut(self, node);
        }
    }
    toml_edit::visit_mut::visit_document_mut(&mut SetDotted, &mut schema_defaults);

    for (key, schema_default) in schema_defaults.into_table().get_values() {
        let key = key.iter().join(".");
        match key.as_str() {
            // These keys technically don't have a default value, but they exhibit a default
            // behavior consistent with the value claimed by the schema. When these defaults are
            // used, a hint is printed to stdout.
            "ui.default-command" => insta::assert_snapshot!(schema_default, @r#""log""#),
            "ui.diff-editor" => insta::assert_snapshot!(schema_default, @r#"":builtin""#),
            "ui.merge-editor" => insta::assert_snapshot!(schema_default, @r#"":builtin""#),
            "git.fetch" => insta::assert_snapshot!(schema_default, @r#""origin""#),
            "git.push" => insta::assert_snapshot!(schema_default, @r#""origin""#),

            // When no `short-prefixes` revset is explicitly configured, the revset for `log` is
            // used instead, even if that has a value different from the default. The schema
            // represents this behavior with a symbolic default value.
            "revsets.short-prefixes" => {
                insta::assert_snapshot!(schema_default, @r#""<revsets.log>""#);
            }

            // The default for `ui.pager` is a table; `ui.pager.command` is an array and `jj config
            // get` currently cannot print that. The schema default omits the env variable
            // `LESSCHARSET` and gives the default as a plain string.
            "ui.pager" => insta::assert_snapshot!(schema_default, @r#""less -FRX""#),

            // The `immutable_heads()` revset actually defaults to `builtin_immutable_heads()` but
            // this would be a poor starting point for a custom revset, so the schema "inlines"
            // `builtin_immutable_heads()`.
            r#"revset-aliases."immutable_heads()""# => {
                let builtin_default =
                    get_true_default("revset-aliases.'builtin_immutable_heads()'");
                assert!(
                    builtin_default.to_string() == schema_default.to_string(),
                    "{key}: the schema claims a default ({schema_default}) which is different \
                     from what builtin_immutable_heads() resolves to ({builtin_default})"
                );
            }

            _ => {
                let true_default = get_true_default(&key);
                assert!(
                    true_default.to_string() == schema_default.to_string(),
                    "{key}: true default value ({true_default}) is not consistent with default \
                     claimed by schema ({schema_default})"
                );
            }
        }
    }
    Ok(())
}

#[test]
fn test_config_path_syntax() {
    let test_env = TestEnvironment::default();
    test_env.add_config(
        r#"
    a.'b()' = 0
    'b c'.d = 1
    'b c'.e.'f[]' = 2
    - = 3
    _ = 4
    '.' = 5
    "#,
    );

    let output = test_env.run_jj_in(".", ["config", "list", "a.'b()'"]);
    insta::assert_snapshot!(output, @"
    a.'b()' = 0
    [EOF]
    ");
    let output = test_env.run_jj_in(".", ["config", "list", "'b c'"]);
    insta::assert_snapshot!(output, @r#"
    'b c'.d = 1
    'b c'.e."f[]" = 2
    [EOF]
    "#);
    let output = test_env.run_jj_in(".", ["config", "list", "'b c'.d"]);
    insta::assert_snapshot!(output, @"
    'b c'.d = 1
    [EOF]
    ");
    let output = test_env.run_jj_in(".", ["config", "list", "'b c'.e.'f[]'"]);
    insta::assert_snapshot!(output, @"
    'b c'.e.'f[]' = 2
    [EOF]
    ");
    let output = test_env.run_jj_in(".", ["config", "get", "'b c'.e.'f[]'"]);
    insta::assert_snapshot!(output, @"
    2
    [EOF]
    ");

    // Not a table
    let output = test_env.run_jj_in(".", ["config", "list", "a.'b()'.x"]);
    insta::assert_snapshot!(output, @r"
    ------- stderr -------
    Warning: No matching config key for: a.'b()'.x
    [EOF]
    ");
    let output = test_env.run_jj_in(".", ["config", "get", "a.'b()'.x"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Config error: Value not found for a.'b()'.x
    For help, see https://docs.jj-vcs.dev/latest/config/ or use `jj help -k config`.
    [EOF]
    [exit status: 1]
    ");

    // "-" and "_" are valid TOML keys
    let output = test_env.run_jj_in(".", ["config", "list", "-"]);
    insta::assert_snapshot!(output, @"
    - = 3
    [EOF]
    ");
    let output = test_env.run_jj_in(".", ["config", "list", "_"]);
    insta::assert_snapshot!(output, @"
    _ = 4
    [EOF]
    ");

    // "." requires quoting
    let output = test_env.run_jj_in(".", ["config", "list", "'.'"]);
    insta::assert_snapshot!(output, @"
    '.' = 5
    [EOF]
    ");
    let output = test_env.run_jj_in(".", ["config", "get", "'.'"]);
    insta::assert_snapshot!(output, @"
    5
    [EOF]
    ");
    let output = test_env.run_jj_in(".", ["config", "get", "."]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    error: invalid value '.' for '<NAME>': TOML parse error at line 1, column 1
      |
    1 | .
      | ^
    unquoted keys cannot be empty, expected letters, numbers, `-`, `_`


    For more information, try '--help'.
    [EOF]
    [exit status: 2]
    ");

    // Invalid TOML keys
    let output = test_env.run_jj_in(".", ["config", "list", "b c"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    error: invalid value 'b c' for '[NAME]': TOML parse error at line 1, column 3
      |
    1 | b c
      |   ^
    unexpected content, expected nothing


    For more information, try '--help'.
    [EOF]
    [exit status: 2]
    ");
    let output = test_env.run_jj_in(".", ["config", "list", ""]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    error: invalid value '' for '[NAME]': TOML parse error at line 1, column 1
      |
    1 | 
      | ^
    unquoted keys cannot be empty, expected letters, numbers, `-`, `_`


    For more information, try '--help'.
    [EOF]
    [exit status: 2]
    ");
}

#[test]
#[cfg_attr(windows, ignore = "dirs::home_dir() can't be overridden by $HOME")] // TODO
fn test_config_conditional() -> TestResult {
    let mut test_env = TestEnvironment::default();
    let home_dir = test_env.work_dir(test_env.home_dir());
    home_dir.run_jj(["git", "init", "repo1"]).success();
    home_dir.run_jj(["git", "init", "repo2"]).success();
    // Test with fresh new config file
    let user_config_path = test_env.env_root().join("config.toml");
    test_env.set_config_path(&user_config_path);
    std::fs::write(
        &user_config_path,
        indoc! {"
            foo = 'global'
            baz = 'global'
            qux = 'global'

            [[--scope]]
            --when.repositories = ['~/repo1']
            foo = 'repo1'
            [[--scope]]
            --when.repositories = ['~/repo2']
            foo = 'repo2'
            [[--scope]]
            --when.workspaces = ['~/repo2']
            foo2 = 'repo2'
            [[--scope]]
            --when.workspaces = ['~/repo2_1']
            foo2 = 'repo2_1'

            [[--scope]]
            --when.commands = ['config']
            baz = 'config'
            [[--scope]]
            --when.commands = ['config get']
            qux = 'get'
            [[--scope]]
            --when.commands = ['config list']
            qux = 'list'
        "},
    )?;
    let home_dir = test_env.work_dir(test_env.home_dir());
    let work_dir1 = home_dir.dir("repo1");
    let work_dir2 = home_dir.dir("repo2");
    let work_dir2_1 = home_dir.dir("repo2_1");
    work_dir2
        .run_jj(&["workspace", "add", "../repo2_1"])
        .success();

    // get and list should refer to the resolved config
    let output = test_env.run_jj_in(".", ["config", "get", "foo"]);
    insta::assert_snapshot!(output, @"
    global
    [EOF]
    ");
    let output = work_dir1.run_jj(["config", "get", "foo"]);
    insta::assert_snapshot!(output, @"
    repo1
    [EOF]
    ");
    // baz should be the same for `jj config get` and `jj config list`
    // qux should be different
    let output = work_dir1.run_jj(["config", "get", "baz"]);
    insta::assert_snapshot!(output, @"
    config
    [EOF]
    ");
    let output = work_dir1.run_jj(["config", "get", "qux"]);
    insta::assert_snapshot!(output, @"
    get
    [EOF]
    ");
    let output = test_env.run_jj_in(".", ["config", "list", "--user"]);
    insta::assert_snapshot!(output, @"
    foo = 'global'
    baz = 'config'
    qux = 'list'
    [EOF]
    ");
    let output = work_dir1.run_jj(["config", "list", "--user"]);
    insta::assert_snapshot!(output, @"
    foo = 'repo1'
    baz = 'config'
    qux = 'list'
    [EOF]
    ");
    let output = work_dir2.run_jj(["config", "list", "--user"]);
    insta::assert_snapshot!(output, @"
    foo = 'repo2'
    foo2 = 'repo2'
    baz = 'config'
    qux = 'list'
    [EOF]
    ");
    let output = work_dir2_1.run_jj(["config", "list", "--user"]);
    insta::assert_snapshot!(output, @"
    foo = 'repo2'
    foo2 = 'repo2_1'
    baz = 'config'
    qux = 'list'
    [EOF]
    ");

    // relative workspace path
    let output = work_dir2.run_jj(["config", "list", "--user", "-R../repo1"]);
    insta::assert_snapshot!(output, @"
    foo = 'repo1'
    baz = 'config'
    qux = 'list'
    [EOF]
    ");

    // set and unset should refer to the source config
    // (there's no option to update scoped table right now.)
    let output = test_env.run_jj_in(".", ["config", "set", "--user", "bar", "new value"]);
    insta::assert_snapshot!(output, @"");
    let config_contents = std::fs::read_to_string(&user_config_path)?;
    insta::assert_snapshot!(config_contents, @r#"
    foo = 'global'
    baz = 'global'
    qux = 'global'
    bar = "new value"

    [[--scope]]
    --when.repositories = ['~/repo1']
    foo = 'repo1'
    [[--scope]]
    --when.repositories = ['~/repo2']
    foo = 'repo2'
    [[--scope]]
    --when.workspaces = ['~/repo2']
    foo2 = 'repo2'
    [[--scope]]
    --when.workspaces = ['~/repo2_1']
    foo2 = 'repo2_1'

    [[--scope]]
    --when.commands = ['config']
    baz = 'config'
    [[--scope]]
    --when.commands = ['config get']
    qux = 'get'
    [[--scope]]
    --when.commands = ['config list']
    qux = 'list'
    "#);
    let output = work_dir1.run_jj(["config", "unset", "--user", "foo"]);
    insta::assert_snapshot!(output, @"");
    let config_contents = std::fs::read_to_string(&user_config_path)?;
    insta::assert_snapshot!(config_contents, @r#"
    baz = 'global'
    qux = 'global'
    bar = "new value"

    [[--scope]]
    --when.repositories = ['~/repo1']
    foo = 'repo1'
    [[--scope]]
    --when.repositories = ['~/repo2']
    foo = 'repo2'
    [[--scope]]
    --when.workspaces = ['~/repo2']
    foo2 = 'repo2'
    [[--scope]]
    --when.workspaces = ['~/repo2_1']
    foo2 = 'repo2_1'

    [[--scope]]
    --when.commands = ['config']
    baz = 'config'
    [[--scope]]
    --when.commands = ['config get']
    qux = 'get'
    [[--scope]]
    --when.commands = ['config list']
    qux = 'list'
    "#);
    Ok(())
}

// Minimal test for Windows where the home directory can't be switched.
// (Can be removed if test_config_conditional() is enabled on Windows.)
#[test]
fn test_config_conditional_without_home_dir() -> TestResult {
    let mut test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    // Test with fresh new config file
    let user_config_path = test_env.env_root().join("config.toml");
    test_env.set_config_path(&user_config_path);
    let work_dir = test_env.work_dir("repo");
    std::fs::write(
        &user_config_path,
        format!(
            indoc! {"
                foo = 'global'
                [[--scope]]
                --when.repositories = [{repo_path}]
                foo = 'repo'
            "},
            // "\\?\" paths shouldn't be required on Windows
            repo_path = to_toml_value(dunce::simplified(work_dir.root()).to_str().unwrap())
        ),
    )?;

    let output = test_env.run_jj_in(".", ["config", "get", "foo"]);
    insta::assert_snapshot!(output, @"
    global
    [EOF]
    ");
    let output = work_dir.run_jj(["config", "get", "foo"]);
    insta::assert_snapshot!(output, @"
    repo
    [EOF]
    ");
    Ok(())
}

#[test]
fn test_config_show_paths() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    work_dir
        .run_jj(["config", "set", "--user", "ui.paginate", ":builtin"])
        .success();
    let output = test_env.run_jj_in(".", ["st"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Config error: Invalid type or value for ui.paginate
    Caused by: unknown variant `:builtin`, expected `never` or `auto`

    Hint: Check the config file: $TEST_ENV/config/config0001.toml
    For help, see https://docs.jj-vcs.dev/latest/config/ or use `jj help -k config`.
    [EOF]
    [exit status: 1]
    ");
}

#[test]
fn test_config_author_change_warning() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    let output = work_dir.run_jj(["config", "set", "--repo", "user.email", "'Foo'"]);
    insta::assert_snapshot!(output, @r#"
    ------- stderr -------
    Warning: This setting will only impact future commits.
    The author of the working copy will stay "Test User <test.user@example.com>".
    To change the working copy author, use "jj metaedit --update-author".
    [EOF]
    "#);

    // test_env.run_jj*() resets state for every invocation
    // for this test, the state (user.email) is needed
    work_dir
        .run_jj_with(|cmd| {
            cmd.args(["metaedit", "--update-author"])
                .env_remove("JJ_EMAIL")
        })
        .success();

    let output = work_dir.run_jj(["show"]);
    insta::assert_snapshot!(output, @"
    Commit ID: c2090b51d7ecd861e83a677fdf5e9d855efd14fa
    Change ID: qpvuntsmwlqtpsluzzsnyyzlmlwvmlnu
    Author   : Test User <Foo> (2001-02-03 08:05:07)
    Committer: Test User <Foo> (2001-02-03 08:05:09)

        (no description set)

    [EOF]
    ");

    let output = work_dir.run_jj(["config", "set", "--repo", "user.name", "'Bar'"]);
    insta::assert_snapshot!(output, @r#"
    ------- stderr -------
    Warning: This setting will only impact future commits.
    The author of the working copy will stay "Test User <Foo>".
    To change the working copy author, use "jj metaedit --update-author".
    [EOF]
    "#);

    // test_env.run_jj*() resets state for every invocation
    // for this test, both user.name and user.email are needed
    work_dir
        .run_jj_with(|cmd| {
            cmd.args(["metaedit", "--update-author"])
                .env_remove("JJ_EMAIL")
                .env_remove("JJ_USER")
        })
        .success();

    let output = work_dir.run_jj(["show"]);
    insta::assert_snapshot!(output, @"
    Commit ID: cab1afa907c193ca2db14f6bc971fde030e3970a
    Change ID: qpvuntsmwlqtpsluzzsnyyzlmlwvmlnu
    Author   : Bar <Foo> (2001-02-03 08:05:07)
    Committer: Bar <Foo> (2001-02-03 08:05:12)

        (no description set)

    [EOF]
    ");

    // no warnings if no change
    let output = work_dir.run_jj(["config", "set", "--repo", "user.name", "'Bar'"]);
    insta::assert_snapshot!(output, @"");
}

#[test]
fn test_config_author_change_warning_root_env() {
    let test_env = TestEnvironment::default();
    let output = test_env.run_jj_in(".", ["config", "set", "--user", "user.email", "'Foo'"]);
    insta::assert_snapshot!(output, @"");
}

fn find_stdout_lines(keyname_pattern: &str, stdout: &str) -> String {
    let key_line_re = Regex::new(&format!(r"(?m)^{keyname_pattern} = .*\n")).unwrap();
    key_line_re.find_iter(stdout).map(|m| m.as_str()).collect()
}

/// Set up a per-repo config directory by running `config set --repo`. Returns
/// the path to the per-repo config directory
/// (e.g. `~/.config/jj/repos/<config_id>/`).
fn create_repo_with_config(test_env: &mut TestEnvironment, repo_name: &str) -> TestResult<PathBuf> {
    test_env
        .run_jj_in(".", ["git", "init", repo_name])
        .success();
    let work_dir = test_env.work_dir(repo_name);
    work_dir
        .run_jj(["config", "set", "--repo", "user.name", "test"])
        .success();
    let output = work_dir.run_jj(["config", "path", "--repo"]).success();
    let config_file = Path::new(output.stdout.raw().trim_end_matches('\n'));
    Ok(config_file.parent().unwrap().to_path_buf())
}

#[test]
fn test_config_gc_no_repos_dir() {
    let test_env = TestEnvironment::default();
    // No repo config dir created at all.
    let output = test_env.run_jj_in(".", ["config", "gc"]);
    insta::assert_snapshot!(output, @r"
    ------- stderr -------
    Missing repo configs (repo path no longer exists):
      (none)
    [EOF]
    ");
}

#[test]
fn test_config_gc_all_existing() -> TestResult {
    let mut test_env = TestEnvironment::default();
    create_repo_with_config(&mut test_env, "repo")?;

    let output = test_env.run_jj_in(".", ["config", "gc"]);
    insta::assert_snapshot!(output, @r"
    ------- stderr -------
    Missing repo configs (repo path no longer exists):
      (none)
    [EOF]
    ");
    Ok(())
}

#[test]
fn test_config_gc_missing_default_no() -> TestResult {
    let mut test_env = TestEnvironment::default();
    let config_dir = create_repo_with_config(&mut test_env, "repo")?;
    // Remove the repo so its metadata path no longer exists.
    std::fs::remove_dir_all(test_env.env_root().join("repo"))?;

    // Non-interactive: the prompt auto-answers with the default ("no").
    let output = test_env.run_jj_in(".", ["config", "gc"]);
    insta::assert_snapshot!(output, @r"
    ------- stderr -------
    Missing repo configs (repo path no longer exists):
      $TEST_ENV/home/.config/jj/repos/8e4fac809cbb3b162c95
        repo path: $TEST_ENV/repo/.jj/repo
    Delete 1 missing repo config directories? (yN): n
    Aborted; nothing was deleted.
    [EOF]
    ");
    // The directory should still be there.
    assert!(config_dir.is_dir());
    Ok(())
}

#[test]
fn test_config_gc_missing_confirmed() -> TestResult {
    let mut test_env = TestEnvironment::default();
    let config_dir = create_repo_with_config(&mut test_env, "repo")?;
    std::fs::remove_dir_all(test_env.env_root().join("repo"))?;

    let output = test_env.work_dir("").run_jj_with(|cmd| {
        force_interactive(cmd)
            .args(["config", "gc"])
            .write_stdin("y\n")
    });
    insta::assert_snapshot!(output, @r"
    ------- stderr -------
    Missing repo configs (repo path no longer exists):
      $TEST_ENV/home/.config/jj/repos/8e4fac809cbb3b162c95
        repo path: $TEST_ENV/repo/.jj/repo
    Delete 1 missing repo config directories? (yN): Deleted 1 config directories.
    [EOF]
    ");
    assert!(!config_dir.exists());
    Ok(())
}

#[test]
fn test_config_gc_missing_with_extra_file() -> TestResult {
    let mut test_env = TestEnvironment::default();
    let config_dir = create_repo_with_config(&mut test_env, "repo")?;
    std::fs::remove_dir_all(test_env.env_root().join("repo"))?;
    // An unrelated file in the per-repo config directory should prevent us
    // from deleting it: we only remove the known jj-managed files and then
    // try to rmdir the (hopefully empty) directory.
    let extra_file = config_dir.join("unexpected.txt");
    std::fs::write(&extra_file, b"hello")?;

    let output = test_env.work_dir("").run_jj_with(|cmd| {
        force_interactive(cmd)
            .args(["config", "gc"])
            .write_stdin("y\n")
    });
    insta::assert_snapshot!(output.normalize_stderr_with(|s| {
        // The OS-specific "directory not empty" message varies, so strip the
        // trailing detail after the path for a stable snapshot.
        regex::Regex::new(r"(?m)(Failed to delete \S+):.*$")
            .unwrap()
            .replace_all(&s, "$1: <directory not empty>")
            .into_owned()
    }), @r"
    ------- stderr -------
    Missing repo configs (repo path no longer exists):
      $TEST_ENV/home/.config/jj/repos/8e4fac809cbb3b162c95
        repo path: $TEST_ENV/repo/.jj/repo
    Delete 1 missing repo config directories? (yN): Warning: Failed to delete $TEST_ENV/home/.config/jj/repos/8e4fac809cbb3b162c95: <directory not empty>
    Deleted 0 config directories.
    [EOF]
    ");
    // The directory must still be there (with the unrelated file intact).
    assert!(config_dir.is_dir());
    assert!(extra_file.is_file());
    // The known jj-managed files should be gone.
    assert!(!config_dir.join("config.toml").exists());
    assert!(!config_dir.join("metadata.binpb").exists());
    Ok(())
}
