bring the repo up to repo standards (closes #1)

Adopt the standard tooling from sneak/prompts: the Makefile and script/
entrypoints, lint and test stages in one Dockerfile, the lint config, the
CI workflow and the formatter settings. make fmt also formats Go, with a
pinned Go from script/bootstrap. The module path becomes
sneak.berlin/go/dcf.

Fix the lint findings in pkg/dcf. DCFStore becomes Store and DCFObject
becomes Object. Three behaviours change. An error listing mount points is
now returned, where a typo used to drop it. Store.String() prints Store{
instead of DCFStore{, and a negative total as 0 B instead of a huge size.

Replace the placeholder test with real tests. Move the README into the
required sections. make build compiles the library; it pointed at a
command that does not exist.

Model: opus-5-5
This commit is contained in:
clawbot authored and sneak committed 2026-11-02 19:09:04 +00:00
1 parent f585e8fa34
commit c86694c410
32 files changed
+1828 -109

No files matched your search

+80 -48
View File
@@ -1,29 +1,32 @@
// Package dcf reads DCF cards: memory cards laid out to the "Design rule for
// Camera File system" specification (JEITA CP-3461). It finds the cards among
// the mounted filesystems and lists the images and videos on each one.
package dcf
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strings"
"github.com/dustin/go-humanize"
)
var imageExtensions = []string{".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".raw", ".arw"}
var videoExtensions = []string{".mp4", ".mov", ".avi", ".mkv", ".wmv"}
// DCFStore represents a DCF store, which is a directory sturcture that contains
// Store represents a DCF store, which is a directory structure that contains
// images and videos in the root of a mounted filesystem.
type DCFStore struct {
type Store struct {
RootDirectory string
Images []*Image
Videos []*Video
}
// DCFObject represents a file in a DCF store. It is embedded in Image and Video types.
type DCFObject struct {
// Object represents a file in a DCF store. It is embedded in Image and Video
// types.
type Object struct {
DCFStoreRoot string
Path string
Size int64
@@ -32,104 +35,133 @@ type DCFObject struct {
// Image represents an image file on a DCF store.
type Image struct {
DCFObject
Object
}
// Video represents a video file on a DCF store.
type Video struct {
DCFObject
Object
}
func (d *DCFObject) FullFilePath() string {
// FullFilePath returns the path of the file on the local filesystem.
func (d *Object) FullFilePath() string {
return filepath.Join(d.DCFStoreRoot, d.Path)
}
// VideosCount returns the number of videos in the DCF store.
func (d *DCFStore) VideosCount() int {
func (d *Store) VideosCount() int {
return len(d.Videos)
}
// ImagesCount returns the number of images in the DCF store.
func (d *DCFStore) ImagesCount() int {
func (d *Store) ImagesCount() int {
return len(d.Images)
}
func (d *DCFStore) TotalImageSize() int64 {
// TotalImageSize returns the total size in bytes of the images in the DCF
// store.
func (d *Store) TotalImageSize() int64 {
totalSize := int64(0)
for _, image := range d.Images {
totalSize += image.Size
}
return totalSize
}
func (d *DCFStore) TotalVideoSize() int64 {
// TotalVideoSize returns the total size in bytes of the videos in the DCF
// store.
func (d *Store) TotalVideoSize() int64 {
totalSize := int64(0)
for _, video := range d.Videos {
totalSize += video.Size
}
return totalSize
}
func (d *DCFStore) TotalSize() int64 {
// TotalSize returns the total size in bytes of the images and videos in the
// DCF store.
func (d *Store) TotalSize() int64 {
return d.TotalImageSize() + d.TotalVideoSize()
}
func (d *DCFStore) String() string {
return fmt.Sprintf("DCFStore{RootDirectory: %s, Images: %d, Videos: %d, TotalSize: %s}",
func (d *Store) String() string {
// A caller can set a negative Size, and uint64 would make that huge.
totalSize := uint64(max(d.TotalSize(), 0))
return fmt.Sprintf(
"Store{RootDirectory: %s, Images: %d, Videos: %d, TotalSize: %s}",
d.RootDirectory,
d.ImagesCount(),
d.VideosCount(),
humanize.Bytes(uint64(d.TotalSize())),
humanize.Bytes(totalSize),
)
}
func (v *Video) String() string {
return fmt.Sprintf("Video{Path: %s, Size: %d, Extension: %s}", v.Path, v.Size, v.Extension)
return fmt.Sprintf(
"Video{Path: %s, Size: %d, Extension: %s}",
v.Path, v.Size, v.Extension,
)
}
func (i *Image) String() string {
return fmt.Sprintf("Image{Path: %s, Size: %d, Extension: %s}", i.Path, i.Size, i.Extension)
return fmt.Sprintf(
"Image{Path: %s, Size: %d, Extension: %s}",
i.Path, i.Size, i.Extension,
)
}
// Hash() returns the SHA256 hash of the file as a hex string.
func (d *DCFObject) Hash() (string, error) {
// Hash returns the SHA256 hash of the file as a hex string.
func (d *Object) Hash() (string, error) {
return pathToSHA256(d.FullFilePath())
}
func pathToSHA256(path string) (string, error) {
file, err := os.Open(path)
file, err := os.Open(filepath.Clean(path))
if err != nil {
return "", err
}
defer file.Close()
defer func() { _ = file.Close() }()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
_, err = io.Copy(hash, file)
if err != nil {
return "", err
}
return fmt.Sprintf("%x", hash.Sum(nil)), nil
return hex.EncodeToString(hash.Sum(nil)), nil
}
// GetDCFStores returns a list of DCF stores found on the system in the root of any mounted filesystems.
// the integer argument is the number of DCF stores to return. If the argument is 0, all DCF stores are returned.
// It does not mount filesystems or search outside of filesystem root directories. It does, however,
// walk the filesystems to find images and videos and populate the returned DCFStores.
func GetDCFStores(requestedCount int) (*[]DCFStore, error) {
// GetDCFStores returns a list of DCF stores found on the system in the root of
// any mounted filesystems. The integer argument is the number of DCF stores to
// return. If the argument is 0, all DCF stores are returned. It does not mount
// filesystems or search outside of filesystem root directories. It does,
// however, walk the filesystems to find images and videos and populate the
// returned Stores.
func GetDCFStores(requestedCount int) (*[]Store, error) {
dcfStorePaths, err := findDCFMountPoints(requestedCount)
if err != nil {
return nil, err
}
dcfStores := []DCFStore{}
dcfStores := []Store{}
for _, dcfStorePath := range dcfStorePaths {
dcfStore := DCFStore{
dcfStore := Store{
RootDirectory: dcfStorePath,
Images: make([]*Image, 0),
Videos: make([]*Video, 0),
}
err := filepath.Walk(dcfStorePath, func(path string, info os.FileInfo, err error) error {
addFile := func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && isImageFile(path) {
image := Image{}
image.Path = strings.TrimPrefix(path, dcfStorePath+"/")
@@ -138,6 +170,7 @@ func GetDCFStores(requestedCount int) (*[]DCFStore, error) {
image.DCFStoreRoot = dcfStorePath
dcfStore.Images = append(dcfStore.Images, &image)
}
if !info.IsDir() && isVideoFile(path) {
video := Video{}
video.Path = strings.TrimPrefix(path, dcfStorePath+"/")
@@ -146,32 +179,31 @@ func GetDCFStores(requestedCount int) (*[]DCFStore, error) {
video.DCFStoreRoot = dcfStorePath
dcfStore.Videos = append(dcfStore.Videos, &video)
}
return nil
})
}
err := filepath.Walk(dcfStorePath, addFile)
if err != nil {
return nil, err
}
dcfStores = append(dcfStores, dcfStore)
}
return &dcfStores, nil
}
func isImageFile(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
for _, imageExt := range imageExtensions {
if ext == imageExt {
return true
}
imageExtensions := []string{
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".raw", ".arw",
}
return false
return slices.Contains(imageExtensions, strings.ToLower(filepath.Ext(path)))
}
func isVideoFile(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
for _, videoExt := range videoExtensions {
if ext == videoExt {
return true
}
}
return false
videoExtensions := []string{".mp4", ".mov", ".avi", ".mkv", ".wmv"}
return slices.Contains(videoExtensions, strings.ToLower(filepath.Ext(path)))
}
+39
View File
@@ -0,0 +1,39 @@
package dcf
import "testing"
func TestIsImageFile(t *testing.T) {
t.Parallel()
cases := map[string]bool{
"DCIM/100MSDCF/DSC00001.jpg": true,
"DCIM/100MSDCF/DSC00001.ARW": true,
"PRIVATE/M4ROOT/CLIP/C0001.MP4": false,
"DCIM/100MSDCF/notes.txt": false,
"DCIM/100MSDCF/noextension": false,
}
for path, want := range cases {
if got := isImageFile(path); got != want {
t.Errorf("isImageFile(%q) = %v, want %v", path, got, want)
}
}
}
func TestIsVideoFile(t *testing.T) {
t.Parallel()
cases := map[string]bool{
"PRIVATE/M4ROOT/CLIP/C0001.mp4": true,
"PRIVATE/M4ROOT/CLIP/C0001.MOV": true,
"DCIM/100MSDCF/DSC00001.JPG": false,
"DCIM/100MSDCF/notes.txt": false,
"DCIM/100MSDCF/noextension": false,
}
for path, want := range cases {
if got := isVideoFile(path); got != want {
t.Errorf("isVideoFile(%q) = %v, want %v", path, got, want)
}
}
}
+155 -4
View File
@@ -1,10 +1,161 @@
package dcf
package dcf_test
import (
"os"
"path/filepath"
"testing"
"sneak.berlin/go/dcf/pkg/dcf"
)
func TestCompile(t *testing.T) {
// This is a placeholder test to ensure that the module compiles successfully.
// You can add more meaningful tests here once you start implementing your code.
const cardRoot = "/mnt/card"
// newStore returns a store holding two images and one video of known sizes.
func newStore() *dcf.Store {
return &dcf.Store{
RootDirectory: cardRoot,
Images: []*dcf.Image{
{Object: dcf.Object{Size: 2000}},
{Object: dcf.Object{Size: 500}},
},
Videos: []*dcf.Video{
{Object: dcf.Object{Size: 9000}},
},
}
}
func TestObjectFullFilePath(t *testing.T) {
t.Parallel()
object := dcf.Object{
DCFStoreRoot: cardRoot,
Path: "DCIM/100MSDCF/DSC00001.ARW",
}
want := cardRoot + "/DCIM/100MSDCF/DSC00001.ARW"
if got := object.FullFilePath(); got != want {
t.Fatalf("FullFilePath() = %q, want %q", got, want)
}
}
func TestStoreCounts(t *testing.T) {
t.Parallel()
store := newStore()
if got := store.ImagesCount(); got != 2 {
t.Errorf("ImagesCount() = %d, want 2", got)
}
if got := store.VideosCount(); got != 1 {
t.Errorf("VideosCount() = %d, want 1", got)
}
}
func TestStoreSizes(t *testing.T) {
t.Parallel()
store := newStore()
if got := store.TotalImageSize(); got != 2500 {
t.Errorf("TotalImageSize() = %d, want 2500", got)
}
if got := store.TotalVideoSize(); got != 9000 {
t.Errorf("TotalVideoSize() = %d, want 9000", got)
}
if got := store.TotalSize(); got != 11500 {
t.Errorf("TotalSize() = %d, want 11500", got)
}
}
func TestStoreString(t *testing.T) {
t.Parallel()
want := "Store{RootDirectory: /mnt/card, Images: 2, Videos: 1, " +
"TotalSize: 12 kB}"
if got := newStore().String(); got != want {
t.Fatalf("String() = %q, want %q", got, want)
}
}
func TestStoreStringShowsNegativeTotalAsZero(t *testing.T) {
t.Parallel()
store := dcf.Store{
RootDirectory: cardRoot,
Images: []*dcf.Image{{Object: dcf.Object{Size: -1}}},
}
want := "Store{RootDirectory: /mnt/card, Images: 1, Videos: 0, " +
"TotalSize: 0 B}"
if got := store.String(); got != want {
t.Fatalf("String() = %q, want %q", got, want)
}
}
func TestImageString(t *testing.T) {
t.Parallel()
image := dcf.Image{Object: dcf.Object{
Path: "DCIM/100MSDCF/DSC00001.ARW",
Size: 2000,
Extension: ".ARW",
}}
want := "Image{Path: DCIM/100MSDCF/DSC00001.ARW, Size: 2000, Extension: .ARW}"
if got := image.String(); got != want {
t.Fatalf("String() = %q, want %q", got, want)
}
}
func TestVideoString(t *testing.T) {
t.Parallel()
video := dcf.Video{Object: dcf.Object{
Path: "PRIVATE/M4ROOT/CLIP/C0001.MP4",
Size: 9000,
Extension: ".MP4",
}}
want := "Video{Path: PRIVATE/M4ROOT/CLIP/C0001.MP4, Size: 9000, Extension: .MP4}"
if got := video.String(); got != want {
t.Fatalf("String() = %q, want %q", got, want)
}
}
func TestObjectHashIsSHA256OfFile(t *testing.T) {
t.Parallel()
root := t.TempDir()
err := os.WriteFile(filepath.Join(root, "a.jpg"), []byte("abc"), 0o600)
if err != nil {
t.Fatalf("WriteFile: %v", err)
}
object := dcf.Object{DCFStoreRoot: root, Path: "a.jpg"}
got, err := object.Hash()
if err != nil {
t.Fatalf("Hash() returned error: %v", err)
}
// The published SHA-256 test vector for "abc".
want := "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
if got != want {
t.Fatalf("Hash() = %q, want %q", got, want)
}
}
func TestObjectHashOfMissingFileReturnsError(t *testing.T) {
t.Parallel()
object := dcf.Object{DCFStoreRoot: t.TempDir(), Path: "absent.jpg"}
_, err := object.Hash()
if err == nil {
t.Fatal("Hash() of a missing file returned no error")
}
}
+24 -27
View File
@@ -5,71 +5,68 @@ import (
"log/slog"
"os"
"path/filepath"
"runtime"
"slices"
"github.com/shirou/gopsutil/disk"
)
func findAllMountPoints() ([]string, error) {
mountpoints := []string{}
partitions, err := disk.Partitions(false) // physical devices only, so false
if err != nil {
return nil, err
}
for _, partition := range partitions {
if !contains(mountpoints, partition.Mountpoint) {
if !slices.Contains(mountpoints, partition.Mountpoint) {
mountpoints = append(mountpoints, partition.Mountpoint)
}
}
return mountpoints, nil
}
func dump(x interface{}) {
_, file, line, _ := runtime.Caller(1)
slog.Debug(fmt.Sprintf("%s:%d %#v\n", file, line, x))
}
// findDCFMountPoints returns a list of strings of the paths of mountpoints that
// contain a DCIM or PRIVATE directory, which is how we detect DCF stores. Its
// only argument is an integer of how many mountpoints to return. If the
// findDCFMountPoints returns a list of strings of the paths of mountpoints
// that contain a DCIM or M4ROOT directory, which is how we detect DCF stores.
// Its only argument is an integer of how many mountpoints to return. If the
// argument is 0, all mountpoints are returned.
func findDCFMountPoints(requestedCount int) ([]string, error) {
filteredMountpoints := []string{}
var erro error
mountpoints, err := findAllMountPoints()
if erro != nil {
mountpoints, err := findAllMountPoints()
if err != nil {
return nil, err
}
for _, mountpoint := range mountpoints {
dcimPath := filepath.Join(mountpoint, "DCIM")
privatePath := filepath.Join(mountpoint, "PRIVATE")
privatePath = filepath.Join(mountpoint, "M4ROOT")
m4rootPath := filepath.Join(mountpoint, "M4ROOT")
shouldKeep := false
if _, err := os.Stat(dcimPath); err == nil {
_, err = os.Stat(dcimPath)
if err == nil {
shouldKeep = true
slog.Debug(fmt.Sprintf("Found DCIM directory at %s\n", dcimPath))
}
if _, err := os.Stat(privatePath); err == nil {
_, err = os.Stat(m4rootPath)
if err == nil {
shouldKeep = true
slog.Debug(fmt.Sprintf("Found M4ROOT directory at %s\n", privatePath))
slog.Debug(fmt.Sprintf("Found M4ROOT directory at %s\n", m4rootPath))
}
if shouldKeep {
slog.Debug(fmt.Sprintf("Keeping mountpoint %s\n", mountpoint))
filteredMountpoints = append(filteredMountpoints, mountpoint)
if (requestedCount > 0) && (len(filteredMountpoints)+1 >= requestedCount) {
break
}
}
}
return filteredMountpoints, nil
}
func contains(slice []string, str string) bool {
for _, s := range slice {
if s == str {
return true
}
}
return false
}