use cargo_rdme::find_first_file_in_ancestors;
use cargo_rdme::transform::{IntralinksConfig, IntralinksDocsConfig};
use clap::{ArgAction, value_parser};
use std::error::Error;
use std::ffi::OsString;
use std::fmt::{Display, Formatter};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use thiserror::Error;

const PROJECT_NAME: &str = env!("CARGO_PKG_NAME");
const VERSION: &str = env!("CARGO_PKG_VERSION");

#[derive(Debug)]
pub struct InvalidOptValue {
    value: String,
}

impl Display for InvalidOptValue {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        f.write_fmt(format_args!("invalid value \"{}\"", self.value))
    }
}

impl Error for InvalidOptValue {}

#[derive(PartialEq, Eq, Debug, Default, Clone, Copy)]
pub enum LineTerminatorOpt {
    #[default]
    Auto,
    Lf,
    CrLf,
}

impl FromStr for LineTerminatorOpt {
    type Err = InvalidOptValue;

    fn from_str(s: &str) -> Result<LineTerminatorOpt, InvalidOptValue> {
        match s {
            "auto" => Ok(LineTerminatorOpt::Auto),
            "lf" => Ok(LineTerminatorOpt::Lf),
            "crlf" => Ok(LineTerminatorOpt::CrLf),
            v => Err(InvalidOptValue { value: v.to_owned() }),
        }
    }
}

#[derive(PartialEq, Eq, Debug, Default, Clone)]
pub enum EntrypointOpt {
    #[default]
    Auto,
    Lib,
    BinDefault,
    BinName(String),
}

impl FromStr for EntrypointOpt {
    type Err = InvalidOptValue;

    fn from_str(s: &str) -> Result<EntrypointOpt, InvalidOptValue> {
        match s {
            "auto" => Ok(EntrypointOpt::Auto),
            "lib" => Ok(EntrypointOpt::Lib),
            "bin" => Ok(EntrypointOpt::BinDefault),
            v if v.starts_with("bin:") && v.len() > "bin:".len() => {
                let name = v["bin:".len()..].to_owned();
                Ok(EntrypointOpt::BinName(name))
            }
            v => Err(InvalidOptValue { value: v.to_owned() }),
        }
    }
}

#[derive(Debug)]
pub struct CmdOptions {
    workspace_project: Option<String>,
    entrypoint: Option<EntrypointOpt>,
    line_terminator: Option<LineTerminatorOpt>,
    check: bool,
    no_fail_on_warnings: bool,
    intralinks_strip_links: bool,
    force: bool,
    readme_path: Option<PathBuf>,
    manifest_path: Option<PathBuf>,
    intralinks_all_features: bool,
    intralinks_features: Option<Vec<String>>,
    intralinks_no_default_features: bool,
    heading_base_level: Option<u8>,
}

impl CmdOptions {
    pub fn manifest_path(&self) -> Option<&Path> {
        self.manifest_path.as_deref()
    }
}

fn get_cmd_args() -> Vec<OsString> {
    let mut args: Vec<OsString> = std::env::args_os().collect();
    let subcommand: &str = {
        let package_name = env!("CARGO_PKG_NAME");

        assert!(package_name.starts_with("cargo-"), "package name does not start with `cargo-`");

        &package_name["cargo-".len()..]
    };

    // When cargo executes an external subcommand it passes the name of the command itself as the
    // second argument.  Here we remove that so that we can simply run `cargo run` instead of
    // `cargo run -- rdme` for local development.
    if args.len() >= 2 && args[1] == subcommand {
        args.remove(1);
    }

    args
}

#[derive(Debug)]
pub enum Command {
    Run(CmdOptions),
    InstallRustToolchainForIntralinks,
}

