This document provides concrete code examples for implementing the proposed features.
// src/ui/formatter.rs
use std::fmt::Write;
pub struct UnicodeFormatter {
pub use_unicode: bool,
pub use_colors: bool,
pub width: usize,
}
impl UnicodeFormatter {
pub fn format_commit_graph(&self, commits: &[CommitInfo]) -> String {
let mut output = String::new();
for (i, commit) in commits.iter().enumerate() {
// Determine symbols
let symbol = match commit.kind {
CommitKind::Current => if self.use_unicode { "@" } else { "[*]" },
CommitKind::MainBranch => if self.use_unicode { "◆" } else { "[+]" },
CommitKind::Regular => if self.use_unicode { "◆" } else { "[*]" },
CommitKind::Root => if self.use_unicode { "~" } else { "[~]" },
};
// Determine if there's a continuation
let continuation = if i < commits.len() - 1 {
if self.use_unicode { "│" } else { "|" }
} else {
if self.use_unicode { "~" } else { "~" }
};
// Format the line
writeln!(
&mut output,
"{} {} {} {}",
symbol,
&commit.hash[..8],
if commit.is_current { "(HEAD)" } else { "" },
commit.message
).unwrap();
if i < commits.len() - 1 {
writeln!(&mut output, "{}", continuation).unwrap();
}
}
output
}
pub fn format_status(&self, status: &StatusInfo) -> String {
let mut output = String::new();
// Create a box
let title = "Status";
let width = self.width.min(60);
writeln!(&mut output, "{}", self.box_top(title, width)).unwrap();
// Branch info
writeln!(
&mut output,
"│ {} On branch {}",
if self.use_unicode { "📍" } else { "*" },
status.branch
).unwrap();
// Changes section
if !status.changed.is_empty() {
writeln!(&mut output, "│").unwrap();
writeln!(
&mut output,
"│ {} Changes not staged:",
if self.use_unicode { "📝" } else { "*" }
).unwrap();
for (path, kind) in &status.changed {
let icon = match kind {
ChangeKind::Modified => if self.use_unicode { "✏️" } else { "M" },
ChangeKind::Added => if self.use_unicode { "➕" } else { "A" },
ChangeKind::Deleted => if self.use_unicode { "🗑" } else { "D" },
};
writeln!(&mut output, "│ {} {}", icon, path).unwrap();
}
}
writeln!(&mut output, "{}", self.box_bottom(width)).unwrap();
output
}
pub fn format_progress_bar(&self, current: u64, total: u64) -> String {
let percent = if total > 0 {
(current as f64 / total as f64 * 100.0) as u64
} else {
0
};
let bar_width = 30;
let filled = (percent as usize * bar_width) / 100;
let empty = bar_width - filled;
if self.use_unicode {
format!(
"{}{} {}%",
"█".repeat(filled),
"░".repeat(empty),
percent
)
} else {
format!(
"{}{} {}%",
"=".repeat(filled),
" ".repeat(empty),
percent
)
}
}
fn box_top(&self, title: &str, width: usize) -> String {
if self.use_unicode {
format!("┌─ {} {}", title, "─".repeat(width.saturating_sub(title.len() + 5)))
} else {
format!("+-- {} {}", title, "-".repeat(width.saturating_sub(title.len() + 6)))
}
}
fn box_bottom(&self, width: usize) -> String {
if self.use_unicode {
format!("└{}", "─".repeat(width - 1))
} else {
format!("+{}", "-".repeat(width - 1))
}
}
}
// Test example
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_commit_graph_formatting() {
let formatter = UnicodeFormatter {
use_unicode: true,
use_colors: false,
width: 80,
};
let commits = vec![
CommitInfo {
hash: "abc1234567890".to_string(),
message: "Update docs".to_string(),
kind: CommitKind::Current,
is_current: true,
branch: Some("main".to_string()),
},
CommitInfo {
hash: "def5678901234".to_string(),
message: "Add feature".to_string(),
kind: CommitKind::Regular,
is_current: false,
branch: None,
},
];
let output = formatter.format_commit_graph(&commits);
assert!(output.contains("@")); // Current indicator
assert!(output.contains("◆")); // Regular commit
assert!(output.contains("│")); // Continuation
assert!(output.contains("Update docs"));
assert!(output.contains("Add feature"));
}
#[test]
fn test_progress_bar() {
let formatter = UnicodeFormatter {
use_unicode: true,
use_colors: false,
width: 80,
};
let bar = formatter.format_progress_bar(50, 100);
assert!(bar.contains("50%"));
assert!(bar.contains("█"));
assert!(bar.contains("░"));
}
}// src/commands/status.rs (modified)
use crate::ui::formatter::UnicodeFormatter;
pub fn cmd_status(repo: &Repository, args: StatusArgs) -> Result<()> {
let status = repo.status()?;
// Get config to check if Unicode is enabled
let config = repo.config();
let use_unicode = config.ui.use_unicode.unwrap_or(true);
let use_colors = config.ui.use_colors.unwrap_or(true);
let formatter = UnicodeFormatter {
use_unicode,
use_colors,
width: term_size::dimensions().map(|(w, _)| w).unwrap_or(80),
};
let output = formatter.format_status(&status);
println!("{}", output);
println!("Happy Mugging!");
Ok(())
}// src/core/snapshots.rs
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::collections::HashMap;
use chrono::{DateTime, Local};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Snapshot {
pub id: String,
pub timestamp: DateTime<Local>,
pub source: SnapshotSource,
pub description: Option<String>,
pub changed_files: Vec<String>,
pub total_files: usize,
pub bytes_saved: u64,
pub compression_ratio: f32,
pub parent_id: Option<String>,
pub is_full: bool,
pub metadata: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SnapshotSource {
Manual,
Auto,
PreOperation,
Checkpoint,
}
#[derive(Debug)]
pub struct SnapshotConfig {
pub enabled: bool,
pub auto_interval_secs: u64,
pub max_snapshots: usize,
pub compression: bool,
pub exclude_patterns: Vec<String>,
}
pub struct SnapshotManager {
db: MugDb,
snapshot_dir: PathBuf,
config: SnapshotConfig,
}
impl SnapshotManager {
pub fn new(db: MugDb, repo_root: &Path, config: SnapshotConfig) -> Self {
let snapshot_dir = repo_root.join(".mug/snapshots");
SnapshotManager {
db,
snapshot_dir,
config,
}
}
/// Create a new snapshot
pub fn create(
&mut self,
source: SnapshotSource,
description: Option<String>,
) -> Result<Snapshot> {
if !self.config.enabled {
return Err(Error::Custom("Snapshots disabled".to_string()));
}
let id = format!("snap-{}", uuid::Uuid::new_v4());
let now = Local::now();
// Get all modified files
let changed_files = self.get_changed_files()?;
let total_files = self.count_files()?;
// Create tarball (incremental or full)
let (bytes_saved, is_full, parent_id) =
self.create_snapshot_file(&id, &changed_files)?;
// Calculate compression ratio
let original_size = self.calculate_original_size(&changed_files)?;
let compression_ratio = if original_size > 0 {
bytes_saved as f32 / original_size as f32
} else {
1.0
};
let snapshot = Snapshot {
id: id.clone(),
timestamp: now,
source,
description,
changed_files,
total_files,
bytes_saved,
compression_ratio,
parent_id,
is_full,
metadata: HashMap::new(),
};
// Store metadata
let serialized = serde_json::to_vec(&snapshot)?;
self.db.set("snapshots", &id, serialized)?;
// Update manifest
self.update_snapshot_manifest()?;
// Cleanup old snapshots if necessary
self.cleanup_excess_snapshots()?;
Ok(snapshot)
}
/// Restore from a snapshot
pub fn restore(&self, snapshot_id: &str) -> Result<()> {
let snapshot = self.get(snapshot_id)?
.ok_or_else(|| Error::Custom("Snapshot not found".to_string()))?;
// Extract snapshot file
let snapshot_path = self.snapshot_dir.join(format!("{}.tar.zst", snapshot_id));
if !snapshot_path.exists() {
// Try to reconstruct from incremental
if let Some(parent_id) = &snapshot.parent_id {
self.reconstruct_from_incremental(snapshot_id, parent_id)?;
} else {
return Err(Error::Custom("Snapshot file not found".to_string()));
}
}
// Decompress and extract
self.extract_snapshot(&snapshot_path)?;
Ok(())
}
/// List all snapshots
pub fn list(&self) -> Result<Vec<Snapshot>> {
let entries = self.db.scan("snapshots", "")?;
let mut snapshots = Vec::new();
for (_, value) in entries {
if let Ok(snap) = serde_json::from_slice::<Snapshot>(&value) {
snapshots.push(snap);
}
}
snapshots.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
Ok(snapshots)
}
/// Get snapshot by ID
pub fn get(&self, id: &str) -> Result<Option<Snapshot>> {
match self.db.get("snapshots", id)? {
Some(data) => {
let snapshot: Snapshot = serde_json::from_slice(&data)?;
Ok(Some(snapshot))
}
None => Ok(None),
}
}
/// Delete a snapshot
pub fn delete(&self, id: &str) -> Result<()> {
let snapshot_path = self.snapshot_dir.join(format!("{}.tar.zst", id));
if snapshot_path.exists() {
std::fs::remove_file(snapshot_path)?;
}
self.db.delete("snapshots", id)?;
self.update_snapshot_manifest()?;
Ok(())
}
/// Cleanup old snapshots
pub fn cleanup_old(&self, days: i64) -> Result<usize> {
let snapshots = self.list()?;
let cutoff = Local::now() - chrono::Duration::days(days);
let mut deleted = 0;
for snapshot in snapshots {
if snapshot.timestamp < cutoff {
self.delete(&snapshot.id)?;
deleted += 1;
}
}
Ok(deleted)
}
// Private helper methods
fn create_snapshot_file(
&self,
snapshot_id: &str,
changed_files: &[String],
) -> Result<(u64, bool, Option<String>)> {
// Get last full snapshot for incremental
let snapshots = self.list()?;
let last_full = snapshots.iter()
.find(|s| s.is_full)
.map(|s| s.id.clone());
let snapshot_path = self.snapshot_dir.join(format!("{}.tar.zst", snapshot_id));
if let Some(last_full_id) = &last_full {
// Create incremental snapshot
let bytes = self.create_incremental_snapshot(
snapshot_id,
last_full_id,
changed_files,
)?;
Ok((bytes, false, Some(last_full_id.clone())))
} else {
// Create full snapshot
let bytes = self.create_full_snapshot(snapshot_id, changed_files)?;
Ok((bytes, true, None))
}
}
fn create_full_snapshot(
&self,
snapshot_id: &str,
changed_files: &[String],
) -> Result<u64> {
// Simplified: use tar + zstd
use std::fs::File;
use tar::Builder;
use zstd::Encoder;
let snapshot_path = self.snapshot_dir.join(format!("{}.tar.zst", snapshot_id));
let encoder = Encoder::new(File::create(&snapshot_path)?, 10)?;
let mut builder = Builder::new(encoder);
let mut bytes_written = 0;
for file_path in changed_files {
if Path::new(file_path).exists() {
let file = File::open(file_path)?;
bytes_written += file.metadata()?.len();
builder.append_path(file_path)?;
}
}
builder.finish()?;
Ok(bytes_written)
}
fn create_incremental_snapshot(
&self,
snapshot_id: &str,
parent_id: &str,
changed_files: &[String],
) -> Result<u64> {
// Store only changed files
// Similar to create_full_snapshot but smaller
Ok(0) // Placeholder
}
fn extract_snapshot(&self, snapshot_path: &Path) -> Result<()> {
// Extract tar.zst file to working directory
Ok(())
}
fn reconstruct_from_incremental(
&self,
snapshot_id: &str,
parent_id: &str,
) -> Result<()> {
// Reconstruct full snapshot from incremental + parent
Ok(())
}
fn get_changed_files(&self) -> Result<Vec<String>> {
// Get list of modified files from repo status
Ok(vec![])
}
fn count_files(&self) -> Result<usize> {
Ok(0)
}
fn calculate_original_size(&self, files: &[String]) -> Result<u64> {
Ok(0)
}
fn update_snapshot_manifest(&self) -> Result<()> {
// Update .mug/snapshots/manifest.json
Ok(())
}
fn cleanup_excess_snapshots(&self) -> Result<()> {
let snapshots = self.list()?;
if snapshots.len() > self.config.max_snapshots {
let to_delete = snapshots.len() - self.config.max_snapshots;
for snapshot in snapshots.iter().rev().take(to_delete) {
self.delete(&snapshot.id)?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_snapshot_creation() {
// Create test repo and snapshot
// Verify snapshot is stored correctly
}
#[test]
fn test_snapshot_restore() {
// Create snapshot, modify files, restore
// Verify files are restored to original state
}
#[test]
fn test_incremental_snapshots() {
// Create first full snapshot
// Create incremental snapshot
// Verify storage efficiency
}
#[test]
fn test_cleanup() {
// Create multiple snapshots
// Run cleanup
// Verify old ones removed, recent kept
}
}// src/main.rs (additions)
#[derive(Subcommand)]
enum Commands {
// ... existing commands ...
/// Manage work snapshots
Snapshot {
#[command(subcommand)]
action: Option<SnapshotAction>,
},
}
#[derive(Subcommand)]
enum SnapshotAction {
/// Create a new snapshot
Create {
/// Description for the snapshot
message: Option<String>,
},
/// List all snapshots
List {
/// Limit number of results
#[arg(long, default_value = "20")]
limit: usize,
},
/// Show snapshot details
Show {
/// Snapshot ID
snapshot_id: String,
},
/// Restore from snapshot
Restore {
/// Snapshot ID to restore
snapshot_id: String,
/// Create a snapshot before restoring
#[arg(long, default_value = "true")]
create_snapshot_first: bool,
},
/// Delete a snapshot
Delete {
/// Snapshot ID
snapshot_id: String,
},
/// Start auto-snapshots
AutoStart,
/// Stop auto-snapshots
AutoStop,
/// Clean old snapshots
Cleanup {
/// Delete snapshots older than N days
#[arg(long, default_value = "30")]
older_than_days: i64,
},
}
// Handler implementation
Commands::Snapshot { action } => {
let repo = Repository::open(".")?;
let config = SnapshotConfig {
enabled: repo.config().snapshots.enabled,
auto_interval_secs: repo.config().snapshots.interval,
max_snapshots: repo.config().snapshots.max_count,
compression: true,
exclude_patterns: vec![".git".to_string(), ".mug".to_string()],
};
let mut manager = SnapshotManager::new(
repo.get_db().clone(),
repo.path(),
config,
);
match action {
Some(SnapshotAction::Create { message }) => {
let snapshot = manager.create(
SnapshotSource::Manual,
message,
)?;
println!("✓ Snapshot created: {}", snapshot.id);
println!(" Files: {}", snapshot.changed_files.len());
println!(" Size: {:.2}MB", snapshot.bytes_saved as f64 / 1_000_000.0);
}
Some(SnapshotAction::List { limit }) => {
let snapshots = manager.list()?;
println!("Snapshots (latest first):\n");
for snapshot in snapshots.iter().take(limit) {
println!("ID: {}", &snapshot.id[..16]);
println!(" Time: {}", snapshot.timestamp);
println!(" Desc: {}", snapshot.description.as_deref().unwrap_or(""));
println!(" Size: {:.2}MB", snapshot.bytes_saved as f64 / 1_000_000.0);
println!();
}
}
Some(SnapshotAction::Restore { snapshot_id, create_snapshot_first }) => {
if create_snapshot_first {
println!("Creating backup snapshot...");
let _backup = manager.create(SnapshotSource::PreOperation, None)?;
}
manager.restore(&snapshot_id)?;
println!("✓ Restored from snapshot: {}", &snapshot_id[..16]);
}
Some(SnapshotAction::Cleanup { older_than_days }) => {
let deleted = manager.cleanup_old(older_than_days)?;
println!("✓ Deleted {} old snapshots (older than {} days)", deleted, older_than_days);
}
_ => {
// Show help
println!("mug snapshot create [message]");
println!("mug snapshot list");
println!("mug snapshot restore <id>");
}
}
println!("Happy Mugging!");
}// src/commands/reset.rs (modified)
pub fn cmd_reset(repo: &Repository, args: ResetArgs) -> Result<()> {
// Auto-create snapshot before dangerous operation
if args.mode == "hard" {
println!("⚠️ Creating safety snapshot before hard reset...");
let config = SnapshotConfig {
enabled: true,
auto_interval_secs: 0,
max_snapshots: 50,
compression: true,
exclude_patterns: vec![".git".to_string()],
};
let mut manager = SnapshotManager::new(
repo.get_db().clone(),
repo.path(),
config,
);
let snapshot = manager.create(
SnapshotSource::PreOperation,
Some(format!("Before: reset --hard {}", args.commit)),
)?;
println!("✓ Snapshot created: {}", snapshot.id);
println!(" Recover with: mug snapshot restore {}", &snapshot.id[..16]);
}
// Perform the reset
let (old_head, new_head) = repo.reset(&args.commit, &args.mode)?;
println!("Reset {} → {}",
&old_head[..8],
&new_head[..8]
);
println!("Happy Mugging!");
Ok(())
}These code examples show:
- Unicode Formatter: Clean abstraction for beautiful output
- Snapshot Manager: Core snapshot functionality with CRUD operations
- CLI Integration: Adding snapshot commands to MUG
- Safety Integration: Auto-snapshots before dangerous operations
All examples follow MUG's existing patterns and use already-available dependencies.