use crate::common::*;
use std::fs;
use std::path::Path;
fn saya_isolated(root: &Path, args: &[&str]) -> std::process::Output {
std::process::Command::new(env!("CARGO_BIN_EXE_saya"))
.args(args)
.current_dir(root)
.env("SAYA_CONFIG_HOME", root.join("user-config"))
.env("SAYA_STATE_DB", root.join("state.sqlite3"))
.env("HOME", root.join("home"))
.output()
.unwrap()
}
#[test]
fn config_init_creates_parseable_templates_with_stable_output() {
let user_dir = |root: &std::path::Path| root.join("user-config/saya");
for format in ["text", "json", "ndjson"] {
let root = test_root(&format!("init-{format}"));
let output = saya_process(&root, &["--format", format, "config", "init"]);
assert!(output.status.success(), "stderr: {}", stderr(&output));
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("Created config.toml and connections.toml in"),
"stable message prefix: {stdout}"
);
assert!(output.stderr.is_empty());
let config = fs::read_to_string(user_dir(&root).join("config.toml")).unwrap();
let connections = fs::read_to_string(user_dir(&root).join("connections.toml")).unwrap();
saya_config::ConfigFile::from_toml(&config).unwrap();
saya_config::ConnectionsFile::from_toml(&connections).unwrap();
assert!(connections.contains("{ env = \"SAYA_ANALYTICS_PASSWORD\" }"));
assert!(!config.contains("password = \""));
assert!(
saya_process(&root, &["connection", "list"])
.status
.success()
);
assert!(saya_process(&root, &["config", "show"]).status.success());
let doctor = std::process::Command::new(env!("CARGO_BIN_EXE_saya"))
.args(["config", "doctor"])
.current_dir(&root)
.env("SAYA_CONFIG_HOME", root.join("user-config"))
.env("SAYA_ANALYTICS_PASSWORD", "a-resolved-secret")
.output()
.unwrap();
assert!(doctor.status.success(), "stderr: {}", stderr(&doctor));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(user_dir(&root)).unwrap().permissions().mode() & 0o777,
0o700
);
for name in ["config.toml", "connections.toml"] {
assert_eq!(
fs::metadata(user_dir(&root).join(name))
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
}
}
let _ = fs::remove_dir_all(root);
}
}
#[test]
fn config_init_refuses_overwrite_and_rolls_back_partial_creation() {
let user_dir = |root: &std::path::Path| root.join("user-config/saya");
let root = test_root("init-no-overwrite");
let first = saya_process(&root, &["config", "init"]);
assert!(first.status.success());
let config_before = fs::read_to_string(user_dir(&root).join("config.toml")).unwrap();
let second = saya_process(&root, &["config", "init"]);
assert_eq!(second.status.code(), Some(2));
assert!(stderr(&second).contains("already exists"));
assert_eq!(
fs::read_to_string(user_dir(&root).join("config.toml")).unwrap(),
config_before
);
let _ = fs::remove_dir_all(&root);
let root = test_root("init-rollback");
fs::create_dir_all(user_dir(&root)).unwrap();
fs::create_dir(user_dir(&root).join("connections.toml")).unwrap();
let output = saya_process(&root, &["config", "init"]);
assert_eq!(output.status.code(), Some(2));
assert!(stderr(&output).contains("connections.toml"));
assert!(!user_dir(&root).join("config.toml").exists());
let _ = fs::remove_dir_all(root);
}
#[test]
fn config_init_failures_use_stable_error_envelopes_without_path_leaks() {
let user_dir = |root: &std::path::Path| root.join("user-config/saya");
for (format, expected) in [
("text", "connections.toml already exists\n"),
(
"json",
"{\"event\":\"error\",\"message\":\"connections.toml already exists\"}\n",
),
(
"ndjson",
"{\"event\":\"error\",\"message\":\"connections.toml already exists\"}\n",
),
] {
let root = test_root(&format!("init-failure-{format}"));
fs::create_dir_all(user_dir(&root)).unwrap();
fs::create_dir(user_dir(&root).join("connections.toml")).unwrap();
let output = saya_process(&root, &["--format", format, "config", "init"]);
assert_eq!(output.status.code(), Some(2));
assert!(output.stdout.is_empty());
assert_eq!(String::from_utf8_lossy(&output.stderr), expected);
assert!(!stderr(&output).contains(&root.display().to_string()));
assert!(!user_dir(&root).join("config.toml").exists());
let _ = fs::remove_dir_all(root);
}
}
#[test]
fn discovery_prefers_project_over_user_and_explicit_env_file_is_opt_in() {
let root = std::env::temp_dir().join(format!("saya-cli-discovery-{}", std::process::id()));
let user = root.join("user");
let project = root.join("project");
std::fs::create_dir_all(project.join(".saya")).unwrap();
std::fs::create_dir_all(&user).unwrap();
std::fs::write(user.join("config.toml"), "[ai]\nmodel = 'user'\n").unwrap();
std::fs::write(
project.join(".saya/config.toml"),
"[ai]\nmodel = 'project'\n",
)
.unwrap();
let options = saya_cli::GlobalOptions::default();
let loaded =
saya_cli::load_with_sources(&options, &project, &user, std::collections::BTreeMap::new())
.unwrap();
assert_eq!(loaded.resolved.ai.model, "project");
std::fs::write(project.join(".env"), "SAYA_AI_MODEL = 'ignored'\n").unwrap();
let without_opt_in =
saya_cli::load_with_sources(&options, &project, &user, std::collections::BTreeMap::new())
.unwrap();
assert_eq!(without_opt_in.resolved.ai.model, "project");
let env_file = project.join(".env.saya");
std::fs::write(&env_file, "SAYA_AI_MODEL=explicit-env\n").unwrap();
let explicit = saya_cli::GlobalOptions {
env_file: Some(env_file),
..Default::default()
};
let with_opt_in = saya_cli::load_with_sources(
&explicit,
&project,
&user,
std::collections::BTreeMap::new(),
)
.unwrap();
assert_eq!(with_opt_in.resolved.ai.model, "explicit-env");
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn runtime_debug_redacts_merged_environment_values_and_keys() {
let root = std::env::temp_dir().join(format!("saya-cli-debug-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let sentinel_key = "SAYA_TEST_SECRET_SENTINEL";
let sentinel_value = "never-print-this-secret";
let runtime = saya_cli::load_with_sources(
&saya_cli::GlobalOptions::default(),
&root,
&root,
std::collections::BTreeMap::from([(sentinel_key.into(), sentinel_value.into())]),
)
.unwrap();
let diagnostic = format!("{runtime:?}");
assert!(!diagnostic.contains(sentinel_key));
assert!(!diagnostic.contains(sentinel_value));
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn default_init_then_command_does_not_warn_about_ignored_settings() {
let root = test_root("s18-init-no-warning");
let init = saya_isolated(&root, &["config", "init"]);
assert!(init.status.success(), "stderr: {}", stderr(&init));
let user_config = root.join("user-config/saya/config.toml");
assert!(user_config.exists(), "user config not written");
assert!(root.join("user-config/saya/connections.toml").exists());
assert!(
!root.join(".saya").exists(),
"default init must not write .saya/"
);
let doctor = saya_isolated(&root, &["config", "doctor"]);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&doctor.stdout),
String::from_utf8_lossy(&doctor.stderr)
);
assert!(
!combined.contains("ignored") && !combined.contains("security-critical"),
"default init must not produce an ignored-settings warning: {combined}"
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn project_init_writes_saya_dir_and_the_next_command_warns() {
let root = test_root("s18-init-project");
let init = saya_isolated(&root, &["config", "init", "--project"]);
assert!(init.status.success(), "stderr: {}", stderr(&init));
assert!(root.join(".saya/config.toml").exists());
assert!(root.join(".saya/connections.toml").exists());
assert!(!root.join("user-config/saya/config.toml").exists());
let doctor = saya_isolated(&root, &["config", "doctor"]);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&doctor.stdout),
String::from_utf8_lossy(&doctor.stderr)
);
assert!(
combined.contains("ignored") && combined.contains("security-critical"),
"project init is the untrusted layer, so a command should warn: {combined}"
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn default_init_message_names_the_user_config_directory() {
let root = test_root("s18-init-message");
let init = saya_isolated(&root, &["config", "init"]);
assert!(init.status.success(), "stderr: {}", stderr(&init));
let message = String::from_utf8_lossy(&init.stdout);
assert!(
!message.contains(".saya/"),
"default init must not claim it wrote .saya/: {message}"
);
assert!(
message.contains("user config") || message.contains("user-config"),
"default init should name the user config directory: {message}"
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn ask_with_unreachable_provider_names_a_next_command() {
let root = test_root("s18-ask-provider-down");
fs::create_dir_all(root.join("user-config/saya")).unwrap();
fs::write(
root.join("user-config/saya/config.toml"),
"[ai]\nbase_url = \"http://127.0.0.1:1\"\n",
)
.unwrap();
let ask = saya_isolated(&root, &["--non-interactive", "ask", "count films"]);
assert_eq!(ask.status.code(), Some(5));
let stderr = String::from_utf8_lossy(&ask.stderr);
assert!(
stderr.contains("could not reach the provider"),
"should report the unreachable provider: {stderr}"
);
assert!(
stderr.contains("saya config doctor"),
"provider-unreachable must name an actionable next command: {stderr}"
);
assert!(
!stderr.contains("config init"),
"a down provider must not be told to re-run init: {stderr}"
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn ask_with_unresolvable_secret_names_a_next_command() {
let root = test_root("s18-ask-secret");
fs::create_dir_all(root.join("user-config/saya")).unwrap();
fs::write(
root.join("user-config/saya/config.toml"),
"default_profile = \"analytics\"\n[ai]\nbase_url = \"http://127.0.0.1:1\"\n",
)
.unwrap();
fs::write(
root.join("user-config/saya/connections.toml"),
"[profiles.analytics]\ntype = \"postgresql\"\nhost = \"localhost\"\nport = 5432\n\
database = \"warehouse\"\nuser = \"saya_readonly\"\n\
password = { env = \"SAYA_ANALYTICS_PASSWORD\" }\nsslmode = \"require\"\n",
)
.unwrap();
let ask = saya_isolated(&root, &["--non-interactive", "ask", "count films"]);
assert_eq!(ask.status.code(), Some(5));
let stderr = String::from_utf8_lossy(&ask.stderr);
assert!(
stderr.contains("secret reference") && stderr.contains("could not be resolved"),
"should report the unresolved secret: {stderr}"
);
assert!(
stderr.contains("saya config doctor"),
"an unresolvable secret must name an actionable next command: {stderr}"
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn doctor_with_no_config_advises_init_and_exits_nonzero() {
let root = test_root("s18-doctor-empty");
let doctor = saya_isolated(&root, &["config", "doctor"]);
let stdout = String::from_utf8_lossy(&doctor.stdout);
assert!(
stdout.contains("config file: not found"),
"doctor keeps the factual line: {stdout}"
);
assert!(
stdout.contains("saya config init"),
"doctor must advise an actionable next command when nothing is configured: {stdout}"
);
assert_ne!(
doctor.status.code(),
Some(0),
"doctor must exit non-zero when the setup cannot work (Q4): {:?}",
doctor.status.code()
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn doctor_exits_zero_when_setup_can_work() {
let root = test_root("s18-doctor-ok");
let init = saya_isolated(&root, &["config", "init"]);
assert!(init.status.success(), "stderr: {}", stderr(&init));
let doctor = std::process::Command::new(env!("CARGO_BIN_EXE_saya"))
.args(["config", "doctor"])
.current_dir(&root)
.env("SAYA_CONFIG_HOME", root.join("user-config"))
.env("SAYA_STATE_DB", root.join("state.sqlite3"))
.env("HOME", root.join("home"))
.env("SAYA_ANALYTICS_PASSWORD", "a-resolved-secret")
.output()
.unwrap();
assert_eq!(
doctor.status.code(),
Some(0),
"a resolvable setup must exit 0: stdout={:?} stderr={:?}",
String::from_utf8_lossy(&doctor.stdout),
String::from_utf8_lossy(&doctor.stderr),
);
let _ = fs::remove_dir_all(root);
}