add --experimental-prompt support

This commit is contained in:
easong-openai
2025-07-28 17:32:59 -07:00
parent 094d7af8c3
commit d276932354
13 changed files with 494 additions and 15 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -860,6 +860,7 @@ dependencies = [
"shlex",
"strum 0.27.2",
"strum_macros 0.27.2",
"tempfile",
"tokio",
"tracing",
"tracing-appender",

View File

@@ -63,6 +63,40 @@ pub struct Cli {
/// if `-` is used), instructions are read from stdin.
#[arg(value_name = "PROMPT")]
pub prompt: Option<String>,
/// Override the built-in system prompt (base instructions).
///
/// If the value looks like a path to an existing file, the contents of the
/// file are used. Otherwise, the value itself is used verbatim as the
/// instructions string.
#[arg(long = "experimental-instructions")]
pub experimental_instructions: Option<String>,
}
#[cfg(test)]
mod tests {
use super::Cli;
use clap::CommandFactory;
#[test]
fn help_includes_file_behavior_for_experimental_instructions() {
let mut cmd = Cli::command();
let mut buf: Vec<u8> = Vec::new();
assert!(cmd.write_long_help(&mut buf).is_ok(), "help should render");
let help = match String::from_utf8(buf) {
Ok(s) => s,
Err(e) => panic!("invalid utf8: {e}"),
};
assert!(help.contains("Override the built-in system prompt (base instructions)."));
assert!(help.contains(
"If the value looks like a path to an existing file, the contents of the file are used."
));
assert!(
help.contains(
"Otherwise, the value itself is used verbatim as the instructions string."
)
);
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]

View File

@@ -1,4 +1,5 @@
use std::path::Path;
use std::path::PathBuf;
use codex_common::summarize_sandbox_policy;
use codex_core::WireApi;
@@ -20,7 +21,16 @@ pub(crate) trait EventProcessor {
fn process_event(&mut self, event: Event) -> CodexStatus;
}
pub(crate) fn create_config_summary_entries(config: &Config) -> Vec<(&'static str, String)> {
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum ExperimentalInstructionsOrigin {
File(PathBuf),
Literal,
}
pub(crate) fn create_config_summary_entries(
config: &Config,
experimental_origin: Option<&ExperimentalInstructionsOrigin>,
) -> Vec<(&'static str, String)> {
let mut entries = vec![
("workdir", config.cwd.display().to_string()),
("model", config.model.clone()),
@@ -28,6 +38,16 @@ pub(crate) fn create_config_summary_entries(config: &Config) -> Vec<(&'static st
("approval", config.approval_policy.to_string()),
("sandbox", summarize_sandbox_policy(&config.sandbox_policy)),
];
if let Some(origin) = experimental_origin {
let prompt_val = match origin {
ExperimentalInstructionsOrigin::Literal => "experimental".to_string(),
ExperimentalInstructionsOrigin::File(path) => path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| path.display().to_string()),
};
entries.push(("prompt", prompt_val));
}
if config.model_provider.wire_api == WireApi::Responses
&& model_supports_reasoning_summaries(config)
{
@@ -68,3 +88,65 @@ fn write_last_message_file(contents: &str, last_message_path: Option<&Path>) {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
use std::collections::HashMap;
use tempfile::TempDir;
fn minimal_config() -> Config {
let cwd = match TempDir::new() {
Ok(t) => t,
Err(e) => panic!("tempdir error: {e}"),
};
let codex_home = match TempDir::new() {
Ok(t) => t,
Err(e) => panic!("tempdir error: {e}"),
};
let cfg = ConfigToml {
..Default::default()
};
let overrides = ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
};
match Config::load_from_base_config_with_overrides(
cfg,
overrides,
codex_home.path().to_path_buf(),
) {
Ok(c) => c,
Err(e) => panic!("config error: {e}"),
}
}
#[test]
fn entries_include_prompt_experimental_for_literal_origin() {
let mut cfg = minimal_config();
cfg.base_instructions = Some("hello".to_string());
let entries =
create_config_summary_entries(&cfg, Some(&ExperimentalInstructionsOrigin::Literal));
let map: HashMap<_, _> = entries.into_iter().collect();
assert_eq!(map.get("prompt").cloned(), Some("experimental".to_string()));
}
#[test]
fn entries_include_prompt_filename_for_file_origin() {
let mut cfg = minimal_config();
cfg.base_instructions = Some("hello".to_string());
let path = PathBuf::from("/tmp/custom_instructions.txt");
let entries = create_config_summary_entries(
&cfg,
Some(&ExperimentalInstructionsOrigin::File(path.clone())),
);
let map: HashMap<_, _> = entries.into_iter().collect();
assert_eq!(
map.get("prompt").cloned(),
Some("custom_instructions.txt".to_string())
);
}
}

View File

@@ -27,6 +27,7 @@ use std::time::Instant;
use crate::event_processor::CodexStatus;
use crate::event_processor::EventProcessor;
use crate::event_processor::ExperimentalInstructionsOrigin;
use crate::event_processor::create_config_summary_entries;
use crate::event_processor::handle_last_message;
@@ -59,6 +60,7 @@ pub(crate) struct EventProcessorWithHumanOutput {
answer_started: bool,
reasoning_started: bool,
last_message_path: Option<PathBuf>,
experimental_origin: Option<ExperimentalInstructionsOrigin>,
}
impl EventProcessorWithHumanOutput {
@@ -66,6 +68,7 @@ impl EventProcessorWithHumanOutput {
with_ansi: bool,
config: &Config,
last_message_path: Option<PathBuf>,
experimental_origin: Option<ExperimentalInstructionsOrigin>,
) -> Self {
let call_id_to_command = HashMap::new();
let call_id_to_patch = HashMap::new();
@@ -87,6 +90,7 @@ impl EventProcessorWithHumanOutput {
answer_started: false,
reasoning_started: false,
last_message_path,
experimental_origin,
}
} else {
Self {
@@ -104,6 +108,7 @@ impl EventProcessorWithHumanOutput {
answer_started: false,
reasoning_started: false,
last_message_path,
experimental_origin,
}
}
}
@@ -150,7 +155,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
VERSION
);
let entries = create_config_summary_entries(config);
let entries = create_config_summary_entries(config, self.experimental_origin.as_ref());
for (key, value) in entries {
println!("{} {}", format!("{key}:").style(self.bold), value);

View File

@@ -9,22 +9,30 @@ use serde_json::json;
use crate::event_processor::CodexStatus;
use crate::event_processor::EventProcessor;
use crate::event_processor::ExperimentalInstructionsOrigin;
use crate::event_processor::create_config_summary_entries;
use crate::event_processor::handle_last_message;
pub(crate) struct EventProcessorWithJsonOutput {
last_message_path: Option<PathBuf>,
experimental_origin: Option<ExperimentalInstructionsOrigin>,
}
impl EventProcessorWithJsonOutput {
pub fn new(last_message_path: Option<PathBuf>) -> Self {
Self { last_message_path }
pub fn new(
last_message_path: Option<PathBuf>,
experimental_origin: Option<ExperimentalInstructionsOrigin>,
) -> Self {
Self {
last_message_path,
experimental_origin,
}
}
}
impl EventProcessor for EventProcessorWithJsonOutput {
fn print_config_summary(&mut self, config: &Config, prompt: &str) {
let entries = create_config_summary_entries(config)
let entries = create_config_summary_entries(config, self.experimental_origin.as_ref())
.into_iter()
.map(|(key, value)| (key.to_string(), value))
.collect::<HashMap<String, String>>();

View File

@@ -5,9 +5,11 @@ mod event_processor_with_json_output;
use std::io::IsTerminal;
use std::io::Read;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use crate::event_processor::ExperimentalInstructionsOrigin;
pub use cli::Cli;
use codex_core::codex_wrapper::CodexConversation;
use codex_core::codex_wrapper::{self};
@@ -45,9 +47,38 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
json: json_mode,
sandbox_mode: sandbox_mode_cli_arg,
prompt,
experimental_instructions,
config_overrides,
} = cli;
// Determine how to describe experimental instructions in the summary and
// prepare the effective base instructions. If the flag points at a file,
// read its contents; otherwise use the value verbatim.
let mut experimental_origin = match experimental_instructions.as_deref() {
Some(val) => {
let p = std::path::Path::new(val);
if p.is_file() {
Some(ExperimentalInstructionsOrigin::File(p.to_path_buf()))
} else {
Some(ExperimentalInstructionsOrigin::Literal)
}
}
None => None,
};
let experimental_instructions = match experimental_instructions {
Some(val) => match maybe_read_file(&val) {
Ok(Some(contents)) => Some(contents),
Ok(None) => None,
Err(e) => {
eprintln!("Failed to read --experimental-instructions file: {e}");
std::process::exit(1);
}
},
None => None,
};
let has_experimental = experimental_instructions.is_some();
// Determine the prompt based on CLI arg and/or stdin.
let prompt = match prompt {
Some(p) if p != "-" => p,
@@ -111,7 +142,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
cwd: cwd.map(|p| p.canonicalize().unwrap_or(p)),
model_provider: None,
codex_linux_sandbox_exe,
base_instructions: None,
base_instructions: experimental_instructions,
};
// Parse `-c` overrides.
let cli_kv_overrides = match config_overrides.parse_overrides() {
@@ -123,13 +154,21 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
};
let config = Config::load_with_cli_overrides(cli_kv_overrides, overrides)?;
if !has_experimental {
experimental_origin = None;
}
let mut event_processor: Box<dyn EventProcessor> = if json_mode {
Box::new(EventProcessorWithJsonOutput::new(last_message_file.clone()))
Box::new(EventProcessorWithJsonOutput::new(
last_message_file.clone(),
experimental_origin.clone(),
))
} else {
Box::new(EventProcessorWithHumanOutput::create_with_ansi(
stdout_with_ansi,
&config,
last_message_file.clone(),
experimental_origin,
))
};
@@ -245,3 +284,66 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
Ok(())
}
// If `val` is a path to a readable file, return its trimmed contents. If the
// file is empty after trimming, return Ok(None). Otherwise, return Ok(Some(val)).
fn maybe_read_file(val: &str) -> std::io::Result<Option<String>> {
let p = Path::new(val);
if p.is_file() {
let s = std::fs::read_to_string(p)?;
let s = s.trim().to_string();
if s.is_empty() { Ok(None) } else { Ok(Some(s)) }
} else {
Ok(Some(val.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::maybe_read_file;
use std::fs;
use tempfile::NamedTempFile;
#[test]
fn maybe_read_file_returns_literal_for_non_path() {
let res = match maybe_read_file("You are a helpful assistant.") {
Ok(v) => v,
Err(e) => panic!("error: {e}"),
};
assert_eq!(res, Some("You are a helpful assistant.".to_string()));
}
#[test]
fn maybe_read_file_reads_and_trims_file_contents() {
let tf = match NamedTempFile::new() {
Ok(t) => t,
Err(e) => panic!("tempfile: {e}"),
};
if let Err(e) = fs::write(tf.path(), " Hello world\n") {
panic!("write temp file: {e}");
}
let path_s = tf.path().to_string_lossy().to_string();
let res = match maybe_read_file(&path_s) {
Ok(v) => v,
Err(e) => panic!("should read file successfully: {e}"),
};
assert_eq!(res, Some("Hello world".to_string()));
}
#[test]
fn maybe_read_file_empty_file_returns_none() {
let tf = match NamedTempFile::new() {
Ok(t) => t,
Err(e) => panic!("tempfile: {e}"),
};
if let Err(e) = fs::write(tf.path(), " \n\t ") {
panic!("write temp file: {e}");
}
let path_s = tf.path().to_string_lossy().to_string();
let res = match maybe_read_file(&path_s) {
Ok(v) => v,
Err(e) => panic!("should read file successfully: {e}"),
};
assert_eq!(res, None);
}
}

View File

@@ -65,3 +65,4 @@ uuid = "1"
[dev-dependencies]
insta = "1.43.1"
pretty_assertions = "1"
tempfile = "3.13.0"

View File

@@ -68,6 +68,7 @@ struct ChatWidgetArgs {
config: Config,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
prompt_label: Option<String>,
}
impl App<'_> {
@@ -77,6 +78,7 @@ impl App<'_> {
show_login_screen: bool,
show_git_warning: bool,
initial_images: Vec<std::path::PathBuf>,
prompt_label: Option<String>,
) -> Self {
let (app_event_tx, app_event_rx) = channel();
let app_event_tx = AppEventSender::new(app_event_tx);
@@ -147,6 +149,7 @@ impl App<'_> {
config: config.clone(),
initial_prompt,
initial_images,
prompt_label: prompt_label.clone(),
}),
)
} else if show_git_warning {
@@ -158,6 +161,7 @@ impl App<'_> {
config: config.clone(),
initial_prompt,
initial_images,
prompt_label: prompt_label.clone(),
}),
)
} else {
@@ -166,6 +170,7 @@ impl App<'_> {
app_event_tx.clone(),
initial_prompt,
initial_images,
prompt_label.clone(),
);
(
AppState::Chat {
@@ -301,6 +306,7 @@ impl App<'_> {
self.app_event_tx.clone(),
None,
Vec::new(),
None,
));
self.app_state = AppState::Chat { widget: new_widget };
self.app_event_tx.send(AppEvent::RequestRedraw);
@@ -392,6 +398,7 @@ impl App<'_> {
self.app_event_tx.clone(),
args.initial_prompt,
args.initial_images,
args.prompt_label,
));
self.app_state = AppState::Chat { widget };
self.app_event_tx.send(AppEvent::RequestRedraw);

