Skip to content

Commit c387baa

Browse files
authored
Merge pull request #25 from dwalleck/feat/catalyst-cli-phase6-update-command
feat: Phase 6 - Update Command with Comprehensive Error Handling
2 parents b0249a6 + 30f7fdb commit c387baa

5 files changed

Lines changed: 699 additions & 5 deletions

File tree

catalyst-cli/src/bin/catalyst.rs

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
use anyhow::{Context, Result};
3232
use catalyst_cli::init;
3333
use catalyst_cli::types::{InitConfig, AVAILABLE_SKILLS, AVAILABLE_SKILLS_WITH_DESC};
34+
use catalyst_cli::update;
3435
use catalyst_cli::validation::check_binaries_installed;
3536
use catalyst_core::settings::*;
3637
use clap::{Parser, Subcommand};
@@ -548,12 +549,97 @@ fn main() -> Result<()> {
548549
path.unwrap_or_else(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
549550

550551
if use_color {
551-
println!("{}", "⚠️ Not implemented yet".yellow().bold());
552+
println!("{}", "🔄 Updating Catalyst...".cyan().bold());
552553
} else {
553-
println!("⚠️ Not implemented yet");
554+
println!("🔄 Updating Catalyst...");
555+
}
556+
println!();
557+
558+
// Run update
559+
let report = update::update(&target_dir, force)?;
560+
561+
// Display results
562+
if report.updated_skills.is_empty()
563+
&& report.updated_hooks.is_empty()
564+
&& report.skipped_skills.is_empty()
565+
{
566+
if use_color {
567+
println!("{}", "✅ Already up to date!".green().bold());
568+
} else {
569+
println!("✅ Already up to date!");
570+
}
571+
} else {
572+
// Show updated hooks
573+
if !report.updated_hooks.is_empty() {
574+
if use_color {
575+
println!("{}", "Updated hooks:".green().bold());
576+
} else {
577+
println!("Updated hooks:");
578+
}
579+
for hook in &report.updated_hooks {
580+
println!(" ✓ {}", hook);
581+
}
582+
println!();
583+
}
584+
585+
// Show updated skills
586+
if !report.updated_skills.is_empty() {
587+
if use_color {
588+
println!("{}", "Updated skills:".green().bold());
589+
} else {
590+
println!("Updated skills:");
591+
}
592+
for skill in &report.updated_skills {
593+
println!(" ✓ {}", skill);
594+
}
595+
println!();
596+
}
597+
598+
// Show skipped skills
599+
if !report.skipped_skills.is_empty() {
600+
if use_color {
601+
println!("{}", "Skipped skills (modified locally):".yellow().bold());
602+
} else {
603+
println!("Skipped skills (modified locally):");
604+
}
605+
for skipped in &report.skipped_skills {
606+
println!(" ⚠️ {} - {}", skipped.name, skipped.reason);
607+
}
608+
println!();
609+
if use_color {
610+
println!("{}", " Use --force to overwrite modified skills".yellow());
611+
} else {
612+
println!(" Use --force to overwrite modified skills");
613+
}
614+
println!();
615+
}
616+
617+
// Show errors
618+
if !report.errors.is_empty() {
619+
if use_color {
620+
println!("{}", "Errors:".red().bold());
621+
} else {
622+
println!("Errors:");
623+
}
624+
for error in &report.errors {
625+
println!(" ❌ {}", error);
626+
}
627+
println!();
628+
}
629+
630+
// Final status
631+
if report.success {
632+
if use_color {
633+
println!("{}", "✅ Update completed successfully!".green().bold());
634+
} else {
635+
println!("✅ Update completed successfully!");
636+
}
637+
} else if use_color {
638+
println!("{}", "⚠️ Update completed with errors".yellow().bold());
639+
} else {
640+
println!("⚠️ Update completed with errors");
641+
}
554642
}
555-
println!("Would update: {:?}", target_dir);
556-
println!(" Force: {}", force);
557643
}
558644

559645
Commands::Settings { command } => {

catalyst-cli/src/init.rs

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
66
use crate::types::{
77
CatalystError, InitConfig, InitReport, Platform, Result, AGENTS_DIR, AVAILABLE_SKILLS,
8-
CLAUDE_DIR, COMMANDS_DIR, HOOKS_DIR, SKILLS_DIR,
8+
CATALYST_VERSION, CLAUDE_DIR, COMMANDS_DIR, HOOKS_DIR, SKILLS_DIR, VERSION_FILE,
99
};
1010
use include_dir::{include_dir, Dir};
1111
use indicatif::{ProgressBar, ProgressStyle};
@@ -899,6 +899,54 @@ fn collect_file_hashes(
899899
/// # Returns
900900
///
901901
/// Returns an `InitReport` with details of what was created
902+
///
903+
/// Write .catalyst-version file to track installation version
904+
///
905+
/// # Arguments
906+
///
907+
/// * `target_dir` - Directory where .catalyst-version should be created
908+
///
909+
/// # Returns
910+
///
911+
/// Returns Ok(()) on success
912+
pub fn write_version_file(target_dir: &Path) -> Result<()> {
913+
let version_path = target_dir.join(VERSION_FILE);
914+
fs::write(&version_path, format!("{}\n", CATALYST_VERSION)).map_err(|e| {
915+
CatalystError::FileWriteFailed {
916+
path: version_path.clone(),
917+
source: e,
918+
}
919+
})?;
920+
Ok(())
921+
}
922+
923+
/// Read .catalyst-version file
924+
///
925+
/// # Arguments
926+
///
927+
/// * `target_dir` - Directory where .catalyst-version exists
928+
///
929+
/// # Returns
930+
///
931+
/// Returns the version string on success, None if file doesn't exist
932+
///
933+
/// # Implementation Note
934+
///
935+
/// Avoids TOCTOU (Time-of-Check-Time-of-Use) race by directly attempting
936+
/// to read the file instead of checking existence first.
937+
pub fn read_version_file(target_dir: &Path) -> Result<Option<String>> {
938+
let version_path = target_dir.join(VERSION_FILE);
939+
940+
match fs::read_to_string(&version_path) {
941+
Ok(content) => Ok(Some(content.trim().to_string())),
942+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
943+
Err(e) => Err(CatalystError::FileReadFailed {
944+
path: version_path,
945+
source: e,
946+
}),
947+
}
948+
}
949+
902950
pub fn initialize(config: &InitConfig) -> Result<InitReport> {
903951
// Acquire lock to prevent concurrent init
904952
let _lock = acquire_init_lock(&config.directory)?;
@@ -950,6 +998,15 @@ pub fn initialize(config: &InitConfig) -> Result<InitReport> {
950998
}
951999
}
9521000

1001+
// Phase 6.1: Write .catalyst-version file to track installation
1002+
if let Err(e) = write_version_file(&config.directory) {
1003+
let warning = format!("⚠️ Failed to write .catalyst-version: {}", e);
1004+
eprintln!("{}", warning);
1005+
report.warnings.push(warning);
1006+
} else {
1007+
report.version_file_created = true;
1008+
}
1009+
9531010
Ok(report)
9541011
}
9551012

@@ -1517,4 +1574,99 @@ mod tests {
15171574
assert!(hashes.is_object());
15181575
assert!(!hashes.as_object().unwrap().is_empty());
15191576
}
1577+
1578+
#[test]
1579+
fn test_read_version_file_success() {
1580+
let temp_dir = TempDir::new().unwrap();
1581+
let target = temp_dir.path();
1582+
1583+
// Write a version file
1584+
let version_path = target.join(VERSION_FILE);
1585+
fs::write(&version_path, "0.1.0\n").unwrap();
1586+
1587+
// Read it back
1588+
let result = read_version_file(target).unwrap();
1589+
assert_eq!(result, Some("0.1.0".to_string()));
1590+
}
1591+
1592+
#[test]
1593+
fn test_read_version_file_not_found() {
1594+
let temp_dir = TempDir::new().unwrap();
1595+
let target = temp_dir.path();
1596+
1597+
// No version file exists
1598+
let result = read_version_file(target).unwrap();
1599+
assert_eq!(result, None);
1600+
}
1601+
1602+
#[test]
1603+
#[cfg(unix)] // Only run on Unix systems that support file permissions
1604+
fn test_read_version_file_with_error_context() {
1605+
use std::os::unix::fs::PermissionsExt;
1606+
1607+
let temp_dir = TempDir::new().unwrap();
1608+
let target = temp_dir.path();
1609+
1610+
// Create a version file
1611+
let version_path = target.join(VERSION_FILE);
1612+
fs::write(&version_path, "0.1.0\n").unwrap();
1613+
1614+
// Make it unreadable
1615+
fs::set_permissions(&version_path, fs::Permissions::from_mode(0o000)).unwrap();
1616+
1617+
// Try to read it - should fail with proper error context
1618+
let result = read_version_file(target);
1619+
assert!(result.is_err());
1620+
match result {
1621+
Err(CatalystError::FileReadFailed { path, source }) => {
1622+
assert_eq!(path, version_path);
1623+
assert_eq!(source.kind(), std::io::ErrorKind::PermissionDenied);
1624+
}
1625+
_ => panic!("Expected FileReadFailed with context"),
1626+
}
1627+
1628+
// Clean up - restore permissions so tempdir can be deleted
1629+
fs::set_permissions(&version_path, fs::Permissions::from_mode(0o644)).unwrap();
1630+
}
1631+
1632+
#[test]
1633+
fn test_write_version_file_success() {
1634+
let temp_dir = TempDir::new().unwrap();
1635+
let target = temp_dir.path();
1636+
1637+
// Write version file
1638+
write_version_file(target).unwrap();
1639+
1640+
// Verify it was written correctly
1641+
let version_path = target.join(VERSION_FILE);
1642+
assert!(version_path.exists());
1643+
let content = fs::read_to_string(&version_path).unwrap();
1644+
assert_eq!(content, format!("{}\n", CATALYST_VERSION));
1645+
}
1646+
1647+
#[test]
1648+
#[cfg(unix)] // Only run on Unix systems that support file permissions
1649+
fn test_write_version_file_with_error_context() {
1650+
use std::os::unix::fs::PermissionsExt;
1651+
1652+
let temp_dir = TempDir::new().unwrap();
1653+
let target = temp_dir.path();
1654+
1655+
// Create a read-only directory
1656+
fs::set_permissions(target, fs::Permissions::from_mode(0o555)).unwrap();
1657+
1658+
// Try to write version file - should fail with proper error context
1659+
let result = write_version_file(target);
1660+
assert!(result.is_err());
1661+
match result {
1662+
Err(CatalystError::FileWriteFailed { path, source }) => {
1663+
assert_eq!(path, target.join(VERSION_FILE));
1664+
assert_eq!(source.kind(), std::io::ErrorKind::PermissionDenied);
1665+
}
1666+
_ => panic!("Expected FileWriteFailed with context"),
1667+
}
1668+
1669+
// Clean up - restore permissions so tempdir can be deleted
1670+
fs::set_permissions(target, fs::Permissions::from_mode(0o755)).unwrap();
1671+
}
15201672
}

catalyst-cli/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
pub mod init;
77
pub mod status;
88
pub mod types;
9+
pub mod update;
910
pub mod validation;
1011

1112
// Re-export commonly used types

catalyst-cli/src/types.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,27 @@ pub enum CatalystError {
1717
#[error("JSON serialization error: {0}")]
1818
Json(#[from] serde_json::Error),
1919

20+
#[error("Failed to read file {path}: {source}")]
21+
FileReadFailed {
22+
path: PathBuf,
23+
#[source]
24+
source: std::io::Error,
25+
},
26+
27+
#[error("Failed to write file {path}: {source}")]
28+
FileWriteFailed {
29+
path: PathBuf,
30+
#[source]
31+
source: std::io::Error,
32+
},
33+
34+
#[error("Failed to create directory {path}: {source}")]
35+
DirectoryCreationFailed {
36+
path: PathBuf,
37+
#[source]
38+
source: std::io::Error,
39+
},
40+
2041
#[error("Path not found: {0}")]
2142
PathNotFound(PathBuf),
2243

0 commit comments

Comments
 (0)