1use anyhow::{Context, Result};
2use chrono::{DateTime, Utc};
3use colored::*;
4use notify::{Event, RecursiveMode, Watcher};
5use serde::{Deserialize, Serialize};
6use sha2::{Sha256, Digest};
7use std::collections::HashMap;
8use std::fs;
9use std::io::Read;
10use std::path::{Path, PathBuf};
11use std::process::Command;
12use std::sync::mpsc::channel;
13use crate::captain::{license, config::ConfigManager};
14#[derive(Debug, Serialize, Deserialize, Clone)]
15pub struct Anchor {
16 pub name: String,
17 pub timestamp: DateTime<Utc>,
18 pub description: String,
19 pub git_commit: Option<String>,
20 pub cargo_lock_hash: String,
21 pub files_snapshot: HashMap<String, FileSnapshot>,
22 pub environment: HashMap<String, String>,
23 pub metadata: AnchorMetadata,
24}
25#[derive(Debug, Serialize, Deserialize, Clone)]
26pub struct FileSnapshot {
27 pub path: PathBuf,
28 pub hash: String,
29 pub size: u64,
30 pub modified: DateTime<Utc>,
31}
32#[derive(Debug, Serialize, Deserialize, Clone)]
33pub struct AnchorMetadata {
34 pub project_name: String,
35 pub rust_version: String,
36 pub dependencies_count: usize,
37 pub total_loc: usize,
38}
39pub struct AnchorManager {
40 anchors_dir: PathBuf,
41 snapshots_dir: PathBuf,
42}
43impl AnchorManager {
44 pub fn new() -> Result<Self> {
45 let shipwreck = dirs::home_dir()
46 .context("Could not find home directory")?
47 .join(".shipwreck");
48 let anchors_dir = shipwreck.join("anchors");
49 let snapshots_dir = shipwreck.join("snapshots");
50 fs::create_dir_all(&anchors_dir)?;
51 fs::create_dir_all(&snapshots_dir)?;
52 Ok(Self { anchors_dir, snapshots_dir })
53 }
54 pub fn save(&self, name: &str, description: &str) -> Result<()> {
55 println!("ā Dropping anchor: {}", name.cyan().bold());
56 let config = ConfigManager::new()?;
57 let auto_anchor_git = config.get("version_control.auto_anchor_git")
58 .unwrap_or_else(|| "true".to_string())
59 .parse()
60 .unwrap_or(true);
61 let git_commit = if auto_anchor_git {
62 self.get_current_git_commit().ok()
63 } else {
64 None
65 };
66 let cargo_lock_hash = self.hash_cargo_lock()?;
67 let files_snapshot = self.create_files_snapshot()?;
68 let environment = self.capture_environment();
69 let metadata = self.gather_metadata()?;
70 let anchor = Anchor {
71 name: name.to_string(),
72 timestamp: Utc::now(),
73 description: description.to_string(),
74 git_commit,
75 cargo_lock_hash,
76 files_snapshot: files_snapshot.clone(),
77 environment,
78 metadata,
79 };
80 self.save_anchor(&anchor)?;
81 self.save_file_backups(&anchor)?;
82 println!("ā
Anchor '{}' saved successfully!", name.green());
83 println!(" š {} files backed up", files_snapshot.len());
84 Ok(())
85 }
86 pub fn restore(&self, name: &str) -> Result<()> {
87 println!("ā Restoring anchor: {}", name.cyan().bold());
88 let anchor = self.load_anchor(name)?;
89 self.restore_cargo_lock(&anchor)?;
90 let restored_count = self.restore_files(&anchor)?;
91 println!("ā
Anchor '{}' restored successfully!", name.green());
92 println!(" š {} files restored", restored_count);
93 println!(" š From: {}", anchor.timestamp.format("%Y-%m-%d %H:%M:%S"));
94 Ok(())
95 }
96 pub fn update_file(&self, anchor_name: &str, file_path: &Path) -> Result<()> {
97 let mut anchor = self.load_anchor(anchor_name)?;
98 if let Some(file_key) = anchor
99 .files_snapshot
100 .keys()
101 .find(|&path| path == &file_path.to_string_lossy())
102 {
103 let file_snapshot = self.create_file_snapshot(file_path)?;
104 anchor.files_snapshot.insert(file_key.clone(), file_snapshot);
105 anchor.timestamp = Utc::now();
106 self.save_anchor(&anchor)?;
107 println!("š Updated {} in anchor '{}'", file_path.display(), anchor_name);
108 }
109 Ok(())
110 }
111 pub fn start_auto_update(&self, anchor_name: &str) -> Result<()> {
112 self.start_auto_update_with_options(anchor_name, false)
113 }
114 pub fn start_auto_update_background(&self, anchor_name: &str) -> Result<()> {
115 self.start_auto_update_with_options(anchor_name, true)
116 }
117 pub fn start_auto_update_with_options(
118 &self,
119 anchor_name: &str,
120 background: bool,
121 ) -> Result<()> {
122 let anchor = self.load_anchor(anchor_name)?;
123 if background {
124 println!(
125 "š {}", format!("Starting auto-update for anchor: {}", anchor_name)
126 .cyan().bold()
127 );
128 println!("š Setting up file monitoring...");
129 let manager = AnchorManager::new()?;
130 let anchor_clone = anchor.clone();
131 let anchor_name_clone = anchor_name.to_string();
132 std::thread::spawn(move || {
133 if let Err(e) = manager
134 .run_auto_update_loop(&anchor_clone, &anchor_name_clone)
135 {
136 eprintln!("ā Auto-update error for {}: {}", anchor_name_clone, e);
137 }
138 });
139 println!("ā
{}", "Auto-update STARTED successfully!".green().bold());
140 println!("š Files will be updated automatically when changed");
141 println!("š Use 'cargo anchor stop {}' to stop monitoring", anchor_name);
142 println!();
143 println!(
144 "š” {}", format!("Background daemon running for anchor '{}'",
145 anchor_name) .dimmed()
146 );
147 return Ok(());
148 } else {
149 println!(
150 "š Monitoring {} files for changes...", anchor.files_snapshot.len()
151 );
152 println!("š” Press Ctrl+C to stop auto-update");
153 println!();
154 }
155 let (tx, rx) = channel();
156 let mut watcher = notify::recommended_watcher(tx)?;
157 let mut watched_dirs = HashMap::new();
158 for file_path in anchor.files_snapshot.keys() {
159 let path = Path::new(file_path);
160 if let Some(parent) = path.parent() {
161 if !watched_dirs.contains_key(parent) {
162 watcher.watch(parent, RecursiveMode::NonRecursive)?;
163 watched_dirs.insert(parent.to_path_buf(), true);
164 }
165 }
166 }
167 println!("š Watching {} directories", watched_dirs.len());
168 println!("ā
Auto-update started! Files will be updated automatically.");
169 println!();
170 loop {
171 match rx.recv() {
172 Ok(event) => {
173 match event {
174 Ok(Event { paths, kind: _, attrs: _ }) => {
175 for path in paths {
176 let path_str = path.to_string_lossy();
177 if anchor.files_snapshot.contains_key(&path_str.to_string())
178 {
179 if let Err(e) = self.update_file(anchor_name, &path) {
180 eprintln!("ā Failed to update {}: {}", path.display(), e);
181 } else {
182 println!(
183 "š Updated {} in anchor '{}'", path.display(),
184 anchor_name
185 );
186 }
187 }
188 }
189 }
190 Err(e) => {
191 eprintln!("ā File watcher error: {}", e);
192 }
193 }
194 }
195 Err(e) => {
196 eprintln!("ā Channel receive error: {}", e);
197 break;
198 }
199 }
200 }
201 Ok(())
202 }
203 pub fn run_auto_update_loop(
204 &self,
205 anchor: &Anchor,
206 anchor_name: &str,
207 ) -> Result<()> {
208 let (tx, rx) = channel();
209 let mut watcher = notify::recommended_watcher(tx)?;
210 let mut watched_dirs = HashMap::new();
211 for file_path in anchor.files_snapshot.keys() {
212 let path = Path::new(file_path);
213 if let Some(parent) = path.parent() {
214 if !watched_dirs.contains_key(parent) {
215 watcher.watch(parent, RecursiveMode::NonRecursive)?;
216 watched_dirs.insert(parent.to_path_buf(), true);
217 }
218 }
219 }
220 println!("š Auto-update daemon running for '{}'", anchor_name);
221 loop {
222 match rx.recv() {
223 Ok(event) => {
224 match event {
225 Ok(Event { paths, kind: _, attrs: _ }) => {
226 for path in paths {
227 let path_str = path.to_string_lossy();
228 if anchor.files_snapshot.contains_key(&path_str.to_string())
229 {
230 if let Err(e) = self.update_file(anchor_name, &path) {
231 eprintln!("ā Failed to update {}: {}", path.display(), e);
232 } else {
233 println!(
234 "š [{}] Updated {} in anchor '{}'", chrono::Utc::now()
235 .format("%H:%M:%S"), path.display(), anchor_name
236 );
237 }
238 }
239 }
240 }
241 Err(e) => {
242 eprintln!("ā File watcher error: {}", e);
243 }
244 }
245 }
246 Err(e) => {
247 eprintln!("ā Channel receive error: {}", e);
248 break;
249 }
250 }
251 }
252 Ok(())
253 }
254 pub fn stop_auto_update(&self, anchor_name: &str) -> Result<()> {
255 println!("š Stopping auto-update for anchor: {}", anchor_name.cyan().bold());
256 println!(
257 "ā ļø Note: In this implementation, stopping requires restarting the shell"
258 );
259 println!("š” Future versions will have proper daemon management");
260 Ok(())
261 }
262 pub fn list(&self) -> Result<Vec<AnchorSummary>> {
263 let mut anchors = Vec::new();
264 for entry in fs::read_dir(&self.anchors_dir)? {
265 let entry = entry?;
266 let path = entry.path();
267 if path.extension() == Some(std::ffi::OsStr::new("json")) {
268 let content = fs::read_to_string(&path)?;
269 let anchor: Anchor = serde_json::from_str(&content)?;
270 anchors
271 .push(AnchorSummary {
272 name: anchor.name,
273 timestamp: anchor.timestamp,
274 description: anchor.description,
275 files_count: anchor.files_snapshot.len(),
276 });
277 }
278 }
279 anchors.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
280 Ok(anchors)
281 }
282 pub fn show(&self, name: &str) -> Result<()> {
283 let anchor = self.load_anchor(name)?;
284 println!("{}", format!("=== Anchor: {} ===", anchor.name) .blue().bold());
285 println!("š
Created: {}", anchor.timestamp.format("%Y-%m-%d %H:%M:%S"));
286 println!("š Description: {}", anchor.description);
287 if let Some(ref commit) = anchor.git_commit {
288 println!("š Git commit: {}", commit.dimmed());
289 }
290 println!("\nš Metadata:");
291 println!(" Project: {}", anchor.metadata.project_name);
292 println!(" Rust version: {}", anchor.metadata.rust_version);
293 println!(" Dependencies: {}", anchor.metadata.dependencies_count);
294 println!(" Lines of code: {}", anchor.metadata.total_loc);
295 println!("\nš Files snapshot ({} files):", anchor.files_snapshot.len());
296 let mut files: Vec<_> = anchor.files_snapshot.values().collect();
297 files.sort_by(|a, b| a.path.cmp(&b.path));
298 for (i, file) in files.iter().enumerate().take(10) {
299 println!(" {} {}", if i < 9 { " " } else { "" }, file.path.display());
300 }
301 if anchor.files_snapshot.len() > 10 {
302 println!(" ... and {} more files", anchor.files_snapshot.len() - 10);
303 }
304 Ok(())
305 }
306 pub fn diff(&self, name: &str) -> Result<()> {
307 let anchor = self.load_anchor(name)?;
308 let current_snapshot = self.create_files_snapshot()?;
309 println!("{}", format!("=== Diff from anchor '{}' ===", name) .blue().bold());
310 let mut added = Vec::new();
311 let mut modified = Vec::new();
312 let mut deleted = Vec::new();
313 for (path, current_file) in ¤t_snapshot {
314 match anchor.files_snapshot.get(path) {
315 Some(anchor_file) => {
316 if anchor_file.hash != current_file.hash {
317 modified.push(path.clone());
318 }
319 }
320 None => added.push(path.clone()),
321 }
322 }
323 for path in anchor.files_snapshot.keys() {
324 if !current_snapshot.contains_key(path) {
325 deleted.push(path.clone());
326 }
327 }
328 if !added.is_empty() {
329 println!("\n⨠Added files:");
330 for path in &added {
331 println!(" + {}", path.green());
332 }
333 }
334 if !modified.is_empty() {
335 println!("\nš Modified files:");
336 for path in &modified {
337 println!(" ~ {}", path.yellow());
338 }
339 }
340 if !deleted.is_empty() {
341 println!("\nšļø Deleted files:");
342 for path in &deleted {
343 println!(" - {}", path.red());
344 }
345 }
346 if added.is_empty() && modified.is_empty() && deleted.is_empty() {
347 println!("ā
No changes since anchor '{}'", name);
348 }
349 Ok(())
350 }
351 fn save_anchor(&self, anchor: &Anchor) -> Result<()> {
352 let anchor_file = self.anchors_dir.join(format!("{}.json", anchor.name));
353 let json = serde_json::to_string_pretty(anchor)?;
354 fs::write(&anchor_file, json)?;
355 Ok(())
356 }
357 fn load_anchor(&self, name: &str) -> Result<Anchor> {
358 let anchor_file = self.anchors_dir.join(format!("{}.json", name));
359 if !anchor_file.exists() {
360 return Err(anyhow::anyhow!("Anchor '{}' not found", name));
361 }
362 let content = fs::read_to_string(&anchor_file)?;
363 let anchor: Anchor = serde_json::from_str(&content)?;
364 Ok(anchor)
365 }
366 fn get_current_git_commit(&self) -> Result<String> {
367 let output = Command::new("git")
368 .args(&["rev-parse", "HEAD"])
369 .output()?;
370 if !output.status.success() {
371 return Err(anyhow::anyhow!(
372 "Failed to get current git commit: {}",
373 String::from_utf8_lossy(&output.stderr)
374 ));
375 }
376 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
377 }
378
379 fn checkout_git_commit(&self, commit: &str) -> Result<()> {
380 let output = Command::new("git").args(&["checkout", commit]).output()?;
381 if !output.status.success() {
382 return Err(
383 anyhow::anyhow!(
384 "Failed to checkout commit: {}", String::from_utf8_lossy(& output
385 .stderr)
386 ),
387 );
388 }
389 Ok(())
390 }
391 fn hash_cargo_lock(&self) -> Result<String> {
392 let cargo_lock = Path::new("Cargo.lock");
393 if !cargo_lock.exists() {
394 return Ok("no-cargo-lock".to_string());
395 }
396 let mut file = fs::File::open(cargo_lock)?;
397 let mut hasher = Sha256::new();
398 let mut buffer = [0; 8192];
399 loop {
400 let bytes_read = file.read(&mut buffer)?;
401 if bytes_read == 0 {
402 break;
403 }
404 hasher.update(&buffer[..bytes_read]);
405 }
406 Ok(format!("{:x}", hasher.finalize()))
407 }
408 fn create_files_snapshot(&self) -> Result<HashMap<String, FileSnapshot>> {
409 let mut snapshot = HashMap::new();
410 let patterns = vec![
411 "src/**/*.rs", "tests/**/*.rs", "**/*.toml", "**/*.lock", "**/build.rs",
412 "**/*.rs", ".env", "**/.env*",
413 ];
414 for pattern in patterns {
415 for entry in glob::glob(pattern)? {
416 if let Ok(path) = entry {
417 if path.is_file() {
418 let metadata = fs::metadata(&path)?;
419 let hash = self.hash_file(&path)?;
420 snapshot
421 .insert(
422 path.to_string_lossy().to_string(),
423 FileSnapshot {
424 path: path.clone(),
425 hash,
426 size: metadata.len(),
427 modified: DateTime::from(metadata.modified()?),
428 },
429 );
430 }
431 }
432 }
433 }
434 Ok(snapshot)
435 }
436 fn create_file_snapshot(&self, path: &Path) -> Result<FileSnapshot> {
437 let metadata = fs::metadata(path)?;
438 let hash = self.hash_file(path)?;
439 Ok(FileSnapshot {
440 path: path.to_path_buf(),
441 hash,
442 size: metadata.len(),
443 modified: DateTime::from(metadata.modified()?),
444 })
445 }
446 fn hash_file(&self, path: &Path) -> Result<String> {
447 let mut file = fs::File::open(path)?;
448 let mut hasher = Sha256::new();
449 let mut buffer = [0; 8192];
450 loop {
451 let bytes_read = file.read(&mut buffer)?;
452 if bytes_read == 0 {
453 break;
454 }
455 hasher.update(&buffer[..bytes_read]);
456 }
457 Ok(format!("{:x}", hasher.finalize()))
458 }
459 fn save_file_backups(&self, anchor: &Anchor) -> Result<()> {
460 let backup_dir = self.snapshots_dir.join(&anchor.name);
461 fs::create_dir_all(&backup_dir)?;
462 for (_, file) in &anchor.files_snapshot {
463 if file.path.exists() {
464 let backup_path = backup_dir
465 .join(file.path.strip_prefix("./").unwrap_or(&file.path));
466 if let Some(parent) = backup_path.parent() {
467 fs::create_dir_all(parent)?;
468 }
469 fs::copy(&file.path, &backup_path)?;
470 }
471 }
472 Ok(())
473 }
474 fn restore_cargo_lock(&self, anchor: &Anchor) -> Result<()> {
475 let current_hash = self.hash_cargo_lock()?;
476 if current_hash != anchor.cargo_lock_hash
477 && anchor.cargo_lock_hash != "no-cargo-lock"
478 {
479 let backup_dir = self.snapshots_dir.join(&anchor.name);
480 let backup_cargo_lock = backup_dir.join("Cargo.lock");
481 if backup_cargo_lock.exists() {
482 fs::copy(&backup_cargo_lock, "Cargo.lock")?;
483 println!(" š¦ Cargo.lock restored");
484 }
485 }
486 Ok(())
487 }
488 fn restore_files(&self, anchor: &Anchor) -> Result<usize> {
489 let backup_dir = self.snapshots_dir.join(&anchor.name);
490 let mut restored_count = 0;
491 for (_, file) in &anchor.files_snapshot {
492 let backup_path = backup_dir
493 .join(file.path.strip_prefix("./").unwrap_or(&file.path));
494 if backup_path.exists() {
495 let current_hash = if file.path.exists() {
496 self.hash_file(&file.path).unwrap_or_default()
497 } else {
498 String::new()
499 };
500 if current_hash != file.hash {
501 if let Some(parent) = file.path.parent() {
502 fs::create_dir_all(parent)?;
503 }
504 fs::copy(&backup_path, &file.path)?;
505 restored_count += 1;
506 }
507 }
508 }
509 Ok(restored_count)
510 }
511 fn capture_environment(&self) -> HashMap<String, String> {
512 let mut env = HashMap::new();
513 for (key, value) in std::env::vars() {
514 if key.starts_with("CARGO_") || key.starts_with("RUST_") {
515 env.insert(key, value);
516 }
517 }
518 env
519 }
520 fn gather_metadata(&self) -> Result<AnchorMetadata> {
521 let cargo_toml = fs::read_to_string("Cargo.toml")?;
522 let manifest: toml::Value = toml::from_str(&cargo_toml)?;
523 let project_name = manifest
524 .get("package")
525 .and_then(|p| p.get("name"))
526 .and_then(|n| n.as_str())
527 .unwrap_or("unknown")
528 .to_string();
529 let rust_version = Command::new("rustc")
530 .arg("--version")
531 .output()
532 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
533 .unwrap_or_else(|_| "unknown".to_string());
534 let dependencies_count = manifest
535 .get("dependencies")
536 .and_then(|d| d.as_table())
537 .map(|t| t.len())
538 .unwrap_or(0);
539 let total_loc = self.count_lines_of_code()?;
540 Ok(AnchorMetadata {
541 project_name,
542 rust_version,
543 dependencies_count,
544 total_loc,
545 })
546 }
547 fn count_lines_of_code(&self) -> Result<usize> {
548 let mut total = 0;
549 for entry in glob::glob("src/**/*.rs")? {
550 if let Ok(path) = entry {
551 if path.is_file() {
552 let content = fs::read_to_string(&path)?;
553 total += content.lines().count();
554 }
555 }
556 }
557 Ok(total)
558 }
559}
560#[derive(Debug)]
561pub struct AnchorSummary {
562 pub name: String,
563 pub timestamp: DateTime<Utc>,
564 pub description: String,
565 pub files_count: usize,
566}
567impl AnchorSummary {
568 pub fn display(&self) {
569 println!(
570 "ā {} - {} ({} files)", self.name.cyan().bold(), self.timestamp
571 .format("%Y-%m-%d %H:%M:%S").to_string().dimmed(), self.files_count
572 );
573 println!(" {}", self.description.dimmed());
574 }
575}
576pub fn check_license_ahoy(command: &str) -> Result<bool> {
577 println!(
578 "š“āā ļø Ahoy there, matey! Let me check yer license for '{}'", command
579 .cyan()
580 );
581 let license_manager = license::LicenseManager::new();
582 match license_manager?.enforce_license(command) {
583 Ok(_) => {
584 println!("ā
Aye aye, Captain! License be valid. Full speed ahead!");
585 Ok(true)
586 }
587 Err(e) => {
588 if e.to_string().contains("limit") {
589 println!("ā ļø Blimey! Ye've hit the daily limit, scallywag!");
590 println!(" š° Walk the plank to Pro: https://cargo.do/checkout");
591 } else if e.to_string().contains("License not found") {
592 println!("ā Arr! No license found! Register with 'cm register <key>'");
593 } else {
594 println!(
595 "ā Shiver me timbers! License error: {}", e.to_string().red()
596 );
597 }
598 Ok(false)
599 }
600 }
601}