diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0bae2a3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,101 @@ +name: Release + +on: + push: + tags: + - 'v*' + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + name: Build (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + archive: tar.gz + - target: x86_64-apple-darwin + os: macos-latest + archive: tar.gz + - target: aarch64-apple-darwin + os: macos-latest + archive: tar.gz + - target: x86_64-pc-windows-msvc + os: windows-latest + archive: zip + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + + - name: Build + run: cargo build --release --target ${{ matrix.target }} + + - name: Package (Unix) + if: matrix.os != 'windows-latest' + run: | + cd target/${{ matrix.target }}/release + tar -czvf ../../../forgekit-${{ github.ref_name }}-${{ matrix.target }}.tar.gz forgekit + cd ../../.. + + - name: Package (Windows) + if: matrix.os == 'windows-latest' + run: | + cd target/${{ matrix.target }}/release + 7z a ../../../forgekit-${{ github.ref_name }}-${{ matrix.target }}.zip forgekit.exe + cd ../../.. + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: forgekit-${{ matrix.target }} + path: forgekit-${{ github.ref_name }}-${{ matrix.target }}.* + + release: + name: Create Release + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Create checksums + run: | + cd artifacts + find . -type f \( -name "*.tar.gz" -o -name "*.zip" \) -exec mv {} . \; + sha256sum forgekit-* > checksums.txt + cat checksums.txt + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + draft: false + prerelease: ${{ contains(github.ref_name, '-') }} + generate_release_notes: true + files: | + artifacts/forgekit-* + artifacts/checksums.txt + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + diff --git a/Cargo.lock b/Cargo.lock index 0eee29e..a7ed27e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,7 +124,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "forgekit" -version = "0.0.8" +version = "0.0.9" dependencies = [ "anyhow", "clap", @@ -135,7 +135,7 @@ dependencies = [ [[package]] name = "forgekit-core" -version = "0.0.8" +version = "0.0.9" dependencies = [ "anyhow", "serde", diff --git a/Cargo.toml b/Cargo.toml index bd3d035..a4b5c43 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,11 +3,12 @@ members = ["crates/core", "crates/cli"] resolver = "2" [workspace.package] -version = "0.0.8" +version = "0.0.9" edition = "2021" authors = ["ForgeKit Contributors"] license = "MIT" -repository = "https://github.com/nedanwar/forgekit" +repository = "https://github.com/nedanwr/forgekit" +homepage = "https://github.com/nedanwr/forgekit" [workspace.dependencies] anyhow = "1.0" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index e0cd461..fdad842 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -4,6 +4,8 @@ version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true +repository.workspace = true +homepage.workspace = true [[bin]] name = "forgekit" diff --git a/crates/cli/src/commands/audio.rs b/crates/cli/src/commands/audio.rs index 55b922b..10a8e94 100644 --- a/crates/cli/src/commands/audio.rs +++ b/crates/cli/src/commands/audio.rs @@ -741,3 +741,173 @@ fn format_duration(seconds: f64) -> String { format!("{}:{:02}", minutes, secs) } } + +#[cfg(test)] +mod tests { + use super::*; + + // Time parsing tests + #[test] + fn test_parse_time_seconds() { + assert_eq!(parse_time("30").unwrap(), 30.0); + assert_eq!(parse_time("90.5").unwrap(), 90.5); + assert_eq!(parse_time("0").unwrap(), 0.0); + } + + #[test] + fn test_parse_time_mm_ss() { + assert_eq!(parse_time("1:30").unwrap(), 90.0); + assert_eq!(parse_time("2:00").unwrap(), 120.0); + assert_eq!(parse_time("0:45").unwrap(), 45.0); + } + + #[test] + fn test_parse_time_hh_mm_ss() { + assert_eq!(parse_time("1:30:00").unwrap(), 5400.0); + assert_eq!(parse_time("0:01:30").unwrap(), 90.0); + assert_eq!(parse_time("2:00:00").unwrap(), 7200.0); + } + + #[test] + fn test_parse_time_invalid() { + assert!(parse_time("invalid").is_err()); + assert!(parse_time("1:2:3:4").is_err()); + assert!(parse_time("abc:def").is_err()); + } + + // Gain parsing tests + #[test] + fn test_parse_gain_positive() { + assert_eq!(parse_gain("+6").unwrap(), 6.0); + assert_eq!(parse_gain("6").unwrap(), 6.0); + assert_eq!(parse_gain("6dB").unwrap(), 6.0); + assert_eq!(parse_gain("+6dB").unwrap(), 6.0); + } + + #[test] + fn test_parse_gain_negative() { + assert_eq!(parse_gain("-3").unwrap(), -3.0); + assert_eq!(parse_gain("-3dB").unwrap(), -3.0); + assert_eq!(parse_gain("-10db").unwrap(), -10.0); + } + + #[test] + fn test_parse_gain_invalid() { + assert!(parse_gain("invalid").is_err()); + assert!(parse_gain("abc").is_err()); + } + + // Duration formatting tests + #[test] + fn test_format_duration_minutes() { + assert_eq!(format_duration(90.0), "1:30"); + assert_eq!(format_duration(0.0), "0:00"); + assert_eq!(format_duration(59.0), "0:59"); + } + + #[test] + fn test_format_duration_hours() { + assert_eq!(format_duration(3600.0), "1:00:00"); + assert_eq!(format_duration(5400.0), "1:30:00"); + assert_eq!(format_duration(7265.0), "2:01:05"); + } + + // Validation tests + #[test] + fn test_convert_requires_output_or_to() { + let args = ConvertArgs { + input: PathBuf::from("input.wav"), + output: None, + to: None, + bitrate: None, + }; + let result = handle_convert(&args, true); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("Either --output or --to is required")); + } + + #[test] + fn test_convert_invalid_format() { + let args = ConvertArgs { + input: PathBuf::from("input.wav"), + output: None, + to: Some("invalid".to_string()), + bitrate: None, + }; + let result = handle_convert(&args, true); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("Unknown format")); + } + + #[test] + fn test_normalize_invalid_target() { + let args = NormalizeArgs { + input: PathBuf::from("input.wav"), + output: None, + target: "invalid_target".to_string(), + lufs: None, + }; + let result = handle_normalize(&args, true); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("Unknown target")); + } + + #[test] + fn test_normalize_valid_targets() { + for target in ["ebu-r128", "ebu", "broadcast", "streaming", "stream"] { + let args = NormalizeArgs { + input: PathBuf::from("input.wav"), + output: Some(PathBuf::from("output.wav")), + target: target.to_string(), + lufs: None, + }; + let result = handle_normalize(&args, true); + // Should not fail on target validation + assert!(result.is_ok() || !result.unwrap_err().to_string().contains("Unknown target")); + } + } + + #[test] + fn test_normalize_custom_lufs() { + let args = NormalizeArgs { + input: PathBuf::from("input.wav"), + output: Some(PathBuf::from("output.wav")), + target: "ebu-r128".to_string(), // ignored when lufs is set + lufs: Some(-16.0), + }; + let result = handle_normalize(&args, true); + // Custom LUFS should work + assert!(result.is_ok() || !result.unwrap_err().to_string().contains("Unknown target")); + } + + #[test] + fn test_trim_requires_start_or_end() { + let args = TrimArgs { + input: PathBuf::from("input.mp3"), + output: PathBuf::from("output.mp3"), + start: None, + end: None, + }; + let result = handle_trim(&args, true); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("At least one of --start or --end")); + } + + #[test] + fn test_extract_requires_output_or_to() { + let args = ExtractArgs { + input: PathBuf::from("video.mp4"), + output: None, + to: None, + bitrate: None, + }; + let result = handle_extract(&args, true); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("Either --output or --to is required")); + } +} diff --git a/crates/cli/src/commands/video.rs b/crates/cli/src/commands/video.rs index 74653f5..23dd91e 100644 --- a/crates/cli/src/commands/video.rs +++ b/crates/cli/src/commands/video.rs @@ -892,3 +892,231 @@ fn handle_stitch(args: &StitchArgs, plan_only: bool) -> Result<()> { println!("{}", result); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + // Time parsing tests + #[test] + fn test_parse_time_seconds() { + assert_eq!(parse_time("30").unwrap(), 30.0); + assert_eq!(parse_time("90.5").unwrap(), 90.5); + assert_eq!(parse_time("0").unwrap(), 0.0); + } + + #[test] + fn test_parse_time_mm_ss() { + assert_eq!(parse_time("1:30").unwrap(), 90.0); + assert_eq!(parse_time("2:00").unwrap(), 120.0); + assert_eq!(parse_time("0:45").unwrap(), 45.0); + } + + #[test] + fn test_parse_time_hh_mm_ss() { + assert_eq!(parse_time("1:30:00").unwrap(), 5400.0); + assert_eq!(parse_time("0:01:30").unwrap(), 90.0); + assert_eq!(parse_time("2:00:00").unwrap(), 7200.0); + } + + #[test] + fn test_parse_time_invalid() { + assert!(parse_time("invalid").is_err()); + assert!(parse_time("1:2:3:4").is_err()); + assert!(parse_time("abc:def").is_err()); + } + + // Duration formatting tests + #[test] + fn test_format_duration_minutes() { + assert_eq!(format_duration(90.0), "1:30"); + assert_eq!(format_duration(0.0), "0:00"); + assert_eq!(format_duration(59.0), "0:59"); + } + + #[test] + fn test_format_duration_hours() { + assert_eq!(format_duration(3600.0), "1:00:00"); + assert_eq!(format_duration(5400.0), "1:30:00"); + assert_eq!(format_duration(7265.0), "2:01:05"); + } + + // Natural sorting tests + #[test] + fn test_natural_sort_key_numbers() { + let mut files = [ + PathBuf::from("frame_10.png"), + PathBuf::from("frame_2.png"), + PathBuf::from("frame_1.png"), + ]; + files.sort_by_key(|a| natural_sort_key(a)); + assert_eq!(files[0], PathBuf::from("frame_1.png")); + assert_eq!(files[1], PathBuf::from("frame_2.png")); + assert_eq!(files[2], PathBuf::from("frame_10.png")); + } + + #[test] + fn test_natural_sort_key_mixed() { + let mut files = [ + PathBuf::from("part_100.mp4"), + PathBuf::from("part_20.mp4"), + PathBuf::from("part_3.mp4"), + ]; + files.sort_by_key(|a| natural_sort_key(a)); + assert_eq!(files[0], PathBuf::from("part_3.mp4")); + assert_eq!(files[1], PathBuf::from("part_20.mp4")); + assert_eq!(files[2], PathBuf::from("part_100.mp4")); + } + + // Validation tests + #[test] + fn test_transcode_crf_validation() { + let args = TranscodeArgs { + input: PathBuf::from("input.mp4"), + output: PathBuf::from("output.mp4"), + crf: 52, // Invalid - max is 51 + preset: "medium".to_string(), + scale: None, + reencode_audio: false, + }; + let result = handle_transcode(&args, true); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("CRF must be 0-51")); + } + + #[test] + fn test_transcode_preset_validation() { + let args = TranscodeArgs { + input: PathBuf::from("input.mp4"), + output: PathBuf::from("output.mp4"), + crf: 23, + preset: "invalid_preset".to_string(), + scale: None, + reencode_audio: false, + }; + let result = handle_transcode(&args, true); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("Invalid preset")); + } + + #[test] + fn test_trim_requires_start_or_end() { + let args = TrimArgs { + input: PathBuf::from("input.mp4"), + output: PathBuf::from("output.mp4"), + start: None, + end: None, + }; + let result = handle_trim(&args, true); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("At least one of --start or --end")); + } + + #[test] + fn test_speed_must_be_positive() { + let args = SpeedArgs { + input: PathBuf::from("input.mp4"), + output: PathBuf::from("output.mp4"), + speed: 0.0, + }; + let result = handle_speed(&args, true); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("Speed must be greater than 0")); + } + + #[test] + fn test_speed_negative() { + let args = SpeedArgs { + input: PathBuf::from("input.mp4"), + output: PathBuf::from("output.mp4"), + speed: -1.0, + }; + let result = handle_speed(&args, true); + assert!(result.is_err()); + } + + #[test] + fn test_rotate_valid_angles() { + // 90, 180, 270 are valid + for angle in [90, 180, 270] { + let args = RotateArgs { + input: PathBuf::from("input.mp4"), + output: PathBuf::from("output.mp4"), + degrees: angle, + }; + // Will fail because input doesn't exist, but validation passes + let result = handle_rotate(&args, true); + // In plan mode, it should generate a plan (may fail due to missing file) + assert!( + result.is_ok() || !result.unwrap_err().to_string().contains("Invalid rotation") + ); + } + } + + #[test] + fn test_rotate_invalid_angle() { + let args = RotateArgs { + input: PathBuf::from("input.mp4"), + output: PathBuf::from("output.mp4"), + degrees: 45, // Invalid + }; + let result = handle_rotate(&args, true); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("Invalid rotation angle")); + } + + #[test] + fn test_convert_valid_formats() { + for format in ["gif", "webm", "mp4", "mov", "avi", "mkv"] { + let args = ConvertArgs { + input: PathBuf::from("input.mp4"), + output: PathBuf::from("output.mp4"), + format: format.to_string(), + start: None, + duration: None, + width: None, + fps: None, + }; + let result = handle_convert(&args, true); + // Should not fail on format validation + assert!(result.is_ok() || !result.unwrap_err().to_string().contains("Invalid format")); + } + } + + #[test] + fn test_convert_invalid_format() { + let args = ConvertArgs { + input: PathBuf::from("input.mp4"), + output: PathBuf::from("output.mp4"), + format: "invalid".to_string(), + start: None, + duration: None, + width: None, + fps: None, + }; + let result = handle_convert(&args, true); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("Invalid format")); + } + + #[test] + fn test_stitch_invalid_format() { + let args = StitchArgs { + inputs: vec!["frame.png".to_string()], + output: PathBuf::from("output.mp4"), + format: "invalid".to_string(), + fps: 24, + width: None, + }; + let result = handle_stitch(&args, true); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("Invalid format")); + } +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 93f9d36..f063ac5 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -4,6 +4,8 @@ version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true +repository.workspace = true +homepage.workspace = true [dependencies] anyhow = { workspace = true } diff --git a/crates/core/src/job/executor.rs b/crates/core/src/job/executor.rs index 7e75558..4bfcf61 100644 --- a/crates/core/src/job/executor.rs +++ b/crates/core/src/job/executor.rs @@ -2578,4 +2578,194 @@ mod video_operation_tests { assert!(result.contains("-c:a aac")); assert!(result.contains("-b:a 128k")); } + + #[test] + fn test_execute_video_trim_plan() { + let input = PathBuf::from("video.mp4"); + let output = PathBuf::from("clip.mp4"); + + let result = execute_video_trim(&input, &output, Some(30.0), Some(60.0), true).unwrap(); + + assert!(result.contains("ffmpeg")); + assert!(result.contains("-ss 30")); + // When both start and end are specified, duration (-t) is used, not -to + assert!(result.contains("-t 30")); // duration = end - start = 60 - 30 = 30 + assert!(result.contains("-c copy")); + } + + #[test] + fn test_execute_video_trim_start_only_plan() { + let input = PathBuf::from("video.mp4"); + let output = PathBuf::from("clip.mp4"); + + let result = execute_video_trim(&input, &output, Some(120.0), None, true).unwrap(); + + assert!(result.contains("-ss 120")); + assert!(!result.contains("-to")); + } + + #[test] + fn test_execute_video_trim_end_only_plan() { + let input = PathBuf::from("video.mp4"); + let output = PathBuf::from("clip.mp4"); + + let result = execute_video_trim(&input, &output, None, Some(60.0), true).unwrap(); + + assert!(result.contains("-to 60")); + assert!(!result.contains("-ss")); + } + + #[test] + fn test_execute_video_join_plan() { + let inputs = vec![PathBuf::from("part1.mp4"), PathBuf::from("part2.mp4")]; + let output = PathBuf::from("full.mp4"); + + let result = execute_video_join(&inputs, &output, true).unwrap(); + + assert!(result.contains("ffmpeg")); + assert!(result.contains("-f concat")); + assert!(result.contains("-c copy")); + } + + #[test] + fn test_execute_video_thumbnail_plan() { + let input = PathBuf::from("video.mp4"); + let output = PathBuf::from("thumb.jpg"); + + let result = execute_video_thumbnail(&input, &output, 5.0, true).unwrap(); + + assert!(result.contains("ffmpeg")); + assert!(result.contains("-ss 5")); + assert!(result.contains("-frames:v 1")); + } + + #[test] + fn test_execute_video_convert_gif_plan() { + let input = PathBuf::from("video.mp4"); + let output = PathBuf::from("output.gif"); + + let result = execute_video_convert( + &input, + &output, + "gif", + None, + None, + Some(480), + Some(10), + true, + ) + .unwrap(); + + assert!(result.contains("ffmpeg")); + assert!(result.contains("palettegen")); + assert!(result.contains("paletteuse")); + assert!(result.contains("scale=480")); + } + + #[test] + fn test_execute_video_convert_webm_plan() { + let input = PathBuf::from("video.mp4"); + let output = PathBuf::from("output.webm"); + + let result = + execute_video_convert(&input, &output, "webm", None, None, None, None, true).unwrap(); + + assert!(result.contains("ffmpeg")); + assert!(result.contains("-c copy")); + } + + #[test] + fn test_execute_video_speed_plan() { + let input = PathBuf::from("video.mp4"); + let output = PathBuf::from("fast.mp4"); + + let result = execute_video_speed(&input, &output, 2.0, true).unwrap(); + + assert!(result.contains("ffmpeg")); + assert!(result.contains("setpts=PTS/2")); + assert!(result.contains("atempo=2")); + } + + #[test] + fn test_execute_video_speed_slow_plan() { + let input = PathBuf::from("video.mp4"); + let output = PathBuf::from("slow.mp4"); + + let result = execute_video_speed(&input, &output, 0.5, true).unwrap(); + + assert!(result.contains("setpts=PTS/0.5")); + assert!(result.contains("atempo=0.5")); + } + + #[test] + fn test_execute_video_rotate_90_plan() { + let input = PathBuf::from("video.mp4"); + let output = PathBuf::from("rotated.mp4"); + + let result = execute_video_rotate(&input, &output, 90, true).unwrap(); + + assert!(result.contains("ffmpeg")); + assert!(result.contains("transpose=1")); + } + + #[test] + fn test_execute_video_rotate_180_plan() { + let input = PathBuf::from("video.mp4"); + let output = PathBuf::from("rotated.mp4"); + + let result = execute_video_rotate(&input, &output, 180, true).unwrap(); + + assert!(result.contains("transpose=1,transpose=1")); + } + + #[test] + fn test_execute_video_rotate_270_plan() { + let input = PathBuf::from("video.mp4"); + let output = PathBuf::from("rotated.mp4"); + + let result = execute_video_rotate(&input, &output, 270, true).unwrap(); + + assert!(result.contains("transpose=2")); + } + + // Note: Invalid rotation angle validation only happens during actual execution, + // not in plan mode. The plan function generates output for any angle. + // Validation is handled at the CLI layer for plan mode. + + #[test] + fn test_execute_video_mute_plan() { + let input = PathBuf::from("video.mp4"); + let output = PathBuf::from("silent.mp4"); + + let result = execute_video_mute(&input, &output, true).unwrap(); + + assert!(result.contains("ffmpeg")); + assert!(result.contains("-an")); + assert!(result.contains("-c:v copy")); + } + + #[test] + fn test_execute_video_stitch_mp4_plan() { + let inputs = vec![PathBuf::from("frame1.png"), PathBuf::from("frame2.png")]; + let output = PathBuf::from("video.mp4"); + + let result = execute_video_stitch(&inputs, &output, "mp4", 24, None, true).unwrap(); + + assert!(result.contains("ffmpeg")); + assert!(result.contains("-f concat")); + assert!(result.contains("-r 24")); + } + + #[test] + fn test_execute_video_stitch_gif_plan() { + let inputs = vec![PathBuf::from("frame1.png"), PathBuf::from("frame2.png")]; + let output = PathBuf::from("anim.gif"); + + let result = execute_video_stitch(&inputs, &output, "gif", 10, Some(480), true).unwrap(); + + assert!(result.contains("ffmpeg")); + assert!(result.contains("palettegen")); + assert!(result.contains("paletteuse")); + assert!(result.contains("scale=480")); + } } diff --git a/crates/core/src/utils/error.rs b/crates/core/src/utils/error.rs index 04e7a5a..b7fe966 100644 --- a/crates/core/src/utils/error.rs +++ b/crates/core/src/utils/error.rs @@ -144,3 +144,108 @@ impl ForgeKitError { /// Result type alias pub type Result = std::result::Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_exit_code_tool_not_found() { + let err = ForgeKitError::ToolNotFound { + tool: "qpdf".to_string(), + hint: "Install with brew install qpdf".to_string(), + }; + assert_eq!(err.exit_code(), ExitCode::MissingTool); + assert_eq!(i32::from(err.exit_code()), 2); + } + + #[test] + fn test_exit_code_invalid_input() { + let err = ForgeKitError::InvalidInput { + path: PathBuf::from("test.pdf"), + reason: "File not found".to_string(), + }; + assert_eq!(err.exit_code(), ExitCode::InvalidInput); + assert_eq!(i32::from(err.exit_code()), 3); + } + + #[test] + fn test_exit_code_permission_denied() { + let err = ForgeKitError::PermissionDenied { + path: PathBuf::from("/root/file"), + }; + assert_eq!(err.exit_code(), ExitCode::PermissionDenied); + assert_eq!(i32::from(err.exit_code()), 4); + } + + #[test] + fn test_exit_code_disk_full() { + let err = ForgeKitError::DiskFull { + path: PathBuf::from("/output.pdf"), + }; + assert_eq!(err.exit_code(), ExitCode::DiskFull); + assert_eq!(i32::from(err.exit_code()), 5); + } + + #[test] + fn test_exit_code_cancelled() { + let err = ForgeKitError::Cancelled; + assert_eq!(err.exit_code(), ExitCode::Cancelled); + assert_eq!(i32::from(err.exit_code()), 130); + } + + #[test] + fn test_exit_code_processing_failed() { + let err = ForgeKitError::ProcessingFailed { + tool: "ffmpeg".to_string(), + stderr: "Error encoding video".to_string(), + }; + assert_eq!(err.exit_code(), ExitCode::GeneralError); + assert_eq!(i32::from(err.exit_code()), 1); + } + + #[test] + fn test_exit_code_tool_version_mismatch() { + let err = ForgeKitError::ToolVersionMismatch { + tool: "qpdf".to_string(), + required: "11.0".to_string(), + found: "10.0".to_string(), + }; + assert_eq!(err.exit_code(), ExitCode::GeneralError); + assert_eq!(i32::from(err.exit_code()), 1); + } + + #[test] + fn test_error_display_tool_not_found() { + let err = ForgeKitError::ToolNotFound { + tool: "qpdf".to_string(), + hint: "Install with brew install qpdf".to_string(), + }; + let msg = err.to_string(); + assert!(msg.contains("qpdf")); + assert!(msg.contains("not found")); + assert!(msg.contains("brew install qpdf")); + } + + #[test] + fn test_error_display_invalid_input() { + let err = ForgeKitError::InvalidInput { + path: PathBuf::from("missing.pdf"), + reason: "File does not exist".to_string(), + }; + let msg = err.to_string(); + assert!(msg.contains("missing.pdf")); + assert!(msg.contains("File does not exist")); + } + + #[test] + fn test_error_display_processing_failed() { + let err = ForgeKitError::ProcessingFailed { + tool: "ffmpeg".to_string(), + stderr: "Invalid codec".to_string(), + }; + let msg = err.to_string(); + assert!(msg.contains("ffmpeg")); + assert!(msg.contains("Invalid codec")); + } +} diff --git a/packaging/chocolatey/forgekit.nuspec b/packaging/chocolatey/forgekit.nuspec new file mode 100644 index 0000000..2f05569 --- /dev/null +++ b/packaging/chocolatey/forgekit.nuspec @@ -0,0 +1,29 @@ + + + + forgekit + 0.0.9 + ForgeKit + ForgeKit Contributors + ForgeKit Contributors + https://opensource.org/licenses/MIT + https://github.com/nedanwr/forgekit + false + Local-first media and PDF toolkit. Fast, privacy-focused CLI for PDF, image, audio, and video operations. All processing runs locally - no data leaves your device. + CLI toolkit for PDF, image, audio, and video operations + pdf media cli video audio image toolkit ffmpeg + + + + + + + + + + + + + + + diff --git a/packaging/chocolatey/tools/chocolateyinstall.ps1 b/packaging/chocolatey/tools/chocolateyinstall.ps1 new file mode 100644 index 0000000..4547f71 --- /dev/null +++ b/packaging/chocolatey/tools/chocolateyinstall.ps1 @@ -0,0 +1,41 @@ +$ErrorActionPreference = 'Stop' + +$packageName = 'forgekit' +$version = '0.0.9' +$url64 = "https://github.com/nedanwr/forgekit/releases/download/v$version/forgekit-v$version-x86_64-pc-windows-msvc.zip" +$checksum64 = 'PLACEHOLDER_SHA256' + +$toolsDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)" + +# Download and extract +$packageArgs = @{ + packageName = $packageName + unzipLocation = $toolsDir + url64bit = $url64 + checksum64 = $checksum64 + checksumType64= 'sha256' +} +Install-ChocolateyZipPackage @packageArgs + +# Install ocrmypdf via pip (dependencies already installed by Chocolatey) +Write-Host "Installing ocrmypdf Python package..." -ForegroundColor Cyan +try { + & python -m pip install --quiet --upgrade pip 2>$null + & python -m pip install --quiet ocrmypdf 2>$null + Write-Host "Successfully installed ocrmypdf" -ForegroundColor Green +} catch { + Write-Warning "Could not install ocrmypdf. Run manually: pip install ocrmypdf" +} + +# Install libvips via scoop if available (not in Chocolatey) +if (Get-Command scoop -ErrorAction SilentlyContinue) { + Write-Host "Installing libvips via scoop..." -ForegroundColor Cyan + & scoop install libvips 2>$null +} else { + Write-Warning "libvips not installed (requires Scoop). Image operations may be limited." + Write-Warning "To install: Install Scoop (scoop.sh), then run: scoop install libvips" +} + +Write-Host "" +Write-Host "ForgeKit installed successfully!" -ForegroundColor Green +Write-Host "Run 'forgekit check-deps' to verify all dependencies." -ForegroundColor Gray diff --git a/packaging/chocolatey/tools/chocolateyuninstall.ps1 b/packaging/chocolatey/tools/chocolateyuninstall.ps1 new file mode 100644 index 0000000..a2ee8d8 --- /dev/null +++ b/packaging/chocolatey/tools/chocolateyuninstall.ps1 @@ -0,0 +1,13 @@ +$ErrorActionPreference = 'Stop' + +$packageName = 'forgekit' +$toolsDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)" + +# Remove the binary +$exePath = Join-Path $toolsDir 'forgekit.exe' +if (Test-Path $exePath) { + Remove-Item $exePath -Force +} + +Write-Host "ForgeKit uninstalled." -ForegroundColor Green +Write-Host "Note: Dependencies (qpdf, ffmpeg, etc.) were not removed." -ForegroundColor Gray diff --git a/packaging/debian/control b/packaging/debian/control index 313b5d4..39059a7 100644 --- a/packaging/debian/control +++ b/packaging/debian/control @@ -3,7 +3,7 @@ # When users install forgekit.deb, these dependencies will be automatically installed Package: forgekit -Version: 0.0.3 +Version: 0.0.9 Section: utils Priority: optional Architecture: amd64 diff --git a/packaging/homebrew/forgekit.rb b/packaging/homebrew/forgekit.rb index 859141a..4d4ad49 100644 --- a/packaging/homebrew/forgekit.rb +++ b/packaging/homebrew/forgekit.rb @@ -4,8 +4,8 @@ class Forgekit < Formula desc "Local-first media and PDF toolkit" - homepage "https://github.com/nedanwar/forgekit" - url "https://github.com/nedanwar/forgekit/releases/download/v0.0.3/forgekit-0.0.3.tar.gz" + homepage "https://github.com/nedanwr/forgekit" + url "https://github.com/nedanwr/forgekit/releases/download/v0.0.9/forgekit-0.0.9-x86_64-apple-darwin.tar.gz" sha256 "PLACEHOLDER_SHA256" license "MIT" diff --git a/packaging/rpm/forgekit.spec b/packaging/rpm/forgekit.spec index 89144af..0e36afb 100644 --- a/packaging/rpm/forgekit.spec +++ b/packaging/rpm/forgekit.spec @@ -3,11 +3,11 @@ # When users install forgekit.rpm, these dependencies will be automatically installed Name: forgekit -Version: 0.0.3 +Version: 0.0.9 Release: 1%{?dist} Summary: Local-first media and PDF toolkit License: MIT -URL: https://github.com/nedanwar/forgekit +URL: https://github.com/nedanwr/forgekit Source0: %{name}-%{version}.tar.gz BuildRequires: rust @@ -49,6 +49,12 @@ fi %{_bindir}/forgekit %changelog +* Fri Jan 2026 ForgeKit Contributors - 0.0.9-1 +- Added GitHub Actions release workflow +- Added Dockerfile for containerized distribution +- Added Makefile for common tasks +- Updated Cargo.toml with crates.io publishing fields + * Wed Dec 2025 ForgeKit Contributors - 0.0.3-1 - Initial RPM package diff --git a/packaging/winget/forgekit.yaml b/packaging/winget/forgekit.yaml index a52c716..b3d9de8 100644 --- a/packaging/winget/forgekit.yaml +++ b/packaging/winget/forgekit.yaml @@ -1,32 +1,33 @@ # winget manifest for ForgeKit -# This file defines dependencies for Windows installation -# When users install via `winget install forgekit`, these dependencies will be automatically installed +# Note: winget does not auto-install dependencies. Users must install them manually. +# See postinstall.ps1 for dependency installation commands. Id: forgekit.forgekit -Version: 0.0.3 +Version: 0.0.9 Name: ForgeKit Publisher: ForgeKit Contributors Description: Local-first media and PDF toolkit. Fast, privacy-focused CLI for PDF, image, audio, and video operations. License: MIT LicenseUrl: https://opensource.org/licenses/MIT -Homepage: https://github.com/nedanwar/forgekit +Homepage: https://github.com/nedanwr/forgekit Tags: - pdf - media - cli - toolkit -InstallerType: exe +InstallerType: zip Installers: - Architecture: x64 - InstallerUrl: https://github.com/nedanwar/forgekit/releases/download/v0.0.3/forgekit-0.0.3-x64.exe + InstallerUrl: https://github.com/nedanwr/forgekit/releases/download/v0.0.9/forgekit-v0.0.9-x86_64-pc-windows-msvc.zip InstallerSha256: PLACEHOLDER_SHA256 ManifestType: version -Dependencies: - - PackageIdentifier: qpdf.qpdf - - PackageIdentifier: ArtifexSoftware.GhostScript - - PackageIdentifier: tesseract-ocr - - PackageIdentifier: Gyan.FFmpeg - - PackageIdentifier: libvips.libvips - - PackageIdentifier: exiftool.exiftool - - PackageIdentifier: Python.Python.3 + +# Dependencies (informational - must be installed manually): +# winget install qpdf.qpdf +# winget install ArtifexSoftware.GhostScript +# winget install UB-Mannheim.TesseractOCR +# winget install Gyan.FFmpeg +# winget install Python.Python.3 +# scoop install libvips exiftool (not available in winget) +# pip install ocrmypdf diff --git a/packaging/winget/postinstall.ps1 b/packaging/winget/postinstall.ps1 index e26949d..54cfd9c 100644 --- a/packaging/winget/postinstall.ps1 +++ b/packaging/winget/postinstall.ps1 @@ -1,15 +1,55 @@ -# Post-installation script for ForgeKit winget package -# Automatically installs ocrmypdf Python package +# ForgeKit Windows Dependency Installer +# Run this script after installing ForgeKit to install all required dependencies. +# Usage: .\postinstall.ps1 -# Check if Python and pip are available +Write-Host "ForgeKit Dependency Installer" -ForegroundColor Green +Write-Host "==============================" -ForegroundColor Green +Write-Host "" + +# Check for winget +if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { + Write-Host "Error: winget not found. Please install App Installer from Microsoft Store." -ForegroundColor Red + exit 1 +} + +# Install winget packages +Write-Host "Installing winget packages..." -ForegroundColor Cyan +$wingetPackages = @( + "qpdf.qpdf", + "ArtifexSoftware.GhostScript", + "UB-Mannheim.TesseractOCR", + "Gyan.FFmpeg", + "Python.Python.3" +) + +foreach ($pkg in $wingetPackages) { + Write-Host " Installing $pkg..." -ForegroundColor Yellow + winget install --id $pkg --silent --accept-package-agreements --accept-source-agreements 2>$null +} + +# Check for scoop (needed for libvips and exiftool) +if (Get-Command scoop -ErrorAction SilentlyContinue) { + Write-Host "Installing scoop packages..." -ForegroundColor Cyan + scoop install libvips exiftool 2>$null +} else { + Write-Host "" + Write-Host "Note: Scoop not found. For full functionality, install Scoop and run:" -ForegroundColor Yellow + Write-Host " scoop install libvips exiftool" -ForegroundColor White + Write-Host "" + Write-Host "To install Scoop: https://scoop.sh" -ForegroundColor Gray +} + +# Install ocrmypdf via pip +Write-Host "Installing Python packages..." -ForegroundColor Cyan if (Get-Command python -ErrorAction SilentlyContinue) { - Write-Host "Installing Python dependencies for ForgeKit..." -ForegroundColor Cyan + python -m pip install --quiet --upgrade pip 2>$null python -m pip install --quiet ocrmypdf 2>$null -} elseif (Get-Command python3 -ErrorAction SilentlyContinue) { - Write-Host "Installing Python dependencies for ForgeKit..." -ForegroundColor Cyan - python3 -m pip install --quiet ocrmypdf 2>$null -} elseif (Get-Command pip -ErrorAction SilentlyContinue) { - Write-Host "Installing Python dependencies for ForgeKit..." -ForegroundColor Cyan - pip install --quiet ocrmypdf 2>$null + Write-Host " Installed ocrmypdf" -ForegroundColor Yellow +} else { + Write-Host " Warning: Python not found. Restart terminal and run: pip install ocrmypdf" -ForegroundColor Yellow } +Write-Host "" +Write-Host "Installation complete!" -ForegroundColor Green +Write-Host "Run 'forgekit check-deps' to verify all dependencies." -ForegroundColor Gray +