Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 136 additions & 4 deletions cmd/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package cmd
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
Expand Down Expand Up @@ -63,6 +64,16 @@ Examples:
return err
}

// Get recursive and includeHidden flags
recursive, _ := cmd.Flags().GetBool("recursive")
includeHidden, _ := cmd.Flags().GetBool("include-hidden")

// Expand directories if needed
filePairs, err = expandDirectories(filePairs, recursive, includeHidden)
if err != nil {
return err
}

// Get tags from flags
tagsFlag, err := cmd.Flags().GetString("tags")
if err != nil {
Expand Down Expand Up @@ -138,15 +149,61 @@ Examples:
fmt.Printf("Processing: %s\n", pair.Source)
}

// Check if file exists and that it is not a directory or symlink
fileInfo, err := fs.VerifyFileAndReturnFileInfo(pair.Source)
// Determine path type and handle accordingly
fileInfo, pathType, err := fs.GetPathInfo(pair.Source)
if err != nil {
errorMsg := fmt.Sprintf("✗ %s: %v", filepath.Base(pair.Source), err)
fmt.Println(errorMsg)
failedFiles = append(failedFiles, errorMsg)
continue
}

// Handle different path types
var actualSourcePath string
switch pathType {
case fs.PathTypeFile:
// Regular file - use as is
actualSourcePath = pair.Source

case fs.PathTypeSymlink:
// Resolve symlink and verify target is a regular file
targetPath, targetInfo, targetType, err := fs.ResolveSymlink(pair.Source)
if err != nil {
errorMsg := fmt.Sprintf("✗ %s: %v", filepath.Base(pair.Source), err)
fmt.Println(errorMsg)
failedFiles = append(failedFiles, errorMsg)
continue
}

if targetType != fs.PathTypeFile {
errorMsg := fmt.Sprintf("✗ %s: symlink target is not a regular file", filepath.Base(pair.Source))
fmt.Println(errorMsg)
failedFiles = append(failedFiles, errorMsg)
continue
}

// Use the resolved target path for processing
actualSourcePath = targetPath
fileInfo = targetInfo

if verbose {
fmt.Printf(" Resolved symlink: %s → %s\n", pair.Source, targetPath)
}

case fs.PathTypeDir:
// Directories should have been expanded already
errorMsg := fmt.Sprintf("✗ %s: unexpected directory in processing loop", filepath.Base(pair.Source))
fmt.Println(errorMsg)
failedFiles = append(failedFiles, errorMsg)
continue

default:
errorMsg := fmt.Sprintf("✗ %s: unsupported file type", filepath.Base(pair.Source))
fmt.Println(errorMsg)
failedFiles = append(failedFiles, errorMsg)
continue
}

// Get file size in human-readable format
sizeInBytes := fileInfo.Size()
sizeReadable := util.HumanReadableSize(sizeInBytes)
Expand All @@ -163,7 +220,7 @@ Examples:

// Process the file and store chunks - using the appropriate chunking function
var chunkRefs []config.ChunkRef
chunkRefs, err = chunk.ChunkFile(ctx, pair.Source, chunkSize, vaultRoot, passphrase, progressMgr)
chunkRefs, err = chunk.ChunkFile(ctx, actualSourcePath, chunkSize, vaultRoot, passphrase, progressMgr)

if err != nil {
errorMsg := fmt.Sprintf("✗ %s: chunking failed - %v", filepath.Base(pair.Source), err)
Expand Down Expand Up @@ -357,15 +414,90 @@ func parseFileArguments(args []string) ([]FilePair, error) {
return pairs, nil
}

// expandDirectories expands directories into file pairs if recursive flag is set
func expandDirectories(pairs []FilePair, recursive bool, includeHidden bool) ([]FilePair, error) {
var expandedPairs []FilePair

for _, pair := range pairs {
// Get path info to determine type
fileInfo, pathType, err := fs.GetPathInfo(pair.Source)
if err != nil {
return nil, err
}

switch pathType {
case fs.PathTypeFile:
// Regular file - add as is
expandedPairs = append(expandedPairs, pair)

case fs.PathTypeSymlink:
// Symlink - will be handled in processing loop, add as is
expandedPairs = append(expandedPairs, pair)

case fs.PathTypeDir:
// Directory - expand if recursive, otherwise error
if !recursive {
return nil, fmt.Errorf("'%s' is a directory. Use --recursive flag to add directories", pair.Source)
}

// Walk the directory tree
err := filepath.WalkDir(pair.Source, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}

// Skip hidden files/directories if includeHidden is false
if fs.ShouldSkipHidden(d.Name(), includeHidden) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}

// Only add regular files and symlinks
if !d.IsDir() {
// Compute relative path from source directory
relPath, err := filepath.Rel(pair.Source, path)
if err != nil {
return fmt.Errorf("failed to compute relative path: %v", err)
}

// Preserve directory structure in destination
destPath := filepath.Join(pair.Destination, relPath)

expandedPairs = append(expandedPairs, FilePair{
Source: path,
Destination: destPath,
})
}

return nil
})

if err != nil {
return nil, fmt.Errorf("error walking directory '%s': %v", pair.Source, err)
}

default:
return nil, fmt.Errorf("'%s' is not a regular file, directory, or symlink", pair.Source)
}

_ = fileInfo // fileInfo might be used for verbose output later
}

return expandedPairs, nil
}

