feat: Comprehensive test suite and documentation update

- Added complete test suite with srfi-64
- Fixed fold-tree implementation in tree module
- Renamed canonicalize-path to normalize-path
- Resolved module import conflicts
- Updated all documentation files
- Added proper test infrastructure
- Enhanced path handling and tree operations
This commit is contained in:
GLENN THOMPSON 2024-12-06 16:08:27 +03:00
parent 08f06c6b47
commit 478ba3ae92
20 changed files with 1082 additions and 277 deletions

View File

@ -30,7 +30,7 @@
1. Channel Configuration
- Location: `.guix-channel/`
- Status: Configured for distribution
- URL: <https://codeberg.org/glenneth/stash>~~~~
- URL: <https://codeberg.org/glenneth/stash>
- Branch: main
2. Package Definition
@ -76,50 +76,43 @@
- Updated package definition for channel distribution
- Added minimal-package.scm for local development
3. Testing Improvements
- Implemented comprehensive test suite for paths and tree modules
- Fixed fold-tree implementation in tree module
- Resolved module import conflicts
- Fixed declarative module warnings
- Added proper test coverage for path normalization
- Improved test organization and clarity
### Next Steps
1. Testing
- Implement comprehensive test suite
- Add more test cases for recursive mode
2. Documentation
1. Documentation
- Add API documentation for modules
- Include more advanced usage examples
3. Features
2. Features
- Enhance conflict resolution
- Add backup functionality
- Improve error reporting
### Known Issues
1. Warnings
- Intermittent warning about canonicalize-path override
- Auto-compilation messages (resolved with GUILE_AUTO_COMPILE=0)
No major issues currently. All previous warnings have been resolved:
- Fixed warning about canonicalize-path override by renaming to normalize-path
- Fixed read-string import conflict in conflict module
- Fixed declarative module warning in test runner
- Auto-compilation messages resolved with GUILE_AUTO_COMPILE=0
### Repository Structure
```sh
stash/
├── .guix-channel/
│ └── stash/
│ └── packages/
│ └── stash.scm
├── modules/
│ └── stash/
│ ├── colors.scm
│ ├── conflict.scm
│ ├── file-ops.scm
│ ├── help.scm
│ ├── log.scm
│ ├── package.scm
│ ├── paths.scm
│ └── tree.scm
├── test-source/
├── .gitignore
├── README.md
├── USER_GUIDE.md
├── channels.scm.example
├── minimal-package.scm
└── stash.scm
```
1. Root Directory
- stash.scm: Main executable
- run-tests.sh: Test runner script
- .dev-notes.md: Development notes and tracking
- README.md: Project overview
- USER_GUIDE.md: Detailed user documentation
2. Modules
- modules/stash/: Core functionality modules
- tests/: Test suite and helpers
- .guix-channel/: Guix distribution configuration

108
DEVLOG.md
View File

@ -22,41 +22,48 @@ The project is organized into several modules:
## Recent Changes
### Testing Infrastructure
- Implemented comprehensive test suite with srfi-64
- Added test-helpers module for common test operations
- Created test cases for all core modules
- Fixed test runner to use primitive-load for better module handling
- Added proper cleanup of test directories
### Path Handling Improvements
- Enhanced `canonicalize-path` function to handle:
- Renamed `canonicalize-path` to `normalize-path` for clarity
- Enhanced path normalization to handle:
- Dot (.) and dot-dot (..) notation
- Home directory expansion (~)
- Absolute and relative paths
- Improved path resolution for both dot syntax and explicit paths
- Redundant path separators
- Improved path comparison with proper normalization
- Added robust path resolution for symlinks
### Symlink Creation
### Tree Operations Enhancement
- Rewrote `plan-operations` function to handle:
- Dot syntax (stash .)
- Explicit source/target paths
- Fixed symlink creation to maintain correct directory structure
- Added support for creating parent directories as needed
- Implemented efficient tree traversal with `fold-tree`
- Added proper directory scanning with ignore patterns
- Improved symlink planning with cycle detection
- Enhanced tree comparison functionality
- Added support for recursive operations
- Fixed directory structure preservation
### Package Management
### Module Organization
- Implemented package record type for managing:
- Package name
- Source path
- Target path
- Ignore patterns
- Added support for reading ignore patterns from:
- .stash-local-ignore
- .stash-global-ignore
- Resolved import conflicts between modules
- Fixed warnings about duplicate bindings
- Improved module interface consistency
- Enhanced error handling and reporting
- Added proper documentation strings
### Testing
### Distribution
- Added comprehensive test suite
- Implemented test cases for:
- Dot syntax stashing
- Explicit path stashing
- Path resolution
- Symlink creation
- Configured Guix channel for distribution
- Updated package definition
- Added proper version tracking
- Improved installation documentation
## Current Operation
@ -92,37 +99,36 @@ Stash now supports three main modes of operation:
- Interactively prompts for target directory
- Ideal for first-time users and exploratory stashing
### Symlink Creation Process
### Tree Traversal Process
1. Determines source and target paths
2. Creates parent directories if needed
3. Removes existing symlink/directory if present
4. Creates new symlink pointing to correct location
1. Scans source directory recursively
2. Builds tree representation
3. Applies ignore patterns
4. Plans symlink operations
5. Validates tree structure
6. Creates necessary symlinks
### Path Resolution
### Error Handling
- Handles both absolute and relative paths
- Expands home directory references
- Resolves dot and dot-dot notation
- Maintains correct directory structure
- Detects circular symlinks
- Validates path existence
- Checks permissions
- Handles conflicts gracefully
- Provides detailed error messages
## Known Issues
## Next Steps
- Warning about overriding core binding `canonicalize-path`
- This is expected behavior and doesn't affect functionality
- Could be addressed in future by renaming the function
1. API Documentation
- Document module interfaces
- Add usage examples
- Create API reference
## Future Plans
2. Feature Enhancements
- Add backup functionality
- Enhance conflict resolution
- Improve error reporting
1. Implement more robust conflict resolution
2. Add comprehensive documentation
3. Optimize path handling
4. Address module import warnings
5. Add user configuration options
6. Enhance cross-platform support
## Dependencies
- Guile Scheme 3.0.9
- Standard Guile libraries
- Custom modules for path handling and file operations
3. Performance Optimization
- Profile tree operations
- Optimize path handling
- Improve memory usage

