Skip to content

Commit a5ef405

Browse files
committed
refactor: root command
1 parent bf228bf commit a5ef405

File tree

7 files changed

+2803
-182
lines changed

7 files changed

+2803
-182
lines changed

cmd/args.go

Lines changed: 195 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"os"
77
"path/filepath"
88
"strings"
9+
"syscall"
910
)
1011

1112
// CLIArgs represents the parsed command line arguments for the enhanced CLI interface
@@ -73,29 +74,58 @@ func NewCLIArgs() *CLIArgs {
7374
// ValidateURL validates that the provided URL is valid and accessible
7475
func ValidateURL(rawURL string) error {
7576
if rawURL == "" {
76-
return fmt.Errorf("URL cannot be empty")
77+
return fmt.Errorf("Error: No URL provided\n\nA download URL is required to proceed.\n\nUsage:\n dr <URL>\n dr --url <URL>\n\nExamples:\n dr https://example.com/file.zip\n dr ftp://files.example.com/data.tar.gz")
78+
}
79+
80+
// Trim whitespace that might cause parsing issues
81+
rawURL = strings.TrimSpace(rawURL)
82+
if rawURL == "" {
83+
return fmt.Errorf("Error: Empty URL provided\n\nPlease provide a valid URL.\n\nExamples:\n dr https://example.com/file.zip\n dr ftp://files.example.com/data.tar.gz")
7784
}
7885

7986
parsedURL, err := url.ParseRequestURI(rawURL)
8087
if err != nil {
81-
return fmt.Errorf("invalid URL format: %v\n\nPlease provide a complete URL including protocol.\n\nExamples:\n https://example.com/file.zip\n ftp://files.example.com/data.tar.gz", err)
88+
// Provide more specific error messages based on common mistakes
89+
if !strings.Contains(rawURL, "://") {
90+
return fmt.Errorf("Error: Invalid URL format - missing protocol\n\nURL: %s\n\nThe URL must include a protocol (http://, https://, or ftp://).\n\nDid you mean:\n https://%s\n http://%s", rawURL, rawURL, rawURL)
91+
}
92+
return fmt.Errorf("Error: Invalid URL format\n\nURL: %s\nReason: %v\n\nPlease provide a complete URL including protocol.\n\nExamples:\n https://example.com/file.zip\n ftp://files.example.com/data.tar.gz", rawURL, err)
8293
}
8394

8495
// Check if scheme is supported
8596
scheme := strings.ToLower(parsedURL.Scheme)
86-
if scheme != "http" && scheme != "https" && scheme != "ftp" {
87-
return fmt.Errorf("unsupported URL scheme '%s'\n\nSupported schemes: http, https, ftp", scheme)
97+
supportedSchemes := []string{"http", "https", "ftp", "ftps"}
98+
isSupported := false
99+
for _, supported := range supportedSchemes {
100+
if scheme == supported {
101+
isSupported = true
102+
break
103+
}
104+
}
105+
106+
if !isSupported {
107+
return fmt.Errorf("Error: Unsupported URL protocol '%s'\n\nURL: %s\n\nSupported protocols: %s\n\nExamples:\n https://example.com/file.zip\n ftp://files.example.com/data.tar.gz", scheme, rawURL, strings.Join(supportedSchemes, ", "))
88108
}
89109

90110
// Check if host is present
91111
if parsedURL.Host == "" {
92-
return fmt.Errorf("URL must include a hostname\n\nExample: https://example.com/file.zip")
112+
return fmt.Errorf("Error: Invalid URL - missing hostname\n\nURL: %s\n\nThe URL must include a hostname after the protocol.\n\nExample: https://example.com/file.zip", rawURL)
113+
}
114+
115+
// Validate hostname format (basic check)
116+
if strings.Contains(parsedURL.Host, " ") {
117+
return fmt.Errorf("Error: Invalid hostname in URL\n\nURL: %s\n\nHostnames cannot contain spaces.\n\nExample: https://example.com/file.zip", rawURL)
118+
}
119+
120+
// Check for suspicious or malformed URLs
121+
if strings.HasPrefix(parsedURL.Host, ".") || strings.HasSuffix(parsedURL.Host, ".") {
122+
return fmt.Errorf("Error: Invalid hostname format\n\nURL: %s\n\nHostname cannot start or end with a dot.\n\nExample: https://example.com/file.zip", rawURL)
93123
}
94124

95125
return nil
96126
}
97127

98-
// ValidatePath validates that the provided path is valid and writable
128+
// ValidatePath validates that the provided path is valid, writable, and has sufficient disk space
99129
func ValidatePath(path string) error {
100130
if path == "" {
101131
return nil // Empty path is valid (will use current directory)
@@ -104,7 +134,12 @@ func ValidatePath(path string) error {
104134
// Resolve relative paths
105135
absPath, err := filepath.Abs(path)
106136
if err != nil {
107-
return fmt.Errorf("invalid path '%s': %v", path, err)
137+
return fmt.Errorf("Error: Invalid path format\n\nPath: %s\nReason: %v\n\nPlease provide a valid file or directory path.", path, err)
138+
}
139+
140+
// Check for invalid characters in path (basic validation)
141+
if strings.Contains(path, "\x00") {
142+
return fmt.Errorf("Error: Invalid path - contains null character\n\nPath: %s\n\nPaths cannot contain null characters.", path)
108143
}
109144

110145
// Check if path exists
@@ -115,25 +150,37 @@ func ValidatePath(path string) error {
115150
parentDir := filepath.Dir(absPath)
116151
parentInfo, parentErr := os.Stat(parentDir)
117152
if parentErr != nil {
118-
return fmt.Errorf("parent directory '%s' does not exist", parentDir)
153+
if os.IsNotExist(parentErr) {
154+
return fmt.Errorf("Error: Parent directory does not exist\n\nPath: %s\nParent directory: %s\n\nPlease create the directory first or choose an existing location.", path, parentDir)
155+
}
156+
return fmt.Errorf("Error: Cannot access parent directory\n\nPath: %s\nParent directory: %s\nReason: %v", path, parentDir, parentErr)
119157
}
120158
if !parentInfo.IsDir() {
121-
return fmt.Errorf("parent path '%s' is not a directory", parentDir)
159+
return fmt.Errorf("Error: Parent path is not a directory\n\nPath: %s\nParent path: %s\n\nThe parent path must be a directory, not a file.", path, parentDir)
122160
}
123-
// Check write permission on parent directory
124-
return checkWritePermission(parentDir)
161+
// Check write permission and disk space on parent directory
162+
if err := checkWritePermission(parentDir); err != nil {
163+
return err
164+
}
165+
return checkDiskSpace(parentDir)
125166
}
126-
return fmt.Errorf("cannot access path '%s': %v", path, err)
167+
return fmt.Errorf("Error: Cannot access path\n\nPath: %s\nReason: %v\n\nPlease check that the path exists and you have permission to access it.", path, err)
127168
}
128169

129-
// If path exists and is a directory, check write permission
170+
// If path exists and is a directory, check write permission and disk space
130171
if info.IsDir() {
131-
return checkWritePermission(absPath)
172+
if err := checkWritePermission(absPath); err != nil {
173+
return err
174+
}
175+
return checkDiskSpace(absPath)
132176
}
133177

134-
// If path exists and is a file, check write permission on parent directory
178+
// If path exists and is a file, check write permission and disk space on parent directory
135179
parentDir := filepath.Dir(absPath)
136-
return checkWritePermission(parentDir)
180+
if err := checkWritePermission(parentDir); err != nil {
181+
return err
182+
}
183+
return checkDiskSpace(parentDir)
137184
}
138185

139186
// checkWritePermission checks if the directory is writable
@@ -142,33 +189,90 @@ func checkWritePermission(dir string) error {
142189
tempFile := filepath.Join(dir, ".durable-resume-write-test")
143190
file, err := os.Create(tempFile)
144191
if err != nil {
145-
return fmt.Errorf("directory '%s' is not writable: %v", dir, err)
192+
if os.IsPermission(err) {
193+
return fmt.Errorf("Error: Permission denied\n\nDirectory: %s\n\nYou don't have write permission to this directory.\n\nSolutions:\n - Choose a different directory (e.g., your home directory)\n - Run with appropriate permissions\n - Check directory ownership and permissions", dir)
194+
}
195+
return fmt.Errorf("Error: Cannot write to directory\n\nDirectory: %s\nReason: %v\n\nPlease ensure the directory is writable.", dir, err)
146196
}
147197
file.Close()
148198
os.Remove(tempFile) // Clean up
149199
return nil
150200
}
151201

152-
// ValidateSegmentParams validates segment-related parameters
202+
// checkDiskSpace checks if there's sufficient disk space available
203+
func checkDiskSpace(dir string) error {
204+
// Get disk usage information
205+
var stat syscall.Statfs_t
206+
err := syscall.Statfs(dir, &stat)
207+
if err != nil {
208+
// If we can't get disk space info, just warn but don't fail
209+
// This might happen on some filesystems or in containers
210+
return nil
211+
}
212+
213+
// Calculate available space in bytes
214+
availableBytes := stat.Bavail * uint64(stat.Bsize)
215+
216+
// Require at least 100MB of free space as a safety margin
217+
const minRequiredBytes = 100 * 1024 * 1024 // 100MB
218+
219+
if availableBytes < minRequiredBytes {
220+
return fmt.Errorf("Error: Insufficient disk space\n\nDirectory: %s\nAvailable space: %s\nMinimum required: %s\n\nPlease free up disk space or choose a different location.",
221+
dir,
222+
formatBytes(availableBytes),
223+
formatBytes(minRequiredBytes))
224+
}
225+
226+
// Warn if less than 1GB available
227+
const warnThresholdBytes = 1024 * 1024 * 1024 // 1GB
228+
if availableBytes < warnThresholdBytes {
229+
fmt.Fprintf(os.Stderr, "Warning: Low disk space\n\nDirectory: %s\nAvailable space: %s\n\nConsider freeing up space before downloading large files.\n\n",
230+
dir, formatBytes(availableBytes))
231+
}
232+
233+
return nil
234+
}
235+
236+
// formatBytes formats byte count as human-readable string
237+
func formatBytes(bytes uint64) string {
238+
const unit = 1024
239+
if bytes < unit {
240+
return fmt.Sprintf("%d B", bytes)
241+
}
242+
div, exp := uint64(unit), 0
243+
for n := bytes / unit; n >= unit; n /= unit {
244+
div *= unit
245+
exp++
246+
}
247+
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
248+
}
249+
250+
// ValidateSegmentParams validates segment-related parameters with enhanced error messages
153251
func ValidateSegmentParams(segments int, segmentSize int64, noSegments bool) error {
154252
if noSegments {
155253
return nil // No validation needed when segments are disabled
156254
}
157255

158256
if segments < 1 {
159-
return fmt.Errorf("segment count must be at least 1, got %d\n\nSuggestion: Use --segments 4 for optimal performance", segments)
257+
return fmt.Errorf("Error: Invalid segment count\n\nValue: %d\n\nSegment count must be at least 1.\n\nRecommended values:\n --segments 4 (default, good for most files)\n --segments 8 (for large files or fast connections)\n --segments 1 (single-threaded download)", segments)
160258
}
161259

162260
if segments > 32 {
163-
return fmt.Errorf("segment count too high: %d\n\nUsing too many segments may hurt performance.\nSuggestion: Use --segments 8 or fewer", segments)
261+
return fmt.Errorf("Error: Too many segments\n\nValue: %d\nMaximum: 32\n\nUsing too many segments can hurt performance and may be blocked by servers.\n\nRecommended values:\n --segments 4 (default)\n --segments 8 (for large files)\n --segments 16 (maximum for most use cases)", segments)
164262
}
165263

166264
if segmentSize < 0 {
167-
return fmt.Errorf("segment size cannot be negative: %d", segmentSize)
265+
return fmt.Errorf("Error: Invalid segment size\n\nValue: %d bytes\n\nSegment size cannot be negative.\n\nValid options:\n --segment-size 0 (auto-calculate based on file size)\n --segment-size 1048576 (1MB segments)\n --segment-size 10485760 (10MB segments)", segmentSize)
168266
}
169267

170268
if segmentSize > 0 && segmentSize < 1024 {
171-
return fmt.Errorf("segment size too small: %d bytes\n\nMinimum recommended size: 1024 bytes (1KB)", segmentSize)
269+
return fmt.Errorf("Error: Segment size too small\n\nValue: %d bytes\nMinimum: 1024 bytes (1KB)\n\nSmall segments create excessive overhead.\n\nRecommended values:\n --segment-size 0 (auto-calculate)\n --segment-size 1048576 (1MB)\n --segment-size 10485760 (10MB)", segmentSize)
270+
}
271+
272+
// Warn about very large segment sizes
273+
const maxRecommendedSize = 100 * 1024 * 1024 // 100MB
274+
if segmentSize > maxRecommendedSize {
275+
fmt.Fprintf(os.Stderr, "Warning: Very large segment size\n\nValue: %s\n\nLarge segments may reduce the benefits of parallel downloading.\nConsider using smaller segments (1-10MB) for better performance.\n\n", formatBytes(uint64(segmentSize)))
172276
}
173277

174278
return nil
@@ -184,7 +288,7 @@ func ResolveOutputPath(url, output, name string) (string, error) {
184288
// Extract filename from URL
185289
urlFilename := extractFilenameFromURL(url)
186290
if urlFilename == "" {
187-
return "", fmt.Errorf("cannot determine filename from URL '%s'\n\nPlease specify a filename using --name flag", url)
291+
return "", fmt.Errorf("Error: Cannot determine filename\n\nURL: %s\n\nThe URL doesn't contain a clear filename.\n\nSolutions:\n dr %s --name myfile.ext\n dr %s --output /path/to/myfile.ext\n\nExamples:\n dr %s --name download.zip\n dr %s --output ./downloads/file.bin", url, url, url, url, url)
188292
}
189293
return urlFilename, nil
190294
}
@@ -197,15 +301,15 @@ func ResolveOutputPath(url, output, name string) (string, error) {
197301
if filename == "" {
198302
filename = extractFilenameFromURL(url)
199303
if filename == "" {
200-
return "", fmt.Errorf("cannot determine filename from URL '%s'\n\nPlease specify a filename using --name flag", url)
304+
return "", fmt.Errorf("Error: Cannot determine filename for directory output\n\nURL: %s\nDirectory: %s\n\nThe URL doesn't contain a clear filename.\n\nSolution:\n dr %s --output %s --name myfile.ext\n\nExample:\n dr %s --output %s --name download.zip", url, output, url, output, url, output)
201305
}
202306
}
203307
return filepath.Join(output, filename), nil
204308
}
205309

206310
// Case 3: Output is a file path (existing file or new file path)
207311
if name != "" {
208-
return "", fmt.Errorf("cannot specify both file path (%s) and filename (%s)\n\nUse either:\n --output /path/to/file.ext\n --output /path/to/dir --name file.ext", output, name)
312+
return "", fmt.Errorf("Error: Conflicting output specification\n\nFile path: %s\nFilename: %s\n\nYou cannot specify both a complete file path and a separate filename.\n\nChoose one:\n dr %s --output %s\n dr %s --output %s --name %s", output, name, url, output, url, filepath.Dir(output), name)
209313
}
210314

211315
return output, nil
@@ -231,8 +335,74 @@ func extractFilenameFromURL(rawURL string) string {
231335
return filename
232336
}
233337

338+
// ValidateFlagCombinations checks for incompatible flag combinations
339+
func ValidateFlagCombinations(args *CLIArgs) error {
340+
var conflicts []string
341+
342+
// Check for quiet and verbose conflict
343+
if args.Quiet && args.Verbose {
344+
conflicts = append(conflicts, "Cannot use both --quiet and --verbose flags simultaneously")
345+
}
346+
347+
// Check for no-segments with segment-specific flags
348+
// Note: We can't easily detect if segments was explicitly set vs default here,
349+
// so we'll only flag obvious conflicts where segments is significantly different from default
350+
if args.NoSegments {
351+
// Only conflict if segments is explicitly set to something other than the default
352+
// This is a limitation - ideally we'd track which flags were explicitly set
353+
if args.Segments != 4 && args.Segments != 0 && args.Segments != 1 {
354+
conflicts = append(conflicts, "Cannot use --no-segments with --segments flag")
355+
}
356+
if args.SegmentSize != 0 {
357+
conflicts = append(conflicts, "Cannot use --no-segments with --segment-size flag")
358+
}
359+
}
360+
361+
// Check for conflicting output specifications
362+
if args.Output != "" && args.Name != "" {
363+
// This is only a conflict if output appears to be a file path, not a directory
364+
if !strings.HasSuffix(args.Output, "/") {
365+
// Check if output path exists and is a file
366+
if info, err := os.Stat(args.Output); err == nil && !info.IsDir() {
367+
conflicts = append(conflicts, "Cannot specify both file path (--output) and filename (--name) when output is a file")
368+
}
369+
// Also check if output path looks like a file (has extension)
370+
if filepath.Ext(args.Output) != "" {
371+
conflicts = append(conflicts, "Cannot specify both file path (--output) and filename (--name) when output appears to be a file path")
372+
}
373+
}
374+
}
375+
376+
// Check for unreasonable segment combinations
377+
if !args.NoSegments && args.Segments > 1 && args.SegmentSize > 0 {
378+
// Warn if segment size is very large relative to number of segments
379+
totalEstimatedSize := int64(args.Segments) * args.SegmentSize
380+
if totalEstimatedSize > 10*1024*1024*1024 { // 10GB
381+
fmt.Fprintf(os.Stderr, "Warning: Large estimated download size\n\nSegments: %d\nSegment size: %s\nEstimated total: %s\n\nThis configuration may not be suitable for smaller files.\nConsider using --segment-size 0 for automatic sizing.\n\n",
382+
args.Segments, formatBytes(uint64(args.SegmentSize)), formatBytes(uint64(totalEstimatedSize)))
383+
}
384+
}
385+
386+
// Report conflicts
387+
if len(conflicts) > 0 {
388+
errorMsg := "Error: Incompatible flag combinations detected\n\n"
389+
for i, conflict := range conflicts {
390+
errorMsg += fmt.Sprintf("%d. %s\n", i+1, conflict)
391+
}
392+
errorMsg += "\nPlease review your command and remove conflicting flags.\n\nFor help: dr --help"
393+
return fmt.Errorf(errorMsg)
394+
}
395+
396+
return nil
397+
}
398+
234399
// ToDownloadConfig converts CLIArgs to DownloadConfig with validation and resolution
235400
func (args *CLIArgs) ToDownloadConfig() (*DownloadConfig, error) {
401+
// Validate flag combinations first
402+
if err := ValidateFlagCombinations(args); err != nil {
403+
return nil, err
404+
}
405+
236406
// Validate URL
237407
if err := ValidateURL(args.URL); err != nil {
238408
return nil, err

0 commit comments

Comments
 (0)