use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
use std::time::Duration;
pub const MAX_HISTORY_ENTRIES: usize = 1000;
const HISTORY_FILE_NAME: &str = "composer_history.txt";
fn default_history_path() -> Option<PathBuf> {
history_path_with_home(dirs::home_dir())
}
fn history_path_with_home(home: Option<PathBuf>) -> Option<PathBuf> {
let home = home?;
let primary = home.join(".codewhale").join(HISTORY_FILE_NAME);
if primary.exists() {
return Some(primary);
}
let legacy = home.join(".deepseek").join(HISTORY_FILE_NAME);
if legacy.exists() {
return Some(legacy);
}
Some(primary)
}
#[must_use]
pub fn load_history() -> Vec<String> {
let Some(path) = default_history_path() else {
return Vec::new();
};
load_history_from(&path)
}
fn load_history_from(path: &Path) -> Vec<String> {
let Ok(file) = fs::File::open(path) else {
return Vec::new();
};
BufReader::new(file)
.lines()
.map_while(Result::ok)
.filter(|line| !line.trim().is_empty())
.collect()
}
pub fn append_history(entry: &str) {
let Some(path) = default_history_path() else {
return;
};
append_history_dispatched(&path, entry);
}
fn append_history_dispatched(path: &Path, entry: &str) {
let entry = entry.to_string();
if let Err(err) = writer_sender().send(HistoryWrite::Append(path.to_path_buf(), entry)) {
match err.0 {
HistoryWrite::Append(path, entry) => append_history_to(&path, &entry),
#[cfg(test)]
HistoryWrite::Flush(_) => unreachable!("flush messages are only sent by tests"),
}
}
}
enum HistoryWrite {
Append(PathBuf, String),
#[cfg(test)]
Flush(Sender<()>),
}
fn writer_sender() -> &'static Sender<HistoryWrite> {
static SENDER: OnceLock<Sender<HistoryWrite>> = OnceLock::new();
SENDER.get_or_init(|| {
let (tx, rx) = channel::<HistoryWrite>();
let spawn_result = std::thread::Builder::new()
.name("composer-history-writer".to_string())
.spawn(move || {
while let Ok(message) = rx.recv() {
match message {
HistoryWrite::Append(path, entry) => {
append_history_batch(&rx, (path, entry));
}
#[cfg(test)]
HistoryWrite::Flush(done) => {
let _ = done.send(());
}
}
}
});
if let Err(err) = spawn_result {
tracing::warn!("Failed to spawn composer-history-writer: {err}");
}
tx
})
}
fn append_history_batch(rx: &Receiver<HistoryWrite>, first: (PathBuf, String)) {
let mut pending = vec![first];
#[cfg(test)]
let mut flush = None;
loop {
match rx.recv_timeout(Duration::from_millis(2)) {
Ok(HistoryWrite::Append(path, entry)) => pending.push((path, entry)),
#[cfg(test)]
Ok(HistoryWrite::Flush(done)) => {
flush = Some(done);
break;
}
Err(RecvTimeoutError::Timeout) => break,
Err(RecvTimeoutError::Disconnected) => break,
}
}
for (path, entries) in group_history_writes_by_path(pending) {
append_history_entries_to(&path, entries.iter().map(String::as_str));
}
#[cfg(test)]
if let Some(done) = flush {
let _ = done.send(());
}
}
fn group_history_writes_by_path(writes: Vec<(PathBuf, String)>) -> Vec<(PathBuf, Vec<String>)> {
let mut grouped: Vec<(PathBuf, Vec<String>)> = Vec::new();
for (path, entry) in writes {
if let Some((_, entries)) = grouped
.iter_mut()
.find(|(existing_path, _)| existing_path == &path)
{
entries.push(entry);
} else {
grouped.push((path, vec![entry]));
}
}
grouped
}
fn append_history_to(path: &Path, entry: &str) {
append_history_entries_to(path, std::iter::once(entry));
}
fn append_history_entries_to<'a>(
path: &Path,
entries_to_append: impl IntoIterator<Item = &'a str>,
) {
if let Some(parent) = path.parent()
&& let Err(err) = fs::create_dir_all(parent)
{
tracing::warn!(
"Failed to create composer history dir {}: {err}",
parent.display()
);
return;
}
let mut entries = load_history_from(path);
let mut changed = false;
for entry in entries_to_append {
let trimmed = entry.trim();
if trimmed.is_empty() || trimmed.starts_with('/') {
continue;
}
if entries.last().map(String::as_str) == Some(trimmed) {
continue;
}
entries.push(trimmed.to_string());
changed = true;
}
if !changed {
return;
}
if entries.len() > MAX_HISTORY_ENTRIES {
let excess = entries.len() - MAX_HISTORY_ENTRIES;
entries.drain(0..excess);
}
let payload = entries.join("\n") + "\n";
if let Err(err) = write_history_atomic(path, payload.as_bytes()) {
tracing::warn!(
"Failed to persist composer history at {}: {err}",
path.display()
);
}
}
fn write_history_atomic(path: &Path, payload: &[u8]) -> std::io::Result<()> {
const RETRY_DELAYS: &[Duration] = &[
Duration::from_millis(5),
Duration::from_millis(10),
Duration::from_millis(25),
Duration::from_millis(50),
Duration::from_millis(100),
Duration::from_millis(200),
Duration::from_millis(400),
];
for (attempt, delay) in RETRY_DELAYS
.iter()
.map(Some)
.chain(std::iter::once(None))
.enumerate()
{
match crate::utils::write_atomic(path, payload) {
Ok(()) => return Ok(()),
Err(err) if delay.is_some() => {
tracing::debug!(
"Retrying composer history write to {} after attempt {} failed: {err}",
path.display(),
attempt + 1
);
std::thread::sleep(*delay.expect("delay checked"));
}
Err(err) => return Err(err),
}
}
unreachable!("retry iterator always ends with a final write attempt")
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, Instant};
fn temp_history_path() -> (tempfile::TempDir, PathBuf) {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join(HISTORY_FILE_NAME);
(tmp, path)
}
fn flush_history_writer_for_tests(timeout: Duration) {
let (done_tx, done_rx) = channel();
writer_sender()
.send(HistoryWrite::Flush(done_tx))
.expect("history writer accepts flush");
done_rx
.recv_timeout(timeout)
.expect("history writer flush timed out");
}
#[test]
fn fresh_install_uses_codewhale_not_legacy() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = history_path_with_home(Some(tmp.path().to_path_buf()))
.expect("path resolves with a home dir");
assert_eq!(path, tmp.path().join(".codewhale").join(HISTORY_FILE_NAME));
assert!(
!path.starts_with(tmp.path().join(".deepseek")),
"fresh install must not target the legacy .deepseek dir: {path:?}"
);
}
#[test]
fn existing_legacy_history_is_still_used() {
let tmp = tempfile::tempdir().expect("tempdir");
let legacy = tmp.path().join(".deepseek").join(HISTORY_FILE_NAME);
fs::create_dir_all(legacy.parent().expect("legacy parent")).expect("mkdir legacy");
fs::write(&legacy, "old entry\n").expect("seed legacy history");
let path = history_path_with_home(Some(tmp.path().to_path_buf())).expect("path resolves");
assert_eq!(path, legacy);
}
#[test]
fn codewhale_history_preferred_over_legacy() {
let tmp = tempfile::tempdir().expect("tempdir");
let primary = tmp.path().join(".codewhale").join(HISTORY_FILE_NAME);
let legacy = tmp.path().join(".deepseek").join(HISTORY_FILE_NAME);
for p in [&primary, &legacy] {
fs::create_dir_all(p.parent().expect("parent")).expect("mkdir");
fs::write(p, "x\n").expect("seed");
}
let path = history_path_with_home(Some(tmp.path().to_path_buf())).expect("path resolves");
assert_eq!(path, primary);
}
#[test]
fn append_and_load_round_trip() {
let (_tmp, path) = temp_history_path();
append_history_to(&path, "first");
append_history_to(&path, "second");
append_history_to(&path, "third");
assert_eq!(load_history_from(&path), vec!["first", "second", "third"]);
}
#[test]
fn slash_commands_skipped() {
let (_tmp, path) = temp_history_path();
append_history_to(&path, "/help");
append_history_to(&path, "real prompt");
append_history_to(&path, "/cost");
assert_eq!(load_history_from(&path), vec!["real prompt"]);
}
#[test]
fn empty_and_whitespace_skipped() {
let (_tmp, path) = temp_history_path();
append_history_to(&path, "");
append_history_to(&path, " ");
append_history_to(&path, "\n\t");
append_history_to(&path, "real");
assert_eq!(load_history_from(&path), vec!["real"]);
}
#[test]
fn consecutive_duplicates_deduped() {
let (_tmp, path) = temp_history_path();
append_history_to(&path, "same");
append_history_to(&path, "same");
append_history_to(&path, "same");
append_history_to(&path, "different");
append_history_to(&path, "same");
assert_eq!(load_history_from(&path), vec!["same", "different", "same"]);
}
#[test]
fn pruned_to_cap_at_append_time() {
let (_tmp, path) = temp_history_path();
for i in 0..(MAX_HISTORY_ENTRIES + 50) {
append_history_to(&path, &format!("entry {i}"));
}
let history = load_history_from(&path);
assert_eq!(history.len(), MAX_HISTORY_ENTRIES);
assert_eq!(history.first().map(String::as_str), Some("entry 50"));
assert_eq!(
history.last().map(String::as_str),
Some(format!("entry {}", MAX_HISTORY_ENTRIES + 49)).as_deref()
);
}
#[test]
fn missing_file_loads_empty() {
let (_tmp, path) = temp_history_path();
assert!(load_history_from(&path).is_empty());
}
#[test]
fn append_history_dispatched_does_not_block_the_caller() {
let (_tmp, path) = temp_history_path();
let seed = (0..(MAX_HISTORY_ENTRIES - 50))
.map(|i| format!("seed entry {i}"))
.collect::<Vec<_>>()
.join("\n")
+ "\n";
std::fs::write(&path, seed).expect("seed history");
let start = Instant::now();
for i in 0..50 {
append_history_dispatched(&path, &format!("new entry {i}"));
}
let dispatch_elapsed = start.elapsed();
assert!(
dispatch_elapsed < Duration::from_millis(150),
"append_history dispatch was too slow: {dispatch_elapsed:?} \
(likely re-introduced #1927: caller blocked on disk write)"
);
flush_history_writer_for_tests(Duration::from_secs(if cfg!(windows) { 10 } else { 5 }));
let loaded = load_history_from(&path);
assert!(
loaded.iter().any(|line| line == "new entry 49"),
"writer thread did not persist the dispatched entries; \
loaded {} entries, last = {:?}",
loaded.len(),
loaded.last()
);
assert!(loaded.iter().any(|line| line == "new entry 0"));
}
}