#[allow(clippy::too_many_lines)]
pub fn command() -> Command {
    use clap::{Arg, Command as ClapCommand};

    let cmd_opts = ClapCommand::new(PROJECT_NAME)
        .version(VERSION)
        .about("Create the README from your crate’s documentation.")
        .styles(clap_cargo::style::CLAP_STYLING)
        .subcommand(
            ClapCommand::new("install-rust-toolchain-for-intralinks")
                .about(
                    "Install the nightly rust toolchain needed for intralink resolution.\n\
                     \n\
                     If the toolchain is already installed this is command does nothing.",
                )
                .hide(true),
        )
        .arg(
            Arg::new("entrypoint")
                .long("entrypoint")
                .help("selects the source code entrypoint of the crate (e.g. auto, lib, bin, bin:<name>)")
                .value_parser(EntrypointOpt::from_str),
        )
        .arg(
            Arg::new("line-terminator")
                .long("line-terminator")
                .help("line terminator to use when writing the README file")
                .value_parser(LineTerminatorOpt::from_str),
        )
        .arg(
            Arg::new("readme-path")
                .long("readme-path")
                .short('r')
                .help("README file path to use (overrides what is specified in the project `Cargo.toml`)")
                .value_parser(value_parser!(PathBuf)),
        )
        .arg(
            Arg::new("manifest-path")
                .long("manifest-path")
                .help("path to `Cargo.toml`")
                .value_parser(value_parser!(PathBuf)),
        )
        .arg(
            Arg::new("workspace-project")
                .long("workspace-project")
                .short('w')
                .help("project to get the documentation from if your are using workspaces"),
        )
        .arg(
            Arg::new("check")
                .long("check")
                .short('c')
                .help("checks if the README is up to date (exit code 3 if there’s a mismatch, 4 if warnings were emitted)")
                .action(ArgAction::SetTrue),
        )
        .arg(
            Arg::new("no-fail-on-warnings")
                .long("no-fail-on-warnings")
                .help("do not exit with a error status code when checking if the README is up to date")
                .action(ArgAction::SetTrue),
        )
        .arg(
            Arg::new("intralinks-strip-links")
                .long("intralinks-strip-links")
                .help("remove the intralinks")
                .action(ArgAction::SetTrue),
        )
        .arg(
            Arg::new("intralinks-all-features")
                .long("intralinks-all-features")
                .help("enable all features when calling rustdoc to resolve intralinks")
                .action(ArgAction::SetTrue),
        )
        .arg(
            Arg::new("intralinks-features")
                .long("intralinks-features")
                .help("features to enable when calling rustdoc to resolve intralinks (comma separated list)")
                .value_delimiter(',')
                .action(ArgAction::Append),
        )
        .arg(
            Arg::new("intralinks-no-default-features")
                .long("intralinks-no-default-features")
                .help("disable default features when calling rustdoc to resolve intralinks")
                .action(ArgAction::SetTrue),
        )
        .arg(
            Arg::new("heading-base-level")
                .long("heading-base-level")
                .help("heading level to be added to the heading level in the rust documentation")
                .value_parser(value_parser!(u8)),
        )
        .arg(
            Arg::new("force")
                .long("force")
                .short('f')
                .help("force README update, even when there are uncommitted changes")
                .action(ArgAction::SetTrue),
        )
        .get_matches_from(get_cmd_args());

    if let Some(("install-rust-toolchain-for-intralinks", _)) = cmd_opts.subcommand() {
        return Command::InstallRustToolchainForIntralinks;
    }

    let workspace_project = cmd_opts.get_one::<String>("workspace-project").cloned();

    let line_terminator: Option<LineTerminatorOpt> =
        cmd_opts.get_one::<LineTerminatorOpt>("line-terminator").copied();

    let entrypoint = cmd_opts.get_one::<EntrypointOpt>("entrypoint").cloned();

    let readme_path = cmd_opts.get_one::<PathBuf>("readme-path").cloned();
    let manifest_path = cmd_opts.get_one::<PathBuf>("manifest-path").cloned();

    let intralinks_features =
        cmd_opts.get_many::<String>("intralinks-features").map(|v| v.cloned().collect());

    let heading_base_level = cmd_opts.get_one::<u8>("heading-base-level").copied();

    Command::Run(CmdOptions {
        workspace_project,
        entrypoint,
        line_terminator,
        check: cmd_opts.get_flag("check"),
        no_fail_on_warnings: cmd_opts.get_flag("no-fail-on-warnings"),
        intralinks_strip_links: cmd_opts.get_flag("intralinks-strip-links"),
        force: cmd_opts.get_flag("force"),
        readme_path,
        manifest_path,
        intralinks_all_features: cmd_opts.get_flag("intralinks-all-features"),
        intralinks_features,
        intralinks_no_default_features: cmd_opts.get_flag("intralinks-no-default-features"),
        heading_base_level,
    })
}

