Compare commits

...

10 Commits

16 changed files with 350 additions and 56 deletions

61
CHANGELOG.md Normal file
View File

@@ -0,0 +1,61 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.3] - 2025-11-22
### Added
- **RAR Support**: Added extraction support for RAR archives (v4 and v5)
- Magic byte detection for RAR files
- Full extraction with permission preservation
- Analysis capabilities for RAR archives
- Note: RAR compression not supported due to proprietary format
- **Semantic Versioning**: Implemented proper semantic versioning system
- Version module with Major.Minor.Patch format
- `--version` flag to display version information
- Version displayed in interactive TUI mode
- **Command-Line Interface (CLI)**: Added non-interactive CLI mode for automation
- `--compress` flag for compression operations
- `--extract` flag for extraction operations
- `--analyze` flag for archive analysis
- `--output` flag for specifying output paths
- `--type` flag for archive type selection
- `--level` flag for compression level control
- `--overwrite` flag for overwrite control
- `--preserve-perms` flag for permission preservation
- `--exclude` and `--include` flags for filtering
- `--verify` flag for integrity verification
- `--help` flag for usage information
- **Remote URL Fetching**: Added ability to download and extract archives from URLs
- `--url` flag for remote archive fetching
- HTTP/HTTPS support
- Progress tracking during download
- Automatic format detection and extraction
- Support for all archive formats via URL
### Changed
- Updated README.md with comprehensive documentation for new features
- Enhanced main.go to support both CLI and interactive modes
- Improved archive type detection to include RAR format
### Dependencies
- Added `github.com/nwaples/rardecode` v1.1.3 for RAR extraction support
## [0.x.x] - Previous Versions
Previous versions included:
- ZIP, TAR, TAR.GZ, and GZIP support
- Interactive TUI mode
- Batch operations
- Archive comparison
- Format conversion
- Compression levels
- Include/exclude patterns
- Integrity verification

View File

@@ -5,7 +5,7 @@
# Application name
BINARY_NAME=zipprine
VERSION?=1.0.0
VERSION?=1.0.3
BUILD_DIR=build
RELEASE_DIR=releases
@@ -41,7 +41,7 @@ help: ## Display this help screen
@echo "$(CYAN)║ 🗜️ zipprine Build System 🚀 ║$(NC)"
@echo "$(CYAN)╚═══════════════════════════════════════════════════╝$(NC)"
@echo ""
@awk 'BEGIN {FS = ":.*##"; printf "Usage:\n make $(CYAN)<target>$(NC)\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " $(CYAN)%-15s$(NC) %s\n", $1, $2 } /^##@/ { printf "\n$(MAGENTA)%s$(NC)\n", substr($0, 5) } ' $(MAKEFILE_LIST)
@awk 'BEGIN {FS = ":.*##"; printf "Usage:\n make $(CYAN)<target>$(NC)\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " $(CYAN)%-15s$(NC) %s\n", $$1, $$2 } /^##@/ { printf "\n$(MAGENTA)%s$(NC)\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
##@ Development
@@ -232,9 +232,15 @@ release: clean build-all ## Create release packages
checksums: ## Generate SHA256 checksums for releases
@echo "$(BLUE)🔐 Generating checksums...$(NC)"
@cd $(RELEASE_DIR) && shasum -a 256 * > SHA256SUMS
@echo "$(GREEN)✅ Checksums generated: $(RELEASE_DIR)/SHA256SUMS$(NC)"
@cat $(RELEASE_DIR)/SHA256SUMS
@mkdir -p $(RELEASE_DIR)
@rm -f $(RELEASE_DIR)/SHA256SUMS
@if [ -n "$$(ls -A $(RELEASE_DIR) 2>/dev/null)" ]; then \
cd $(RELEASE_DIR) && shasum -a 256 * > SHA256SUMS && \
echo "$(GREEN)✅ Checksums generated: $(RELEASE_DIR)/SHA256SUMS$(NC)" && \
cat SHA256SUMS; \
else \
echo "$(YELLOW)⚠️ No files found in $(RELEASE_DIR). Run 'make release' first.$(NC)"; \
fi
##@ Docker (Bonus)

