use std::collections::VecDeque; use std::fs; use std::io; use std::path::{Path, PathBuf}; use protocol::Segment; use serde::{Deserialize, Serialize}; pub(crate) const COMPOSER_INPUT_HISTORY_LIMIT: usize = 30; const COMPOSER_HISTORY_VERSION: u32 = 1; #[derive(Debug, Clone)] pub(crate) struct ComposerHistoryStore { path: PathBuf, workspace: WorkspaceIdentity, } #[derive(Debug)] pub(crate) enum ComposerHistoryLoadError { Io(io::Error), Json(serde_json::Error), } impl std::fmt::Display for ComposerHistoryLoadError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Io(error) => write!(f, "{error}"), Self::Json(error) => write!(f, "{error}"), } } } impl std::error::Error for ComposerHistoryLoadError {} impl From for ComposerHistoryLoadError { fn from(error: io::Error) -> Self { Self::Io(error) } } impl From for ComposerHistoryLoadError { fn from(error: serde_json::Error) -> Self { Self::Json(error) } } #[derive(Debug, Clone, Serialize, Deserialize)] struct ComposerHistoryFile { version: u32, workspace: WorkspaceIdentity, entries: Vec>, } #[derive(Debug, Clone, Serialize, Deserialize)] struct WorkspaceIdentity { root: String, label: String, key: String, } impl ComposerHistoryStore { pub(crate) fn default_for_workspace(workspace_root: &Path) -> io::Result> { let Some(data_dir) = manifest::paths::data_dir() else { return Ok(None); }; Ok(Some(Self::for_data_dir(data_dir, workspace_root))) } pub(crate) fn for_data_dir(data_dir: impl AsRef, workspace_root: &Path) -> Self { let workspace = workspace_identity(workspace_root); let path = data_dir .as_ref() .join("composer-history") .join("workspaces") .join(format!("{}-{}", workspace.label, workspace.key)) .join("history.json"); Self { path, workspace } } #[cfg(test)] pub(crate) fn path(&self) -> &Path { &self.path } pub(crate) fn load(&self) -> Result>, ComposerHistoryLoadError> { let bytes = match fs::read(&self.path) { Ok(bytes) => bytes, Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(VecDeque::new()), Err(error) => return Err(error.into()), }; let persisted: ComposerHistoryFile = serde_json::from_slice(&bytes)?; Ok(normalized_entries(persisted.entries)) } pub(crate) fn save( &self, entries: &VecDeque>, ) -> Result<(), ComposerHistoryLoadError> { let persisted = ComposerHistoryFile { version: COMPOSER_HISTORY_VERSION, workspace: self.workspace.clone(), entries: entries.iter().cloned().collect(), }; let bytes = serde_json::to_vec_pretty(&persisted)?; if let Some(parent) = self.path.parent() { fs::create_dir_all(parent)?; } let tmp = self.path.with_extension("json.tmp"); fs::write(&tmp, bytes)?; fs::rename(tmp, &self.path)?; Ok(()) } } pub(crate) fn normalized_entries(entries: Vec>) -> VecDeque> { let mut normalized = VecDeque::new(); for segments in entries { if segments_are_blank(&segments) { continue; } if normalized.back() == Some(&segments) { continue; } if normalized.len() == COMPOSER_INPUT_HISTORY_LIMIT { normalized.pop_front(); } normalized.push_back(segments); } normalized } pub(crate) fn segments_are_blank(segments: &[Segment]) -> bool { segments.iter().all(|s| match s { Segment::Text { content } => content.trim().is_empty(), _ => false, }) } fn workspace_identity(workspace_root: &Path) -> WorkspaceIdentity { let root = normalized_workspace_key(workspace_root); let label = workspace_leaf(&root); let key = fnv1a64_hex(root.as_bytes()); WorkspaceIdentity { root, label, key } } fn normalized_workspace_key(path: &Path) -> String { path.to_string_lossy().replace('\\', "/") } fn workspace_leaf(workspace_root: &str) -> String { let leaf = workspace_root .rsplit('/') .find(|part| !part.is_empty()) .unwrap_or("workspace"); let sanitized = leaf .chars() .map(|ch| { if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') { ch } else { '-' } }) .collect::() .trim_matches('-') .to_string(); if sanitized.is_empty() { "workspace".to_string() } else { sanitized } } fn fnv1a64_hex(bytes: &[u8]) -> String { let mut hash = 0xcbf29ce484222325u64; for byte in bytes { hash ^= u64::from(*byte); hash = hash.wrapping_mul(0x100000001b3); } format!("{hash:016x}") } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; #[test] fn store_path_is_workspace_scoped_under_data_dir() { let data_dir = TempDir::new().unwrap(); let store = ComposerHistoryStore::for_data_dir(data_dir.path(), Path::new("/repo/yoi")); let other = ComposerHistoryStore::for_data_dir(data_dir.path(), Path::new("/repo/other")); assert!(store.path().starts_with(data_dir.path())); assert!( store .path() .to_string_lossy() .contains("composer-history/workspaces/yoi-") ); assert_ne!(store.path(), other.path()); } #[test] fn file_records_workspace_identity_metadata_and_typed_segments() { let data_dir = TempDir::new().unwrap(); let store = ComposerHistoryStore::for_data_dir(data_dir.path(), Path::new("/repo/yoi")); let entries = VecDeque::from([vec![ Segment::text("see "), Segment::FileRef { path: "src/main.rs".into(), }, ]]); store.save(&entries).unwrap(); let raw: serde_json::Value = serde_json::from_slice(&fs::read(store.path()).unwrap()).unwrap(); assert_eq!(raw["workspace"]["root"], "/repo/yoi"); assert_eq!(raw["workspace"]["label"], "yoi"); assert!(raw["workspace"]["key"].as_str().unwrap().len() >= 16); assert_eq!(raw["entries"][0][1]["kind"], "file_ref"); assert_eq!(store.load().unwrap(), entries); } #[test] fn normalization_bounds_blanks_and_consecutive_duplicates() { let mut entries = Vec::new(); entries.push(vec![Segment::text(" ")]); entries.push(vec![Segment::text("same")]); entries.push(vec![Segment::text("same")]); for i in 0..COMPOSER_INPUT_HISTORY_LIMIT + 5 { entries.push(vec![Segment::text(format!("entry-{i}"))]); } let normalized = normalized_entries(entries); assert_eq!(normalized.len(), COMPOSER_INPUT_HISTORY_LIMIT); assert_eq!(normalized.front(), Some(&vec![Segment::text("entry-5")])); assert_eq!(normalized.back(), Some(&vec![Segment::text("entry-34")])); } }