課題20: 完全なCLIアプリケーション

課題概要

これはGo Foundationsコースの最終課題です。これまで学んだすべての知識を統合して、完全に機能するCLIアプリケーションを構築します。プロジェクト構成、設計、実装、テスト、ドキュメントまで、実際の開発プロセス全体を経験します。

マンダトリー要件(80点)

プロジェクト概要

ノート管理CLI(notecli) を作成してください。

機能要件

  • ノートの作成、編集、削除、一覧表示
  • タグによる分類
  • 検索機能
  • JSONファイルによるデータ永続化
  • 設定ファイルのサポート

要件1: プロジェクト構造(15点)

以下のディレクトリ構造でプロジェクトを構成してください。

notecli/
├── cmd/
│   └── notecli/
│       └── main.go
├── internal/
│   ├── cli/
│   │   └── cli.go
│   ├── note/
│   │   ├── note.go
│   │   └── note_test.go
│   ├── storage/
│   │   ├── storage.go
│   │   └── storage_test.go
│   └── config/
│       └── config.go
├── go.mod
├── go.sum
├── Makefile
├── README.md
└── .gitignore

要件2: データモデル(15点)

実装ファイル: internal/note/note.go

package note

import (
    "time"
)

type Note struct {
    ID        int       `json:"id"`
    Title     string    `json:"title"`
    Content   string    `json:"content"`
    Tags      []string  `json:"tags"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
}

// New は新しいノートを作成します
func New(id int, title, content string, tags []string) *Note {
    // TODO: 実装
}

// Update はノートを更新します
func (n *Note) Update(title, content string, tags []string) {
    // TODO: 実装
}

// HasTag は指定されたタグを持つか確認します
func (n *Note) HasTag(tag string) bool {
    // TODO: 実装
}

// MatchesSearch は検索クエリにマッチするか確認します
func (n *Note) MatchesSearch(query string) bool {
    // TODO: 実装
    // タイトルまたはコンテンツに検索文字列が含まれるか
}

テストファイル: internal/note/note_test.go

package note

import "testing"

func TestNote(t *testing.T) {
    t.Run("New note", func(t *testing.T) {
        // TODO: 新規作成のテスト
    })

    t.Run("Update note", func(t *testing.T) {
        // TODO: 更新のテスト
    })

    t.Run("HasTag", func(t *testing.T) {
        // TODO: タグ検索のテスト
    })

    t.Run("MatchesSearch", func(t *testing.T) {
        // TODO: 検索マッチングのテスト
    })
}

要件3: ストレージ層(20点)

実装ファイル: internal/storage/storage.go

package storage

import (
    "encoding/json"
    "os"
    "sync"

    "github.com/username/notecli/internal/note"
)

type Store struct {
    filepath string
    notes    map[int]*note.Note
    nextID   int
    mu       sync.RWMutex
}

// NewStore はストアを初期化します
func NewStore(filepath string) (*Store, error) {
    // TODO: 実装
    // 1. ストア構造体を作成
    // 2. ファイルが存在すればロード
}

// Add はノートを追加します
func (s *Store) Add(title, content string, tags []string) (*note.Note, error) {
    // TODO: 実装
}

// Get は指定IDのノートを取得します
func (s *Store) Get(id int) (*note.Note, bool) {
    // TODO: 実装
}

// Update はノートを更新します
func (s *Store) Update(id int, title, content string, tags []string) error {
    // TODO: 実装
}

// Delete はノートを削除します
func (s *Store) Delete(id int) error {
    // TODO: 実装
}

// List はすべてのノートを取得します
func (s *Store) List() []*note.Note {
    // TODO: 実装
}

// Search は検索クエリにマッチするノートを取得します
func (s *Store) Search(query string) []*note.Note {
    // TODO: 実装
}

// FilterByTag はタグでフィルタリングします
func (s *Store) FilterByTag(tag string) []*note.Note {
    // TODO: 実装
}

// load はファイルからデータを読み込みます
func (s *Store) load() error {
    // TODO: 実装
}

// save はデータをファイルに保存します
func (s *Store) save() error {
    // TODO: 実装
}

要件4: CLI インターフェース(30点)

実装ファイル: internal/cli/cli.go

package cli

import (
    "flag"
    "fmt"
    "os"
    "path/filepath"
    "strings"

    "github.com/username/notecli/internal/storage"
)

type CLI struct {
    store *storage.Store
}

func New() (*CLI, error) {
    // ホームディレクトリにデータファイルを配置
    home, err := os.UserHomeDir()
    if err != nil {
        return nil, err
    }

    dataFile := filepath.Join(home, ".notecli.json")
    store, err := storage.NewStore(dataFile)
    if err != nil {
        return nil, err
    }

    return &CLI{store: store}, nil
}

func (c *CLI) Run() error {
    if len(os.Args) < 2 {
        c.printUsage()
        return nil
    }

    command := os.Args[1]

    switch command {
    case "add":
        return c.addNote(os.Args[2:])
    case "list":
        return c.listNotes(os.Args[2:])
    case "show":
        return c.showNote(os.Args[2:])
    case "edit":
        return c.editNote(os.Args[2:])
    case "delete":
        return c.deleteNote(os.Args[2:])
    case "search":
        return c.searchNotes(os.Args[2:])
    case "tag":
        return c.filterByTag(os.Args[2:])
    case "help":
        c.printUsage()
        return nil
    default:
        return fmt.Errorf("unknown command: %s", command)
    }
}

func (c *CLI) printUsage() {
    fmt.Println("Note Manager CLI")
    fmt.Println("\nUsage:")
    fmt.Println("  notecli <command> [options]")
    fmt.Println("\nCommands:")
    fmt.Println("  add         Add a new note")
    fmt.Println("  list        List all notes")
    fmt.Println("  show        Show a note")
    fmt.Println("  edit        Edit a note")
    fmt.Println("  delete      Delete a note")
    fmt.Println("  search      Search notes")
    fmt.Println("  tag         Filter notes by tag")
    fmt.Println("  help        Show this help message")
}

func (c *CLI) addNote(args []string) error {
    fs := flag.NewFlagSet("add", flag.ExitOnError)
    title := fs.String("title", "", "Note title (required)")
    content := fs.String("content", "", "Note content")
    tags := fs.String("tags", "", "Comma-separated tags")

    fs.Parse(args)

    if *title == "" {
        return fmt.Errorf("title is required")
    }

    tagList := []string{}
    if *tags != "" {
        tagList = strings.Split(*tags, ",")
        for i := range tagList {
            tagList[i] = strings.TrimSpace(tagList[i])
        }
    }

    note, err := c.store.Add(*title, *content, tagList)
    if err != nil {
        return err
    }

    fmt.Printf("Note added: [%d] %s\n", note.ID, note.Title)
    return nil
}

func (c *CLI) listNotes(args []string) error {
    // TODO: 実装
    // すべてのノートを一覧表示
}

func (c *CLI) showNote(args []string) error {
    // TODO: 実装
    // 指定IDのノートを詳細表示
}

func (c *CLI) editNote(args []string) error {
    // TODO: 実装
    // ノートを編集
}

func (c *CLI) deleteNote(args []string) error {
    // TODO: 実装
    // ノートを削除
}

func (c *CLI) searchNotes(args []string) error {
    // TODO: 実装
    // キーワードでノートを検索
}

func (c *CLI) filterByTag(args []string) error {
    // TODO: 実装
    // タグでフィルタリング
}

使用例

# ノートを追加
$ notecli add -title "Go学習メモ" -content "contextパッケージについて" -tags "go,programming"
Note added: [1] Go学習メモ

# すべてのノートを一覧表示
$ notecli list
[1] Go学習メモ
    Tags: go, programming
    Created: 2024-01-15 10:00:00

# ノートを表示
$ notecli show -id 1
[1] Go学習メモ
Tags: go, programming
Created: 2024-01-15 10:00:00
Updated: 2024-01-15 10:00:00

Content:
contextパッケージについて

# 検索
$ notecli search -query "context"
Found 1 note(s):
[1] Go学習メモ

# タグでフィルタ
$ notecli tag -tag "go"
[1] Go学習メモ

# ノートを編集
$ notecli edit -id 1 -title "Go Context学習" -content "context.Contextの使い方"
Note updated: [1] Go Context学習

# ノートを削除
$ notecli delete -id 1
Note deleted: [1]

ボーナス課題(20点)

ボーナス1: 設定ファイル(5点)

設定ファイル(~/.notecli.yaml)のサポートを追加してください。

実装ファイル: internal/config/config.go

package config

import (
    "os"
    "path/filepath"

    "gopkg.in/yaml.v3"
)

type Config struct {
    DataFile   string `yaml:"data_file"`
    DefaultTag string `yaml:"default_tag"`
    DateFormat string `yaml:"date_format"`
}

func Load() (*Config, error) {
    // TODO: 実装
    // 1. ~/.notecli.yamlを読み込み
    // 2. 存在しなければデフォルト設定を返す
}

func (c *Config) Save() error {
    // TODO: 実装
}

ボーナス2: エクスポート機能(5点)

ノートをMarkdown形式でエクスポートする機能を追加してください。

# 単一ノートをエクスポート
$ notecli export -id 1 -output note.md

# すべてのノートをエクスポート
$ notecli export -all -output notes/

ボーナス3: インポート機能(5点)

Markdown形式のファイルからノートをインポートする機能を追加してください。

$ notecli import -file note.md

ボーナス4: カラー出力(5点)

ターミナルでカラー出力を使って見やすくしてください。

package cli

import "fmt"

const (
    ColorReset  = "\033[0m"
    ColorRed    = "\033[31m"
    ColorGreen  = "\033[32m"
    ColorYellow = "\033[33m"
    ColorBlue   = "\033[34m"
    ColorCyan   = "\033[36m"
)

func colorize(color, text string) string {
    return color + text + ColorReset
}

func (c *CLI) listNotes(args []string) error {
    notes := c.store.List()

    for _, note := range notes {
        fmt.Printf("%s [%d] %s\n",
            colorize(ColorCyan, "Note"),
            note.ID,
            colorize(ColorGreen, note.Title))
    }

    return nil
}

評価基準

項目 配点 詳細
プロジェクト構造 15点 適切なディレクトリ構造
データモデル 15点 Note構造体と関連メソッド
ストレージ層 20点 永続化とCRUD操作
CLI インターフェース 30点 すべてのコマンドが動作する
**ボーナス1: 設定** 5点 YAML設定ファイルのサポート
**ボーナス2: エクスポート** 5点 Markdownエクスポート
**ボーナス3: インポート** 5点 Markdownインポート
**ボーナス4: カラー出力** 5点 見やすいカラー表示

提出方法

以下を含むGitリポジトリとして提出してください:

notecli/
├── cmd/
├── internal/
├── go.mod
├── go.sum
├── Makefile
├── README.md         # 使い方とビルド方法
└── .gitignore

README.mdに含めるべき内容

  • プロジェクト概要
  • インストール方法
  • 使用例
  • ビルド方法
  • テスト実行方法
  • 必須要件チェックリスト

  • [ ] プロジェクト構造が適切に構成されている
  • [ ] Note構造体が実装されている
  • [ ] ストレージ層が実装されている
  • [ ] すべてのCLIコマンドが動作する
  • [ ] ユニットテストが含まれている
  • [ ] Makefileが含まれている
  • [ ] README.mdが詳細に書かれている
  • [ ] go.modが適切に設定されている
  • ヒント

  • 段階的に実装: まず基本機能を実装し、動作確認してから次へ
  • テスト駆動: 各機能のテストを先に書く
  • エラー処理: すべてのエラーを適切に処理
  • ユーザビリティ: わかりやすいエラーメッセージ
  • ドキュメント: コードコメントとREADME.mdを充実させる
  • 学習リソース

  • Project Layout
  • Effective Go
  • Go Code Review Comments
  • Writing CLI Applications

最後に

これはあなたのGo学習の集大成です。丁寧に、そして楽しんで実装してください。完成したアプリケーションは、実際に日常で使える実用的なツールになります。

頑張ってください!