#[derive(Error, Debug)]
pub enum ConfigFileOptionsError {
    #[error("failed to read configuration file: {0}")]
    ErrorReadingConfigFile(PathBuf),
    #[error("failed to parse toml: {0}")]
    ErrorParsingToml(toml::de::Error),
    #[error("invalid field \"{0}\"")]
    InvalidField(&'static str),
    #[error("invalid entrypoint table")]
    InvalidEntrypointTable,
    #[error("`{new}` and `{old}` cannot be set at the same time")]
    ConflictingDocsOptions { new: &'static str, old: &'static str },
    #[error("`intralinks.docs.base-url` is required when `intralinks.docs.layout = \"flat\"`")]
    FlatLayoutRequiresBaseUrl,
    #[error("`intralinks.docs.version` cannot be set when `intralinks.docs.layout = \"flat\"`")]
    FlatLayoutRejectsVersion,
}

#[derive(Debug, PartialEq, Eq)]
pub struct ConfigFileOptions {
    line_terminator: Option<LineTerminatorOpt>,
    workspace_project: Option<String>,
    entrypoint: Option<EntrypointOpt>,
    readme_path: Option<PathBuf>,
    intralinks: Option<IntralinksConfig>,
    heading_base_level: Option<u8>,
}

#[allow(clippy::too_many_lines)]
fn config_file_options_from_str(
    config_str: &str,
    mut emit_deprecation_warning: impl FnMut(&str),
) -> Result<ConfigFileOptions, ConfigFileOptionsError> {
    let config_toml: toml::Value =
        toml::from_str(config_str).map_err(ConfigFileOptionsError::ErrorParsingToml)?;

    let workspace_project =
        config_toml.get("workspace-project").and_then(toml::Value::as_str).map(ToOwned::to_owned);

    let line_terminator = config_toml
        .get("line-terminator")
        .map(|v| {
            v.as_str().ok_or(ConfigFileOptionsError::InvalidField("line-terminator")).and_then(
                |str| {
                    LineTerminatorOpt::from_str(str)
                        .map_err(|_| ConfigFileOptionsError::InvalidField("line-terminator"))
                },
            )
        })
        .transpose()?;

    let entrypoint_table = config_toml.get("entrypoint").and_then(toml::Value::as_table);

    let entrypoint_type =
        entrypoint_table.and_then(|t| t.get("type")).and_then(toml::Value::as_str);
    let entrypoint_bin_name =
        entrypoint_table.and_then(|t| t.get("bin-name")).and_then(toml::Value::as_str);

    let entrypoint = match (entrypoint_type, entrypoint_bin_name) {
        (Some("lib"), None) => Some(EntrypointOpt::Lib),
        (Some("bin"), None) => Some(EntrypointOpt::BinDefault),
        (Some("bin"), Some(name)) => Some(EntrypointOpt::BinName(name.to_owned())),
        (None, None) => None,
        _ => return Err(ConfigFileOptionsError::InvalidEntrypointTable),
    };

    let readme_path =
        config_toml.get("readme-path").and_then(toml::Value::as_str).map(PathBuf::from);

    let heading_base_level: Option<u8> = match config_toml
        .get("heading-base-level")
        .and_then(toml::Value::as_integer)
        .map(TryInto::try_into)
    {
        None => None,
        Some(Ok(l)) => Some(l),
        Some(Err(_)) => return Err(ConfigFileOptionsError::InvalidField("heading-base-level")),
    };

    let intralinks_table = config_toml.get("intralinks").and_then(toml::Value::as_table);

    // TODO Remove `docs-rs-base-url` and `docs-rs-version` in 3.0.0. Superseded by the
    //      `[intralinks.docs]` table (with `layout`, `base-url`, `version`).
    let intralinks_docs_rs_base_url_deprecated =
        intralinks_table.and_then(|t| t.get("docs-rs-base-url")).and_then(toml::Value::as_str);
    let intralinks_docs_rs_version_deprecated =
        intralinks_table.and_then(|t| t.get("docs-rs-version")).and_then(toml::Value::as_str);

    let intralinks_docs_table =
        intralinks_table.and_then(|t| t.get("docs")).and_then(toml::Value::as_table);
    let intralinks_docs_layout =
        intralinks_docs_table.and_then(|t| t.get("layout")).and_then(toml::Value::as_str);
    let intralinks_docs_base_url =
        intralinks_docs_table.and_then(|t| t.get("base-url")).and_then(toml::Value::as_str);
    let intralinks_docs_version =
        intralinks_docs_table.and_then(|t| t.get("version")).and_then(toml::Value::as_str);

    if intralinks_docs_base_url.is_some() && intralinks_docs_rs_base_url_deprecated.is_some() {
        return Err(ConfigFileOptionsError::ConflictingDocsOptions {
            new: "intralinks.docs.base-url",
            old: "intralinks.docs-rs-base-url",
        });
    }

    if intralinks_docs_version.is_some() && intralinks_docs_rs_version_deprecated.is_some() {
        return Err(ConfigFileOptionsError::ConflictingDocsOptions {
            new: "intralinks.docs.version",
            old: "intralinks.docs-rs-version",
        });
    }

    let base_url = intralinks_docs_base_url.or(intralinks_docs_rs_base_url_deprecated);
    let version = intralinks_docs_version.or(intralinks_docs_rs_version_deprecated);

    let docs = match intralinks_docs_layout {
        Some("flat") => {
            if version.is_some() {
                return Err(ConfigFileOptionsError::FlatLayoutRejectsVersion);
            }
            let base_url =
                base_url.ok_or(ConfigFileOptionsError::FlatLayoutRequiresBaseUrl)?.to_owned();

            IntralinksDocsConfig::Flat { base_url }
        }
        Some("docs-rs") | None => IntralinksDocsConfig::DocsRs {
            base_url: base_url.map(ToOwned::to_owned),
            version: version.map(ToOwned::to_owned),
        },
        Some(_) => return Err(ConfigFileOptionsError::InvalidField("intralinks.docs.layout")),
    };

    if intralinks_docs_rs_base_url_deprecated.is_some() {
        emit_deprecation_warning(
            "`[intralinks].docs-rs-base-url` is deprecated: use `[intralinks.docs].base-url` instead.",
        );
    }

    if intralinks_docs_rs_version_deprecated.is_some() {
        emit_deprecation_warning(
            "`[intralinks].docs-rs-version` is deprecated: use `[intralinks.docs].version` instead.",
        );
    }

    let intralinks_strip_links =
        intralinks_table.and_then(|t| t.get("strip-links")).and_then(toml::Value::as_bool);
    let intralinks_all_features =
        intralinks_table.and_then(|t| t.get("all-features")).and_then(toml::Value::as_bool);
    let intralinks_features = intralinks_table
        .and_then(|t| t.get("features"))
        .and_then(toml::Value::as_array)
        .map(|array| array.iter().filter_map(|v| v.as_str()).map(ToOwned::to_owned).collect());
    let intralinks_no_default_features =
        intralinks_table.and_then(|t| t.get("no-default-features")).and_then(toml::Value::as_bool);
    let rustdoc_toolchain =
        intralinks_table.and_then(|t| t.get("rustdoc-toolchain")).and_then(toml::Value::as_str);

    let intralinks = intralinks_table.map(|_| IntralinksConfig {
        docs,
        strip_links: intralinks_strip_links,
        all_features: intralinks_all_features,
        features: intralinks_features,
        no_default_features: intralinks_no_default_features,
        rustdoc_toolchain: rustdoc_toolchain.map(ToOwned::to_owned),
    });

    Ok(ConfigFileOptions {
        line_terminator,
        workspace_project,
        entrypoint,
        readme_path,
        intralinks,
        heading_base_level,
    })
}

pub fn config_file_options(
    current_dir: impl AsRef<Path>,
    emit_deprecation_warning: impl FnMut(&str),
) -> Result<Option<ConfigFileOptions>, ConfigFileOptionsError> {
    find_first_file_in_ancestors(current_dir, ".cargo-rdme.toml")
        .map(|file_path| {
            let config_str = std::fs::read_to_string(&file_path)
                .map_err(|_| ConfigFileOptionsError::ErrorReadingConfigFile(file_path))?;

            config_file_options_from_str(&config_str, emit_deprecation_warning)
        })
        .transpose()
}

#[derive(PartialEq, Eq, Debug)]
pub struct Options {
    pub workspace_project: Option<String>,
    pub entrypoint: EntrypointOpt,
    pub line_terminator: LineTerminatorOpt,
    pub check: bool,
    pub no_fail_on_warnings: bool,
    pub force: bool,
    pub readme_path: Option<PathBuf>,
    pub manifest_path: Option<PathBuf>,
    pub intralinks: Option<IntralinksConfig>,
    pub heading_base_level: Option<u8>,
}

#[allow(clippy::needless_pass_by_value)]
pub fn merge_options(
    cmd_options: CmdOptions,
    config_file_options: Option<ConfigFileOptions>,
) -> Options {
    let mut config_file_options = config_file_options;

    Options {
        workspace_project: cmd_options
            .workspace_project
            .or_else(|| config_file_options.as_mut().and_then(|c| c.workspace_project.take())),
        entrypoint: cmd_options
            .entrypoint
            .or_else(|| config_file_options.as_mut().and_then(|c| c.entrypoint.take()))
            .unwrap_or_default(),
        line_terminator: cmd_options
            .line_terminator
            .or_else(|| config_file_options.as_ref().and_then(|c| c.line_terminator))
            .unwrap_or_default(),
        check: cmd_options.check,
        no_fail_on_warnings: cmd_options.no_fail_on_warnings,
        force: cmd_options.force,
        readme_path: cmd_options
            .readme_path
            .or_else(|| config_file_options.as_mut().and_then(|c| c.readme_path.take())),
        manifest_path: cmd_options.manifest_path,
        intralinks: Some(IntralinksConfig {
            docs: config_file_options
                .as_mut()
                .and_then(|c| c.intralinks.as_mut())
                .map(|il| std::mem::take(&mut il.docs))
                .unwrap_or_default(),
            strip_links: match cmd_options.intralinks_strip_links {
                true => Some(true),
                false => config_file_options
                    .as_ref()
                    .and_then(|c| c.intralinks.as_ref())
                    .and_then(|il| il.strip_links),
            },
            all_features: match cmd_options.intralinks_all_features {
                true => Some(true),
                false => config_file_options
                    .as_ref()
                    .and_then(|c| c.intralinks.as_ref())
                    .and_then(|il| il.all_features),
            },
            features: match cmd_options.intralinks_features {
                Some(features) => Some(features),
                None => config_file_options
                    .as_mut()
                    .and_then(|c| c.intralinks.as_mut())
                    .and_then(|il| il.features.take()),
            },
            no_default_features: match cmd_options.intralinks_no_default_features {
                true => Some(true),
                false => config_file_options
                    .as_ref()
                    .and_then(|c| c.intralinks.as_ref())
                    .and_then(|il| il.no_default_features),
            },
            rustdoc_toolchain: config_file_options
                .as_ref()
                .and_then(|c| c.intralinks.as_ref().and_then(|il| il.rustdoc_toolchain.clone())),
        }),
        heading_base_level: cmd_options
            .heading_base_level
            .or_else(|| config_file_options.and_then(|c| c.heading_base_level)),
    }
}

pub fn apply_envvar_overrides(options: &mut Options) {
    if let Ok(override_value) = std::env::var("CARGO_RDME_RUSTDOC_TOOLCHAIN") {
        options.intralinks.get_or_insert_default().rustdoc_toolchain = Some(override_value);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use cargo_rdme::transform::IntralinksDocsConfig;
    use indoc::indoc;
    use pretty_assertions::assert_eq;

    fn parse(config_str: &str) -> Result<ConfigFileOptions, ConfigFileOptionsError> {
        config_file_options_from_str(config_str, |_| {})
    }

    fn parse_collect_warnings(
        config_str: &str,
    ) -> Result<(ConfigFileOptions, Vec<String>), ConfigFileOptionsError> {
        let mut warnings: Vec<String> = Vec::new();
        let options =
            config_file_options_from_str(config_str, |msg| warnings.push(msg.to_owned()))?;

        Ok((options, warnings))
    }

    #[test]
    fn test_config_file_options_from_str() {
        let str = indoc! { r#"
            readme-path = "ReAdMe.md"
            workspace-project = "myproj"
            line-terminator = "crlf"
            heading-base-level = 3

            [entrypoint]
            type = "bin"
            bin-name = "baz"

            [intralinks]
            strip-links = true
            rustdoc-toolchain = "nightly-2026-08-03"

            all-features = true
            features = ["foo", "bar"]
            no-default-features = true

            [intralinks.docs]
            base-url = "https://internaldocs.rs"
            version = "1.0.0"
            "#
        };

        let config_file_opts = parse(str).unwrap();

        let expected = ConfigFileOptions {
            workspace_project: Some("myproj".to_owned()),
            entrypoint: Some(EntrypointOpt::BinName("baz".to_owned())),
            line_terminator: Some(LineTerminatorOpt::CrLf),
            readme_path: Some(PathBuf::from("ReAdMe.md")),
            intralinks: Some(IntralinksConfig {
                docs: IntralinksDocsConfig::DocsRs {
                    base_url: Some("https://internaldocs.rs".to_owned()),
                    version: Some("1.0.0".to_owned()),
                },
                strip_links: Some(true),
                all_features: Some(true),
                features: Some(vec!["foo".to_owned(), "bar".to_owned()]),
                no_default_features: Some(true),
                rustdoc_toolchain: Some("nightly-2026-08-03".to_owned()),
            }),
            heading_base_level: Some(3),
        };

        assert_eq!(config_file_opts, expected);
    }

    #[test]
    fn test_config_file_options_from_str_flat_layout() {
        let str = indoc! { r#"
            [intralinks.docs]
            layout = "flat"
            base-url = "https://rust.docs.kernel.org/next"
            "#
        };

        let config_file_opts = parse(str).unwrap();

        let expected_intralinks = IntralinksConfig {
            docs: IntralinksDocsConfig::Flat {
                base_url: "https://rust.docs.kernel.org/next".to_owned(),
            },
            strip_links: None,
            all_features: None,
            features: None,
            no_default_features: None,
            rustdoc_toolchain: None,
        };

        assert_eq!(config_file_opts.intralinks, Some(expected_intralinks));
    }

    #[test]
    fn test_config_file_options_from_str_deprecated_keys_emit_warnings() {
        let str = indoc! { r#"
            [intralinks]
            docs-rs-base-url = "https://internaldocs.rs"
            docs-rs-version = "1.0.0"
            "#
        };

        let (config_file_opts, warnings) = parse_collect_warnings(str).unwrap();

        let expected_intralinks = IntralinksConfig {
            docs: IntralinksDocsConfig::DocsRs {
                base_url: Some("https://internaldocs.rs".to_owned()),
                version: Some("1.0.0".to_owned()),
            },
            strip_links: None,
            all_features: None,
            features: None,
            no_default_features: None,
            rustdoc_toolchain: None,
        };

        assert_eq!(config_file_opts.intralinks, Some(expected_intralinks));
        assert_eq!(warnings.len(), 2);
        assert!(warnings[0].contains("docs-rs-base-url"));
        assert!(warnings[1].contains("docs-rs-version"));
    }

    #[test]
    fn test_config_file_options_from_str_conflicting_base_url() {
        let str = indoc! { r#"
            [intralinks]
            docs-rs-base-url = "https://internaldocs.rs"

            [intralinks.docs]
            base-url = "https://other.docs"
            "#
        };

        let err = parse(str).unwrap_err();
        assert!(matches!(err, ConfigFileOptionsError::ConflictingDocsOptions { .. }));
    }

    #[test]
    fn test_config_file_options_from_str_conflicting_version() {
        let str = indoc! { r#"
            [intralinks]
            docs-rs-version = "1.0.0"

            [intralinks.docs]
            version = "2.0.0"
            "#
        };

        let err = parse(str).unwrap_err();
        assert!(matches!(err, ConfigFileOptionsError::ConflictingDocsOptions { .. }));
    }

    #[test]
    fn test_config_file_options_from_str_flat_requires_base_url() {
        let str = indoc! { r#"
            [intralinks.docs]
            layout = "flat"
            "#
        };

        let err = parse(str).unwrap_err();
        assert!(matches!(err, ConfigFileOptionsError::FlatLayoutRequiresBaseUrl));
    }

    #[test]
    fn test_config_file_options_from_str_flat_rejects_version() {
        let str = indoc! { r#"
            [intralinks.docs]
            layout = "flat"
            base-url = "https://mydocs.example"
            version = "1.0.0"
            "#
        };

        let err = parse(str).unwrap_err();
        assert!(matches!(err, ConfigFileOptionsError::FlatLayoutRejectsVersion));
    }

    #[test]
    fn test_config_file_options_from_str_flat_rejects_deprecated_version() {
        let str = indoc! { r#"
            [intralinks]
            docs-rs-version = "1.0.0"

            [intralinks.docs]
            layout = "flat"
            base-url = "https://mydocs.example"
            "#
        };

        let err = parse(str).unwrap_err();
        assert!(matches!(err, ConfigFileOptionsError::FlatLayoutRejectsVersion));
    }

    #[test]
    fn test_config_file_options_from_str_invalid_layout() {
        let str = indoc! { r#"
            [intralinks.docs]
            layout = "bogus"
            "#
        };

        let err = parse(str).unwrap_err();
        assert!(matches!(err, ConfigFileOptionsError::InvalidField("intralinks.docs.layout")));
    }

    #[test]
    fn test_merge_cmd_wins_over_config_file() {
        let cmd_options = CmdOptions {
            workspace_project: Some("myproj".to_owned()),
            entrypoint: Some(EntrypointOpt::BinDefault),
            line_terminator: Some(LineTerminatorOpt::CrLf),
            check: true,
            no_fail_on_warnings: true,
            intralinks_strip_links: true,
            force: true,
            readme_path: Some(PathBuf::from("rEaDmE.md")),
            manifest_path: Some(PathBuf::from("foo/Cargo.toml")),
            intralinks_all_features: true,
            intralinks_features: Some(vec!["foo".to_owned(), "bar".to_owned()]),
            intralinks_no_default_features: true,
            heading_base_level: Some(4),
        };
        let config_file_options = ConfigFileOptions {
            workspace_project: Some("aproj".to_owned()),
            entrypoint: Some(EntrypointOpt::Lib),
            line_terminator: Some(LineTerminatorOpt::Lf),
            readme_path: Some(PathBuf::from("ReAdMe.md")),
            intralinks: Some(IntralinksConfig {
                docs: IntralinksDocsConfig::DocsRs {
                    base_url: Some("https://internaldocs.rs".to_owned()),
                    version: Some("1.0.0".to_owned()),
                },
                strip_links: Some(false),
                all_features: Some(false),
                features: Some(vec!["mumble".to_owned()]),
                no_default_features: Some(false),
                rustdoc_toolchain: Some("nightly-2026-08-03".to_owned()),
            }),
            heading_base_level: Some(3),
        };

        let options = merge_options(cmd_options, Some(config_file_options));

        let expected = Options {
            workspace_project: Some("myproj".to_owned()),
            entrypoint: EntrypointOpt::BinDefault,
            line_terminator: LineTerminatorOpt::CrLf,
            check: true,
            no_fail_on_warnings: true,
            force: true,
            readme_path: Some(PathBuf::from("rEaDmE.md")),
            manifest_path: Some(PathBuf::from("foo/Cargo.toml")),
            intralinks: Some(IntralinksConfig {
                docs: IntralinksDocsConfig::DocsRs {
                    base_url: Some("https://internaldocs.rs".to_owned()),
                    version: Some("1.0.0".to_owned()),
                },
                strip_links: Some(true),
                all_features: Some(true),
                features: Some(vec!["foo".to_owned(), "bar".to_owned()]),
                no_default_features: Some(true),
                rustdoc_toolchain: Some("nightly-2026-08-03".to_owned()),
            }),
            heading_base_level: Some(4),
        };

        assert_eq!(options, expected);
    }
}
