Implemented tests and added documentation for archive

This commit is contained in:
Philipp Wolfer 2025-05-25 12:46:44 +02:00
parent 4da5697435
commit 28c618ffce
No known key found for this signature in database
GPG key ID: 8FDF744D4919943B
8 changed files with 221 additions and 6 deletions

View file

@ -35,10 +35,17 @@ import (
"path/filepath"
)
// Generic archive interface.
type Archive interface {
Close() error
// Generic interface to access files inside an archive.
type ArchiveReader interface {
io.Closer
// Open the file inside the archive identified by the given path.
// The path is relative to the archive's root.
// The caller must call [fs.File.Close] when finished using the file.
Open(path string) (fs.File, error)
// List files inside the archive which satisfy the given glob pattern.
// This method only returns files, not directories.
Glob(pattern string) ([]FileInfo, error)
}
@ -46,7 +53,7 @@ type Archive interface {
// The archive can be a ZIP file or a directory. The implementation
// will detect the type of archive and return the appropriate
// implementation of the Archive interface.
func OpenArchive(path string) (Archive, error) {
func OpenArchive(path string) (ArchiveReader, error) {
fi, err := os.Stat(path)
if err != nil {
return nil, err
@ -73,10 +80,14 @@ func OpenArchive(path string) (Archive, error) {
// Interface for a file that can be opened when needed.
type OpenableFile interface {
// Open the file for reading.
// The caller is responsible to call [io.ReadCloser.Close] when
// finished reading the file.
Open() (io.ReadCloser, error)
}
// Generic information about a file inside an archive.
// This provides the filename and allows opening the file for reading.
type FileInfo struct {
Name string
File OpenableFile
@ -115,6 +126,10 @@ func (a *zipArchive) Close() error {
func (a *zipArchive) Glob(pattern string) ([]FileInfo, error) {
result := make([]FileInfo, 0)
for _, file := range a.zip.File {
if file.FileInfo().IsDir() {
continue
}
if matched, err := filepath.Match(pattern, file.Name); matched {
if err != nil {
return nil, err
@ -167,6 +182,14 @@ func (a *dirArchive) Glob(pattern string) ([]FileInfo, error) {
}
result := make([]FileInfo, 0)
for _, name := range files {
stat, err := fs.Stat(a.dirFS, name)
if err != nil {
return nil, err
}
if stat.IsDir() {
continue
}
fullPath := filepath.Join(a.path, name)
info := FileInfo{
Name: name,

View file

@ -0,0 +1,189 @@
/*
Copyright © 2025 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
package archive_test
import (
"fmt"
"io"
"log"
"slices"
"testing"
"go.uploadedlobster.com/scotty/internal/archive"
)
func ExampleOpenArchive() {
a, err := archive.OpenArchive("testdata/archive.zip")
if err != nil {
log.Fatal(err)
}
defer a.Close()
files, err := a.Glob("a/*.txt")
for _, fi := range files {
fmt.Println(fi.Name)
f, err := fi.File.Open()
if err != nil {
log.Fatal(err)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(data))
}
// Output: a/1.txt
// a1
}
var testArchives = []string{
"testdata/archive",
"testdata/archive.zip",
}
func TestGlob(t *testing.T) {
for _, path := range testArchives {
a, err := archive.OpenArchive(path)
if err != nil {
t.Fatal(err)
}
defer a.Close()
files, err := a.Glob("[ab]/1.txt")
if err != nil {
t.Fatal(err)
}
if len(files) != 2 {
t.Errorf("Expected 2 files, got %d", len(files))
}
expectedName := "b/1.txt"
var fileInfo *archive.FileInfo = nil
for _, file := range files {
if file.Name == expectedName {
fileInfo = &file
}
}
if fileInfo == nil {
t.Fatalf("Expected file %q to be found", expectedName)
}
if fileInfo.File == nil {
t.Fatalf("Expected FileInfo to hold an openable File")
}
f, err := fileInfo.File.Open()
if err != nil {
t.Fatal(err)
}
expectedData := "b1\n"
data, err := io.ReadAll(f)
if err != nil {
t.Fatal(err)
}
if string(data) != expectedData {
fmt.Printf("%s: Expected file content to be %q, got %q",
path, expectedData, string(data))
}
}
}
func TestGlobAll(t *testing.T) {
for _, path := range testArchives {
a, err := archive.OpenArchive(path)
if err != nil {
t.Fatal(err)
}
defer a.Close()
files, err := a.Glob("*/*")
if err != nil {
t.Fatal(err)
}
filenames := make([]string, 0, len(files))
for _, f := range files {
fmt.Printf("%v: %v\n", path, f.Name)
filenames = append(filenames, f.Name)
}
slices.Sort(filenames)
expectedFilenames := []string{
"a/1.txt",
"b/1.txt",
"b/2.txt",
}
if !slices.Equal(filenames, expectedFilenames) {
t.Errorf("%s: Expected filenames to be %q, got %q",
path, expectedFilenames, filenames)
}
}
}
func TestOpen(t *testing.T) {
for _, path := range testArchives {
a, err := archive.OpenArchive(path)
if err != nil {
t.Fatal(err)
}
defer a.Close()
f, err := a.Open("b/2.txt")
if err != nil {
t.Fatal(err)
}
expectedData := "b2\n"
data, err := io.ReadAll(f)
if err != nil {
t.Fatal(err)
}
if string(data) != expectedData {
fmt.Printf("%s: Expected file content to be %q, got %q",
path, expectedData, string(data))
}
}
}
func TestOpenError(t *testing.T) {
for _, path := range testArchives {
a, err := archive.OpenArchive(path)
if err != nil {
t.Fatal(err)
}
defer a.Close()
_, err = a.Open("b/3.txt")
if err == nil {
t.Errorf("%s: Expected the Open command to fail", path)
}
}
}

BIN
internal/archive/testdata/archive.zip vendored Normal file

Binary file not shown.

View file

@ -0,0 +1 @@
a1

View file

@ -0,0 +1 @@
b1

View file

@ -0,0 +1 @@
b2

View file

@ -33,7 +33,7 @@ var historyFileGlobs = []string{
// This can be either the ZIP file as provided by Spotify
// or a directory where this was extracted to.
type HistoryArchive struct {
backend archive.Archive
backend archive.ArchiveReader
}
// Open a Spotify history archive from file path.

View file

@ -40,7 +40,7 @@ import (
// The export contains the user's listen history, favorite tracks and
// user information.
type ExportArchive struct {
backend archive.Archive
backend archive.ArchiveReader
}
// Open a ListenBrainz archive from file path.