use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{SystemTime, UNIX_EPOCH};
fn binary() -> PathBuf {
match std::env::var_os("LUCIDA_TEST_BIN") {
Some(path) => PathBuf::from(path),
None => PathBuf::from(env!("CARGO_BIN_EXE_lucida")),
}
}
struct Sandbox {
dir: PathBuf,
}
impl Sandbox {
fn new(label: &str) -> Sandbox {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|since| since.subsec_nanos())
.unwrap_or(0);
let dir =
std::env::temp_dir().join(format!("lucida-cli-{label}-{}-{nanos}", std::process::id()));
fs::create_dir_all(&dir).expect("could not create a sandbox directory");
Sandbox { dir }
}
fn config_file(&self) -> PathBuf {
self.dir.join(".config").join("lucida").join("config.env")
}
fn write_config(&self, contents: &str) {
let path = self.config_file();
fs::create_dir_all(path.parent().unwrap()).expect("could not create the config directory");
fs::write(&path, contents).expect("could not write the config file");
}
fn name(&self) -> String {
self.dir.file_name().unwrap().to_string_lossy().into_owned()
}
}
impl Drop for Sandbox {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.dir);
}
}
fn lucida(sandbox: &Sandbox) -> Command {
let mut cmd = Command::new(binary());
cmd.env_clear();
for key in ["PATH", "SYSTEMROOT", "SystemRoot", "TEMP", "TMP", "COMSPEC"] {
if let Some(value) = std::env::var_os(key) {
cmd.env(key, value);
}
}
cmd.env("HOME", &sandbox.dir)
.env("USERPROFILE", &sandbox.dir);
cmd
}
struct Run {
code: i32,
stdout: String,
stderr: String,
argv: String,
}
impl Run {
fn output(&self) -> String {
format!("{}{}", self.stdout, self.stderr)
}
#[track_caller]
fn says(&self, needle: &str) -> &Run {
assert!(
self.output().contains(needle),
"expected {needle:?} in the output of `{}`\n--- exit {} ---\n{}",
self.argv,
self.code,
self.output()
);
self
}
#[track_caller]
fn never_says(&self, needle: &str) -> &Run {
assert!(
!self.output().contains(needle),
"{needle:?} should not appear in the output of `{}`\n--- exit {} ---\n{}",
self.argv,
self.code,
self.output()
);
self
}
#[track_caller]
fn exits(&self, want: i32) -> &Run {
assert_eq!(
self.code,
want,
"`{}` exited {} rather than {want}\n{}",
self.argv,
self.code,
self.output()
);
self
}
}
fn run(cmd: &mut Command) -> Run {
run_with_stdin(cmd, "")
}
fn run_with_stdin(cmd: &mut Command, stdin: &str) -> Run {
let argv = format!(
"lucida {}",
cmd.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(" ")
);
let mut child = cmd
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|e| panic!("could not run {}: {e}", binary().display()));
{
let mut pipe = child.stdin.take().expect("stdin was piped");
match pipe.write_all(stdin.as_bytes()) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {}
Err(e) => panic!("could not write stdin: {e}"),
}
}
let done = child
.wait_with_output()
.expect("the process never finished");
Run {
code: done.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&done.stdout).into_owned(),
stderr: String::from_utf8_lossy(&done.stderr).into_owned(),
argv,
}
}
#[test]
fn it_runs_and_reports_the_version_it_was_built_from() {
let sandbox = Sandbox::new("version");
let out = run(lucida(&sandbox).arg("--version"));
out.exits(0);
assert_eq!(
out.stdout.trim(),
format!("lucida {}", env!("CARGO_PKG_VERSION")),
"the binary under test is not the one this checkout describes"
);
}
#[test]
fn help_succeeds() {
let sandbox = Sandbox::new("help");
run(lucida(&sandbox).arg("--help")).exits(0);
}
#[test]
fn a_missing_key_explains_itself_rather_than_panicking() {
let sandbox = Sandbox::new("nokey");
let out = run(lucida(&sandbox).arg("models"));
out.says("no API key found")
.says("lucida config")
.never_says("panicked");
}
#[test]
fn capabilities_print_without_a_credential() {
let sandbox = Sandbox::new("caps-nokey");
let out = run(lucida(&sandbox).arg("models"));
out.says("This provider supports:").says("output carries");
}
#[test]
fn config_init_writes_into_a_bare_home() {
let sandbox = Sandbox::new("init");
let out = run(lucida(&sandbox).args(["config", "--init"]));
out.says(&sandbox.name()).says("config.env");
assert!(
sandbox.config_file().exists(),
"config --init reported a path it did not create"
);
}
#[test]
fn the_config_file_is_read_with_no_environment_at_all() {
let sandbox = Sandbox::new("file-read");
sandbox.write_config("GEMINI_API_KEY=cli-test-value\n");
run(lucida(&sandbox).arg("config"))
.says("GEMINI_API_KEY")
.says("set (config file)");
}
#[test]
fn the_config_file_beats_the_environment_and_names_what_it_beat() {
let sandbox = Sandbox::new("file-wins");
sandbox.write_config("GEMINI_API_KEY=cli-test-value\n");
run(lucida(&sandbox)
.env("GEMINI_API_KEY", "from-the-environment")
.arg("config"))
.says("set (config file)")
.says("not used — the config file wins");
}
#[test]
fn config_never_prints_a_value() {
let sandbox = Sandbox::new("no-values");
sandbox.write_config("GEMINI_API_KEY=cli-test-value\n");
run(lucida(&sandbox)
.env("GEMINI_API_KEY", "from-the-environment")
.arg("config"))
.never_says("cli-test-value")
.never_says("from-the-environment");
}
#[test]
fn a_retired_key_name_names_its_replacement() {
let sandbox = Sandbox::new("retired");
run(lucida(&sandbox).env("GOOGLE_API_KEY", "x").arg("config"))
.says("no longer read")
.says("GEMINI_API_KEY");
}
#[test]
fn a_completed_migration_says_nothing_about_the_old_name() {
let sandbox = Sandbox::new("migrated");
run(lucida(&sandbox)
.env("GOOGLE_API_KEY", "x")
.env("GEMINI_API_KEY", "y")
.arg("config"))
.never_says("no longer read");
}
#[test]
fn config_set_refuses_a_retired_name() {
let sandbox = Sandbox::new("set-retired");
run_with_stdin(
lucida(&sandbox).args(["config", "--set", "GOOGLE_API_KEY"]),
"v\n",
)
.says("no longer read")
.says("--set GEMINI_API_KEY");
}
#[test]
fn config_remove_deletes_a_setting_and_says_so_when_there_was_none() {
let sandbox = Sandbox::new("remove");
sandbox.write_config("GEMINI_API_KEY=cli-test-value\n");
run(lucida(&sandbox).args(["config", "--remove", "GEMINI_API_KEY"]))
.says("Removed GEMINI_API_KEY");
run(lucida(&sandbox).arg("config"))
.says("GEMINI_API_KEY")
.says("not set");
run(lucida(&sandbox).args(["config", "--remove", "GEMINI_API_KEY"])).says("nothing to remove");
}
#[test]
fn an_unsupported_seed_names_a_provider_that_has_one() {
let sandbox = Sandbox::new("seed");
run(lucida(&sandbox).args(["generate", "x", "--seed", "1"]))
.says("no concept of a seed")
.says("comfyui")
.never_says("no API key found");
}
#[test]
fn an_unsupported_aspect_ratio_is_rejected() {
let sandbox = Sandbox::new("aspect");
run(lucida(&sandbox).args(["generate", "x", "--aspect", "7:3"]))
.says("supports only these aspect ratios");
}
#[test]
fn a_refused_mask_names_the_provider_whose_mask_binds() {
let sandbox = Sandbox::new("mask");
run(lucida(&sandbox).args(["generate", "x", "--mask", "m.png"]))
.says("comfyui")
.says("the mask is binding");
}
#[test]
fn a_seeded_batch_is_refused() {
let sandbox = Sandbox::new("seeded-batch");
run(lucida(&sandbox).args([
"generate",
"x",
"--provider",
"comfyui",
"--seed",
"5",
"--count",
"3",
]))
.exits(2)
.says("same picture");
}
#[test]
fn an_unreachable_comfyui_explains_itself() {
let sandbox = Sandbox::new("comfy-down");
run(lucida(&sandbox)
.env("LUCIDA_COMFYUI_URL", "http://127.0.0.1:1")
.args(["models", "--provider", "comfyui"]))
.says("could not reach ComfyUI")
.says("LUCIDA_COMFYUI_URL")
.never_says("panicked");
}
#[test]
fn a_capability_refusal_exits_2_and_an_ordinary_error_exits_1() {
let sandbox = Sandbox::new("codes");
run(lucida(&sandbox).args(["generate", "x", "--provider", "google", "--seed", "5"])).exits(2);
run(lucida(&sandbox).args(["generate", "x", "--provider", "nonsense"])).exits(1);
}
#[test]
fn json_reports_a_refusal_as_one_document() {
let sandbox = Sandbox::new("json-refusal");
let out = run(lucida(&sandbox).args([
"--json",
"generate",
"x",
"--provider",
"google",
"--seed",
"5",
]));
out.exits(2);
assert!(
out.stdout.contains("\"refused\":true"),
"no refusal document on stdout:\n{}",
out.stdout
);
}
#[test]
fn json_writes_json_alone_to_stdout() {
let sandbox = Sandbox::new("json-clean");
let out = run(lucida(&sandbox).args(["--json", "ops"]));
let doc = out.stdout.trim();
assert!(
doc.starts_with('{') && doc.ends_with('}'),
"stdout was not a bare JSON object:\n{doc}"
);
}
#[test]
fn a_dry_run_reports_its_plan() {
let sandbox = Sandbox::new("dry-plan");
run(lucida(&sandbox).args([
"generate",
"x",
"--provider",
"comfyui",
"--dry-run",
"--json",
]))
.says("\"status\":\"dry-run\"");
}
#[test]
fn a_dry_run_still_refuses_what_a_real_run_would() {
let sandbox = Sandbox::new("dry-refusal");
run(lucida(&sandbox).args([
"generate",
"x",
"--provider",
"google",
"--seed",
"5",
"--dry-run",
]))
.exits(2);
}
#[test]
fn ops_reports_an_empty_ledger() {
let sandbox = Sandbox::new("ops-empty");
run(lucida(&sandbox).arg("ops")).says("No video renders are waiting");
}
#[test]
fn config_names_the_ledger_and_it_can_be_switched_off() {
let sandbox = Sandbox::new("ledger-visible");
run(lucida(&sandbox).arg("config")).says("Render ledger:");
run(lucida(&sandbox).env("LUCIDA_NO_LEDGER", "1").arg("ops")).says("LUCIDA_NO_LEDGER");
}
const TOOLS_LIST: &str = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#;
fn mcp(sandbox: &Sandbox, request: &str) -> Run {
run_with_stdin(lucida(sandbox).arg("mcp"), request)
}
#[test]
fn tools_list_answers_over_stdio() {
let sandbox = Sandbox::new("mcp-list");
let out = mcp(&sandbox, &format!("{TOOLS_LIST}\n"));
for tool in [
"generate_image",
"image_providers",
"start_video",
"check_video",
"video_providers",
"list_operations",
] {
assert!(
out.stdout.contains(tool),
"{tool} is missing from tools/list:\n{}",
out.stdout
);
}
}
fn schema_providers(out: &Run) -> Vec<String> {
let doc: serde_json::Value =
serde_json::from_str(out.stdout.trim()).expect("tools/list must return one JSON document");
let tools = doc["result"]["tools"]
.as_array()
.expect("tools/list must return an array of tools");
let image = tools
.iter()
.find(|t| t["name"] == "generate_image")
.expect("generate_image must be listed");
image["inputSchema"]["properties"]["provider"]["enum"]
.as_array()
.expect("provider must arrive as a closed set")
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect()
}
#[test]
fn every_provider_the_schema_offers_is_named_in_the_help_text() {
let sandbox = Sandbox::new("mcp-providers");
let offered = schema_providers(&mcp(&sandbox, &format!("{TOOLS_LIST}\n")));
assert!(
!offered.is_empty(),
"the schema offered no providers at all"
);
let help = run(lucida(&sandbox).args(["generate", "--help"]));
let entry = flag_help(&help.stdout, "--provider <PROVIDER>");
for provider in &offered {
assert!(
entry.contains(provider),
"`{provider}` can be selected over MCP but the `--provider` help does \
not name it — the hand-written list in src/main.rs has gone stale.\n\
the enum offers {offered:?}, and --provider says:\n{entry}"
);
}
}
fn flag_help<'a>(help: &'a str, flag: &str) -> &'a str {
let start = help
.find(flag)
.unwrap_or_else(|| panic!("`{flag}` is not in the help output:\n{help}"));
let rest = &help[start + flag.len()..];
match rest.find("\n\n") {
Some(end) => &rest[..end],
None => rest,
}
}
#[test]
fn a_crlf_terminated_request_is_parsed() {
let sandbox = Sandbox::new("mcp-crlf");
mcp(&sandbox, &format!("{TOOLS_LIST}\r\n")).says("generate_image");
}
#[test]
fn the_server_never_emits_a_carriage_return() {
let sandbox = Sandbox::new("mcp-lf");
let out = mcp(&sandbox, &format!("{TOOLS_LIST}\n"));
assert!(
!out.stdout.contains('\r'),
"the server emitted CR in its output"
);
}
#[test]
fn a_notification_draws_no_response() {
let sandbox = Sandbox::new("mcp-notify");
let out = mcp(
&sandbox,
"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n",
);
assert!(
out.stdout.trim().is_empty(),
"replied to a notification:\n{}",
out.stdout
);
}
#[test]
fn the_smoke_script_delegates_here_rather_than_asserting_twice() {
let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("scripts/smoke.sh");
let body = fs::read_to_string(&script).expect("scripts/smoke.sh should exist");
assert!(
body.contains("--test cli"),
"smoke.sh no longer runs this file against the shipped artifact"
);
assert!(
body.contains("LUCIDA_TEST_BIN"),
"smoke.sh runs the tests without pointing them at the artifact it was given"
);
}
#[test]
fn a_preference_picks_the_first_provider_you_hold_a_key_for() {
let sandbox = Sandbox::new("pref-first");
let out = run(lucida(&sandbox)
.env("BFL_API_KEY", "k")
.env("LUCIDA_IMAGE_PROVIDERS", "bfl,google")
.args(["generate", "x", "--dry-run", "--json"]));
out.exits(0)
.says("\"provider\":\"bfl\"")
.says("\"provider_source\":\"LUCIDA_IMAGE_PROVIDERS\"");
}
#[test]
fn a_preference_skips_the_providers_you_have_no_key_for() {
let sandbox = Sandbox::new("pref-skip");
let out = run(lucida(&sandbox)
.env("BFL_API_KEY", "k")
.env("LUCIDA_IMAGE_PROVIDERS", "openai,stability,bfl")
.args(["generate", "x", "--dry-run", "--json"]));
out.exits(0)
.says("\"provider\":\"bfl\"")
.says("choice 3 of 3");
}
#[test]
fn a_preference_refuses_rather_than_falling_back_to_an_unlisted_provider() {
let sandbox = Sandbox::new("pref-nofallback");
let out = run(lucida(&sandbox)
.env("GEMINI_API_KEY", "g")
.env("LUCIDA_IMAGE_PROVIDERS", "openai,stability")
.args(["generate", "x", "--dry-run", "--json"]));
out.says("no provider in LUCIDA_IMAGE_PROVIDERS has a credential")
.says("openai needs OPENAI_API_KEY")
.says("You do have credentials for google")
.never_says("\"provider\":\"google\"");
assert_ne!(out.code, 0, "an unusable preference must not exit 0");
}
#[test]
fn an_unknown_name_in_the_preference_refuses_rather_than_being_skipped() {
let sandbox = Sandbox::new("pref-typo");
let out = run(lucida(&sandbox)
.env("BFL_API_KEY", "k")
.env("LUCIDA_IMAGE_PROVIDERS", "bflx,bfl")
.args(["generate", "x", "--dry-run"]));
out.says("LUCIDA_IMAGE_PROVIDERS lists `bflx`")
.says("not a provider")
.never_says("\"provider\":\"bfl\"");
assert_ne!(out.code, 0, "an unparseable preference must not exit 0");
}
#[test]
fn naming_a_provider_beats_the_preference_and_announces_nothing() {
let sandbox = Sandbox::new("pref-explicit");
let out = run(lucida(&sandbox)
.env("STABILITY_API_KEY", "s")
.env("LUCIDA_IMAGE_PROVIDERS", "bfl")
.args([
"generate",
"x",
"--provider",
"stability",
"--dry-run",
"--json",
]));
out.exits(0)
.says("\"provider\":\"stability\"")
.says("\"provider_source\":null")
.never_says("Provider: ");
}
#[test]
fn no_preference_leaves_the_built_in_default_exactly_as_it_was() {
let sandbox = Sandbox::new("pref-none");
let out = run(lucida(&sandbox)
.env("GEMINI_API_KEY", "g")
.args(["generate", "x", "--dry-run", "--json"]));
out.exits(0)
.says("\"provider\":\"google\"")
.says("\"provider_source\":\"built-in\"");
}
#[test]
fn video_has_its_own_preference_list() {
let sandbox = Sandbox::new("pref-video");
let out = run(lucida(&sandbox)
.env("RUNWAY_API_KEY", "r")
.env("LUCIDA_VIDEO_PROVIDERS", "google,runway")
.args(["video", "x", "--dry-run", "--json"]));
out.exits(0)
.says("\"provider\":\"runway\"")
.says("\"provider_source\":\"LUCIDA_VIDEO_PROVIDERS\"");
}
#[test]
fn the_default_marker_follows_the_preference() {
let sandbox = Sandbox::new("mcp-default-marker");
let out = run_with_stdin(
lucida(&sandbox)
.arg("mcp")
.env("BFL_API_KEY", "k")
.env("LUCIDA_IMAGE_PROVIDERS", "bfl,google"),
&format!("{TOOLS_LIST}\n"),
);
out.says("- bfl (default)")
.says("- google: Highest quality");
}
#[test]
fn an_unsatisfiable_preference_still_lists_the_tools() {
let sandbox = Sandbox::new("mcp-default-none");
let out = run_with_stdin(
lucida(&sandbox).arg("mcp").env("LUCIDA_IMAGE_PROVIDERS", "openai"),
&format!("{TOOLS_LIST}\n"),
);
out.says("generate_image")
.says("- google: Highest quality")
.never_says("- google (default): Highest quality");
}
#[test]
fn a_retired_video_model_says_so_before_it_is_sent() {
let sandbox = Sandbox::new("veo-retired");
let out = run(lucida(&sandbox).args([
"video",
"x",
"--model",
"veo-3.0-generate-001",
"--dry-run",
]));
out.says("retired 2026-06-30").says("expect this to fail");
}
#[test]
fn a_current_video_model_carries_no_such_warning() {
let sandbox = Sandbox::new("veo-current");
let out = run(lucida(&sandbox).args(["video", "x", "--model", "veo-fast", "--dry-run"]));
out.never_says("retired").never_says("expect this to fail");
}