use clap::ArgMatches;
use clap::parser::ValueSource;
use ortho_config::{OrthoError, OrthoResult};
use std::sync::Arc;
use super::discovery::{
DiscoveryOutcome, EnvProvider, StdEnvProvider, collect_diag_file_layers_with_env,
};
use super::parser::Cli;
const JSON_ENV_VAR: &str = "NETSUKE_JSON";
pub fn resolve_merged_json(cli: &Cli, matches: &ArgMatches) -> OrthoResult<bool> {
resolve_merged_json_with_env(cli, matches, &StdEnvProvider)
}
pub fn resolve_merged_json_with_env(
cli: &Cli,
matches: &ArgMatches,
env: &impl EnvProvider,
) -> OrthoResult<bool> {
let (result, outcome) = resolve_json_and_layers_outcome_with_env(cli, matches, env);
let json = result?;
drop(outcome);
Ok(json)
}
pub fn resolve_json_and_layers_outcome_with_env(
cli: &Cli,
matches: &ArgMatches,
env: &impl EnvProvider,
) -> (OrthoResult<bool>, DiscoveryOutcome) {
let outcome = collect_diag_file_layers_with_env(cli, env);
let result = (|| {
if let Some(error) = outcome.first_error() {
return Err(Arc::clone(error));
}
let mut json = json_from_layers(&outcome);
if !has_cli_json_override(matches)
&& let Some(env_json) = json_from_env(env)?
{
json = env_json;
}
Ok(json_from_matches(cli, matches, json))
})();
(result, outcome)
}
#[cfg(test)]
fn json_from_layer(value: &serde_json::Value) -> Option<bool> {
value
.as_object()
.and_then(|map| map.get("json"))
.and_then(serde_json::Value::as_bool)
}
fn json_from_matches(cli: &Cli, matches: &ArgMatches, discovered: bool) -> bool {
if has_cli_json_override(matches) {
cli.json
} else {
discovered
}
}
fn has_cli_json_override(matches: &ArgMatches) -> bool {
matches.value_source("json") == Some(ValueSource::CommandLine)
}
const fn json_from_layers(outcome: &DiscoveryOutcome) -> bool {
outcome.json_preference()
}
fn json_from_env(env: &impl EnvProvider) -> OrthoResult<Option<bool>> {
let Some(value) = env.get(JSON_ENV_VAR) else {
return Ok(None);
};
let raw = value.into_string().map_err(|invalid_value| {
Arc::new(OrthoError::Validation {
key: String::from(JSON_ENV_VAR),
message: format!(
"{JSON_ENV_VAR} must be valid Unicode, got {}",
invalid_value.to_string_lossy()
),
})
})?;
match raw.as_str() {
"true" | "1" => Ok(Some(true)),
"false" | "0" => Ok(Some(false)),
_ => Err(Arc::new(OrthoError::Validation {
key: String::from(JSON_ENV_VAR),
message: format!("{JSON_ENV_VAR} must be true, false, 1, or 0; got {raw:?}"),
})),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::discovery::assert_bounded_path_event;
use crate::cli::test_support::TestEnv;
use crate::test_tracing_capture::with_test_subscriber;
use anyhow::{Context, ensure};
use cap_std::{ambient_authority, fs::Dir};
use clap::CommandFactory;
use clap::Parser;
use rstest::rstest;
use serde_json::json;
use tempfile::tempdir;
use tracing_subscriber::filter::LevelFilter;
fn find_deferred_event<'a>(events: &'a [String], message: &str) -> anyhow::Result<&'a str> {
events
.iter()
.find(|event| event.contains(message))
.map(String::as_str)
.with_context(|| format!("expected event containing {message:?} in {events:?}"))
}
#[test]
fn json_from_layer_reads_json_bool() {
assert_eq!(json_from_layer(&json!({ "json": true })), Some(true));
assert_eq!(json_from_layer(&json!({ "json": false })), Some(false));
}
#[test]
fn json_from_layer_ignores_non_bool_json() {
assert_eq!(json_from_layer(&json!({ "json": "yes" })), None);
}
#[test]
fn resolve_merged_json_reads_injected_env() -> anyhow::Result<()> {
let dir = tempdir()?;
let config_path = dir.path().join("netsuke.toml");
let config_dir = Dir::open_ambient_dir(dir.path(), ambient_authority())?;
config_dir.write("netsuke.toml", b"json = false\n")?;
let matches = Cli::command().get_matches_from(["netsuke"]);
let cli = Cli {
config: Some(config_path),
..Cli::default()
};
let env = TestEnv::default().with_var(JSON_ENV_VAR, "true");
ensure!(
resolve_merged_json_with_env(&cli, &matches, &env)?,
"injected env should enable JSON"
);
Ok(())
}
#[test]
fn resolve_merged_json_rejects_malformed_injected_env() {
let dir = tempdir().expect("tempdir");
let config_path = dir.path().join("netsuke.toml");
let config_dir = Dir::open_ambient_dir(dir.path(), ambient_authority())
.expect("open temp config directory");
config_dir.write("netsuke.toml", b"").expect("write config");
let matches = Cli::command().get_matches_from(["netsuke"]);
let cli = Cli {
config: Some(config_path),
..Cli::default()
};
let env = TestEnv::default().with_var(JSON_ENV_VAR, "yes");
let error = resolve_merged_json_with_env(&cli, &matches, &env)
.expect_err("invalid JSON env value should fail");
assert!(
matches!(error.as_ref(), OrthoError::Validation { key, .. } if key == JSON_ENV_VAR),
"expected validation error for {JSON_ENV_VAR}, got {error:?}"
);
}
#[rstest]
fn resolve_merged_json_does_not_replay_missing_explicit_config_diagnostics()
-> anyhow::Result<()> {
let dir = tempdir()?;
let missing_config_path = dir.path().join("missing-netsuke.toml");
let matches = Cli::command().get_matches_from(["netsuke"]);
let env = TestEnv::default().with_var("NETSUKE_CONFIG", &missing_config_path);
let (result, events) = with_test_subscriber(LevelFilter::TRACE, |captured| {
let result = resolve_merged_json_with_env(&Cli::default(), &matches, &env);
(result, captured.snapshot())
});
let error = result.expect_err("missing injected explicit config should fail");
ensure!(
matches!(error.as_ref(), OrthoError::File { path, .. } if path == &missing_config_path),
"expected missing explicit config error for {missing_config_path:?}, got {error:?}"
);
ensure!(
events.is_empty(),
"JSON resolution must leave deferred diagnostics for its composition boundary: {events:?}"
);
Ok(())
}
#[rstest]
fn resolve_json_and_layers_outcome_replays_successful_explicit_config_diagnostics()
-> anyhow::Result<()> {
let dir = tempdir()?;
let config_path = dir.path().join("customer@example.com.toml");
let config_dir = Dir::open_ambient_dir(dir.path(), ambient_authority())?;
config_dir.write("customer@example.com.toml", b"json = true\n")?;
let matches = Cli::command().get_matches_from(["netsuke"]);
let cli = Cli {
config: Some(config_path.clone()),
..Cli::default()
};
let ((result, outcome), resolution_events, replay_events) =
with_test_subscriber(LevelFilter::TRACE, |captured| {
let (result, outcome) =
resolve_json_and_layers_outcome_with_env(&cli, &matches, &TestEnv::default());
let resolution_events = captured.snapshot();
outcome.emit_diagnostics();
((result, outcome), resolution_events, captured.snapshot())
});
ensure!(
resolution_events.is_empty(),
"side-effect-free JSON resolution must not emit diagnostics: {resolution_events:?}"
);
let json = result?;
ensure!(json, "explicit configuration should enable JSON mode");
ensure!(
!outcome.layers().is_empty(),
"successful resolution should retain file layers for the full merge"
);
let selector_event = find_deferred_event(&replay_events, "resolved config path")?;
ensure!(
selector_event.contains("selector=\"cli_flag\"")
&& selector_event.contains("path_present=true"),
"selector event should record the CLI selector: {selector_event}"
);
assert_bounded_path_event(selector_event, &config_path)?;
assert_bounded_path_event(
find_deferred_event(&replay_events, "using explicit config path")?,
&config_path,
)?;
drop(outcome.into_layers());
Ok(())
}
#[test]
fn resolve_merged_json_honours_cli_before_malformed_env() -> anyhow::Result<()> {
let dir = tempdir()?;
let config_path = dir.path().join("netsuke.toml");
let config_dir = Dir::open_ambient_dir(dir.path(), ambient_authority())?;
config_dir.write("netsuke.toml", b"json = false\n")?;
let config_path_string = config_path
.to_str()
.expect("temp config path should be UTF-8");
let args = ["netsuke", "--config", config_path_string, "--json"];
let cli = Cli::parse_from(args);
let matches = Cli::command().get_matches_from(args);
let env = TestEnv::default().with_var(JSON_ENV_VAR, "yes");
ensure!(
resolve_merged_json_with_env(&cli, &matches, &env)?,
"CLI --json should override malformed JSON env"
);
Ok(())
}
}