diff --git a/crates/chat-cli/src/cli/chat/checkpoint.rs b/crates/chat-cli/src/cli/chat/checkpoint.rs new file mode 100644 index 0000000000..c5fb0b8183 --- /dev/null +++ b/crates/chat-cli/src/cli/chat/checkpoint.rs @@ -0,0 +1,422 @@ +use std::collections::{ + HashMap, + VecDeque, +}; +use std::path::{ + Path, + PathBuf, +}; +use std::process::{ + Command, + Output, +}; + +use chrono::{ + DateTime, + Local, +}; +use crossterm::style::Stylize; +use eyre::{ + Result, + bail, + eyre, +}; +use serde::{ + Deserialize, + Serialize, +}; + +use crate::cli::ConversationState; +use crate::cli::chat::conversation::HistoryEntry; +use crate::os::Os; + +/// Manages a shadow git repository for tracking and restoring workspace changes +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CheckpointManager { + /// Path to the shadow (bare) git repository + pub shadow_repo_path: PathBuf, + + /// All checkpoints in chronological order + pub checkpoints: Vec, + + /// Fast lookup: tag -> index in checkpoints vector + pub tag_index: HashMap, + + /// Track the current turn number + pub current_turn: usize, + + /// Track tool uses within current turn + pub tools_in_turn: usize, + + /// Last user message for commit description + pub pending_user_message: Option, + + /// Whether the message has been locked for this turn + pub message_locked: bool, + + /// Cached file change statistics + #[serde(default)] + pub file_stats_cache: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct FileStats { + pub added: usize, + pub modified: usize, + pub deleted: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Checkpoint { + pub tag: String, + pub timestamp: DateTime, + pub description: String, + pub history_snapshot: VecDeque, + pub is_turn: bool, + pub tool_name: Option, +} + +impl CheckpointManager { + /// Initialize checkpoint manager automatically (when in a git repo) + pub async fn auto_init( + os: &Os, + shadow_path: impl AsRef, + current_history: &VecDeque, + ) -> Result { + if !is_git_installed() { + bail!("Git is not installed. Checkpoints require git to function."); + } + if !is_in_git_repo() { + bail!("Not in a git repository. Use '/checkpoint init' to manually enable checkpoints."); + } + + let manager = Self::manual_init(os, shadow_path, current_history).await?; + Ok(manager) + } + + /// Initialize checkpoint manager manually + pub async fn manual_init( + os: &Os, + path: impl AsRef, + current_history: &VecDeque, + ) -> Result { + let path = path.as_ref(); + os.fs.create_dir_all(path).await?; + + // Initialize bare repository + run_git(path, false, &["init", "--bare", &path.to_string_lossy()])?; + + // Configure git + configure_git(&path.to_string_lossy())?; + + // Create initial checkpoint + stage_commit_tag(&path.to_string_lossy(), "Initial state", "0")?; + + let initial_checkpoint = Checkpoint { + tag: "0".to_string(), + timestamp: Local::now(), + description: "Initial state".to_string(), + history_snapshot: current_history.clone(), + is_turn: true, + tool_name: None, + }; + + let mut tag_index = HashMap::new(); + tag_index.insert("0".to_string(), 0); + + Ok(Self { + shadow_repo_path: path.to_path_buf(), + checkpoints: vec![initial_checkpoint], + tag_index, + current_turn: 0, + tools_in_turn: 0, + pending_user_message: None, + message_locked: false, + file_stats_cache: HashMap::new(), + }) + } + + /// Create a new checkpoint point + pub fn create_checkpoint( + &mut self, + tag: &str, + description: &str, + history: &VecDeque, + is_turn: bool, + tool_name: Option, + ) -> Result<()> { + // Stage, commit and tag + stage_commit_tag(&self.shadow_repo_path.to_string_lossy(), description, tag)?; + + // Record checkpoint metadata + let checkpoint = Checkpoint { + tag: tag.to_string(), + timestamp: Local::now(), + description: description.to_string(), + history_snapshot: history.clone(), + is_turn, + tool_name, + }; + + self.checkpoints.push(checkpoint); + self.tag_index.insert(tag.to_string(), self.checkpoints.len() - 1); + + // Cache file stats for this checkpoint + if let Ok(stats) = self.compute_file_stats(tag) { + self.file_stats_cache.insert(tag.to_string(), stats); + } + + Ok(()) + } + + /// Restore workspace to a specific checkpoint + pub fn restore(&self, conversation: &mut ConversationState, tag: &str, hard: bool) -> Result<()> { + let checkpoint = self.get_checkpoint(tag)?; + + if hard { + // Hard: reset the whole work-tree to the tag + let output = run_git(&self.shadow_repo_path, true, &["reset", "--hard", tag])?; + if !output.status.success() { + bail!("Failed to restore: {}", String::from_utf8_lossy(&output.stderr)); + } + } else { + // Soft: only restore tracked files. If the tag is an empty tree, this is a no-op. + if !self.tag_has_any_paths(tag)? { + // Nothing tracked in this checkpoint -> nothing to restore; treat as success. + conversation.restore_to_checkpoint(checkpoint)?; + return Ok(()); + } + // Use checkout against work-tree + let output = run_git(&self.shadow_repo_path, true, &["checkout", tag, "--", "."])?; + if !output.status.success() { + bail!("Failed to restore: {}", String::from_utf8_lossy(&output.stderr)); + } + } + + // Restore conversation history + conversation.restore_to_checkpoint(checkpoint)?; + + Ok(()) + } + + /// Return true iff the given tag/tree has any tracked paths. + fn tag_has_any_paths(&self, tag: &str) -> eyre::Result { + // Use `git ls-tree -r --name-only ` to check if the tree is empty + let out = run_git( + &self.shadow_repo_path, + // work_tree + false, + &["ls-tree", "-r", "--name-only", tag], + )?; + Ok(!out.stdout.is_empty()) + } + + /// Get file change statistics for a checkpoint + pub fn compute_file_stats(&self, tag: &str) -> Result { + if tag == "0" { + return Ok(FileStats::default()); + } + + let prev_tag = get_previous_tag(tag); + self.compute_stats_between(&prev_tag, tag) + } + + /// Compute file statistics between two checkpoints + pub fn compute_stats_between(&self, from: &str, to: &str) -> Result { + let output = run_git(&self.shadow_repo_path, false, &["diff", "--name-status", from, to])?; + + let mut stats = FileStats::default(); + for line in String::from_utf8_lossy(&output.stdout).lines() { + if let Some((status, _)) = line.split_once('\t') { + match status.chars().next() { + Some('A') => stats.added += 1, + Some('M') => stats.modified += 1, + Some('D') => stats.deleted += 1, + Some('R' | 'C') => stats.modified += 1, + _ => {}, + } + } + } + + Ok(stats) + } + + /// Generate detailed diff between checkpoints + pub fn diff(&self, from: &str, to: &str) -> Result { + let mut result = String::new(); + + // Get file changes + let output = run_git(&self.shadow_repo_path, false, &["diff", "--name-status", from, to])?; + + for line in String::from_utf8_lossy(&output.stdout).lines() { + if let Some((status, file)) = line.split_once('\t') { + match status.chars().next() { + Some('A') => result.push_str(&format!(" + {} (added)\n", file).green().to_string()), + Some('M') => result.push_str(&format!(" ~ {} (modified)\n", file).yellow().to_string()), + Some('D') => result.push_str(&format!(" - {} (deleted)\n", file).red().to_string()), + Some('R' | 'C') => result.push_str(&format!(" ~ {} (renamed)\n", file).yellow().to_string()), + _ => {}, + } + } + } + + // Add statistics + let stat_output = run_git(&self.shadow_repo_path, false, &[ + "diff", + from, + to, + "--stat", + "--color=always", + ])?; + + if stat_output.status.success() { + result.push('\n'); + result.push_str(&String::from_utf8_lossy(&stat_output.stdout)); + } + + Ok(result) + } + + /// Check for uncommitted changes + pub fn has_changes(&self) -> Result { + let output = run_git(&self.shadow_repo_path, true, &["status", "--porcelain"])?; + Ok(!output.stdout.is_empty()) + } + + /// Clean up shadow repository + pub async fn cleanup(&self, os: &Os) -> Result<()> { + if self.shadow_repo_path.exists() { + os.fs.remove_dir_all(&self.shadow_repo_path).await?; + } + Ok(()) + } + + fn get_checkpoint(&self, tag: &str) -> Result<&Checkpoint> { + self.tag_index + .get(tag) + .and_then(|&idx| self.checkpoints.get(idx)) + .ok_or_else(|| eyre!("Checkpoint '{}' not found", tag)) + } +} + +impl Drop for CheckpointManager { + fn drop(&mut self) { + let path = self.shadow_repo_path.clone(); + // Try to spawn cleanup task + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + let _ = tokio::fs::remove_dir_all(path).await; + }); + } else { + // Fallback to thread + std::thread::spawn(move || { + let _ = std::fs::remove_dir_all(path); + }); + } + } +} + +// Helper functions + +/// Truncate message for display +pub fn truncate_message(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + return s.to_string(); + } + + let truncated = &s[..max_len]; + if let Some(pos) = truncated.rfind(' ') { + format!("{}...", &truncated[..pos]) + } else { + format!("{}...", truncated) + } +} + +pub const CHECKPOINT_MESSAGE_MAX_LENGTH: usize = 60; + +fn is_git_installed() -> bool { + Command::new("git") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn is_in_git_repo() -> bool { + Command::new("git") + .args(["rev-parse", "--is-inside-work-tree"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn configure_git(shadow_path: &str) -> Result<()> { + run_git(Path::new(shadow_path), false, &["config", "user.name", "Q"])?; + run_git(Path::new(shadow_path), false, &["config", "user.email", "qcli@local"])?; + run_git(Path::new(shadow_path), false, &["config", "core.preloadindex", "true"])?; + Ok(()) +} + +fn stage_commit_tag(shadow_path: &str, message: &str, tag: &str) -> Result<()> { + // Stage all changes + run_git(Path::new(shadow_path), true, &["add", "-A"])?; + + // Commit + let output = run_git(Path::new(shadow_path), true, &[ + "commit", + "--allow-empty", + "--no-verify", + "-m", + message, + ])?; + + if !output.status.success() { + bail!("Git commit failed: {}", String::from_utf8_lossy(&output.stderr)); + } + + // Tag + let output = run_git(Path::new(shadow_path), false, &["tag", tag])?; + if !output.status.success() { + bail!("Git tag failed: {}", String::from_utf8_lossy(&output.stderr)); + } + + Ok(()) +} + +fn run_git(dir: &Path, with_work_tree: bool, args: &[&str]) -> Result { + let mut cmd = Command::new("git"); + cmd.arg(format!("--git-dir={}", dir.display())); + + if with_work_tree { + cmd.arg("--work-tree=."); + } + + cmd.args(args); + + let output = cmd.output()?; + if !output.status.success() && !output.stderr.is_empty() { + bail!(String::from_utf8_lossy(&output.stderr).to_string()); + } + + Ok(output) +} + +fn get_previous_tag(tag: &str) -> String { + // Parse turn.tool format + if let Some((turn_str, tool_str)) = tag.split_once('.') { + if let Ok(tool_num) = tool_str.parse::() { + return if tool_num > 1 { + format!("{}.{}", turn_str, tool_num - 1) + } else { + turn_str.to_string() + }; + } + } + + // Parse turn-only format + if let Ok(turn) = tag.parse::() { + return turn.saturating_sub(1).to_string(); + } + + "0".to_string() +} diff --git a/crates/chat-cli/src/cli/chat/cli/checkpoint.rs b/crates/chat-cli/src/cli/chat/cli/checkpoint.rs new file mode 100644 index 0000000000..634da119c3 --- /dev/null +++ b/crates/chat-cli/src/cli/chat/cli/checkpoint.rs @@ -0,0 +1,573 @@ +use std::io::Write; + +use clap::Subcommand; +use crossterm::style::{ + Attribute, + Color, + StyledContent, + Stylize, +}; +use crossterm::{ + execute, + style, +}; +use dialoguer::Select; + +use crate::cli::chat::checkpoint::{ + Checkpoint, + CheckpointManager, + FileStats, +}; +use crate::cli::chat::{ + ChatError, + ChatSession, + ChatState, +}; +use crate::database::settings::Setting; +use crate::os::Os; +use crate::util::directories::get_shadow_repo_dir; + +#[derive(Debug, PartialEq, Subcommand)] +pub enum CheckpointSubcommand { + /// Initialize checkpoints manually + Init, + + /// Restore workspace to a checkpoint + #[command( + about = "Restore workspace to a checkpoint", + long_about = r#"Restore files to a checkpoint . If is omitted, you'll pick one interactively. + +Default mode: + • Restores tracked file changes + • Keeps new files created after the checkpoint + +With --hard: + • Exactly matches the checkpoint state + • Removes files created after the checkpoint"# + )] + Restore { + /// Checkpoint tag (e.g., 3 or 3.1). Leave empty to select interactively. + tag: Option, + + /// Exactly match checkpoint state (removes newer files) + #[arg(long)] + hard: bool, + }, + + /// List all checkpoints + List { + /// Limit number of results shown + #[arg(short, long)] + limit: Option, + }, + + /// Delete the shadow repository + Clean, + + /// Show details of a checkpoint + Expand { + /// Checkpoint tag to expand + tag: String, + }, + + /// Show differences between checkpoints + Diff { + /// First checkpoint tag + tag1: String, + + /// Second checkpoint tag (defaults to current state) + #[arg(required = false)] + tag2: Option, + }, +} + +impl CheckpointSubcommand { + pub async fn execute(self, os: &Os, session: &mut ChatSession) -> Result { + // Check if checkpoint is enabled + if !os + .database + .settings + .get_bool(Setting::EnabledCheckpoint) + .unwrap_or(false) + { + execute!( + session.stderr, + style::SetForegroundColor(Color::Red), + style::Print("\nCheckpoint is disabled. Enable it with: q settings chat.enableCheckpoint true\n"), + style::SetForegroundColor(Color::Reset) + )?; + return Ok(ChatState::PromptUser { + skip_printing_tools: true, + }); + } + + // Check if in tangent mode - captures are disabled during tangent mode + if session.conversation.is_in_tangent_mode() { + execute!( + session.stderr, + style::SetForegroundColor(Color::Yellow), + style::Print( + "⚠️ Checkpoint is disabled while in tangent mode. Disable tangent mode with: q settings -d chat.enableTangentMode.\n\n" + ), + style::SetForegroundColor(Color::Reset), + )?; + return Ok(ChatState::PromptUser { + skip_printing_tools: true, + }); + } + match self { + Self::Init => self.handle_init(os, session).await, + Self::Restore { ref tag, hard } => self.handle_restore(session, tag.clone(), hard).await, + Self::List { limit } => Self::handle_list(session, limit), + Self::Clean => self.handle_clean(os, session).await, + Self::Expand { ref tag } => Self::handle_expand(session, tag.clone()), + Self::Diff { ref tag1, ref tag2 } => Self::handle_diff(session, tag1.clone(), tag2.clone()), + } + } + + async fn handle_init(&self, os: &Os, session: &mut ChatSession) -> Result { + if session.conversation.checkpoint_manager.is_some() { + execute!( + session.stderr, + style::SetForegroundColor(Color::Blue), + style::Print( + "✓ Checkpoints are already enabled for this session! Use /checkpoint list to see current checkpoints.\n" + ), + style::SetForegroundColor(Color::Reset) + )?; + } else { + let path = get_shadow_repo_dir(os, session.conversation.conversation_id().to_string()) + .map_err(|e| ChatError::Custom(e.to_string().into()))?; + + let start = std::time::Instant::now(); + session.conversation.checkpoint_manager = Some( + CheckpointManager::manual_init(os, path, session.conversation.history()) + .await + .map_err(|e| ChatError::Custom(format!("Checkpoints could not be initialized: {e}").into()))?, + ); + + execute!( + session.stderr, + style::SetForegroundColor(Color::Blue), + style::SetAttribute(Attribute::Bold), + style::Print(format!( + "📷 Checkpoints are enabled! (took {:.2}s)\n", + start.elapsed().as_secs_f32() + )), + style::SetForegroundColor(Color::Reset), + style::SetAttribute(Attribute::Reset), + )?; + } + + Ok(ChatState::PromptUser { + skip_printing_tools: true, + }) + } + + async fn handle_restore( + &self, + session: &mut ChatSession, + tag: Option, + hard: bool, + ) -> Result { + // Take manager out temporarily to avoid borrow issues + let Some(manager) = session.conversation.checkpoint_manager.take() else { + execute!( + session.stderr, + style::SetForegroundColor(Color::Yellow), + style::Print("⚠️ Checkpoints not enabled. Use '/checkpoint init' to enable.\n"), + style::SetForegroundColor(Color::Reset), + )?; + return Ok(ChatState::PromptUser { + skip_printing_tools: true, + }); + }; + + let tag_result = if let Some(tag) = tag { + Ok(tag) + } else { + // Interactive selection + match gather_turn_checkpoints(&manager) { + Ok(entries) => { + if let Some(idx) = select_checkpoint(&entries, "Select checkpoint to restore:") { + Ok(entries[idx].tag.clone()) + } else { + Err(()) + } + }, + Err(e) => { + session.conversation.checkpoint_manager = Some(manager); + return Err(ChatError::Custom(format!("Failed to gather checkpoints: {}", e).into())); + }, + } + }; + + let tag = match tag_result { + Ok(tag) => tag, + Err(_) => { + session.conversation.checkpoint_manager = Some(manager); + return Ok(ChatState::PromptUser { + skip_printing_tools: true, + }); + }, + }; + + match manager.restore(&mut session.conversation, &tag, hard) { + Ok(_) => { + execute!( + session.stderr, + style::SetForegroundColor(Color::Blue), + style::SetAttribute(Attribute::Bold), + style::Print(format!("✓ Restored to checkpoint {}\n", tag)), + style::SetForegroundColor(Color::Reset), + style::SetAttribute(Attribute::Reset), + )?; + session.conversation.checkpoint_manager = Some(manager); + }, + Err(e) => { + session.conversation.checkpoint_manager = Some(manager); + return Err(ChatError::Custom(format!("Failed to restore: {}", e).into())); + }, + } + + Ok(ChatState::PromptUser { + skip_printing_tools: true, + }) + } + + fn handle_list(session: &mut ChatSession, limit: Option) -> Result { + let Some(manager) = session.conversation.checkpoint_manager.as_ref() else { + execute!( + session.stderr, + style::SetForegroundColor(Color::Yellow), + style::Print("⚠️ Checkpoints not enabled. Use '/checkpoint init' to enable.\n"), + style::SetForegroundColor(Color::Reset), + )?; + return Ok(ChatState::PromptUser { + skip_printing_tools: true, + }); + }; + + print_checkpoints(manager, &mut session.stderr, limit) + .map_err(|e| ChatError::Custom(format!("Could not display all checkpoints: {}", e).into()))?; + + Ok(ChatState::PromptUser { + skip_printing_tools: true, + }) + } + + async fn handle_clean(&self, os: &Os, session: &mut ChatSession) -> Result { + let Some(manager) = session.conversation.checkpoint_manager.take() else { + execute!( + session.stderr, + style::SetForegroundColor(Color::Yellow), + style::Print("⚠️ ️Checkpoints not enabled.\n"), + style::SetForegroundColor(Color::Reset), + )?; + return Ok(ChatState::PromptUser { + skip_printing_tools: true, + }); + }; + + // Print the path that will be deleted + execute!( + session.stderr, + style::Print(format!("Deleting: {}\n", manager.shadow_repo_path.display())) + )?; + + match manager.cleanup(os).await { + Ok(()) => { + execute!( + session.stderr, + style::SetAttribute(Attribute::Bold), + style::Print("✓ Deleted shadow repository for this session.\n"), + style::SetAttribute(Attribute::Reset), + )?; + }, + Err(e) => { + session.conversation.checkpoint_manager = Some(manager); + return Err(ChatError::Custom(format!("Failed to clean: {e}").into())); + }, + } + + Ok(ChatState::PromptUser { + skip_printing_tools: true, + }) + } + + fn handle_expand(session: &mut ChatSession, tag: String) -> Result { + let Some(manager) = session.conversation.checkpoint_manager.as_ref() else { + execute!( + session.stderr, + style::SetForegroundColor(Color::Yellow), + style::Print("⚠️ ️Checkpoints not enabled. Use '/checkpoint init' to enable.\n"), + style::SetForegroundColor(Color::Reset), + )?; + return Ok(ChatState::PromptUser { + skip_printing_tools: true, + }); + }; + + expand_checkpoint(manager, &mut session.stderr, &tag) + .map_err(|e| ChatError::Custom(format!("Failed to expand checkpoint: {}", e).into()))?; + + Ok(ChatState::PromptUser { + skip_printing_tools: true, + }) + } + + fn handle_diff(session: &mut ChatSession, tag1: String, tag2: Option) -> Result { + let Some(manager) = session.conversation.checkpoint_manager.as_ref() else { + execute!( + session.stderr, + style::SetForegroundColor(Color::Yellow), + style::Print("⚠️ Checkpoints not enabled. Use '/checkpoint init' to enable.\n"), + style::SetForegroundColor(Color::Reset), + )?; + return Ok(ChatState::PromptUser { + skip_printing_tools: true, + }); + }; + + let tag2 = tag2.unwrap_or_else(|| "HEAD".to_string()); + + // Validate tags exist + if tag1 != "HEAD" && !manager.tag_index.contains_key(&tag1) { + execute!( + session.stderr, + style::SetForegroundColor(Color::Yellow), + style::Print(format!( + "⚠️ Checkpoint '{}' not found! Use /checkpoint list to see available checkpoints\n", + tag1 + )), + style::SetForegroundColor(Color::Reset), + )?; + return Ok(ChatState::PromptUser { + skip_printing_tools: true, + }); + } + + if tag2 != "HEAD" && !manager.tag_index.contains_key(&tag2) { + execute!( + session.stderr, + style::SetForegroundColor(Color::Yellow), + style::Print(format!( + "⚠️ Checkpoint '{}' not found! Use /checkpoint list to see available checkpoints\n", + tag2 + )), + style::SetForegroundColor(Color::Reset), + )?; + return Ok(ChatState::PromptUser { + skip_printing_tools: true, + }); + } + + let header = if tag2 == "HEAD" { + format!("Changes since checkpoint {}:\n", tag1) + } else { + format!("Changes from {} to {}:\n", tag1, tag2) + }; + + execute!( + session.stderr, + style::SetForegroundColor(Color::Blue), + style::Print(header), + style::SetForegroundColor(Color::Reset), + )?; + + match manager.diff(&tag1, &tag2) { + Ok(diff) => { + if diff.trim().is_empty() { + execute!( + session.stderr, + style::SetForegroundColor(Color::DarkGrey), + style::Print("No changes.\n"), + style::SetForegroundColor(Color::Reset), + )?; + } else { + execute!(session.stderr, style::Print(diff))?; + } + }, + Err(e) => { + return Err(ChatError::Custom(format!("Failed to generate diff: {e}").into())); + }, + } + + Ok(ChatState::PromptUser { + skip_printing_tools: true, + }) + } +} + +// Display helpers + +struct CheckpointDisplay { + tag: String, + parts: Vec>, +} + +impl CheckpointDisplay { + fn from_checkpoint(checkpoint: &Checkpoint, manager: &CheckpointManager) -> Result { + let mut parts = Vec::new(); + + // Tag + parts.push(format!("[{}] ", checkpoint.tag).blue()); + + // Content + if checkpoint.is_turn { + // Turn checkpoint: show timestamp and description + parts.push( + format!( + "{} - {}", + checkpoint.timestamp.format("%Y-%m-%d %H:%M:%S"), + checkpoint.description + ) + .reset(), + ); + + // Add file stats if available + if let Some(stats) = manager.file_stats_cache.get(&checkpoint.tag) { + let stats_str = format_stats(stats); + if !stats_str.is_empty() { + parts.push(format!(" ({})", stats_str).dark_grey()); + } + } + } else { + // Tool checkpoint: show tool name and description + let tool_name = checkpoint.tool_name.clone().unwrap_or_else(|| "Tool".to_string()); + parts.push(format!("{}: ", tool_name).magenta()); + parts.push(checkpoint.description.clone().reset()); + } + + Ok(Self { + tag: checkpoint.tag.clone(), + parts, + }) + } +} + +impl std::fmt::Display for CheckpointDisplay { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for part in &self.parts { + write!(f, "{}", part)?; + } + Ok(()) + } +} + +fn format_stats(stats: &FileStats) -> String { + let mut parts = Vec::new(); + + if stats.added > 0 { + parts.push(format!("+{}", stats.added)); + } + if stats.modified > 0 { + parts.push(format!("~{}", stats.modified)); + } + if stats.deleted > 0 { + parts.push(format!("-{}", stats.deleted)); + } + + parts.join(" ") +} + +fn gather_turn_checkpoints(manager: &CheckpointManager) -> Result, eyre::Report> { + manager + .checkpoints + .iter() + .filter(|c| c.is_turn) + .map(|c| CheckpointDisplay::from_checkpoint(c, manager)) + .collect() +} + +fn print_checkpoints( + manager: &CheckpointManager, + output: &mut impl Write, + limit: Option, +) -> Result<(), eyre::Report> { + let entries = gather_turn_checkpoints(manager)?; + let limit = limit.unwrap_or(entries.len()); + + for entry in entries.iter().take(limit) { + execute!(output, style::Print(&entry), style::Print("\n"))?; + } + + Ok(()) +} + +fn expand_checkpoint(manager: &CheckpointManager, output: &mut impl Write, tag: &str) -> Result<(), eyre::Report> { + let Some(&idx) = manager.tag_index.get(tag) else { + execute!( + output, + style::SetForegroundColor(Color::Yellow), + style::Print(format!("⚠️ checkpoint '{}' not found\n", tag)), + style::SetForegroundColor(Color::Reset), + )?; + return Ok(()); + }; + + let checkpoint = &manager.checkpoints[idx]; + + // Print main checkpoint + let display = CheckpointDisplay::from_checkpoint(checkpoint, manager)?; + execute!(output, style::Print(&display), style::Print("\n"))?; + + if !checkpoint.is_turn { + return Ok(()); + } + + // Print tool checkpoints for this turn + let mut tool_checkpoints = Vec::new(); + for i in (0..idx).rev() { + let c = &manager.checkpoints[i]; + if c.is_turn { + break; + } + tool_checkpoints.push((i, CheckpointDisplay::from_checkpoint(c, manager)?)); + } + + for (checkpoint_idx, display) in tool_checkpoints.iter().rev() { + // Compute stats for this tool + let curr_tag = &manager.checkpoints[*checkpoint_idx].tag; + let prev_tag = if *checkpoint_idx > 0 { + &manager.checkpoints[checkpoint_idx - 1].tag + } else { + "0" + }; + + let stats_str = manager + .compute_stats_between(prev_tag, curr_tag) + .map(|s| format_stats(&s)) + .unwrap_or_default(); + + execute!( + output, + style::SetForegroundColor(Color::Blue), + style::Print(" └─ "), + style::Print(display), + style::SetForegroundColor(Color::Reset), + )?; + + if !stats_str.is_empty() { + execute!( + output, + style::SetForegroundColor(Color::DarkGrey), + style::Print(format!(" ({})", stats_str)), + style::SetForegroundColor(Color::Reset), + )?; + } + + execute!(output, style::Print("\n"))?; + } + + Ok(()) +} + +fn select_checkpoint(entries: &[CheckpointDisplay], prompt: &str) -> Option { + Select::with_theme(&crate::util::dialoguer_theme()) + .with_prompt(prompt) + .items(entries) + .report(false) + .interact_opt() + .unwrap_or(None) +} diff --git a/crates/chat-cli/src/cli/chat/cli/experiment.rs b/crates/chat-cli/src/cli/chat/cli/experiment.rs index 121419b244..9b7c3a8cd2 100644 --- a/crates/chat-cli/src/cli/chat/cli/experiment.rs +++ b/crates/chat-cli/src/cli/chat/cli/experiment.rs @@ -50,6 +50,15 @@ static AVAILABLE_EXPERIMENTS: &[Experiment] = &[ description: "Enables Q to create todo lists that can be viewed and managed using /todos", setting_key: Setting::EnabledTodoList, }, + Experiment { + name: "Checkpoint", + description: concat!( + "Enables workspace checkpoints to snapshot, list, expand, diff, and restore files (/checkpoint)\n", + " ", + "Cannot be used in tangent mode (to avoid mixing up conversation history)" + ), + setting_key: Setting::EnabledCheckpoint, + }, Experiment { name: "Context Usage Indicator", description: "Shows context usage percentage in the prompt (e.g., [rust-agent] 6% >)", diff --git a/crates/chat-cli/src/cli/chat/cli/mod.rs b/crates/chat-cli/src/cli/chat/cli/mod.rs index e5a20ed1c2..bf951596e6 100644 --- a/crates/chat-cli/src/cli/chat/cli/mod.rs +++ b/crates/chat-cli/src/cli/chat/cli/mod.rs @@ -1,4 +1,5 @@ pub mod changelog; +pub mod checkpoint; pub mod clear; pub mod compact; pub mod context; @@ -35,6 +36,7 @@ use tangent::TangentArgs; use todos::TodoSubcommand; use tools::ToolsArgs; +use crate::cli::chat::cli::checkpoint::CheckpointSubcommand; use crate::cli::chat::cli::subscribe::SubscribeArgs; use crate::cli::chat::cli::usage::UsageArgs; use crate::cli::chat::consts::AGENT_MIGRATION_DOC_URL; @@ -102,6 +104,8 @@ pub enum SlashCommand { Persist(PersistSubcommand), // #[command(flatten)] // Root(RootSubcommand), + #[command(subcommand)] + Checkpoint(CheckpointSubcommand), /// View, manage, and resume to-do lists #[command(subcommand)] Todos(TodoSubcommand), @@ -169,6 +173,7 @@ impl SlashCommand { // skip_printing_tools: true, // }) // }, + Self::Checkpoint(subcommand) => subcommand.execute(os, session).await, Self::Todos(subcommand) => subcommand.execute(os, session).await, } } @@ -198,6 +203,7 @@ impl SlashCommand { PersistSubcommand::Save { .. } => "save", PersistSubcommand::Load { .. } => "load", }, + Self::Checkpoint(_) => "checkpoint", Self::Todos(_) => "todos", } } diff --git a/crates/chat-cli/src/cli/chat/cli/tangent.rs b/crates/chat-cli/src/cli/chat/cli/tangent.rs index 94184c4828..65165c84f3 100644 --- a/crates/chat-cli/src/cli/chat/cli/tangent.rs +++ b/crates/chat-cli/src/cli/chat/cli/tangent.rs @@ -65,6 +65,22 @@ impl TangentArgs { match self.subcommand { Some(TangentSubcommand::Tail) => { + // Check if checkpoint is enabled + if os + .database + .settings + .get_bool(Setting::EnabledCheckpoint) + .unwrap_or(false) + { + execute!( + session.stderr, + style::SetForegroundColor(Color::Yellow), + style::Print( + "⚠️ Checkpoint is disabled while in tangent mode. Please exit tangent mode if you want to use checkpoint.\n" + ), + style::SetForegroundColor(Color::Reset), + )?; + } if session.conversation.is_in_tangent_mode() { let duration_seconds = session.conversation.get_tangent_duration_seconds().unwrap_or(0); session.conversation.exit_tangent_mode_with_tail(); @@ -106,6 +122,23 @@ impl TangentArgs { style::SetForegroundColor(Color::Reset) )?; } else { + // Check if checkpoint is enabled + if os + .database + .settings + .get_bool(Setting::EnabledCheckpoint) + .unwrap_or(false) + { + execute!( + session.stderr, + style::SetForegroundColor(Color::Yellow), + style::Print( + "⚠️ Checkpoint is disabled while in tangent mode. Please exit tangent mode if you want to use checkpoint.\n" + ), + style::SetForegroundColor(Color::Reset), + )?; + } + session.conversation.enter_tangent_mode(); // Get the configured tangent mode key for display diff --git a/crates/chat-cli/src/cli/chat/conversation.rs b/crates/chat-cli/src/cli/chat/conversation.rs index 3d064c0180..1217c0289b 100644 --- a/crates/chat-cli/src/cli/chat/conversation.rs +++ b/crates/chat-cli/src/cli/chat/conversation.rs @@ -74,6 +74,10 @@ use crate::cli::agent::hook::{ HookTrigger, }; use crate::cli::chat::ChatError; +use crate::cli::chat::checkpoint::{ + Checkpoint, + CheckpointManager, +}; use crate::cli::chat::cli::model::{ ModelInfo, get_model_info, @@ -138,6 +142,8 @@ pub struct ConversationState { /// Maps from a file path to [FileLineTracker] #[serde(default)] pub file_line_tracker: HashMap, + + pub checkpoint_manager: Option, #[serde(default = "default_true")] pub mcp_enabled: bool, /// Tangent mode checkpoint - stores main conversation when in tangent mode @@ -203,6 +209,7 @@ impl ConversationState { model: None, model_info: model, file_line_tracker: HashMap::new(), + checkpoint_manager: None, mcp_enabled, tangent_state: None, } @@ -891,6 +898,20 @@ Return only the JSON configuration, no additional text.", self.transcript.push_back(message); } + /// Restore conversation from a checkpoint's history snapshot + pub fn restore_to_checkpoint(&mut self, checkpoint: &Checkpoint) -> Result<(), eyre::Report> { + // 1. Restore history from snapshot + self.history = checkpoint.history_snapshot.clone(); + + // 2. Clear any pending next message (uncommitted state) + self.next_message = None; + + // 3. Update valid history range + self.valid_history_range = (0, self.history.len()); + + Ok(()) + } + /// Swapping agent involves the following: /// - Reinstantiate the context manager /// - Swap agent on tool manager diff --git a/crates/chat-cli/src/cli/chat/mod.rs b/crates/chat-cli/src/cli/chat/mod.rs index a576440c80..fcdb8b30ef 100644 --- a/crates/chat-cli/src/cli/chat/mod.rs +++ b/crates/chat-cli/src/cli/chat/mod.rs @@ -6,11 +6,13 @@ mod input_source; mod message; mod parse; use std::path::MAIN_SEPARATOR; +pub mod checkpoint; mod line_tracker; mod parser; mod prompt; mod prompt_parser; pub mod server_messenger; +use crate::cli::chat::checkpoint::CHECKPOINT_MESSAGE_MAX_LENGTH; #[cfg(unix)] mod skim_integration; mod token_counter; @@ -142,6 +144,10 @@ use crate::auth::AuthError; use crate::auth::builder_id::is_idc_user; use crate::cli::TodoListState; use crate::cli::agent::Agents; +use crate::cli::chat::checkpoint::{ + CheckpointManager, + truncate_message, +}; use crate::cli::chat::cli::SlashCommand; use crate::cli::chat::cli::editor::open_editor; use crate::cli::chat::cli::prompts::{ @@ -165,6 +171,7 @@ use crate::telemetry::{ TelemetryResult, get_error_reason, }; +use crate::util::directories::get_shadow_repo_dir; use crate::util::{ MCP_SERVER_TOOL_DELIMITER, directories, @@ -455,7 +462,7 @@ const RESUME_TEXT: &str = color_print::cstr! {"Picking up where we left off. const CHANGELOG_MAX_SHOW_COUNT: i64 = 2; // Only show the model-related tip for now to make users aware of this feature. -const ROTATING_TIPS: [&str; 19] = [ +const ROTATING_TIPS: [&str; 20] = [ color_print::cstr! {"You can resume the last conversation from your current directory by launching with q chat --resume"}, color_print::cstr! {"Get notified whenever Q CLI finishes responding. @@ -490,6 +497,7 @@ const ROTATING_TIPS: [&str; 19] = [ color_print::cstr! {"Use /tangent or ctrl + t (customizable) to start isolated conversations ( ↯ ) that don't affect your main chat history"}, color_print::cstr! {"Ask me directly about my capabilities! Try questions like \"What can you do?\" or \"Can you save conversations?\""}, color_print::cstr! {"Stay up to date with the latest features and improvements! Use /changelog to see what's new in Amazon Q CLI"}, + color_print::cstr! {"Enable workspace checkpoints to snapshot & restore changes. Just run q settings chat.enableCheckpoint true"}, ]; const GREETING_BREAK_POINT: usize = 80; @@ -1322,6 +1330,38 @@ impl ChatSession { } } + // Initialize capturing if possible + if os + .database + .settings + .get_bool(Setting::EnabledCheckpoint) + .unwrap_or(false) + { + let path = get_shadow_repo_dir(os, self.conversation.conversation_id().to_string())?; + let start = std::time::Instant::now(); + let checkpoint_manager = match CheckpointManager::auto_init(os, &path, self.conversation.history()).await { + Ok(manager) => { + execute!( + self.stderr, + style::Print( + format!( + "📷 Checkpoints are enabled! (took {:.2}s)\n\n", + start.elapsed().as_secs_f32() + ) + .blue() + .bold() + ) + )?; + Some(manager) + }, + Err(e) => { + execute!(self.stderr, style::Print(format!("{e}\n\n").blue()))?; + None + }, + }; + self.conversation.checkpoint_manager = checkpoint_manager; + } + if let Some(user_input) = self.initial_input.take() { self.inner = Some(ChatState::HandleInput { input: user_input }); } @@ -2083,6 +2123,23 @@ impl ChatSession { skip_printing_tools: false, }) } else { + // Track the message for checkpoint descriptions, but only if not already set + // This prevents tool approval responses (y/n/t) from overwriting the original message + if os + .database + .settings + .get_bool(Setting::EnabledCheckpoint) + .unwrap_or(false) + && !self.conversation.is_in_tangent_mode() + { + if let Some(manager) = self.conversation.checkpoint_manager.as_mut() { + if !manager.message_locked && self.pending_tool_index.is_none() { + manager.pending_user_message = Some(user_input.clone()); + manager.message_locked = true; + } + } + } + // Check for a pending tool approval if let Some(index) = self.pending_tool_index { let is_trust = ["t", "T"].contains(&input); @@ -2306,6 +2363,74 @@ impl ChatSession { } execute!(self.stdout, style::Print("\n"))?; + // Handle checkpoint after tool execution - store tag for later display + let checkpoint_tag: Option = { + let enabled = os + .database + .settings + .get_bool(Setting::EnabledCheckpoint) + .unwrap_or(false) + && !self.conversation.is_in_tangent_mode(); + if invoke_result.is_err() || !enabled { + None + } + // Take manager out temporarily to avoid borrow conflicts + else if let Some(mut manager) = self.conversation.checkpoint_manager.take() { + // Check if there are uncommitted changes + let has_changes = match manager.has_changes() { + Ok(b) => b, + Err(e) => { + execute!( + self.stderr, + style::SetForegroundColor(Color::Yellow), + style::Print(format!("Could not check if uncommitted changes exist: {e}\n")), + style::Print("Saving anyways...\n"), + style::SetForegroundColor(Color::Reset), + )?; + true + }, + }; + let tag = if has_changes { + // Generate tag for this tool use + let tag = format!("{}.{}", manager.current_turn + 1, manager.tools_in_turn + 1); + + // Get tool summary for commit message + let is_fs_read = matches!(&tool.tool, Tool::FsRead(_)); + let description = if is_fs_read { + "External edits detected (likely manual change)".to_string() + } else { + match tool.tool.get_summary() { + Some(summary) => summary, + None => tool.tool.display_name(), + } + }; + + // Create checkpoint + if let Err(e) = manager.create_checkpoint( + &tag, + &description, + &self.conversation.history().clone(), + false, + Some(tool.name.clone()), + ) { + debug!("Failed to create tool checkpoint: {}", e); + None + } else { + manager.tools_in_turn += 1; + Some(tag) + } + } else { + None + }; + + // Put manager back + self.conversation.checkpoint_manager = Some(manager); + tag + } else { + None + } + }; + let tool_end_time = Instant::now(); let tool_time = tool_end_time.duration_since(tool_start); tool_telemetry = tool_telemetry.and_modify(|ev| { @@ -2348,8 +2473,18 @@ impl ChatSession { style::SetAttribute(Attribute::Bold), style::Print(format!(" ● Completed in {}s", tool_time)), style::SetForegroundColor(Color::Reset), - style::Print("\n\n"), )?; + if let Some(tag) = checkpoint_tag { + execute!( + self.stdout, + style::SetForegroundColor(Color::Blue), + style::SetAttribute(Attribute::Bold), + style::Print(format!(" [{tag}]")), + style::SetForegroundColor(Color::Reset), + style::SetAttribute(Attribute::Reset), + )?; + } + execute!(self.stdout, style::Print("\n\n"))?; tool_telemetry = tool_telemetry.and_modify(|ev| ev.is_success = Some(true)); if let Tool::Custom(_) = &tool.tool { @@ -2729,7 +2864,10 @@ impl ChatSession { self.stdout, style::Print("\n\n"), style::SetForegroundColor(Color::Yellow), - style::Print(format!("Tool validation failed: {}\n Retrying the request...", error_message)), + style::Print(format!( + "Tool validation failed: {}\n Retrying the request...", + error_message + )), style::ResetColor, style::Print("\n"), ); @@ -2845,6 +2983,64 @@ impl ChatSession { self.pending_tool_index = None; self.tool_turn_start_time = None; + // Create turn checkpoint if tools were used + if os + .database + .settings + .get_bool(Setting::EnabledCheckpoint) + .unwrap_or(false) + && !self.conversation.is_in_tangent_mode() + { + if let Some(mut manager) = self.conversation.checkpoint_manager.take() { + if manager.tools_in_turn > 0 { + // Increment turn counter + manager.current_turn += 1; + + // Get user message for description + let description = manager.pending_user_message.take().map_or_else( + || "Turn completed".to_string(), + |msg| truncate_message(&msg, CHECKPOINT_MESSAGE_MAX_LENGTH), + ); + + // Create turn checkpoint + let tag = manager.current_turn.to_string(); + if let Err(e) = manager.create_checkpoint( + &tag, + &description, + &self.conversation.history().clone(), + true, + None, + ) { + execute!( + self.stderr, + style::SetForegroundColor(Color::Yellow), + style::Print(format!("⚠️ Could not create automatic checkpoint: {}\n\n", e)), + style::SetForegroundColor(Color::Reset), + )?; + } else { + execute!( + self.stderr, + style::SetForegroundColor(Color::Blue), + style::SetAttribute(Attribute::Bold), + style::Print(format!("✓ Created checkpoint {}\n\n", tag)), + style::SetForegroundColor(Color::Reset), + style::SetAttribute(Attribute::Reset), + )?; + } + + // Reset for next turn + manager.tools_in_turn = 0; + manager.message_locked = false; // Unlock for next turn + } else { + // Clear pending message even if no tools were used + manager.pending_user_message = None; + } + + // Put manager back + self.conversation.checkpoint_manager = Some(manager); + } + } + self.send_chat_telemetry(os, TelemetryResult::Succeeded, None, None, None, true) .await; diff --git a/crates/chat-cli/src/cli/chat/tools/fs_write.rs b/crates/chat-cli/src/cli/chat/tools/fs_write.rs index d23a957b60..d72ccb2be6 100644 --- a/crates/chat-cli/src/cli/chat/tools/fs_write.rs +++ b/crates/chat-cli/src/cli/chat/tools/fs_write.rs @@ -451,7 +451,7 @@ impl FsWrite { } /// Returns the summary from any variant of the FsWrite enum - fn get_summary(&self) -> Option<&String> { + pub fn get_summary(&self) -> Option<&String> { match self { FsWrite::Create { summary, .. } => summary.as_ref(), FsWrite::StrReplace { summary, .. } => summary.as_ref(), diff --git a/crates/chat-cli/src/cli/chat/tools/mod.rs b/crates/chat-cli/src/cli/chat/tools/mod.rs index 96cc0d76f2..9b90e1d052 100644 --- a/crates/chat-cli/src/cli/chat/tools/mod.rs +++ b/crates/chat-cli/src/cli/chat/tools/mod.rs @@ -187,6 +187,16 @@ impl Tool { _ => None, } } + + /// Returns the tool's summary if available + pub fn get_summary(&self) -> Option { + match self { + Tool::FsWrite(fs_write) => fs_write.get_summary().cloned(), + Tool::ExecuteCommand(execute_cmd) => execute_cmd.summary.clone(), + Tool::FsRead(fs_read) => fs_read.summary.clone(), + _ => None, + } + } } /// A tool specification to be sent to the model as part of a conversation. Maps to diff --git a/crates/chat-cli/src/database/settings.rs b/crates/chat-cli/src/database/settings.rs index 2ea8510e2a..9f440f3ab4 100644 --- a/crates/chat-cli/src/database/settings.rs +++ b/crates/chat-cli/src/database/settings.rs @@ -81,6 +81,8 @@ pub enum Setting { ChatEnableHistoryHints, #[strum(message = "Enable the todo list feature (boolean)")] EnabledTodoList, + #[strum(message = "Enable the checkpoint feature (boolean)")] + EnabledCheckpoint, } impl AsRef for Setting { @@ -117,6 +119,7 @@ impl AsRef for Setting { Self::ChatDisableAutoCompaction => "chat.disableAutoCompaction", Self::ChatEnableHistoryHints => "chat.enableHistoryHints", Self::EnabledTodoList => "chat.enableTodoList", + Self::EnabledCheckpoint => "chat.enableCheckpoint", Self::EnabledContextUsageIndicator => "chat.enableContextUsageIndicator", } } @@ -164,6 +167,7 @@ impl TryFrom<&str> for Setting { "chat.disableAutoCompaction" => Ok(Self::ChatDisableAutoCompaction), "chat.enableHistoryHints" => Ok(Self::ChatEnableHistoryHints), "chat.enableTodoList" => Ok(Self::EnabledTodoList), + "chat.enableCheckpoint" => Ok(Self::EnabledCheckpoint), "chat.enableContextUsageIndicator" => Ok(Self::EnabledContextUsageIndicator), _ => Err(DatabaseError::InvalidSetting(value.to_string())), } @@ -301,6 +305,7 @@ mod test { .set(Setting::ChatDisableMarkdownRendering, false) .await .unwrap(); + settings.set(Setting::EnabledCheckpoint, true).await.unwrap(); assert_eq!(settings.get(Setting::TelemetryEnabled), Some(&Value::Bool(true))); assert_eq!( @@ -324,6 +329,7 @@ mod test { settings.get(Setting::ChatDisableMarkdownRendering), Some(&Value::Bool(false)) ); + assert_eq!(settings.get(Setting::EnabledCheckpoint), Some(&Value::Bool(true))); settings.remove(Setting::TelemetryEnabled).await.unwrap(); settings.remove(Setting::OldClientId).await.unwrap(); @@ -331,6 +337,7 @@ mod test { settings.remove(Setting::KnowledgeIndexType).await.unwrap(); settings.remove(Setting::McpLoadedBefore).await.unwrap(); settings.remove(Setting::ChatDisableMarkdownRendering).await.unwrap(); + settings.remove(Setting::EnabledCheckpoint).await.unwrap(); assert_eq!(settings.get(Setting::TelemetryEnabled), None); assert_eq!(settings.get(Setting::OldClientId), None); @@ -338,5 +345,6 @@ mod test { assert_eq!(settings.get(Setting::KnowledgeIndexType), None); assert_eq!(settings.get(Setting::McpLoadedBefore), None); assert_eq!(settings.get(Setting::ChatDisableMarkdownRendering), None); + assert_eq!(settings.get(Setting::EnabledCheckpoint), None); } } diff --git a/crates/chat-cli/src/util/directories.rs b/crates/chat-cli/src/util/directories.rs index 6833cb828b..2e89ce1e09 100644 --- a/crates/chat-cli/src/util/directories.rs +++ b/crates/chat-cli/src/util/directories.rs @@ -43,6 +43,7 @@ pub enum DirectoryError { type Result = std::result::Result; const WORKSPACE_AGENT_DIR_RELATIVE: &str = ".amazonq/cli-agents"; +const GLOBAL_SHADOW_REPO_DIR: &str = ".aws/amazonq/cli-checkpoints"; const GLOBAL_AGENT_DIR_RELATIVE_TO_HOME: &str = ".aws/amazonq/cli-agents"; const WORKSPACE_PROMPTS_DIR_RELATIVE: &str = ".amazonq/prompts"; const GLOBAL_PROMPTS_DIR_RELATIVE_TO_HOME: &str = ".aws/amazonq/prompts"; @@ -300,6 +301,10 @@ pub fn get_mcp_auth_dir(os: &Os) -> Result { Ok(home_dir(os)?.join(".aws").join("sso").join("cache")) } +pub fn get_shadow_repo_dir(os: &Os, conversation_id: String) -> Result { + Ok(home_dir(os)?.join(GLOBAL_SHADOW_REPO_DIR).join(conversation_id)) +} + /// Generate a unique identifier for an agent based on its path and name fn generate_agent_unique_id(agent: &crate::cli::Agent) -> String { use std::collections::hash_map::DefaultHasher;