View File

@@ -1,6 +1,8 @@
# 🗜️ Zipprine - TUI zipping tool
# 🗜️ Zipprine - TUI/CLI Archiving Tool
Zipprine is a modern TUI application for managing archives with support for multiple formats including ZIP, TAR, TAR.GZ, and GZIP.
Zipprine is a modern TUI/CLI application for managing archives with support for multiple formats including ZIP, TAR, TAR.GZ, GZIP, and RAR (extraction only).
**Version:** 1.0.3
## ✨ Features
@@ -10,10 +12,13 @@ Zipprine is a modern TUI application for managing archives with support for mult
- **Compression levels**: Fast, Balanced, Best
- **Smart filtering**: Include/exclude patterns with wildcards
- **Integrity verification**: SHA256 checksums and validation
- **CLI mode**: Non-interactive command-line interface for automation
### 📂 Extraction
- **Auto-detection**: Automatically detects archive type by magic bytes
- **RAR support**: Extract RAR archives (v4 and v5)
- **Remote fetching**: Download and extract archives from URLs
- **Safe extraction**: Optional overwrite protection
- **Permission preservation**: Keep original file permissions
- **Progress tracking**: Real-time extraction feedback
@@ -23,7 +28,7 @@ Zipprine is a modern TUI application for managing archives with support for mult
- **Detailed statistics**: File count, sizes, compression ratios
- **File listing**: View contents without extraction
- **Checksum verification**: SHA256 integrity checks
- **Format detection**: Magic byte analysis
- **Format detection**: Magic byte analysis (including RAR)
### 📚 Batch Operations
@@ -45,6 +50,13 @@ Zipprine is a modern TUI application for managing archives with support for mult
- **Preserve contents**: Maintains file structure and permissions
- **Automatic extraction**: Seamless conversion process
### 🌐 Remote Archive Fetching
- **URL download**: Fetch archives from HTTP/HTTPS URLs
- **Auto-extract**: Automatically extract downloaded archives
- **Progress tracking**: Real-time download progress
- **Format detection**: Supports all archive formats via URL
## 🚀 Installation
```bash
@@ -61,11 +73,17 @@ make build
## 📖 Usage
Just run `zipprine` and follow the interactive menu:
### Interactive Mode (TUI)
Just run `zipprine` without arguments to launch the interactive menu:
```bash
zipprine
```
**Compress** - Choose files/folders, pick a format (ZIP, TAR, TAR.GZ, GZIP), and set your preferences
**Extract** - Point to an archive and choose where to extract (format is auto-detected)
**Extract** - Point to an archive and choose where to extract (format is auto-detected, supports RAR)
**Analyze** - View detailed stats about any archive without extracting it
@@ -75,6 +93,53 @@ Just run `zipprine` and follow the interactive menu:
**Convert** - Change archive formats while preserving structure
### Command-Line Mode (CLI)
For automation and scripting, use CLI flags:
```bash
# Compress a directory
zipprine --compress /path/to/source --output archive.zip --type zip
# Extract an archive (auto-detects format)
zipprine --extract archive.tar.gz --output /path/to/dest
# Extract a RAR archive
zipprine --extract archive.rar --output /path/to/dest
# Analyze an archive
zipprine --analyze archive.zip
# Download and extract from URL
zipprine --url https://example.com/archive.zip --output /path/to/dest
# Compress with exclusions
zipprine --compress /project --output project.tar.gz --type tar.gz --exclude '*.log,*.tmp'
# Show version
zipprine --version
# Show help
zipprine --help
```
#### CLI Options
- `--compress <path>` - Compress files/folders at the specified path
- `--extract <path>` - Extract archive at the specified path
- `--analyze <path>` - Analyze archive at the specified path
- `--output <path>` - Output path for compression or extraction
- `--type <type>` - Archive type: zip, tar, tar.gz, gzip, rar (default: zip)
- `--level <1-9>` - Compression level: 1=fast, 6=balanced, 9=best (default: 6)
- `--overwrite` - Overwrite existing files during extraction
- `--preserve-perms` - Preserve file permissions (default: true)
- `--exclude <patterns>` - Comma-separated patterns to exclude
- `--include <patterns>` - Comma-separated patterns to include
- `--verify` - Verify archive integrity after compression
- `--url <url>` - Download and extract archive from remote URL
- `--version` - Show version information
- `--help` - Show help message
## 🔨 Building
```bash
@@ -123,11 +188,31 @@ make bench
- `src/*,docs/*` - Only src and docs folders
- `*.md,*.txt` - Only markdown and text files
## 📚 Supported Formats
### Compression (Create Archives)
- **ZIP** - Universal format, works everywhere
- **TAR** - Unix standard, no compression
- **TAR.GZ** - Compressed TAR, best for Linux
- **GZIP** - Single file compression
### Extraction (Read Archives)
- **ZIP** - Full support
- **TAR** - Full support
- **TAR.GZ** - Full support
- **GZIP** - Full support
- **RAR** - Extraction only (RAR v4 and v5)
**Note:** RAR compression is not supported due to proprietary format restrictions. Use ZIP or TAR.GZ for creating archives.
## 🛠️ Technologies
- **[Charm Bracelet Huh](https://github.com/charmbracelet/huh)** - Beautiful TUI forms
- **[Lipgloss](https://github.com/charmbracelet/lipgloss)** - Styling and colors
- **Go standard library** - Archive formats
- **[rardecode](https://github.com/nwaples/rardecode)** - RAR extraction support
- **Go standard library** - Archive formats and HTTP client
## 📝 License

1
go.mod
View File

@@ -5,6 +5,7 @@ go 1.25.4
require (
github.com/charmbracelet/huh v0.8.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/nwaples/rardecode v1.1.3
)
require (

2
go.sum
View File

@@ -58,6 +58,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=

View File

@@ -80,7 +80,6 @@ func TestCompareIdenticalArchives(t *testing.T) {
}
defer os.RemoveAll(tmpDir)
// Create source directory
sourceDir := filepath.Join(tmpDir, "source")
os.Mkdir(sourceDir, 0755)
os.WriteFile(filepath.Join(sourceDir, "file1.txt"), []byte("content"), 0644)

View File

@@ -21,12 +21,10 @@ func TestCreateZip(t *testing.T) {
os.WriteFile(filepath.Join(sourceDir, "file1.txt"), []byte("content1"), 0644)
os.WriteFile(filepath.Join(sourceDir, "file2.txt"), []byte("content2"), 0644)
// Create subdirectory
subDir := filepath.Join(sourceDir, "subdir")
os.Mkdir(subDir, 0755)
os.WriteFile(filepath.Join(subDir, "file3.txt"), []byte("content3"), 0644)
// Create ZIP
zipPath := filepath.Join(tmpDir, "test.zip")
config := &models.CompressConfig{
SourcePath: sourceDir,
@@ -59,11 +57,9 @@ func TestCreateZipWithCompressionLevels(t *testing.T) {
}
defer os.RemoveAll(tmpDir)
// Create test file with compressible content
sourceDir := filepath.Join(tmpDir, "source")
os.Mkdir(sourceDir, 0755)
// Create a file with repetitive content (compresses well)
content := make([]byte, 10000)
for i := range content {
content[i] = byte(i % 10)
@@ -402,11 +398,9 @@ func TestZipEmptyDirectory(t *testing.T) {
}
defer os.RemoveAll(tmpDir)
// Create empty directory
sourceDir := filepath.Join(tmpDir, "empty")
os.Mkdir(sourceDir, 0755)
// Create ZIP
zipPath := filepath.Join(tmpDir, "empty.zip")
config := &models.CompressConfig{
SourcePath: sourceDir,
@@ -420,7 +414,6 @@ func TestZipEmptyDirectory(t *testing.T) {
t.Fatalf("createZip failed: %v", err)
}
// Verify ZIP was created
if _, err := os.Stat(zipPath); os.IsNotExist(err) {
t.Error("ZIP file was not created")
}

View File

@@ -12,7 +12,6 @@ import (
"zipprine/internal/version"
)
// Run executes the CLI mode
func Run() bool {
// Define flags
compress := flag.String("compress", "", "Compress files/folders (source path)")
@@ -32,24 +31,19 @@ func Run() bool {
flag.Parse()
// Show version
if *showVersion {
fmt.Println(version.FullVersion())
return true
}
// Show help
if *help {
printHelp()
return true
}
// Check if any CLI flags were provided
if flag.NFlag() == 0 {
return false // No flags, use interactive mode
return false
}
// Handle remote URL fetching
if *remoteURL != "" {
if *output == "" {
fmt.Println("❌ Error: --output is required when using --url")
@@ -68,7 +62,6 @@ func Run() bool {
return true
}
// Handle compression
if *compress != "" {
if *output == "" {
fmt.Println("❌ Error: --output is required for compression")
@@ -105,7 +98,6 @@ func Run() bool {
return true
}
// Handle extraction
if *extract != "" {
if *output == "" {
fmt.Println("❌ Error: --output is required for extraction")
@@ -172,7 +164,6 @@ func Run() bool {
return true
}
// If we get here, no valid operation was specified
fmt.Println("❌ Error: No valid operation specified. Use --help for usage information.")
os.Exit(1)
return true

View File

@@ -15,7 +15,7 @@ import (
// FetchAndExtract downloads an archive from a URL and extracts it to the destination path
func FetchAndExtract(archiveURL, destPath string, overwriteAll, preservePerms bool) error {
// Validate URL
parsedURL, err := url.Parse(archiveURL)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
@@ -25,13 +25,11 @@ func FetchAndExtract(archiveURL, destPath string, overwriteAll, preservePerms bo
return fmt.Errorf("only HTTP and HTTPS URLs are supported")
}
// Extract filename from URL
filename := filepath.Base(parsedURL.Path)
if filename == "" || filename == "." || filename == "/" {
filename = "archive.tmp"
}
// Create temporary directory
tempDir, err := os.MkdirTemp("", "zipprine-*")
if err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
@@ -40,7 +38,6 @@ func FetchAndExtract(archiveURL, destPath string, overwriteAll, preservePerms bo
tempFile := filepath.Join(tempDir, filename)
// Download the file
fmt.Printf("📥 Downloading from %s...\n", archiveURL)
if err := downloadFile(tempFile, archiveURL); err != nil {
return fmt.Errorf("failed to download file: %w", err)
@@ -48,7 +45,6 @@ func FetchAndExtract(archiveURL, destPath string, overwriteAll, preservePerms bo
fmt.Printf("✅ Download complete: %s\n", tempFile)
// Detect archive type
archiveType, err := archiver.DetectArchiveType(tempFile)
if err != nil {
return fmt.Errorf("failed to detect archive type: %w", err)
@@ -60,7 +56,6 @@ func FetchAndExtract(archiveURL, destPath string, overwriteAll, preservePerms bo
fmt.Printf("📦 Detected archive type: %s\n", archiveType)
// Extract the archive
fmt.Printf("📂 Extracting to %s...\n", destPath)
extractConfig := &models.ExtractConfig{
ArchivePath: tempFile,
@@ -80,14 +75,12 @@ func FetchAndExtract(archiveURL, destPath string, overwriteAll, preservePerms bo
// downloadFile downloads a file from a URL to a local path with progress indication
func downloadFile(filepath, url string) error {
// Create the file
out, err := os.Create(filepath)
if err != nil {
return err
}
defer out.Close()
// Get the data
resp, err := http.Get(url)
if err != nil {
return err
@@ -99,10 +92,8 @@ func downloadFile(filepath, url string) error {
return fmt.Errorf("bad status: %s", resp.Status)
}
// Get content length for progress
contentLength := resp.ContentLength
// Create progress reader
var reader io.Reader = resp.Body
if contentLength > 0 {
reader = &progressReader{
@@ -119,7 +110,7 @@ func downloadFile(filepath, url string) error {
return err
}
fmt.Println() // New line after progress
fmt.Println()
return nil
}

View File

@@ -14,6 +14,7 @@ func TestArchiveTypeConstants(t *testing.T) {
{"TARGZ type", TARGZ, "TAR.GZ"},
{"TAR type", TAR, "TAR"},
{"GZIP type", GZIP, "GZIP"},
{"RAR type", RAR, "RAR"},
{"AUTO type", AUTO, "AUTO"},
}
@@ -90,4 +91,4 @@ func TestArchiveInfo(t *testing.T) {
if len(info.Files) != 1 {
t.Errorf("Files length = %d; want %d", len(info.Files), 1)
}
}
}

View File

@@ -12,7 +12,6 @@ func getPathCompletions(input string) []string {
input = "."
}
// Expand home directory
if strings.HasPrefix(input, "~") {
home, err := os.UserHomeDir()
if err == nil {
@@ -20,20 +19,16 @@ func getPathCompletions(input string) []string {
}
}
// Get the directory and file pattern
dir := filepath.Dir(input)
pattern := filepath.Base(input)
// If input ends with /, we want to list that directory
if strings.HasSuffix(input, string(filepath.Separator)) {
dir = input
pattern = ""
}
// Read directory
entries, err := os.ReadDir(dir)
if err != nil {
// If can't read, try current directory
entries, err = os.ReadDir(".")
if err != nil {
return []string{}
@@ -63,7 +58,6 @@ func getPathCompletions(input string) []string {
completions = append(completions, fullPath)
}
// Limit to 15 suggestions
if len(completions) > 15 {
completions = completions[:15]
}
@@ -85,13 +79,11 @@ func getArchiveCompletions(input string) []string {
archiveCompletions := []string{}
for _, path := range allCompletions {
// Keep directories
if strings.HasSuffix(path, string(filepath.Separator)) {
archiveCompletions = append(archiveCompletions, path)
continue
}
// Check if file has archive extension
ext := filepath.Ext(path)
if archiveExts[ext] {
archiveCompletions = append(archiveCompletions, path)

View File

@@ -228,7 +228,6 @@ func RunBatchExtractFlow() error {
fmt.Println(InfoStyle.Render(fmt.Sprintf("📂 Batch extracting %d archives...", len(configs))))
fmt.Println()
// Create batch config
batchConfig := &archiver.BatchExtractConfig{
Configs: configs,
Parallel: parallel,
@@ -246,7 +245,6 @@ func RunBatchExtractFlow() error {
errors := archiver.BatchExtract(batchConfig)
// Count successes
successCount := 0
for _, err := range errors {
if err == nil {

View File

@@ -21,7 +21,6 @@ func RunCompressFlow() error {
var verify bool
var compressionLevel string
// Get current working directory
cwd, _ := os.Getwd()
form := huh.NewForm(
@@ -124,14 +123,11 @@ func RunCompressFlow() error {
}
}
// Auto-generate output path if not provided
if outputPath == "" {
sourceName := filepath.Base(sourcePath)
// Remove trailing slashes
sourceName = strings.TrimSuffix(sourceName, string(filepath.Separator))
// Determine file extension based on archive type
var extension string
switch models.ArchiveType(archiveTypeStr) {
case models.ZIP:
@@ -146,7 +142,6 @@ func RunCompressFlow() error {
extension = ".zip"
}
// Create output path in current working directory
outputPath = filepath.Join(cwd, sourceName+extension)
fmt.Println(InfoStyle.Render(fmt.Sprintf("📝 Auto-generated output: %s", outputPath)))

75
internal/ui/remote.go Normal file
View File

@@ -0,0 +1,75 @@
package ui
import (
"fmt"
"zipprine/internal/fetcher"
"github.com/charmbracelet/huh"
)
func RunRemoteFetchFlow() error {
var url, destPath string
var overwrite, preservePerms bool
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("🌐 Remote Archive URL").
Description("HTTP/HTTPS URL to download archive from").
Placeholder("https://example.com/archive.zip").
Value(&url).
Validate(func(s string) error {
if s == "" {
return fmt.Errorf("URL cannot be empty")
}
if !fetcher.IsValidArchiveURL(s) {
return fmt.Errorf("URL does not appear to point to a supported archive format")
}
return nil
}),
huh.NewInput().
Title("📂 Destination Path").
Description("Where to extract the downloaded archive - Tab for completions").
Placeholder("/path/to/destination").
Value(&destPath).
Validate(func(s string) error {
if s == "" {
return fmt.Errorf("destination path cannot be empty")
}
return nil
}).
Suggestions(getDirCompletions("")),
),
huh.NewGroup(
huh.NewConfirm().
Title("⚠️ Overwrite Existing Files").
Description("Replace files if they already exist?").
Value(&overwrite).
Affirmative("Yes, overwrite").
Negative("No, skip"),
huh.NewConfirm().
Title("🔒 Preserve Permissions").
Description("Keep original file permissions?").
Value(&preservePerms).
Affirmative("Yes").
Negative("No"),
),
).WithTheme(huh.ThemeCatppuccin())
if err := form.Run(); err != nil {
return err
}
fmt.Println()
fmt.Println(InfoStyle.Render("🌐 Fetching remote archive..."))
if err := fetcher.FetchAndExtract(url, destPath, overwrite, preservePerms); err != nil {
return fmt.Errorf("failed to fetch and extract: %w", err)
}
return nil
}

View File

@@ -0,0 +1,17 @@
package version
import "fmt"
const (
Major = 1
Minor = 0
Patch = 3
)
func Version() string {
return fmt.Sprintf("%d.%d.%d", Major, Minor, Patch)
}
func FullVersion() string {
return fmt.Sprintf("Zipprine v%s", Version())
}

View File

@@ -0,0 +1,87 @@
package version
import (
"strings"
"testing"
)
func TestVersion(t *testing.T) {
v := Version()
if v == "" {
t.Error("Version() returned empty string")
}
parts := strings.Split(v, ".")
if len(parts) != 3 {
t.Errorf("Version() = %q; expected format X.Y.Z", v)
}
expected := "1.0.3"
if v != expected {
t.Errorf("Version() = %q; want %q", v, expected)
}
}
func TestFullVersion(t *testing.T) {
fv := FullVersion()
if fv == "" {
t.Error("FullVersion() returned empty string")
}
if !strings.Contains(fv, "Zipprine") {
t.Errorf("FullVersion() = %q; expected to contain 'Zipprine'", fv)
}
if !strings.Contains(fv, Version()) {
t.Errorf("FullVersion() = %q; expected to contain version %q", fv, Version())
}
expected := "Zipprine v1.0.3"
if fv != expected {
t.Errorf("FullVersion() = %q; want %q", fv, expected)
}
}
func TestVersionConstants(t *testing.T) {
if Major != 1 {
t.Errorf("Major = %d; want 1", Major)
}
if Minor != 0 {
t.Errorf("Minor = %d; want 0", Minor)
}
if Patch != 3 {
t.Errorf("Patch = %d; want 3", Patch)
}
}
func TestVersionFormat(t *testing.T) {
v := Version()
if strings.Contains(v, " ") {
t.Errorf("Version() contains spaces: %q", v)
}
if strings.HasPrefix(v, "v") {
t.Errorf("Version() should not have 'v' prefix: %q", v)
}
for i, c := range v {
if c != '.' && (c < '0' || c > '9') {
t.Errorf("Version() contains non-numeric character at position %d: %q", i, v)
}
}
}
func TestFullVersionFormat(t *testing.T) {
fv := FullVersion()
expectedPrefix := "Zipprine v"
if !strings.HasPrefix(fv, expectedPrefix) {
t.Errorf("FullVersion() should start with %q, got %q", expectedPrefix, fv)
}
if !strings.HasSuffix(fv, Version()) {
t.Errorf("FullVersion() should end with version %q, got %q", Version(), fv)
}
}