View File

@@ -55,6 +55,7 @@ pub(crate) struct ChatWidget<'a> {
// We wait for the final AgentMessage event and then emit the full text
// at once into scrollback so the history contains a single message.
answer_buffer: String,
prompt_label: Option<String>,
}
struct UserMessage {
@@ -85,6 +86,7 @@ impl ChatWidget<'_> {
app_event_tx: AppEventSender,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
prompt_label: Option<String>,
) -> Self {
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
@@ -140,6 +142,7 @@ impl ChatWidget<'_> {
token_usage: TokenUsage::default(),
reasoning_buffer: String::new(),
answer_buffer: String::new(),
prompt_label,
}
}
@@ -209,8 +212,11 @@ impl ChatWidget<'_> {
match msg {
EventMsg::SessionConfigured(event) => {
// Record session information at the top of the conversation.
self.conversation_history
.add_session_info(&self.config, event.clone());
self.conversation_history.add_session_info(
&self.config,
event.clone(),
self.prompt_label.as_deref(),
);
// Immediately surface the session banner / settings summary in
// scrollback so the user can review configuration (model,
// sandbox, approvals, etc.) before interacting.

View File

@@ -53,4 +53,38 @@ pub struct Cli {
#[clap(skip)]
pub config_overrides: CliConfigOverrides,
/// Override the built-in system prompt (base instructions).
///
/// If the value looks like a path to an existing file, the contents of the
/// file are used. Otherwise, the value itself is used verbatim as the
/// instructions string.
#[arg(long = "experimental-instructions")]
pub experimental_instructions: Option<String>,
}
#[cfg(test)]
mod tests {
use super::Cli;
use clap::CommandFactory;
#[test]
fn help_includes_file_behavior_for_experimental_instructions() {
let mut cmd = Cli::command();
let mut buf: Vec<u8> = Vec::new();
assert!(cmd.write_long_help(&mut buf).is_ok(), "help should render");
let help = match String::from_utf8(buf) {
Ok(s) => s,
Err(e) => panic!("invalid utf8: {e}"),
};
assert!(help.contains("Override the built-in system prompt (base instructions)."));
assert!(help.contains(
"If the value looks like a path to an existing file, the contents of the file are used."
));
assert!(
help.contains(
"Otherwise, the value itself is used verbatim as the instructions string."
)
);
}
}

View File

@@ -99,7 +99,12 @@ impl ConversationHistoryWidget {
/// Note `model` could differ from `config.model` if the agent decided to
/// use a different model than the one requested by the user.
pub fn add_session_info(&mut self, config: &Config, event: SessionConfiguredEvent) {
pub fn add_session_info(
&mut self,
config: &Config,
event: SessionConfiguredEvent,
prompt_label: Option<&str>,
) {
// In practice, SessionConfiguredEvent should always be the first entry
// in the history, but it is possible that an error could be sent
// before the session info.
@@ -111,6 +116,7 @@ impl ConversationHistoryWidget {
config,
event,
!has_welcome_message,
prompt_label,
));
}

View File

@@ -33,6 +33,8 @@ use std::path::PathBuf;
use std::time::Duration;
use std::time::Instant;
use tracing::error;
#[cfg(test)]
use uuid::Uuid;
pub(crate) struct CommandOutput {
pub(crate) exit_code: i32,
@@ -151,6 +153,7 @@ impl HistoryCell {
config: &Config,
event: SessionConfiguredEvent,
is_first_event: bool,
prompt_label: Option<&str>,
) -> Self {
let SessionConfiguredEvent {
model,
@@ -183,6 +186,9 @@ impl HistoryCell {
("approval", config.approval_policy.to_string()),
("sandbox", summarize_sandbox_policy(&config.sandbox_policy)),
];
if let Some(label) = prompt_label {
entries.push(("prompt", label.to_string()));
}
if config.model_provider.wire_api == WireApi::Responses
&& model_supports_reasoning_summaries(config)
{
@@ -581,6 +587,87 @@ impl HistoryCell {
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
use tempfile::TempDir;
fn minimal_config() -> Config {
let cwd = match TempDir::new() {
Ok(t) => t,
Err(e) => panic!("tempdir error: {e}"),
};
let codex_home = match TempDir::new() {
Ok(t) => t,
Err(e) => panic!("tempdir error: {e}"),
};
let cfg = ConfigToml {
..Default::default()
};
let overrides = ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
};
match Config::load_from_base_config_with_overrides(
cfg,
overrides,
codex_home.path().to_path_buf(),
) {
Ok(c) => c,
Err(e) => panic!("config error: {e}"),
}
}
fn lines_to_strings(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
.map(|line| line.spans.iter().map(|s| s.content.to_string()).collect())
.collect()
}
#[test]
fn welcome_includes_prompt_label_experimental() {
let cfg = minimal_config();
let event = SessionConfiguredEvent {
session_id: Uuid::nil(),
model: cfg.model.clone(),
history_log_id: 0,
history_entry_count: 0,
};
let cell = HistoryCell::new_session_info(&cfg, event, true, Some("experimental"));
let lines = cell.plain_lines();
let strings = lines_to_strings(&lines);
assert!(
strings.iter().any(|s| s.contains("prompt: experimental")),
"welcome should include prompt label; got: {strings:?}"
);
}
#[test]
fn welcome_includes_prompt_label_filename() {
let cfg = minimal_config();
let event = SessionConfiguredEvent {
session_id: Uuid::nil(),
model: cfg.model.clone(),
history_log_id: 0,
history_entry_count: 0,
};
let cell = HistoryCell::new_session_info(&cfg, event, true, Some("instructions.md"));
let lines = cell.plain_lines();
let strings = lines_to_strings(&lines);
assert!(
strings
.iter()
.any(|s| s.contains("prompt: instructions.md")),
"welcome should include filename prompt label; got: {strings:?}"
);
}
}
// ---------------------------------------------------------------------------
// `CellWidget` implementation most variants delegate to their internal
// `TextBlock`. Variants that need custom painting can add their own logic in

View File

@@ -14,6 +14,7 @@ use codex_core::util::is_inside_git_repo;
use codex_login::try_read_openai_api_key;
use log_layer::TuiLogLayer;
use std::fs::OpenOptions;
use std::path::Path;
use std::path::PathBuf;
use tracing_appender::non_blocking;
use tracing_subscriber::EnvFilter;
@@ -68,8 +69,38 @@ pub fn run_main(
)
};
let config = {
let (config, experimental_prompt_label) = {
// Load configuration and support CLI overrides.
// If the experimental instructions flag points at a file, read its
// contents; otherwise use the value verbatim. Avoid printing to stdout
// or stderr in this library crate fallback to the raw string on
// errors.
let base_instructions = cli
.experimental_instructions
.as_deref()
.and_then(|s| maybe_read_file(s).unwrap_or(Some(s.to_string())));
// Derive a label shown in the welcome banner describing the origin of
// the experimental instructions: filename for file paths and
// "experimental" for literals.
let experimental_prompt_label = cli.experimental_instructions.as_deref().map(|s| {
let p = Path::new(s);
if p.is_file() {
p.file_name()
.map(|os| os.to_string_lossy().to_string())
.unwrap_or_else(|| s.to_string())
} else {
"experimental".to_string()
}
});
// Do not show a label if the file was empty (base_instructions is None).
let experimental_prompt_label = if base_instructions.is_some() {
experimental_prompt_label
} else {
None
};
let overrides = ConfigOverrides {
model: cli.model.clone(),
approval_policy,
@@ -78,7 +109,7 @@ pub fn run_main(
model_provider: None,
config_profile: cli.config_profile.clone(),
codex_linux_sandbox_exe,
base_instructions: None,
base_instructions,
};
// Parse `-c` overrides from the CLI.
let cli_kv_overrides = match cli.config_overrides.parse_overrides() {
@@ -92,7 +123,7 @@ pub fn run_main(
#[allow(clippy::print_stderr)]
match Config::load_with_cli_overrides(cli_kv_overrides, overrides) {
Ok(config) => config,
Ok(config) => (config, experimental_prompt_label),
Err(err) => {
eprintln!("Error loading configuration: {err}");
std::process::exit(1);
@@ -150,8 +181,28 @@ pub fn run_main(
// `--allow-no-git-exec` flag.
let show_git_warning = !cli.skip_git_repo_check && !is_inside_git_repo(&config);
run_ratatui_app(cli, config, show_login_screen, show_git_warning, log_rx)
.map_err(|err| std::io::Error::other(err.to_string()))
run_ratatui_app(
cli,
config,
show_login_screen,
show_git_warning,
experimental_prompt_label,
log_rx,
)
.map_err(|err| std::io::Error::other(err.to_string()))
}
// If `val` is a path to a readable file, return its trimmed contents. If the
// file is empty after trimming, return None. Otherwise, return Some(val).
fn maybe_read_file(val: &str) -> std::io::Result<Option<String>> {
let p = Path::new(val);
if p.is_file() {
let s = std::fs::read_to_string(p)?;
let s = s.trim().to_string();
if s.is_empty() { Ok(None) } else { Ok(Some(s)) }
} else {
Ok(Some(val.to_string()))
}
}
fn run_ratatui_app(
@@ -159,6 +210,7 @@ fn run_ratatui_app(
config: Config,
show_login_screen: bool,
show_git_warning: bool,
experimental_prompt_label: Option<String>,
mut log_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
) -> color_eyre::Result<codex_core::protocol::TokenUsage> {
color_eyre::install()?;
@@ -178,6 +230,7 @@ fn run_ratatui_app(
show_login_screen,
show_git_warning,
images,
experimental_prompt_label,
);
// Bridge log receiver into the AppEvent channel so latest log lines update the UI.
@@ -244,3 +297,56 @@ fn is_in_need_of_openai_api_key(config: &Config) -> bool {
.unwrap_or(false);
is_using_openai_key && get_openai_api_key().is_none()
}
#[cfg(test)]
mod tests {
use super::maybe_read_file;
use std::fs;
use std::path::PathBuf;
use uuid::Uuid;
fn temp_path() -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("codex_tui_test_{}.txt", Uuid::new_v4()));
p
}
#[test]
fn maybe_read_file_returns_literal_for_non_path() {
let res = match maybe_read_file("Base instructions as a string") {
Ok(v) => v,
Err(e) => panic!("error: {e}"),
};
assert_eq!(res, Some("Base instructions as a string".to_string()));
}
#[test]
fn maybe_read_file_reads_and_trims_file_contents() {
let p = temp_path();
if let Err(e) = fs::write(&p, " file text \n") {
panic!("write temp file: {e}");
}
let p_s = p.to_string_lossy().to_string();
let res = match maybe_read_file(&p_s) {
Ok(v) => v,
Err(e) => panic!("error: {e}"),
};
assert_eq!(res, Some("file text".to_string()));
let _ = std::fs::remove_file(&p);
}
#[test]
fn maybe_read_file_empty_file_returns_none() {
let p = temp_path();
if let Err(e) = fs::write(&p, " \n\t") {
panic!("write temp file: {e}");
}
let p_s = p.to_string_lossy().to_string();
let res = match maybe_read_file(&p_s) {
Ok(v) => v,
Err(e) => panic!("error: {e}"),
};
assert_eq!(res, None);
let _ = std::fs::remove_file(&p);
}
}