CLI開発 - 解答例
概要
本解答例では、Cobraライブラリを使用した本格的なCLIツール「filetool」を実装します。このツールは、以下の機能を提供します:
- list: ファイル一覧の表示(再帰、JSON出力対応)
- search: ファイル検索(正規表現、再帰対応)
- stats: ディレクトリ統計情報
- watch: ファイル監視(リアルタイム更新)
- compress: 複数ファイルの圧縮
プロジェクト構成
filetool/
├── main.go # エントリーポイント
├── cmd/
│ ├── root.go # ルートコマンド
│ ├── list.go # listコマンド
│ ├── search.go # searchコマンド
│ ├── stats.go # statsコマンド
│ ├── watch.go # watchコマンド
│ └── compress.go # compressコマンド
├── internal/
│ ├── config/
│ │ └── config.go # 設定管理
│ ├── scanner/
│ │ └── scanner.go # ファイルスキャン
│ └── formatter/
│ └── formatter.go # 出力フォーマット
├── config.yaml # 設定ファイル
└── go.mod
依存関係のインストール
go mod init github.com/yourname/filetool
go get github.com/spf13/cobra@latest
go get github.com/spf13/viper@latest
go get github.com/fatih/color@latest
go get github.com/schollz/progressbar/v3@latest
go get github.com/fsnotify/fsnotify@latest
go get github.com/manifoldco/promptui@latest
main.go - エントリーポイント
package main
import (
"github.com/yourname/filetool/cmd"
)
// バージョン情報(ビルド時に埋め込まれる)
var (
version = "dev"
commit = "none"
date = "unknown"
)
func main() {
cmd.Execute(version, commit, date)
}
cmd/root.go - ルートコマンド
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
cfgFile string
verbose bool
)
// ルートコマンド
var rootCmd = &cobra.Command{
Use: "filetool",
Short: "A powerful file management CLI tool",
Long: `filetool is a comprehensive file management tool that provides
various operations on files and directories including listing,
searching, statistics, watching, and compression.
Built with Go and Cobra for maximum performance and usability.`,
Example: ` # List files in current directory
filetool list
# Search for Go files recursively
filetool search -r "\.go$"
# Show directory statistics
filetool stats --human-readable
# Watch directory for changes
filetool watch /path/to/dir`,
}
// Execute は全てのサブコマンドを追加し、フラグを適切に設定します
func Execute(version, commit, date string) {
// バージョン情報を設定
rootCmd.Version = fmt.Sprintf("%s (commit: %s, built at: %s)", version, commit, date)
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
func init() {
cobra.OnInitialize(initConfig)
// グローバルフラグ
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "",
"config file (default is $HOME/.filetool.yaml)")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false,
"verbose output")
// サブコマンドを追加
rootCmd.AddCommand(listCmd)
rootCmd.AddCommand(searchCmd)
rootCmd.AddCommand(statsCmd)
rootCmd.AddCommand(watchCmd)
rootCmd.AddCommand(compressCmd)
}
// initConfig は設定ファイルと環境変数を読み込みます
func initConfig() {
if cfgFile != "" {
// フラグで指定された設定ファイルを使用
viper.SetConfigFile(cfgFile)
} else {
// ホームディレクトリを取得
home, err := os.UserHomeDir()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
// ホームディレクトリとカレントディレクトリから設定を探す
viper.AddConfigPath(home)
viper.AddConfigPath(".")
viper.SetConfigType("yaml")
viper.SetConfigName(".filetool")
}
// 環境変数を読み込む (FILETOOL_で始まる)
viper.SetEnvPrefix("FILETOOL")
viper.AutomaticEnv()
// 設定ファイルを読み込む
if err := viper.ReadInConfig(); err == nil && verbose {
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
}
}
cmd/list.go - ファイル一覧表示
package cmd
import (
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
)
type FileInfo struct {
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
Mode string `json:"mode"`
ModTime time.Time `json:"mod_time"`
IsDir bool `json:"is_dir"`
}
var (
listRecursive bool
listOutput string
listAll bool
listLong bool
listSortBy string
)
var listCmd = &cobra.Command{
Use: "list [path]",
Short: "List files in directory",
Long: `List files and directories with various output formats and options.
Supports recursive listing, different output formats (text/json),
hidden files display, and sorting options.`,
Example: ` # List current directory
filetool list
# List recursively with details
filetool list -r -l
# List with JSON output
filetool list -o json
# List all files including hidden
filetool list -a
# Sort by size
filetool list --sort size`,
Args: cobra.MaximumNArgs(1),
RunE: runList,
}
func init() {
listCmd.Flags().BoolVarP(&listRecursive, "recursive", "r", false,
"list directories recursively")
listCmd.Flags().StringVarP(&listOutput, "output", "o", "text",
"output format (text|json)")
listCmd.Flags().BoolVarP(&listAll, "all", "a", false,
"show hidden files")
listCmd.Flags().BoolVarP(&listLong, "long", "l", false,
"use long listing format")
listCmd.Flags().StringVar(&listSortBy, "sort", "name",
"sort by (name|size|time)")
}
func runList(cmd *cobra.Command, args []string) error {
path := "."
if len(args) > 0 {
path = args[0]
}
// パスの存在確認
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("cannot access '%s': %w", path, err)
}
if !info.IsDir() {
return fmt.Errorf("'%s' is not a directory", path)
}
var files []FileInfo
if listRecursive {
err = filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// 隠しファイルをスキップ
if !listAll && strings.HasPrefix(d.Name(), ".") && p != path {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
info, err := d.Info()
if err != nil {
return nil // エラーを無視して継続
}
files = append(files, FileInfo{
Name: d.Name(),
Path: p,
Size: info.Size(),
Mode: info.Mode().String(),
ModTime: info.ModTime(),
IsDir: d.IsDir(),
})
return nil
})
} else {
entries, err := os.ReadDir(path)
if err != nil {
return fmt.Errorf("cannot read directory '%s': %w", path, err)
}
for _, entry := range entries {
// 隠しファイルをスキップ
if !listAll && strings.HasPrefix(entry.Name(), ".") {
continue
}
info, err := entry.Info()
if err != nil {
continue // エラーを無視して継続
}
fullPath := filepath.Join(path, entry.Name())
files = append(files, FileInfo{
Name: entry.Name(),
Path: fullPath,
Size: info.Size(),
Mode: info.Mode().String(),
ModTime: info.ModTime(),
IsDir: entry.IsDir(),
})
}
}
if err != nil {
return err
}
// ソート
sortFiles(files, listSortBy)
// 出力
switch listOutput {
case "json":
return outputJSON(files)
case "text":
return outputText(files, listLong)
default:
return fmt.Errorf("unknown output format: %s", listOutput)
}
}
func sortFiles(files []FileInfo, sortBy string) {
sort.Slice(files, func(i, j int) bool {
switch sortBy {
case "size":
return files[i].Size > files[j].Size
case "time":
return files[i].ModTime.After(files[j].ModTime)
default: // name
return files[i].Name < files[j].Name
}
})
}
func outputJSON(files []FileInfo) error {
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(files)
}
func outputText(files []FileInfo, long bool) error {
// カラー設定
dirColor := color.New(color.FgBlue, color.Bold)
fileColor := color.New(color.FgWhite)
sizeColor := color.New(color.FgYellow)
timeColor := color.New(color.FgCyan)
for _, file := range files {
if long {
// 詳細表示
fmt.Printf("%s %8s %s ",
file.Mode,
formatSize(file.Size),
file.ModTime.Format("2006-01-02 15:04:05"),
)
}
// ファイル名の表示
if file.IsDir {
dirColor.Printf("%s/\n", file.Name)
} else {
fileColor.Println(file.Name)
}
}
if verbose {
fmt.Fprintf(os.Stderr, "\nTotal: %d items\n", len(files))
}
return nil
}
func formatSize(size int64) string {
const unit = 1024
if size < unit {
return fmt.Sprintf("%d B", size)
}
div, exp := int64(unit), 0
for n := size / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGTPE"[exp])
}
cmd/search.go - ファイル検索
package cmd
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/fatih/color"
"github.com/schollz/progressbar/v3"
"github.com/spf13/cobra"
)
var (
searchRecursive bool
searchIgnoreCase bool
searchInvert bool
searchType string
searchMaxDepth int
searchShowProgress bool
)
var searchCmd = &cobra.Command{
Use: "search <pattern> [path]",
Short: "Search files by pattern",
Long: `Search for files matching a regular expression pattern.
Supports recursive search, case-insensitive matching,
inverted matching, and file type filtering.`,
Example: ` # Search for Go files
filetool search "\.go$"
# Case-insensitive search
filetool search -i "readme"
# Search only directories
filetool search -r --type d "test"
# Inverted match (exclude pattern)
filetool search -v "\.git"
# Search with max depth
filetool search -r --max-depth 3 "main"`,
Args: cobra.RangeArgs(1, 2),
RunE: runSearch,
}
func init() {
searchCmd.Flags().BoolVarP(&searchRecursive, "recursive", "r", false,
"search recursively")
searchCmd.Flags().BoolVarP(&searchIgnoreCase, "ignore-case", "i", false,
"case-insensitive search")
searchCmd.Flags().BoolVarP(&searchInvert, "invert-match", "v", false,
"invert match (exclude pattern)")
searchCmd.Flags().StringVar(&searchType, "type", "",
"file type (f=file, d=directory)")
searchCmd.Flags().IntVar(&searchMaxDepth, "max-depth", -1,
"maximum search depth (-1 for unlimited)")
searchCmd.Flags().BoolVar(&searchShowProgress, "progress", false,
"show progress bar")
}
func runSearch(cmd *cobra.Command, args []string) error {
pattern := args[0]
path := "."
if len(args) > 1 {
path = args[1]
}
// 正規表現のコンパイル
var re *regexp.Regexp
var err error
if searchIgnoreCase {
re, err = regexp.Compile("(?i)" + pattern)
} else {
re, err = regexp.Compile(pattern)
}
if err != nil {
return fmt.Errorf("invalid pattern '%s': %w", pattern, err)
}
// パスの存在確認
if _, err := os.Stat(path); err != nil {
return fmt.Errorf("cannot access '%s': %w", path, err)
}
var matches []string
var totalScanned int
// プログレスバーの初期化(オプション)
var bar *progressbar.ProgressBar
if searchShowProgress {
bar = progressbar.NewOptions(-1,
progressbar.OptionSetDescription("Searching..."),
progressbar.OptionSetWriter(os.Stderr),
progressbar.OptionShowCount(),
progressbar.OptionSpinnerType(14),
)
}
// ファイルの検索
err = filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return nil // エラーを無視して継続
}
totalScanned++
if bar != nil {
bar.Add(1)
}
// 深さ制限のチェック
if searchMaxDepth >= 0 {
depth := strings.Count(strings.TrimPrefix(p, path), string(os.PathSeparator))
if depth > searchMaxDepth {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
}
// タイプフィルタ
switch searchType {
case "f":
if d.IsDir() {
return nil
}
case "d":
if !d.IsDir() {
return nil
}
}
// パターンマッチ
matched := re.MatchString(d.Name())
if searchInvert {
matched = !matched
}
if matched {
matches = append(matches, p)
}
return nil
})
if bar != nil {
bar.Finish()
fmt.Fprintln(os.Stderr)
}
if err != nil {
return err
}
// 結果の表示
highlightColor := color.New(color.FgGreen, color.Bold)
for _, match := range matches {
// マッチした部分をハイライト
highlighted := re.ReplaceAllStringFunc(match, func(s string) string {
return highlightColor.Sprint(s)
})
fmt.Println(highlighted)
}
if verbose {
fmt.Fprintf(os.Stderr, "\nFound %d matches (scanned %d items)\n",
len(matches), totalScanned)
}
return nil
}
cmd/stats.go - ディレクトリ統計
package cmd
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"sync"
"sync/atomic"
"github.com/fatih/color"
"github.com/schollz/progressbar/v3"
"github.com/spf13/cobra"
)
var (
statsHuman bool
statsExtensions bool
statsConcurrent bool
)
type Stats struct {
TotalFiles int64
TotalDirs int64
TotalSize int64
ExtStats map[string]*ExtStat
mu sync.Mutex
}
type ExtStat struct {
Count int64
Size int64
}
var statsCmd = &cobra.Command{
Use: "stats [path]",
Short: "Show directory statistics",
Long: `Calculate and display directory statistics including
file count, directory count, total size, and statistics by file extension.
Supports concurrent processing for better performance on large directories.`,
Example: ` # Show basic statistics
filetool stats
# Show with human-readable sizes
filetool stats -H
# Show extension breakdown
filetool stats --extensions
# Use concurrent processing
filetool stats --concurrent`,
Args: cobra.MaximumNArgs(1),
RunE: runStats,
}
func init() {
statsCmd.Flags().BoolVarP(&statsHuman, "human-readable", "H", false,
"print sizes in human readable format")
statsCmd.Flags().BoolVar(&statsExtensions, "extensions", false,
"show statistics by file extension")
statsCmd.Flags().BoolVar(&statsConcurrent, "concurrent", false,
"use concurrent processing")
}
func runStats(cmd *cobra.Command, args []string) error {
path := "."
if len(args) > 0 {
path = args[0]
}
// パスの存在確認
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("cannot access '%s': %w", path, err)
}
if !info.IsDir() {
return fmt.Errorf("'%s' is not a directory", path)
}
stats := &Stats{
ExtStats: make(map[string]*ExtStat),
}
// プログレスバー
bar := progressbar.NewOptions(-1,
progressbar.OptionSetDescription("Analyzing..."),
progressbar.OptionSetWriter(os.Stderr),
progressbar.OptionShowCount(),
progressbar.OptionSpinnerType(14),
)
if statsConcurrent {
err = collectStatsConcurrent(path, stats, bar)
} else {
err = collectStatsSequential(path, stats, bar)
}
bar.Finish()
fmt.Fprintln(os.Stderr)
if err != nil {
return err
}
// 結果の表示
displayStats(stats)
return nil
}
func collectStatsSequential(path string, stats *Stats, bar *progressbar.ProgressBar) error {
return filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
bar.Add(1)
if d.IsDir() {
atomic.AddInt64(&stats.TotalDirs, 1)
} else {
info, err := d.Info()
if err != nil {
return nil
}
atomic.AddInt64(&stats.TotalFiles, 1)
atomic.AddInt64(&stats.TotalSize, info.Size())
// 拡張子別統計
if statsExtensions {
ext := filepath.Ext(d.Name())
if ext == "" {
ext = "(no extension)"
}
stats.mu.Lock()
if _, ok := stats.ExtStats[ext]; !ok {
stats.ExtStats[ext] = &ExtStat{}
}
stats.ExtStats[ext].Count++
stats.ExtStats[ext].Size += info.Size()
stats.mu.Unlock()
}
}
return nil
})
}
func collectStatsConcurrent(path string, stats *Stats, bar *progressbar.ProgressBar) error {
// セマフォで同時実行数を制限
sem := make(chan struct{}, 10)
var wg sync.WaitGroup
err := filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
bar.Add(1)
if d.IsDir() {
atomic.AddInt64(&stats.TotalDirs, 1)
return nil
}
wg.Add(1)
sem <- struct{}{}
go func(entry fs.DirEntry) {
defer wg.Done()
defer func() { <-sem }()
info, err := entry.Info()
if err != nil {
return
}
atomic.AddInt64(&stats.TotalFiles, 1)
atomic.AddInt64(&stats.TotalSize, info.Size())
if statsExtensions {
ext := filepath.Ext(entry.Name())
if ext == "" {
ext = "(no extension)"
}
stats.mu.Lock()
if _, ok := stats.ExtStats[ext]; !ok {
stats.ExtStats[ext] = &ExtStat{}
}
stats.ExtStats[ext].Count++
stats.ExtStats[ext].Size += info.Size()
stats.mu.Unlock()
}
}(d)
return nil
})
wg.Wait()
return err
}
func displayStats(stats *Stats) {
headerColor := color.New(color.FgCyan, color.Bold)
labelColor := color.New(color.FgWhite)
valueColor := color.New(color.FgGreen, color.Bold)
headerColor.Println("\n=== Directory Statistics ===")
// 基本統計
fmt.Print(labelColor.Sprint("Files: "))
valueColor.Printf("%d\n", stats.TotalFiles)
fmt.Print(labelColor.Sprint("Directories: "))
valueColor.Printf("%d\n", stats.TotalDirs)
fmt.Print(labelColor.Sprint("Total size: "))
if statsHuman {
valueColor.Printf("%s\n", formatSize(stats.TotalSize))
} else {
valueColor.Printf("%d bytes\n", stats.TotalSize)
}
// 拡張子別統計
if statsExtensions && len(stats.ExtStats) > 0 {
headerColor.Println("\n=== Statistics by Extension ===")
// ソート用のスライス
type extEntry struct {
ext string
count int64
size int64
}
var entries []extEntry
for ext, stat := range stats.ExtStats {
entries = append(entries, extEntry{ext, stat.Count, stat.Size})
}
// サイズでソート
sort.Slice(entries, func(i, j int) bool {
return entries[i].size > entries[j].size
})
// 上位10件を表示
maxDisplay := 10
if len(entries) < maxDisplay {
maxDisplay = len(entries)
}
for i := 0; i < maxDisplay; i++ {
e := entries[i]
fmt.Printf(" %-20s %6d files ", e.ext, e.count)
if statsHuman {
fmt.Printf("%s\n", formatSize(e.size))
} else {
fmt.Printf("%d bytes\n", e.size)
}
}
if len(entries) > maxDisplay {
fmt.Printf(" ... and %d more extensions\n", len(entries)-maxDisplay)
}
}
}
cmd/watch.go - ファイル監視
package cmd
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/fatih/color"
"github.com/fsnotify/fsnotify"
"github.com/spf13/cobra"
)
var (
watchRecursive bool
watchEvents []string
)
var watchCmd = &cobra.Command{
Use: "watch <path>",
Short: "Watch directory for changes",
Long: `Monitor a directory for file system changes in real-time.
Displays events for file creation, modification, deletion, and renaming.
Supports recursive watching and event filtering.`,
Example: ` # Watch current directory
filetool watch .
# Watch recursively
filetool watch -r /path/to/dir
# Watch only create and delete events
filetool watch --events create,remove /path/to/dir`,
Args: cobra.ExactArgs(1),
RunE: runWatch,
}
func init() {
watchCmd.Flags().BoolVarP(&watchRecursive, "recursive", "r", false,
"watch recursively")
watchCmd.Flags().StringSliceVar(&watchEvents, "events", []string{},
"filter events (create,write,remove,rename,chmod)")
}
func runWatch(cmd *cobra.Command, args []string) error {
path := args[0]
// パスの存在確認
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("cannot access '%s': %w", path, err)
}
if !info.IsDir() {
return fmt.Errorf("'%s' is not a directory", path)
}
// ウォッチャーの作成
watcher, err := fsnotify.NewWatcher()
if err != nil {
return fmt.Errorf("failed to create watcher: %w", err)
}
defer watcher.Close()
// イベントフィルタのマップ
eventFilter := make(map[string]bool)
for _, e := range watchEvents {
eventFilter[e] = true
}
// パスを追加
if watchRecursive {
err = filepath.Walk(path, func(p string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
return watcher.Add(p)
}
return nil
})
} else {
err = watcher.Add(path)
}
if err != nil {
return fmt.Errorf("failed to add path to watcher: %w", err)
}
// カラー設定
createColor := color.New(color.FgGreen)
modifyColor := color.New(color.FgYellow)
deleteColor := color.New(color.FgRed)
renameColor := color.New(color.FgCyan)
chmodColor := color.New(color.FgMagenta)
fmt.Printf("Watching %s for changes (press Ctrl+C to stop)...\n\n", path)
// イベントループ
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return nil
}
// イベントフィルタリング
if len(eventFilter) > 0 {
shouldDisplay := false
if event.Op&fsnotify.Create != 0 && eventFilter["create"] {
shouldDisplay = true
}
if event.Op&fsnotify.Write != 0 && eventFilter["write"] {
shouldDisplay = true
}
if event.Op&fsnotify.Remove != 0 && eventFilter["remove"] {
shouldDisplay = true
}
if event.Op&fsnotify.Rename != 0 && eventFilter["rename"] {
shouldDisplay = true
}
if event.Op&fsnotify.Chmod != 0 && eventFilter["chmod"] {
shouldDisplay = true
}
if !shouldDisplay {
continue
}
}
// タイムスタンプ
timestamp := time.Now().Format("15:04:05")
// イベントタイプに応じた色付け
switch {
case event.Op&fsnotify.Create != 0:
createColor.Printf("[%s] CREATE: %s\n", timestamp, event.Name)
case event.Op&fsnotify.Write != 0:
modifyColor.Printf("[%s] MODIFY: %s\n", timestamp, event.Name)
case event.Op&fsnotify.Remove != 0:
deleteColor.Printf("[%s] DELETE: %s\n", timestamp, event.Name)
case event.Op&fsnotify.Rename != 0:
renameColor.Printf("[%s] RENAME: %s\n", timestamp, event.Name)
case event.Op&fsnotify.Chmod != 0:
chmodColor.Printf("[%s] CHMOD: %s\n", timestamp, event.Name)
}
// 再帰監視で新しいディレクトリが作成された場合
if watchRecursive && event.Op&fsnotify.Create != 0 {
info, err := os.Stat(event.Name)
if err == nil && info.IsDir() {
watcher.Add(event.Name)
if verbose {
fmt.Fprintf(os.Stderr, "Added directory to watch: %s\n", event.Name)
}
}
}
case err, ok := <-watcher.Errors:
if !ok {
return nil
}
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
}
}
}
cmd/compress.go - ファイル圧縮
package cmd
import (
"archive/zip"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/manifoldco/promptui"
"github.com/schollz/progressbar/v3"
"github.com/spf13/cobra"
)
var (
compressOutput string
compressRecursive bool
compressInteractive bool
)
var compressCmd = &cobra.Command{
Use: "compress <files...>",
Short: "Compress files into a zip archive",
Long: `Compress one or more files or directories into a zip archive.
Supports recursive compression of directories and interactive
file selection mode.`,
Example: ` # Compress single file
filetool compress file.txt
# Compress multiple files
filetool compress file1.txt file2.txt
# Compress directory recursively
filetool compress -r mydir
# Specify output file
filetool compress -o archive.zip files/
# Interactive mode
filetool compress -i`,
RunE: runCompress,
}
func init() {
compressCmd.Flags().StringVarP(&compressOutput, "output", "o", "archive.zip",
"output archive file")
compressCmd.Flags().BoolVarP(&compressRecursive, "recursive", "r", false,
"compress directories recursively")
compressCmd.Flags().BoolVarP(&compressInteractive, "interactive", "i", false,
"interactive file selection")
}
func runCompress(cmd *cobra.Command, args []string) error {
var filesToCompress []string
if compressInteractive {
// インタラクティブモード
selected, err := selectFilesInteractive()
if err != nil {
return err
}
filesToCompress = selected
} else {
if len(args) == 0 {
return fmt.Errorf("no files specified")
}
filesToCompress = args
}
if len(filesToCompress) == 0 {
fmt.Println("No files selected")
return nil
}
// 出力ファイルの作成
outFile, err := os.Create(compressOutput)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer outFile.Close()
zipWriter := zip.NewWriter(outFile)
defer zipWriter.Close()
// 処理するファイルの総数をカウント
totalFiles := 0
for _, file := range filesToCompress {
count, err := countFiles(file, compressRecursive)
if err != nil {
return err
}
totalFiles += count
}
// プログレスバー
bar := progressbar.NewOptions(totalFiles,
progressbar.OptionSetDescription("Compressing"),
progressbar.OptionSetWriter(os.Stderr),
progressbar.OptionShowCount(),
progressbar.OptionShowBytes(true),
progressbar.OptionSetPredictTime(true),
)
// ファイルを圧縮
for _, file := range filesToCompress {
if err := addToZip(zipWriter, file, "", compressRecursive, bar); err != nil {
return err
}
}
bar.Finish()
fmt.Fprintf(os.Stderr, "\n")
info, _ := outFile.Stat()
fmt.Printf("Successfully created %s (%s)\n",
compressOutput, formatSize(info.Size()))
return nil
}
func selectFilesInteractive() ([]string, error) {
// カレントディレクトリのファイル一覧を取得
entries, err := os.ReadDir(".")
if err != nil {
return nil, err
}
var items []string
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".") {
continue
}
name := entry.Name()
if entry.IsDir() {
name += "/"
}
items = append(items, name)
}
// プロンプトUI
prompt := promptui.Select{
Label: "Select files to compress (use arrow keys, press Enter when done)",
Items: append(items, "[Done]"),
Size: 10,
}
var selected []string
for {
_, result, err := prompt.Run()
if err != nil {
return nil, err
}
if result == "[Done]" {
break
}
selected = append(selected, strings.TrimSuffix(result, "/"))
}
return selected, nil
}
func countFiles(path string, recursive bool) (int, error) {
info, err := os.Stat(path)
if err != nil {
return 0, err
}
if !info.IsDir() {
return 1, nil
}
if !recursive {
return 1, nil
}
count := 0
err = filepath.Walk(path, func(p string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if !info.IsDir() {
count++
}
return nil
})
return count, err
}
func addToZip(zipWriter *zip.Writer, path, baseDir string, recursive bool, bar *progressbar.ProgressBar) error {
info, err := os.Stat(path)
if err != nil {
return err
}
if !info.IsDir() {
return addFileToZip(zipWriter, path, baseDir, bar)
}
if !recursive {
return nil
}
// ディレクトリを再帰的に処理
return filepath.Walk(path, func(p string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
return nil
}
relPath, err := filepath.Rel(filepath.Dir(path), p)
if err != nil {
return err
}
return addFileToZip(zipWriter, p, filepath.Join(baseDir, filepath.Dir(relPath)), bar)
})
}
func addFileToZip(zipWriter *zip.Writer, path, baseDir string, bar *progressbar.ProgressBar) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return err
}
header, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
// ZIP内のパスを設定
if baseDir != "" {
header.Name = filepath.Join(baseDir, filepath.Base(path))
} else {
header.Name = filepath.Base(path)
}
// 圧縮方法を設定
header.Method = zip.Deflate
writer, err := zipWriter.CreateHeader(header)
if err != nil {
return err
}
_, err = io.Copy(writer, file)
bar.Add(1)
return err
}
config.yaml - 設定ファイル例
# filetool configuration file
# デフォルトの出力形式
output:
format: text # text | json
color: true # カラー出力を有効化
# list コマンドのデフォルト設定
list:
show_hidden: false
long_format: false
sort_by: name # name | size | time
# search コマンドのデフォルト設定
search:
ignore_case: false
max_depth: -1
show_progress: false
# stats コマンドのデフォルト設定
stats:
human_readable: true
show_extensions: true
concurrent: true
# watch コマンドのデフォルト設定
watch:
recursive: true
events:
- create
- write
- remove
# compress コマンドのデフォルト設定
compress:
default_output: archive.zip
compression_level: 9 # 0-9
# 除外パターン
exclude:
- ".git"
- ".DS_Store"
- "node_modules"
- "*.swp"
テストコード例
// cmd/list_test.go
package cmd
import (
"bytes"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
func TestListCommand(t *testing.T) {
// テスト用の一時ディレクトリを作成
tmpDir, err := os.MkdirTemp("", "filetool-test")
assert.NoError(t, err)
defer os.RemoveAll(tmpDir)
// テストファイルを作成
testFiles := []string{"file1.txt", "file2.go", "file3.md"}
for _, f := range testFiles {
path := filepath.Join(tmpDir, f)
err := os.WriteFile(path, []byte("test"), 0644)
assert.NoError(t, err)
}
// コマンド実行
buf := new(bytes.Buffer)
rootCmd.SetOut(buf)
rootCmd.SetArgs([]string{"list", tmpDir})
err = rootCmd.Execute()
assert.NoError(t, err)
output := buf.String()
for _, f := range testFiles {
assert.Contains(t, output, f)
}
}
func TestListCommandJSON(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "filetool-test")
assert.NoError(t, err)
defer os.RemoveAll(tmpDir)
// テストファイルを作成
testFile := filepath.Join(tmpDir, "test.txt")
err = os.WriteFile(testFile, []byte("test"), 0644)
assert.NoError(t, err)
// JSON出力でコマンド実行
buf := new(bytes.Buffer)
rootCmd.SetOut(buf)
rootCmd.SetArgs([]string{"list", "-o", "json", tmpDir})
err = rootCmd.Execute()
assert.NoError(t, err)
output := buf.String()
assert.Contains(t, output, "\"name\":")
assert.Contains(t, output, "test.txt")
}
ビルドとインストール
# 開発ビルド
go build -o filetool
# リリースビルド(バージョン情報埋め込み)
VERSION=$(git describe --tags --always)
COMMIT=$(git rev-parse --short HEAD)
DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
go build -ldflags "\
-X main.version=${VERSION} \
-X main.commit=${COMMIT} \
-X main.date=${DATE}" \
-o filetool
# インストール
go install
# クロスコンパイル
GOOS=linux GOARCH=amd64 go build -o filetool-linux
GOOS=windows GOARCH=amd64 go build -o filetool.exe
GOOS=darwin GOARCH=arm64 go build -o filetool-darwin-arm64
使用例
# ファイル一覧
filetool list
filetool list -r -l /path/to/dir
filetool list -o json > files.json
# ファイル検索
filetool search "\.go$"
filetool search -i -r "readme" /path/to/project
# 統計情報
filetool stats
filetool stats -H --extensions /path/to/dir
# ファイル監視
filetool watch .
filetool watch -r --events create,write /path/to/dir
# ファイル圧縮
filetool compress file1 file2 file3
filetool compress -r -o backup.zip /path/to/dir
filetool compress -i
# バージョン確認
filetool version
# ヘルプ
filetool --help
filetool list --help
まとめ
この解答例では、以下の実装ポイントを学びました:
これらの技術は、実務レベルのCLIツール開発に直接応用できます。