|
5 | 5 |
|
6 | 6 | use crate::types::{ |
7 | 7 | 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, |
9 | 9 | }; |
10 | 10 | use include_dir::{include_dir, Dir}; |
11 | 11 | use indicatif::{ProgressBar, ProgressStyle}; |
@@ -899,6 +899,54 @@ fn collect_file_hashes( |
899 | 899 | /// # Returns |
900 | 900 | /// |
901 | 901 | /// 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 | + |
902 | 950 | pub fn initialize(config: &InitConfig) -> Result<InitReport> { |
903 | 951 | // Acquire lock to prevent concurrent init |
904 | 952 | let _lock = acquire_init_lock(&config.directory)?; |
@@ -950,6 +998,15 @@ pub fn initialize(config: &InitConfig) -> Result<InitReport> { |
950 | 998 | } |
951 | 999 | } |
952 | 1000 |
|
| 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 | + |
953 | 1010 | Ok(report) |
954 | 1011 | } |
955 | 1012 |
|
@@ -1517,4 +1574,99 @@ mod tests { |
1517 | 1574 | assert!(hashes.is_object()); |
1518 | 1575 | assert!(!hashes.as_object().unwrap().is_empty()); |
1519 | 1576 | } |
| 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 | + } |
1520 | 1672 | } |
0 commit comments