57
Makefile Normal file
View File

@ -0,0 +1,57 @@
.PHONY: test test-paths test-package test-tree test-file-ops test-conflict clean-test
# Set up environment
export GUILE_LOAD_PATH := $(PWD):$(GUILE_LOAD_PATH)
# Default target
test: clean-test
@echo "Running all tests..."
@./run-tests.sh
# Individual test targets
test-paths:
@echo "Running path tests..."
@guile tests/paths-test.scm
test-package:
@echo "Running package tests..."
@guile tests/package-test.scm
test-tree:
@echo "Running tree tests..."
@guile tests/tree-test.scm
test-file-ops:
@echo "Running file operations tests..."
@guile tests/file-ops-test.scm
test-conflict:
@echo "Running conflict tests..."
@guile tests/conflict-test.scm
# Clean temporary test files
clean-test:
@echo "Cleaning test files..."
@rm -f tests/*.log
@rm -rf /tmp/stash-test-*
# Show test coverage (requires guile-coverage)
coverage:
@echo "Generating test coverage report..."
@GUILE_LOAD_PATH="$(PWD):$$GUILE_LOAD_PATH" \
guile --coverage tests/run-tests.scm
@lcov --capture --directory . --output-file coverage.info
@genhtml coverage.info --output-directory coverage
.PHONY: help
help:
@echo "Available targets:"
@echo " test - Run all tests"
@echo " test-paths - Run path handling tests"
@echo " test-package - Run package management tests"
@echo " test-tree - Run tree operation tests"
@echo " test-file-ops- Run file operation tests"
@echo " test-conflict- Run conflict resolution tests"
@echo " clean-test - Clean temporary test files"
@echo " coverage - Generate test coverage report"
@echo " help - Show this help message"

View File

@ -4,27 +4,39 @@
## Installation
There are two ways to install Stash:
### Using Guix (Recommended)
### Method 1: Using Guix (Recommended)
1. Add the Stash channel to your `~/.config/guix/channels.scm`:
```scheme
(cons* (channel
(name 'stash)
(url "https://codeberg.org/glenneth/stash")
(branch "main"))
%default-channels)
```
2. Update your channels and install:
```sh
# Install from the local package definition
guix package --install-from-file=minimal-package.scm
guix pull
guix install stash
```
# Configure your shell environment:
3. Configure your shell environment:
```sh
# For Fish shell (add to ~/.config/fish/config.fish):
set -gx GUIX_PROFILE $HOME/.guix-profile
set -gx PATH $GUIX_PROFILE/bin $PATH
# For Bash (add to ~/.bashrc):
export GUIX_PROFILE="$HOME/.guix-profile"
. "$GUIX_PROFILE/etc/profile"
export GUIX_PROFILE=$HOME/.guix-profile
export PATH=$GUIX_PROFILE/bin:$PATH
# For Zsh (add to ~/.zshrc):
export GUIX_PROFILE="$HOME/.guix-profile"
. "$GUIX_PROFILE/etc/profile"
export GUIX_PROFILE=$HOME/.guix-profile
export PATH=$GUIX_PROFILE/bin:$PATH
```
### Method 2: Manual Installation
@ -170,21 +182,27 @@ Default ignore patterns:
```sh
stash/
├── stash.scm # Main entry point
├── .guix-channel/ # Guix channel configuration
│ └── stash/
│ └── packages/
│ └── stash.scm
├── modules/
│ └── stash/
│ ├── paths.scm # Path handling
│ ├── tree.scm # Tree operations
│ ├── package.scm # Package management
│ ├── file-ops.scm # File operations
│ ├── log.scm # Logging
│ ├── conflict.scm # Conflict resolution
│ ├── colors.scm # Terminal colors
│ └── help.scm # Help messages
├── README.md # Project overview
├── USER-GUIDE.md # Comprehensive user documentation
├── DEVLOG.md # Development log
└── LICENSE # GNU GPL v3
│ ├── colors.scm # Terminal colors
│ ├── conflict.scm # Conflict resolution
│ ├── file-ops.scm # File operations
│ ├── help.scm # Help messages
│ ├── log.scm # Logging utilities
│ ├── package.scm # Package information
│ ├── paths.scm # Path manipulation
│ └── tree.scm # Directory tree handling
├── tests/ # Test suite
│ ├── run-tests.sh
│ ├── test-helpers.scm
│ └── *-test.scm # Individual test files
├── README.md # Project overview
├── USER_GUIDE.md # Comprehensive documentation
└── stash.scm # Main executable
```
## Dependencies

View File

@ -208,28 +208,39 @@ stash --source ~/Videos --target /media/external/videos --recursive
### 1. Path Handling
- Supports home directory expansion (~)
- Handles both absolute and relative paths
- Maintains directory structure in target location
Stash provides robust path handling capabilities:
### 2. Symlink Management
- Supports home directory expansion (`~`)
- Handles relative paths intelligently
- Normalizes paths for consistent comparison
- Resolves symbolic links when needed
- Maintains proper path relationships in recursive operations
- Creates intermediate directories as needed
- Handles existing symlinks gracefully
- Preserves original file permissions
The path normalization ensures that:
- Redundant path separators are removed
- Parent directory references (`..`) are resolved
- Current directory references (`.`) are removed
- Paths are made relative when appropriate
- Symlinks are handled correctly in comparisons
### 3. Ignore Patterns
### 2. Directory Tree Operations
- Create `.stashignore` in source directory
- Add patterns similar to `.gitignore`
Stash includes sophisticated directory tree handling:
```sh
*.tmp
.DS_Store
node_modules/
```
- Recursive directory scanning
- Efficient tree traversal
- Intelligent ignore patterns (e.g., `.git/`, `*.bak`)
- Proper symlink planning and creation
- Conflict detection and resolution
### 4. Conflict Resolution
The tree operations ensure:
- All file relationships are preserved
- Circular symlinks are prevented
- Existing symlinks are handled appropriately
- Conflicts are detected early
- Directory structures are maintained
### 3. Conflict Resolution
- Automatically detects existing files/symlinks
- Interactive prompts for resolution

View File

@ -1,60 +1,57 @@
;; stash-conflict.scm --- Conflict resolution module for Stash
;; (define-module (stash conflict)
;; #:export (prompt-user-for-action handle-conflict))
;; ;; Import necessary modules
;; (use-modules (ice-9 rdelim)) ;; Import read-line function for reading user input
;; ;;; Conflict resolution handler with user options
;; (define (prompt-user-for-action)
;; "Prompt the user to decide how to handle a conflict: overwrite (o), skip (s), or cancel (c)."
;; (display "A conflict was detected. Choose action - Overwrite (o), Skip (s), or Cancel (c): ")
;; (let ((response (read-line)))
;; (cond
;; ((string-ci=? response "o") 'overwrite)
;; ((string-ci=? response "s") 'skip)
;; ((string-ci=? response "c") 'cancel)
;; (else
;; (display "Invalid input. Please try again.\n")
;; (prompt-user-for-action)))))
;; ;;; Helper function to handle conflicts
;; (define (handle-conflict target-source-dir source-dir delete-directory log-action)
;; "Handle conflicts when the target directory already exists."
;; (let ((choice (prompt-user-for-action)))
;; (cond
;; ((eq? choice 'overwrite)
;; (delete-directory target-source-dir)
;; (rename-file source-dir target-source-dir)
;; (display (format #f "Overwriting directory ~a.\n" target-source-dir))
;; (log-action (format #f "Overwritten directory: ~a" target-source-dir)))
;; ((eq? choice 'skip)
;; (display "Skipping move operation.\n")
;; (log-action (format #f "Skipped moving directory: ~a" target-source-dir)))
;; ((eq? choice 'cancel)
;; (display "Operation cancelled by user.\n")
;; (log-action "Operation cancelled by user.")
;; (exit 0)))))
(define-module (stash conflict)
#:export (prompt-user-for-action handle-conflict))
#:use-module (ice-9 ftw)
#:use-module (ice-9 rdelim) ;; For read-string
#:use-module (stash paths) ;; For canonicalize-path
#:export (has-conflict?
resolve-conflict
prompt-user-for-action
handle-conflict))
;; Import necessary modules
(use-modules (ice-9 rdelim)
(stash colors)) ;; Import the colors module
(use-modules (ice-9 rdelim)) ;; Import read-line function for reading user input
(define (copy-file-contents source target)
"Copy contents from source file to target file"
(let ((content (with-input-from-file source read-string)))
(with-output-to-file target
(lambda () (display content)))))
(define (has-conflict? source target)
"Check if there would be a conflict when moving source to target"
(and (file-exists? target)
(not (equal? (canonicalize-path source)
(canonicalize-path target)))))
(define (resolve-conflict source target strategy)
"Resolve a conflict between source and target using the specified strategy"
(case strategy
((backup)
(let ((backup-path (string-append target ".bak")))
(rename-file target backup-path)
(copy-file-contents source target)
#t))
((overwrite)
(when (file-exists? target)
(delete-file target))
(copy-file-contents source target)
#t)
((skip)
#t)
(else #f)))
;;; Conflict resolution handler with user options
(define (prompt-user-for-action)
"Prompt the user to decide how to handle a conflict: overwrite (o), skip (s), or cancel (c)."
(display (color-message "A conflict was detected. Choose action - Overwrite (o), Skip (s), or Cancel (c): " yellow-text)) ;; Yellow for prompt
(display "A conflict was detected. Choose action - Overwrite (o), Skip (s), or Cancel (c): ")
(let ((response (read-line)))
(cond
((string-ci=? response "o") 'overwrite)
((string-ci=? response "s") 'skip)
((string-ci=? response "c") 'cancel)
(else
(display (color-message "Invalid input. Please try again.\n" red-text)) ;; Red for invalid input
(display "Invalid input. Please try again.\n")
(prompt-user-for-action)))))
;;; Helper function to handle conflicts
@ -65,12 +62,12 @@
((eq? choice 'overwrite)
(delete-directory target-source-dir)
(rename-file source-dir target-source-dir)
(display (color-message (format #f "Overwriting directory ~a.\n" target-source-dir) green-text)) ;; Green for success
(display (format #f "Overwriting directory ~a.\n" target-source-dir))
(log-action (format #f "Overwritten directory: ~a" target-source-dir)))
((eq? choice 'skip)
(display (color-message "Skipping move operation.\n" green-text)) ;; Green for skipping
(display "Skipping move operation.\n")
(log-action (format #f "Skipped moving directory: ~a" target-source-dir)))
((eq? choice 'cancel)
(display (color-message "Operation cancelled by user.\n" yellow-text)) ;; Yellow for cancel
(display "Operation cancelled by user.\n")
(log-action "Operation cancelled by user.")
(exit 0)))))

View File

@ -4,7 +4,22 @@
#:use-module (stash log) ;; Import log-action, current-timestamp
#:use-module (stash paths) ;; Import expand-home, concat-path, ensure-config-path
#:use-module (stash conflict) ;; Import prompt-user-for-action, handle-conflict
#:export (move-source-to-target create-symlink delete-directory mkdir-p execute-operations file-is-symlink?))
#:use-module (ice-9 ftw) ;; For file tree walk
#:export (move-source-to-target
create-symlink
delete-directory
mkdir-p
execute-operations
file-is-symlink?
ensure-directory
copy-recursive
delete-recursive
make-file-executable
make-file-readable
file-is-executable?
file-is-readable?
resolve-symlink
update-symlink))
;;; Helper function to quote shell arguments
(define (shell-quote-argument arg)
@ -54,14 +69,14 @@
;;; Helper function to create a symlink
(define (create-symlink source target)
"Create a symlink from source to target."
(when (file-exists? source)
(delete-file source))
(let ((source-dir (dirname source)))
(when (not (file-exists? source-dir))
(mkdir-p source-dir)))
(format #t "Creating symlink: ~a -> ~a~%" source target)
(symlink target source)
(log-action (format #f "Created symlink ~a -> ~a" source target)))
(when (file-exists? target)
(delete-file target))
(let ((target-dir (dirname target)))
(when (not (file-exists? target-dir))
(mkdir-p target-dir)))
(format #t "Creating symlink: ~a -> ~a~%" target source)
(symlink source target)
(log-action (format #f "Created symlink ~a -> ~a" target source)))
;;; Helper function to check if a path is a symlink
(define (file-is-symlink? path)
@ -94,9 +109,65 @@
(else (display (format #f "Unknown operation: ~a\n" op)))))
operations))
;; File permission constants
(define read-permission #o444)
(define execute-permission #o111)
(define (ensure-directory dir)
"Create directory if it doesn't exist"
(when (not (file-exists? dir))
(mkdir-p dir)))
(define (copy-recursive source target)
"Copy directory recursively"
(system (string-append "cp -R " (shell-quote-argument source) " " (shell-quote-argument target))))
(define (delete-recursive path)
"Delete file or directory recursively"
(system (string-append "rm -rf " (shell-quote-argument path))))
(define (make-file-executable path)
"Make file executable"
(chmod path (logior (stat:mode (stat path)) execute-permission)))
(define (make-file-readable path)
"Make file readable"
(chmod path (logior (stat:mode (stat path)) read-permission)))
(define (file-is-executable? path)
"Check if file is executable"
(let ((mode (stat:mode (stat path))))
(not (zero? (logand mode execute-permission)))))
(define (file-is-readable? path)
"Check if file is readable"
(let ((mode (stat:mode (stat path))))
(not (zero? (logand mode read-permission)))))
(define (resolve-symlink path)
"Resolve symlink to its target"
(if (file-is-symlink? path)
(readlink path)
path))
(define (update-symlink target link)
"Update or create symlink"
(when (file-exists? link)
(delete-file link))
(symlink target link))
;;; Export list
(export mkdir-p
execute-operations
move-source-to-target
create-symlink
file-is-symlink?)
file-is-symlink?
ensure-directory
copy-recursive
delete-recursive
make-file-executable
make-file-readable
file-is-executable?
file-is-readable?
resolve-symlink
update-symlink)

View File

@ -10,28 +10,29 @@
(let ((current-time (current-date)))
(date->string current-time "~Y-~m-~d ~H:~M:~S")))
;; Log an action with timestamp
(define (log-action message)
(let ((log-dir (string-append (getenv "HOME") "/.local/share/stash"))
(log-file "stash.log"))
;; Create log directory if it doesn't exist
(when (not (file-exists? log-dir))
(mkdir-p log-dir))
;; Append message to log file
(call-with-output-file (string-append log-dir "/" log-file)
(lambda (port)
(format port "[~a] ~a~%" (current-timestamp) message))
#:append #t)))
;; Helper function to create directory and parents
(define (mkdir-p dir)
(let loop ((components (string-split dir #\/))
(path ""))
(let loop ((components (filter (lambda (x) (not (string-null? x)))
(string-split dir #\/)))
(path (if (string-prefix? "/" dir) "/" "")))
(when (not (null? components))
(let ((new-path (if (string-null? path)
(car components)
(string-append path "/" (car components)))))
(when (and (not (string-null? new-path))
(let ((new-path (if (string=? path "/")
(string-append path (car components))
(if (string-null? path)
(car components)
(string-append path "/" (car components))))))
(when (and (not (string=? new-path "/"))
(not (file-exists? new-path)))
(mkdir new-path #o755))
(loop (cdr components) new-path)))))
;; Log an action with timestamp
(define (log-action message)
(let* ((log-dir (string-append (getenv "HOME") "/.local/share/stash"))
(log-file (string-append log-dir "/stash.log")))
;; Create log directory if it doesn't exist
(mkdir-p log-dir)
;; Append message to log file
(let ((port (open-file log-file "a")))
(format port "[~a] ~a~%" (current-timestamp) message)
(close-port port))))

View File

@ -7,19 +7,43 @@
#:export (make-package
package?
package-name
package-path
package-source
package-target
package-ignore-patterns
read-ignore-patterns))
package-ignores
read-ignore-patterns
should-include-file?
package-exists?
scan-package-files))
;; Package record type
(define-record-type <package>
(make-package name path target ignore-patterns)
(make-package name source target ignores)
package?
(name package-name)
(path package-path)
(source package-source)
(target package-target)
(ignore-patterns package-ignore-patterns))
(ignores package-ignores))
;; Convert glob pattern to regex pattern
(define (glob->regex pattern)
(let ((len (string-length pattern)))
(if (= len 0)
"^$"
(let loop ((i 0) (result "^"))
(if (= i len)
(string-append result
(if (char=? (string-ref pattern (- len 1)) #\/)
".*"
"$"))
(let ((c (string-ref pattern i)))
(loop (+ i 1)
(string-append result
(case c
((#\*) ".*")
((#\?) ".")
((#\. #\$ #\^ #\[ #\] #\( #\) #\| #\+ #\\)
(string-append "\\" (string c)))
(else (string c)))))))))))
;; Read ignore patterns from .stash-local-ignore and .stash-global-ignore
(define (read-ignore-patterns package-path)
@ -44,6 +68,50 @@
;; Check if a path should be ignored based on patterns
(define (should-ignore? path patterns)
(format #t "Checking path: ~a against patterns: ~a~%" path patterns)
(any (lambda (pattern)
(string-match pattern path))
(let ((regex (glob->regex pattern)))
(format #t " Pattern: ~a -> Regex: ~a~%" pattern regex)
(string-match regex path)))
patterns))
;; Check if a file should be included based on package ignore patterns
(define (should-include-file? pkg path)
(let* ((rel-path (string-drop path (string-length (package-source pkg))))
;; Remove leading slash for pattern matching
(normalized-path (if (string-prefix? "/" rel-path)
(string-drop rel-path 1)
rel-path))
(patterns (package-ignores pkg)))
(format #t "Checking file: ~a (rel: ~a, normalized: ~a)~%" path rel-path normalized-path)
(not (should-ignore? normalized-path patterns))))
;; Check if package directory exists
(define (package-exists? pkg)
(file-exists? (package-source pkg)))
;; Scan package files recursively
(define (scan-package-files pkg)
(let ((source (package-source pkg)))
(if (not (file-exists? source))
'()
(let ((files '()))
(file-system-fold
(lambda (path stat result) ; enter?
(should-include-file? pkg path))
(lambda (path stat result) ; leaf
(when (should-include-file? pkg path)
(set! files (cons path files)))
result)
(lambda (path stat result) ; down
result)
(lambda (path stat result) ; up
result)
(lambda (path stat result) ; skip
result)
(lambda (path stat errno result) ; error
(format (current-error-port) "Error accessing ~a: ~a~%" path (strerror errno))
result)
#t
source)
(reverse files)))))

View File

@ -3,7 +3,8 @@
(define-module (stash paths)
#:use-module (ice-9 regex)
#:export (expand-home
canonicalize-path
normalize-path
relative-path
concat-path
ensure-config-path))
@ -31,8 +32,9 @@
(string-append target-dir "/.config")
target-dir)))
;;; Function to canonicalize a path (resolve . and ..)
(define (canonicalize-path path)
;;; Function to normalize a path (resolve . and ..)
(define (normalize-path path)
"Normalize a path by resolving . and .. components and expanding ~"
(let* ((expanded-path (expand-home path))
(absolute-path (if (string-prefix? "/" expanded-path)
expanded-path
@ -50,6 +52,31 @@
(else
(set! result (cons component result)))))
components)
;; Build the final path
(string-append "/" (string-join (reverse result) "/"))))
;;; Function to get relative path from base to target
(define (relative-path base target)
"Get the relative path from base to target"
(let* ((base (normalize-path base))
(target (normalize-path target))
(base-parts (filter (lambda (x) (not (string=? x "")))
(string-split base #\/)))
(target-parts (filter (lambda (x) (not (string=? x "")))
(string-split target #\/)))
(common-prefix-length
(let loop ((i 0))
(if (and (< i (length base-parts))
(< i (length target-parts))
(string=? (list-ref base-parts i)
(list-ref target-parts i)))
(loop (+ i 1))
i)))
(up-count (- (length base-parts) common-prefix-length))
(down-parts (list-tail target-parts common-prefix-length)))
(if (and (= up-count 0) (null? down-parts))
"."
(string-join
(append (make-list up-count "..")
down-parts)
"/"))))

View File

@ -2,19 +2,21 @@
#:use-module (srfi srfi-9) ; For record types
#:use-module (ice-9 ftw)
#:use-module (ice-9 regex) ; For string-match
#:use-module (srfi srfi-1)
#:use-module (stash package)
#:use-module (srfi srfi-1) ; For any and fold
#:use-module (stash file-ops)
#:use-module (stash log)
#:use-module (stash paths)
#:export (analyze-tree
fold-tree
plan-operations))
plan-operations
scan-directory
tree-contains?))
(use-modules (ice-9 ftw)
(ice-9 regex)
(srfi srfi-1)
(stash package)
(stash file-ops))
(stash file-ops)
(stash paths))
;; Tree node record type
(define-record-type <tree-node>
@ -24,71 +26,76 @@
(type node-type) ; 'file, 'directory, or 'symlink
(children node-children))
;; Check if a path should be ignored based on patterns
(define (should-ignore? path patterns)
(any (lambda (pattern)
(string-match pattern path))
patterns))
;; Analyze directory tree and create tree structure
(define (analyze-tree package)
(let ((root-path (package-path package))
(ignore-patterns (package-ignore-patterns package)))
(let analyze ((path root-path))
(if (should-ignore? path ignore-patterns)
#f
(cond
((file-is-symlink? path)
(make-tree-node path 'symlink '()))
((file-is-directory? path)
(let* ((entries (scandir path))
(children (filter-map
(lambda (entry)
(if (member entry '("." ".."))
#f
(analyze (string-append path "/" entry))))
entries)))
(make-tree-node path 'directory children)))
(else
(make-tree-node path 'file '())))))))
(define* (analyze-tree root-path #:optional (ignore-patterns '()))
(let analyze ((path root-path))
(cond
((file-is-symlink? path)
(make-tree-node path 'symlink '()))
((file-is-directory? path)
(let* ((entries (scandir path))
(children (filter-map
(lambda (entry)
(if (or (member entry '("." ".."))
(and (not (null? ignore-patterns))
(any (lambda (pattern)
(string-match pattern entry))
ignore-patterns)))
#f
(analyze (string-append path "/" entry))))
entries)))
(make-tree-node path 'directory children)))
(else
(make-tree-node path 'file '())))))
;; Determine if a directory tree can be folded
(define (can-fold-tree? node target-base)
(and (tree-node? node)
(eq? (node-type node) 'directory)
(= (length (node-children node)) 1)
(let ((child (car (node-children node))))
(and (eq? (node-type child) 'directory)
(not (file-exists? (string-append target-base "/"
(basename (node-path node)))))))))
;; Fold over a tree structure
(define (fold-tree proc init tree)
(let fold-node ((node tree)
(acc init))
(let ((new-acc (proc node acc)))
(if (eq? (node-type node) 'directory)
(let fold-children ((children (node-children node))
(child-acc new-acc))
(if (null? children)
child-acc
(fold-children (cdr children)
(fold-node (car children) child-acc))))
new-acc))))
;; Scan a directory and create a tree structure
(define (scan-directory dir-path)
(analyze-tree dir-path))
;; Check if a tree contains a specific path
(define (tree-contains? tree path)
(let* ((tree-path (node-path tree))
(target-path (if (string-prefix? "/" path)
path
(string-append (dirname tree-path) "/" path))))
(or (string=? tree-path target-path)
(let check-children ((children (node-children tree)))
(and (not (null? children))
(or (string=? (node-path (car children)) target-path)
(and (eq? (node-type (car children)) 'directory)
(tree-contains? (car children) target-path))
(check-children (cdr children))))))))
;; Plan stow operations for a tree
(define (plan-operations tree package)
(let* ((source-base (package-path package))
(target-base (package-target package))
(source-name (package-name package))
(target-path (string-append target-base "/" source-name)))
(format #t "Source base: ~a~%" source-base)
(format #t "Target base: ~a~%" target-base)
(format #t "Source name: ~a~%" source-name)
(format #t "Target path: ~a~%" target-path)
;; Create parent directory if it doesn't exist
(let ((target-dir (dirname target-path)))
(when (not (file-exists? target-dir))
(format #t "Creating parent directory: ~a~%" target-dir)
(mkdir-p target-dir)))
;; Remove existing directory or file
(when (file-exists? target-path)
(format #t "Removing existing path: ~a~%" target-path)
(system (string-append "rm -rf " target-path)))
;; Move source to target
(format #t "Moving ~a to ~a~%" source-base target-path)
(rename-file source-base target-path)
;; Create symlink
(format #t "Creating symlink: ~a -> ~a~%" source-base target-path)
(symlink target-path source-base)
'()))
(define (plan-operations tree target-dir)
(let ((source-base (node-path tree)))
(fold-tree
(lambda (node acc)
(let* ((relative-path (relative-path source-base (node-path node)))
(target-path (string-append target-dir "/" relative-path)))
(cond
((eq? (node-type node) 'directory)
(if (not (file-exists? target-path))
(cons `(mkdir ,target-path) acc)
acc))
((eq? (node-type node) 'file)
(if (not (file-exists? target-path))
(cons `(symlink ,(node-path node) ,target-path) acc)
acc))
(else acc))))
'()
tree)))

39
run-tests.sh Executable file
View File

@ -0,0 +1,39 @@
#!/bin/bash
# Exit on error
set -e
# Directory setup
PROJECT_ROOT="$PWD"
MODULE_DIR="$PROJECT_ROOT/modules"
TEST_DIR="$PROJECT_ROOT/tests"
# Add project directories to GUILE_LOAD_PATH
export GUILE_LOAD_PATH="$PROJECT_ROOT:$MODULE_DIR:$TEST_DIR:$GUILE_LOAD_PATH"
export GUILE_LOAD_COMPILED_PATH="$MODULE_DIR:$TEST_DIR:$GUILE_LOAD_COMPILED_PATH"
# Disable auto-compilation during tests
export GUILE_AUTO_COMPILE=0
# Compile all Scheme files first
echo "Compiling Scheme modules..."
find "$MODULE_DIR" -name "*.scm" -type f -exec guild compile {} \;
echo "Compiling test files..."
find "$TEST_DIR" -name "*.scm" -type f -exec guild compile {} \;
# Function to run a test file
run_test() {
local test_file="$1"
echo "Running tests in $test_file..."
guile --no-auto-compile "$test_file"
}
# Run all test files
echo "Running tests..."
for test_file in "$TEST_DIR"/*-test.scm; do
if [ -f "$test_file" ]; then
run_test "$test_file"
fi
done
echo "All tests completed successfully!"

65
tests/README.md Normal file
View File

@ -0,0 +1,65 @@
# Stash Test Suite
This directory contains the comprehensive test suite for the Stash project. The tests are written using SRFI-64, Scheme's standard testing framework.
## Test Structure
- `run-tests.scm` - Main test runner that executes all test files
- `test-helpers.scm` - Common testing utilities and fixtures
- Module-specific test files:
- `paths-test.scm` - Tests for path handling functionality
- `package-test.scm` - Tests for package management
- `tree-test.scm` - Tests for directory tree operations
- `file-ops-test.scm` - Tests for file system operations
- `conflict-test.scm` - Tests for conflict detection and resolution
## Running Tests
You can run the entire test suite using the script in the project root:
```bash
./run-tests.sh
```
Or run individual test files directly with Guile:
```bash
GUILE_LOAD_PATH="$PWD:$GUILE_LOAD_PATH" guile tests/paths-test.scm
```
## Writing New Tests
1. Create a new test file following the naming convention `*-test.scm`
2. Use the SRFI-64 test framework
3. Import required modules and test helpers:
```scheme
(use-modules (srfi srfi-64)
(stash your-module)
(test-helpers))
```
4. Organize tests into logical groups using `test-group`
5. Use the test helper functions for common operations
Example:
```scheme
(test-begin "your-module")
(test-group "feature-name"
(test-assert "description"
(your-function-call)))
(test-end "your-module")
```
## Test Helpers
The `test-helpers.scm` module provides several utilities:
- `with-temporary-directory`: Creates and manages temporary test directories
- `create-test-file`: Creates test files with optional content
- `create-test-symlink`: Creates test symlinks
- `delete-directory-recursive`: Safely removes test directories
## Adding New Test Helpers
If you find yourself repeating test setup code, consider adding new helper functions to `test-helpers.scm`.

64
tests/conflict-test.scm Normal file
View File

@ -0,0 +1,64 @@
(use-modules (srfi srfi-64)
(stash conflict)
(test-helpers))
(test-begin "conflict")
;; Test conflict detection
(test-group "conflict-detection"
(with-temporary-directory temp-dir
;; Setup test scenario
(let* ((source-path (string-append temp-dir "/source/config"))
(target-path (string-append temp-dir "/target/config")))
;; Test case 1: No conflict (target doesn't exist)
(create-test-file source-path "source content")
(test-assert "Should not detect conflict when target doesn't exist"
(not (has-conflict? source-path target-path)))
;; Test case 2: Conflict with existing file
(create-test-file target-path "target content")
(test-assert "Should detect conflict with existing file"
(has-conflict? source-path target-path))
;; Test case 3: Conflict with directory
(delete-file target-path)
(mkdir target-path)
(test-assert "Should detect conflict with directory"
(has-conflict? source-path target-path)))))
;; Test conflict resolution strategies
(test-group "conflict-resolution"
(with-temporary-directory temp-dir
(let* ((source-path (string-append temp-dir "/source/config"))
(target-path (string-append temp-dir "/target/config")))
;; Setup test files
(create-test-file source-path "source content")
(create-test-file target-path "target content")
;; Test backup strategy
(test-assert "Backup strategy should succeed"
(resolve-conflict source-path target-path 'backup))
(test-assert "Backup file should exist"
(file-exists? (string-append target-path ".bak")))
;; Test overwrite strategy
(create-test-file target-path "target content")
(test-assert "Overwrite strategy should succeed"
(resolve-conflict source-path target-path 'overwrite))
(test-equal "Target should contain source content"
"source content"
(with-input-from-file target-path read-string))
;; Test skip strategy
(test-assert "Skip strategy should succeed"
(resolve-conflict source-path target-path 'skip))
(test-equal "Target should remain unchanged"
"source content"
(with-input-from-file target-path read-string)))))
(test-end "conflict")

101
tests/file-ops-test.scm Normal file
View File

@ -0,0 +1,101 @@
(use-modules (srfi srfi-64)
(stash file-ops)
(test-helpers))
(test-begin "file-ops")
;; Test basic file operations
(test-group "basic-operations"
(with-temporary-directory temp-dir
;; Test directory creation
(let ((test-dir (string-append temp-dir "/new-dir")))
(test-assert "Should create directory"
(begin
(ensure-directory test-dir)
(file-exists? test-dir)))
(test-assert "Should handle existing directory"
(begin
(ensure-directory test-dir)
(file-exists? test-dir))))
;; Test file existence checks
(let ((test-file (string-append temp-dir "/test.txt")))
(create-test-file test-file)
(test-assert "Should detect existing file"
(file-exists? test-file))
(test-assert "Should detect non-existing file"
(not (file-exists? (string-append temp-dir "/nonexistent")))))))
;; Test recursive operations
(test-group "recursive-operations"
(with-temporary-directory temp-dir
;; Create test directory structure
(let ((dir1 (string-append temp-dir "/dir1"))
(dir2 (string-append temp-dir "/dir2")))
(create-test-file (string-append dir1 "/file1.txt"))
(create-test-file (string-append dir1 "/subdir/file2.txt"))
;; Test recursive copy
(copy-recursive dir1 dir2)
(test-assert "Should copy top-level file"
(file-exists? (string-append dir2 "/file1.txt")))
(test-assert "Should copy nested file"
(file-exists? (string-append dir2 "/subdir/file2.txt")))
;; Test recursive delete
(delete-recursive dir2)
(test-assert "Should delete entire directory"
(not (file-exists? dir2))))))
;; Test file permission operations
(test-group "permission-operations"
(with-temporary-directory temp-dir
(let ((test-file (string-append temp-dir "/permissions.txt")))
;; Create test file
(create-test-file test-file)
;; Test permission changes
(make-file-executable test-file)
(test-assert "Should make file executable"
(file-is-executable? test-file))
(make-file-readable test-file)
(test-assert "Should make file readable"
(file-is-readable? test-file)))))
;; Test symlink operations
(test-group "symlink-operations"
(with-temporary-directory temp-dir
(let ((source-file (string-append temp-dir "/source.txt"))
(link-file (string-append temp-dir "/link.txt")))
;; Create source file
(create-test-file source-file "test content")
;; Test symlink creation
(create-symlink source-file link-file)
(test-assert "Should create symlink"
(file-is-symlink? link-file))
(test-equal "Symlink should point to source"
(canonicalize-path source-file)
(resolve-symlink link-file))
;; Test symlink update
(let ((new-source (string-append temp-dir "/new-source.txt")))
(create-test-file new-source "new content")
(update-symlink new-source link-file)
(test-equal "Updated symlink should point to new source"
(canonicalize-path new-source)
(resolve-symlink link-file))))))
(test-end "file-ops")

73
tests/package-test.scm Normal file
View File

@ -0,0 +1,73 @@
(use-modules (srfi srfi-64)
(stash package)
(test-helpers))
(test-begin "package")
;; Test package record creation and manipulation
(test-group "package-record"
(let ((pkg (make-package "test-pkg" "/source/path" "/target/path" '("*.bak" ".git/"))))
(test-equal "Package name"
"test-pkg"
(package-name pkg))
(test-equal "Source path"
"/source/path"
(package-source pkg))
(test-equal "Target path"
"/target/path"
(package-target pkg))
(test-equal "Ignore patterns"
'("*.bak" ".git/")
(package-ignores pkg))))
;; Test ignore pattern handling
(test-group "ignore-patterns"
(with-temporary-directory temp-dir
;; Create test files
(create-test-file (string-append temp-dir "/normal.txt"))
(create-test-file (string-append temp-dir "/ignore.bak"))
(create-test-file (string-append temp-dir "/.git/config"))
(let ((pkg (make-package "test-pkg" temp-dir "/target" '("*.bak" ".git/"))))
(test-equal "Should ignore backup files"
#f
(should-include-file? pkg (string-append temp-dir "/ignore.bak")))
(test-equal "Should ignore git directory"
#f
(should-include-file? pkg (string-append temp-dir "/.git/config")))
(test-equal "Should include normal files"
#t
(should-include-file? pkg (string-append temp-dir "/normal.txt"))))))
;; Test package file operations
(test-group "package-operations"
(with-temporary-directory temp-dir
(let* ((source-dir (string-append temp-dir "/source"))
(target-dir (string-append temp-dir "/target"))
(pkg (make-package "test-pkg" source-dir target-dir '())))
;; Create test directory structure
(create-test-file (string-append source-dir "/config.txt"))
(create-test-file (string-append source-dir "/subdir/nested.txt"))
;; Test package scanning
(test-assert "Package directory exists"
(package-exists? pkg))
(let ((files (scan-package-files pkg)))
(test-equal "Should find all package files"
2
(length files))
(test-assert "Should find root file"
(member "config.txt" (map basename files)))
(test-assert "Should find nested file"
(member "nested.txt" (map basename files)))))))
(test-end "package")

42
tests/paths-test.scm Normal file
View File

@ -0,0 +1,42 @@
(use-modules (srfi srfi-64)
(stash paths))
(test-begin "paths")
;; Test normalize-path
(test-group "normalize-path"
(test-equal "Simple path normalization"
"/home/user/file"
(normalize-path "/home/user/file"))
(test-equal "Remove double slashes"
"/home/user/file"
(normalize-path "/home//user///file"))
(test-equal "Handle dot notation"
"/home/user/file"
(normalize-path "/home/user/./file"))
(test-equal "Handle dot-dot notation"
"/home/file"
(normalize-path "/home/user/../file"))
(test-equal "Handle home directory expansion"
(string-append (getenv "HOME") "/file")
(normalize-path "~/file")))
;; Test relative path handling
(test-group "relative-path"
(test-equal "Simple relative path"
"file"
(relative-path "/home/user" "/home/user/file"))
(test-equal "Nested relative path"
"dir/file"
(relative-path "/home/user" "/home/user/dir/file"))
(test-equal "Parent directory"
"../file"
(relative-path "/home/user/dir" "/home/user/file")))
(test-end "paths")

28
tests/run-tests.scm Executable file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env guile
!#
(use-modules (srfi srfi-64) ; testing framework
(srfi srfi-1) ; list operations
(ice-9 ftw) ; file tree walk
(ice-9 match)) ; pattern matching
;; Configure test engine
(test-runner-current (test-runner-create))
;; Helper function to run all test files
(define (run-test-file filename)
(format #t "\nRunning tests from ~a:\n" filename)
(primitive-load filename))
;; Find and run all test files
(define (run-all-tests)
(let ((test-files (filter (lambda (file)
(string-suffix? "-test.scm" file))
(scandir "."))))
(for-each run-test-file test-files)))
;; Run all tests
(run-all-tests)
;; Exit with appropriate status code
(exit (zero? (test-runner-fail-count (test-runner-current))))

82
tests/test-helpers.scm Normal file
View File

@ -0,0 +1,82 @@
;; test-helpers.scm --- Helper functions for Stash tests
(define-module (test-helpers)
#:use-module (ice-9 ftw)
#:use-module (ice-9 match)
#:use-module (ice-9 rdelim)
#:use-module (srfi srfi-1)
#:export (with-temporary-directory
create-test-file
create-test-symlink
delete-directory-recursive
read-string))
;; Create a temporary directory and run thunk with it
(define-syntax-rule (with-temporary-directory dir-name body ...)
(let* ((tmp-dir (string-append (or (getenv "TMPDIR") "/tmp")
"/stash-test-"
(number->string (current-time))))
(dir-name tmp-dir))
(mkdir tmp-dir)
(dynamic-wind
(lambda () #t)
(lambda () body ...)
(lambda () (delete-directory-recursive tmp-dir)))))
;; Create a test file with optional content
(define* (create-test-file path #:optional (content "test content\n"))
(let ((dir (dirname path)))
(when (not (file-exists? dir))
(mkdir-p dir))
(with-output-to-file path
(lambda ()
(display content)))))
;; Create a test symlink
(define (create-test-symlink target link-name)
(let ((dir (dirname link-name)))
(when (not (file-exists? dir))
(mkdir-p dir))
(symlink target link-name)))
;; Helper function to create directories recursively
(define (mkdir-p dir)
(let loop ((components (filter (lambda (x) (not (string-null? x)))
(string-split dir #\/)))
(path (if (string-prefix? "/" dir) "/" "")))
(unless (null? components)
(let ((new-path (if (string=? path "/")
(string-append path (car components))
(if (string-null? path)
(car components)
(string-append path "/" (car components))))))
(when (not (file-exists? new-path))
(mkdir new-path))
(loop (cdr components) new-path)))))
;; Recursively delete a directory
(define (delete-directory-recursive dir)
(when (file-exists? dir)
(file-system-fold
(const #t) ; enter?
(lambda (path stat result) ; leaf
(delete-file path))
(const #t) ; down
(lambda (path stat result) ; up
(rmdir path))
(const #t) ; skip
(lambda (path stat errno result) ; error
(format (current-error-port)
"Warning: ~a: ~a~%"
path
(strerror errno)))
#t
dir)))
;; Read entire contents of current input port as a string
(define (read-string)
(let loop ((chars '()))
(let ((char (read-char)))
(if (eof-object? char)
(list->string (reverse chars))
(loop (cons char chars))))))

55
tests/tree-test.scm Normal file
View File

@ -0,0 +1,55 @@
(use-modules (srfi srfi-64)
(srfi srfi-1) ; For any
(stash tree)
(stash paths)
(test-helpers))
(test-begin "tree")
;; Test directory tree operations
(test-group "directory-tree"
(with-temporary-directory temp-dir
;; Create test directory structure
(create-test-file (string-append temp-dir "/file1.txt"))
(create-test-file (string-append temp-dir "/dir1/file2.txt"))
(create-test-file (string-append temp-dir "/dir1/subdir/file3.txt"))
;; Test directory scanning
(let ((tree (scan-directory temp-dir)))
(test-assert "Tree should contain root file"
(tree-contains? tree (string-append temp-dir "/file1.txt")))
(test-assert "Tree should contain nested file"
(tree-contains? tree (string-append temp-dir "/dir1/file2.txt")))
(test-assert "Tree should contain deeply nested file"
(tree-contains? tree (string-append temp-dir "/dir1/subdir/file3.txt"))))))
;; Test symlink planning
(test-group "symlink-planning"
(with-temporary-directory temp-dir
(let* ((source-dir (string-append temp-dir "/source"))
(target-dir (string-append temp-dir "/target")))
;; Create source files
(create-test-file (string-append source-dir "/config.txt"))
(create-test-file (string-append source-dir "/dir/nested.txt"))
;; Test operation planning
(let* ((tree (scan-directory source-dir))
(ops (plan-operations tree target-dir)))
(test-assert "Should plan to create target directory"
(any (lambda (op)
(and (eq? (car op) 'mkdir)
(string=? (cadr op)
(string-append target-dir "/dir"))))
ops))
(test-assert "Should plan to create symlinks"
(any (lambda (op)
(and (eq? (car op) 'symlink)
(string=? (caddr op)
(string-append target-dir "/config.txt"))))
ops))))))
(test-end "tree")