func init() {
rootCmd.AddCommand(addCmd)

// Optional flags for the add command
addCmd.Flags().BoolP("force", "f", false, "Force add without confirmation")
addCmd.Flags().StringP("tags", "t", "", "Comma-separated tags to associate with the file")
addCmd.Flags().StringP("passphrase-value", "p", "", "Passphrase for encrypted vault (if required)")
addCmd.Flags().BoolP("recursive", "r", false, "Recursively add directories")
addCmd.Flags().BoolP("include-hidden", "H", false, "Include hidden files and directories")
}

//TODO: Add support for directories and symlinks
//TODO: Need to check how symlinks will be handled
//TODO: Interactive mode with real time progress indicators
3 changes: 3 additions & 0 deletions internal/chunk/chunkFile.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ func ChunkFile(ctx context.Context, filePath string, chunkSize int64, vaultRoot
return nil, fmt.Errorf("failed to initialize deduplication manager: %v", err)
}

// Set progress manager for coordinated output
dedupManager.SetProgressManager(progressMgr)

chunkRefs, err := processFileChunks(ctx, file, chunkSize, *vaultConfig, passphrase, dedupManager, progressMgr)
if err != nil {
return nil, err
Expand Down
30 changes: 22 additions & 8 deletions internal/deduplication/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,15 @@ import (

// Manager handles deduplication operations for a vault
type Manager struct {
vaultRoot string
config config.DeduplicationConfig
index *DeduplicationIndex
vaultRoot string
config config.DeduplicationConfig
index *DeduplicationIndex
progressMgr ProgressManager
}

// ProgressManager is an interface for progress reporting
type ProgressManager interface {
PrintVerbose(format string, args ...interface{})
}

// NewManager creates a new deduplication manager
Expand All @@ -23,12 +29,18 @@ func NewManager(vaultRoot string, dedupConfig config.DeduplicationConfig) (*Mana
}

return &Manager{
vaultRoot: vaultRoot,
config: dedupConfig,
index: index,
vaultRoot: vaultRoot,
config: dedupConfig,
index: index,
progressMgr: nil, // Will be set later if needed
}, nil
}

// SetProgressManager sets the progress manager for verbose output
func (m *Manager) SetProgressManager(pm ProgressManager) {
m.progressMgr = pm
}

// ProcessChunk processes a chunk for deduplication
// Returns: (chunkRef, deduplicated, error)
func (m *Manager) ProcessChunk(chunkRef config.ChunkRef, chunkData []byte, storageHash string) (config.ChunkRef, bool, error) {
Expand All @@ -55,8 +67,10 @@ func (m *Manager) ProcessChunk(chunkRef config.ChunkRef, chunkData []byte, stora
if deduplicated {
// Chunk already exists, no need to store it again
chunkRef.Deduplicated = true
fmt.Printf(" └─ Deduplicated chunk %s (ref count: %d)\n",
chunkRef.Hash[:12], entry.RefCount)
if m.progressMgr != nil {
m.progressMgr.PrintVerbose(" └─ Deduplicated chunk %s (ref count: %d)\n",
chunkRef.Hash[:12], entry.RefCount)
}
} else {
// New chunk, store it
if err := m.storeChunk(storageHash, chunkData); err != nil {
Expand Down
62 changes: 62 additions & 0 deletions internal/fs/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
)

// EnsureDirectory ensures a directory exists, creating it if necessary
Expand Down Expand Up @@ -89,3 +90,64 @@ func VerifyFileAndReturnFile(filePath string) (*os.File, error) {
}
return file, nil
}

// PathType represents the type of a file system path
type PathType int

const (
PathTypeFile PathType = iota
PathTypeDir
PathTypeSymlink
PathTypeOther
)

// GetPathInfo returns file info and the type of path (file/dir/symlink)
func GetPathInfo(path string) (os.FileInfo, PathType, error) {
// Use Lstat to get info about the path itself (not following symlinks)
fileInfo, err := os.Lstat(path)
if err != nil {
if os.IsNotExist(err) {
return nil, PathTypeOther, fmt.Errorf("path does not exist: %s", path)
}
return nil, PathTypeOther, fmt.Errorf("error accessing path: %v", err)
}

// Determine path type
mode := fileInfo.Mode()
switch {
case mode&os.ModeSymlink != 0:
return fileInfo, PathTypeSymlink, nil
case mode.IsDir():
return fileInfo, PathTypeDir, nil
case mode.IsRegular():
return fileInfo, PathTypeFile, nil
default:
return fileInfo, PathTypeOther, fmt.Errorf("unsupported file type: %s", path)
}
}

// ResolveSymlink resolves a symlink to its target path and returns the target's info and type
func ResolveSymlink(symlinkPath string) (targetPath string, targetInfo os.FileInfo, targetType PathType, err error) {
// Resolve the symlink
targetPath, err = filepath.EvalSymlinks(symlinkPath)
if err != nil {
return "", nil, PathTypeOther, fmt.Errorf("failed to resolve symlink: %v", err)
}

// Get info about the target
targetInfo, targetType, err = GetPathInfo(targetPath)
if err != nil {
return "", nil, PathTypeOther, fmt.Errorf("symlink target error: %v", err)
}

return targetPath, targetInfo, targetType, nil
}

// ShouldSkipHidden determines if a file/directory should be skipped based on hidden file rules
func ShouldSkipHidden(name string, includeHidden bool) bool {
if includeHidden {
return false
}
// Skip files/directories starting with '.' (hidden on Unix-like systems)
return strings.HasPrefix(name, ".")
}
Loading
Loading