diff --git a/Cargo.lock b/Cargo.lock index 7432f0ca..65176684 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1070,6 +1070,7 @@ dependencies = [ "rstest", "ruff_python_trivia", "tempfile", + "toml", "tracing", "wild", ] diff --git a/crates/karva/Cargo.toml b/crates/karva/Cargo.toml index 87eb0216..977acc81 100644 --- a/crates/karva/Cargo.toml +++ b/crates/karva/Cargo.toml @@ -36,6 +36,7 @@ clap = { workspace = true, features = ["wrap_help", "string", "env"] } colored = { workspace = true } crossbeam-channel = { workspace = true } notify-debouncer-mini = { workspace = true } +toml = { workspace = true } tracing = { workspace = true, features = ["release_max_level_debug"] } wild = { workspace = true } diff --git a/crates/karva/src/commands/mod.rs b/crates/karva/src/commands/mod.rs index 23969c52..ba064ef3 100644 --- a/crates/karva/src/commands/mod.rs +++ b/crates/karva/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod cache; +pub mod show_config; pub mod snapshot; pub mod test; pub mod version; diff --git a/crates/karva/src/commands/show_config.rs b/crates/karva/src/commands/show_config.rs new file mode 100644 index 00000000..b03904d0 --- /dev/null +++ b/crates/karva/src/commands/show_config.rs @@ -0,0 +1,46 @@ +use std::fmt::Write; + +use anyhow::{Context as _, Result}; +use karva_cli::ShowConfigCommand; +use karva_logging::Printer; +use karva_metadata::{Options, ProjectMetadata, ProjectOptionsOverrides}; +use karva_project::Project; +use karva_project::path::absolute; +use karva_python_semantic::current_python_version; + +use crate::ExitStatus; +use crate::utils::cwd; + +pub fn show_config(args: ShowConfigCommand) -> Result { + let cwd = cwd().map_err(|_| { + anyhow::anyhow!( + "The current working directory contains non-Unicode characters. karva only supports Unicode paths." + ) + })?; + + let python_version = current_python_version(); + + let config_file = args.config_file.as_ref().map(|path| absolute(path, &cwd)); + + let mut project_metadata = if let Some(config_file) = &config_file { + ProjectMetadata::from_config_file(config_file.clone(), &cwd, python_version)? + } else { + ProjectMetadata::discover(&cwd, python_version)? + }; + + let overrides = + ProjectOptionsOverrides::new(config_file, Options::default()).with_profile(args.profile); + project_metadata + .apply_overrides(&overrides) + .map_err(|err| anyhow::anyhow!("{err}"))?; + + let project = Project::from_metadata(project_metadata); + + let serialized = + toml::to_string(project.settings()).context("failed to serialize configuration")?; + + let mut stdout = Printer::default().stream_for_message().lock(); + write!(stdout, "{serialized}")?; + + Ok(ExitStatus::Success) +} diff --git a/crates/karva/src/lib.rs b/crates/karva/src/lib.rs index f8f44e11..90e12c54 100644 --- a/crates/karva/src/lib.rs +++ b/crates/karva/src/lib.rs @@ -45,6 +45,9 @@ fn run(f: impl FnOnce(Vec) -> Vec) -> anyhow::Result commands::test::test(*test_args), Command::Snapshot(snapshot_args) => commands::snapshot::snapshot(snapshot_args), Command::Cache(cache_args) => commands::cache::cache(&cache_args), + Command::ShowConfig(show_config_args) => { + commands::show_config::show_config(show_config_args) + } Command::Version => commands::version::version().map(|()| ExitStatus::Success), } } diff --git a/crates/karva/tests/it/basic.rs b/crates/karva/tests/it/basic.rs index 541dcd0d..ef42854d 100644 --- a/crates/karva/tests/it/basic.rs +++ b/crates/karva/tests/it/basic.rs @@ -2653,11 +2653,12 @@ fn test_no_subcommand_prints_help() { Usage: karva Commands: - test Run tests - snapshot Manage snapshots created by `karva.assert_snapshot()` - cache Manage the karva cache - version Display Karva's version - help Print this message or the help of the given subcommand(s) + test Run tests + snapshot Manage snapshots created by `karva.assert_snapshot()` + cache Manage the karva cache + show-config Print the resolved configuration karva would run with + version Display Karva's version + help Print this message or the help of the given subcommand(s) Options: -h, --help Print help diff --git a/crates/karva/tests/it/common/mod.rs b/crates/karva/tests/it/common/mod.rs index e8c264eb..0e5022a9 100644 --- a/crates/karva/tests/it/common/mod.rs +++ b/crates/karva/tests/it/common/mod.rs @@ -187,6 +187,12 @@ impl TestContext { command.arg("version").current_dir(self.root()); command } + + pub fn show_config(&self) -> Command { + let mut command = self.karva_command(); + command.arg("show-config").current_dir(self.root()); + command + } } impl Default for TestContext { diff --git a/crates/karva/tests/it/main.rs b/crates/karva/tests/it/main.rs index 6e923fd2..d52dd80a 100644 --- a/crates/karva/tests/it/main.rs +++ b/crates/karva/tests/it/main.rs @@ -12,5 +12,6 @@ mod filterset; mod last_failed; mod partition; mod run_ignored; +mod show_config; mod version; mod watch; diff --git a/crates/karva/tests/it/show_config.rs b/crates/karva/tests/it/show_config.rs new file mode 100644 index 00000000..d4dd3a0e --- /dev/null +++ b/crates/karva/tests/it/show_config.rs @@ -0,0 +1,190 @@ +use insta_cmd::assert_cmd_snapshot; + +use crate::common::TestContext; + +#[test] +fn show_config_default_profile() { + let context = TestContext::default(); + + assert_cmd_snapshot!(context.show_config(), @r#" + success: true + exit_code: 0 + ----- stdout ----- + [src] + respect-ignore-files = true + include = [] + + [terminal] + output-format = "full" + show-python-output = false + status-level = "pass" + final-status-level = "pass" + + [test] + test-function-prefix = "test" + try-import-fixtures = false + retry = 0 + no-tests = "auto" + + [coverage] + sources = [] + report = "term" + + ----- stderr ----- + "#); +} + +#[test] +fn show_config_resolves_pyproject_options() { + let context = TestContext::with_file( + "pyproject.toml", + r#" +[tool.karva.profile.default.test] +test-function-prefix = "check" +fail-fast = true + +[tool.karva.profile.default.terminal] +output-format = "concise" +"#, + ); + + assert_cmd_snapshot!(context.show_config(), @r#" + success: true + exit_code: 0 + ----- stdout ----- + [src] + respect-ignore-files = true + include = [] + + [terminal] + output-format = "concise" + show-python-output = false + status-level = "pass" + final-status-level = "pass" + + [test] + test-function-prefix = "check" + max-fail = 1 + try-import-fixtures = false + retry = 0 + no-tests = "auto" + + [coverage] + sources = [] + report = "term" + + ----- stderr ----- + "#); +} + +#[test] +fn show_config_named_profile_layers_over_default() { + let context = TestContext::with_file( + "karva.toml", + r#" +[profile.default.test] +test-function-prefix = "check" + +[profile.ci.test] +retry = 3 + +[profile.ci.terminal] +output-format = "concise" +"#, + ); + + assert_cmd_snapshot!(context.show_config().args(["--profile", "ci"]), @r#" + success: true + exit_code: 0 + ----- stdout ----- + [src] + respect-ignore-files = true + include = [] + + [terminal] + output-format = "concise" + show-python-output = false + status-level = "pass" + final-status-level = "pass" + + [test] + test-function-prefix = "check" + try-import-fixtures = false + retry = 3 + no-tests = "auto" + + [coverage] + sources = [] + report = "term" + + ----- stderr ----- + "#); +} + +#[test] +fn show_config_emits_set_timeouts_and_coverage() { + let context = TestContext::with_file( + "karva.toml", + r#" +[profile.default.test] +slow-timeout = 0.5 +timeout = 120 + +[profile.default.coverage] +sources = ["src"] +report = "term-missing" +fail-under = 90 +"#, + ); + + assert_cmd_snapshot!(context.show_config(), @r#" + success: true + exit_code: 0 + ----- stdout ----- + [src] + respect-ignore-files = true + include = [] + + [terminal] + output-format = "full" + show-python-output = false + status-level = "pass" + final-status-level = "pass" + + [test] + test-function-prefix = "test" + try-import-fixtures = false + retry = 0 + no-tests = "auto" + slow-timeout = 0.5 + timeout = 120.0 + + [coverage] + sources = ["src"] + report = "term-missing" + fail-under = 90.0 + + ----- stderr ----- + "#); +} + +#[test] +fn show_config_unknown_profile_errors() { + let context = TestContext::with_file( + "karva.toml", + r" +[profile.ci.test] +retry = 3 +", + ); + + assert_cmd_snapshot!(context.show_config().args(["--profile", "bogus"]), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Karva failed + Cause: profile `bogus` is not defined in configuration (available: ci, default) + "); +} diff --git a/crates/karva_cli/src/lib.rs b/crates/karva_cli/src/lib.rs index 94181683..a4e9c552 100644 --- a/crates/karva_cli/src/lib.rs +++ b/crates/karva_cli/src/lib.rs @@ -5,6 +5,7 @@ use clap::builder::styling::{AnsiColor, Effects}; mod cache; mod enums; mod partition; +mod show_config; mod snapshot; mod test; mod verbosity; @@ -12,6 +13,7 @@ mod verbosity; pub use cache::{CacheAction, CacheCommand}; pub use enums::{CovReport, NoTests, OutputFormat, RunIgnored}; pub use partition::PartitionSelection; +pub use show_config::ShowConfigCommand; pub use snapshot::{ SnapshotAction, SnapshotCommand, SnapshotDeleteArgs, SnapshotFilterArgs, SnapshotPruneArgs, }; @@ -44,6 +46,9 @@ pub enum Command { /// Manage the karva cache. Cache(CacheCommand), + /// Print the resolved configuration karva would run with. + ShowConfig(ShowConfigCommand), + /// Display Karva's version Version, } diff --git a/crates/karva_cli/src/show_config.rs b/crates/karva_cli/src/show_config.rs new file mode 100644 index 00000000..5897b3f2 --- /dev/null +++ b/crates/karva_cli/src/show_config.rs @@ -0,0 +1,31 @@ +use camino::Utf8PathBuf; +use clap::Parser; + +/// Print the resolved configuration karva would run with. +/// +/// Resolves the same settings the test runner builds — defaults layered with +/// `karva.toml` / `pyproject.toml` and any selected profile — and prints them +/// as TOML. +#[derive(Debug, Parser)] +pub struct ShowConfigCommand { + /// The path to a `karva.toml` file to use for configuration. + #[arg( + long, + env = "KARVA_CONFIG_FILE", + value_name = "PATH", + help_heading = "Config options" + )] + pub config_file: Option, + + /// Configuration profile to resolve. + /// + /// Defaults to `default`. + #[arg( + short = 'P', + long, + env = "KARVA_PROFILE", + value_name = "NAME", + help_heading = "Config options" + )] + pub profile: Option, +} diff --git a/crates/karva_metadata/src/max_fail.rs b/crates/karva_metadata/src/max_fail.rs index f9dba717..83a3a0d1 100644 --- a/crates/karva_metadata/src/max_fail.rs +++ b/crates/karva_metadata/src/max_fail.rs @@ -48,6 +48,15 @@ impl MaxFail { self.0.is_some() } + /// Returns `true` when no failure limit is configured. + /// + /// `MaxFail::unlimited()` wraps `None`, which serializers like TOML + /// cannot represent — this is exposed primarily so `serde`'s + /// `skip_serializing_if` can omit the field. + pub fn is_unlimited(&self) -> bool { + self.0.is_none() + } + /// Returns `true` when the configuration would stop after a single failure. /// /// This is how the legacy `--fail-fast` boolean is surfaced internally. diff --git a/crates/karva_metadata/src/settings.rs b/crates/karva_metadata/src/settings.rs index 94babf39..1c1a6244 100644 --- a/crates/karva_metadata/src/settings.rs +++ b/crates/karva_metadata/src/settings.rs @@ -2,7 +2,7 @@ use std::time::Duration; use karva_combine::Combine; use karva_logging::{FinalStatusLevel, StatusLevel}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize, Serializer}; use crate::filter::FiltersetSet; use crate::max_fail::MaxFail; @@ -121,10 +121,11 @@ impl Combine for CovFailUnder { } } -#[derive(Default, Debug, Clone)] +#[derive(Default, Debug, Clone, Serialize)] +#[serde(rename_all = "kebab-case")] pub struct ProjectSettings { - pub(crate) terminal: TerminalSettings, pub(crate) src: SrcSettings, + pub(crate) terminal: TerminalSettings, pub(crate) test: TestSettings, pub(crate) coverage: CoverageSettings, } @@ -159,7 +160,24 @@ impl ProjectSettings { } } -#[derive(Default, Debug, Clone)] +/// Serialize a `Duration` field as fractional seconds. Unset fields are +/// guarded by `skip_serializing_if = "Option::is_none"`; the `None` arm is +/// preserved so the function is sound when called directly. +/// +/// The `&Option` signature is dictated by serde's `serialize_with`. +#[expect(clippy::ref_option)] +fn serialize_duration_secs( + value: &Option, + serializer: S, +) -> Result { + match value { + Some(d) => serializer.serialize_f64(d.as_secs_f64()), + None => serializer.serialize_none(), + } +} + +#[derive(Default, Debug, Clone, Serialize)] +#[serde(rename_all = "kebab-case")] pub struct TerminalSettings { pub output_format: OutputFormat, pub show_python_output: bool, @@ -167,36 +185,56 @@ pub struct TerminalSettings { pub final_status_level: FinalStatusLevel, } -#[derive(Default, Debug, Clone)] +#[derive(Default, Debug, Clone, Serialize)] +#[serde(rename_all = "kebab-case")] pub struct SrcSettings { pub respect_ignore_files: bool, + #[serde(rename = "include")] pub include_paths: Vec, } -#[derive(Default, Debug, Clone)] +#[derive(Default, Debug, Clone, Serialize)] +#[serde(rename_all = "kebab-case")] pub struct CoverageSettings { pub sources: Vec, pub report: CovReport, /// Minimum total coverage percentage (`0..=100`). When set and the /// reported `TOTAL` coverage is below this value, the test command /// exits with a non-zero status even if every test passed. + #[serde(skip_serializing_if = "Option::is_none")] pub fail_under: Option, } -#[derive(Default, Debug, Clone)] +#[derive(Default, Debug, Clone, Serialize)] +#[serde(rename_all = "kebab-case")] pub struct TestSettings { pub test_function_prefix: String, + /// `MaxFail::unlimited()` wraps `None`, which TOML cannot represent — + /// omit the field when no limit is configured. + #[serde(skip_serializing_if = "MaxFail::is_unlimited")] pub max_fail: MaxFail, pub try_import_fixtures: bool, pub retry: u32, + /// Runtime-only: filters are sourced from CLI flags, never config files. + #[serde(skip)] pub filter: FiltersetSet, + /// Runtime-only: run-ignored mode is sourced from CLI flags. + #[serde(skip)] pub run_ignored: RunIgnoredMode, pub no_tests: NoTestsMode, /// Threshold after which a test is flagged as slow. `None` disables /// slow-test detection entirely. + #[serde( + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_duration_secs" + )] pub slow_timeout: Option, /// Hard per-test timeout. Tests that run longer than this duration are /// killed and reported as failures. `None` disables the hard timeout /// (tests may still set their own limit via `@karva.tags.timeout`). + #[serde( + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_duration_secs" + )] pub timeout: Option, } diff --git a/docs/configuration/profiles.md b/docs/configuration/profiles.md index 7b6fb79a..fff23dc0 100644 --- a/docs/configuration/profiles.md +++ b/docs/configuration/profiles.md @@ -73,6 +73,18 @@ highest to lowest priority. The first source that defines the field wins. Selecting a profile that is not defined in the configuration produces an error that lists the profiles that are available. +## Inspecting the resolved configuration + +Use `karva show-config` to print the configuration Karva would actually run +with for a given profile, formatted as TOML. This is helpful when debugging +precedence between built-in defaults, `[profile.default]`, the selected +profile, and any CLI overrides. + +```bash +karva show-config # default profile +karva show-config --profile ci +``` + ## See also - [Configuration](configuration.md) — reference for every supported field. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index d764a7eb..5e6bcd39 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -17,6 +17,7 @@ karva
karva test

Run tests

karva snapshot

Manage snapshots created by karva.assert_snapshot()

karva cache

Manage the karva cache

+
karva show-config

Print the resolved configuration karva would run with

karva version

Display Karva's version

karva help

Print this message or the help of the given subcommand(s)

@@ -348,6 +349,24 @@ Print this message or the help of the given subcommand(s) karva cache help [COMMAND] ``` +## karva show-config + +Print the resolved configuration karva would run with + +

Usage

+ +``` +karva show-config [OPTIONS] +``` + +

Options

+ +
--config-file path

The path to a karva.toml file to use for configuration

+

May also be set with the KARVA_CONFIG_FILE environment variable.

--help, -h

Print help (see a summary with '-h')

+
--profile, -P name

Configuration profile to resolve.

+

Defaults to default.

+

May also be set with the KARVA_PROFILE environment variable.

+ ## karva version Display Karva's version