Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions cmd/sava/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package main
import (
"strings"
"testing"
"time"

"github.com/rn404/nippo-cli/internal/logfile"
"github.com/rn404/nippo-cli/internal/model"
)

Expand Down Expand Up @@ -231,6 +233,43 @@ func TestClearAllWithYes(t *testing.T) {
}
}

// TestCarryFlow exercises Phase C end-to-end through the CLI: an
// unfinished TODO left over from a previous day should reappear in
// today's list under a new hash, with that source day now frozen,
// the moment the first command of a new day is run.
func TestCarryFlow(t *testing.T) {
t.Setenv("HOME", t.TempDir())
dir := logfile.Dir()

yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
file, err := logfile.Get(dir, yesterday)
if err != nil {
t.Fatal(err)
}
open := false
file.Body.Items = []model.Item{
{Hash: "open1111", Content: "unfinished todo", CreatedAt: "2026-01-01T00:00:00.000Z", UpdatedAt: "2026-01-01T00:00:00.000Z", Closed: &open},
}
if err := logfile.Update(dir, yesterday, file.Body); err != nil {
t.Fatal(err)
}

out := mustExecute(t, "add", "today's memo")
if !strings.Contains(out, "Carried 1 items from "+yesterday+" (that day is now frozen).") {
t.Errorf("carry notice missing from CLI output:\n%s", out)
}

list := mustExecute(t, "list")
if !strings.Contains(list, "[ ] unfinished todo") {
t.Errorf("carried todo should appear in today's list:\n%s", list)
}

stat := mustExecute(t, "list", yesterday, "-s")
if !strings.Contains(stat, yesterday+"*") {
t.Errorf("yesterday should show as frozen in stats:\n%s", stat)
}
}

func TestInvalidDateFails(t *testing.T) {
t.Setenv("HOME", t.TempDir())

Expand Down
2 changes: 1 addition & 1 deletion docs/memo-log-redesign.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ Added!!
`start`・`end`(独立コマンドのまま、複数 hash 対応で #10 を吸収)/
追加・削除時の結果出力(#25)
* [x] Phase B: `list` タイムライン化
* [ ] Phase C: 自動 carry(トリガー・対象日探索・新 hash コピー・
* [x] Phase C: 自動 carry(トリガー・対象日探索・新 hash コピー・
`carriedFrom`・`logfile.Update` の凍結ガード撤去と呼び出し側での
凍結チェック)→ #8 解決、#12 再評価

Expand Down
93 changes: 87 additions & 6 deletions internal/command/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ type AddOptions struct {

// Add appends a memo to today's log.
func Add(w io.Writer, dir, content string, opts AddOptions) error {
file, err := logfile.Get(dir, "")
file, err := ensureToday(w, dir)
if err != nil {
return err
}
Expand Down Expand Up @@ -71,6 +71,80 @@ func persistItem(w io.Writer, dir string, file *logfile.LogFile, item model.Item
return nil
}

// ensureToday returns today's log file, running the automatic carry
// engine first if today has not been written to yet. Carry copies
// every unfinished TODO on the most recent existing log day before
// today forward into today's (about to be created) log with fresh
// hashes, then freezes that source day so it is never carried again.
// A day that is already frozen, or no prior day at all, leaves today
// as a plain empty log, same as before Phase C. Once today's file
// exists, every later call within the same day just returns it as-is:
// the file's existence is itself the one-carry-per-day guarantee, so
// this never re-runs carry.
func ensureToday(w io.Writer, dir string) (*logfile.LogFile, error) {
if today, err := logfile.Stat(dir, ""); err == nil {
return today, nil
} else if !errors.Is(err, logfile.ErrNotFound) {
return nil, err
}

body := model.NewLog()
carried, sourceDate, err := carryFromPreviousDay(dir)
if err != nil {
return nil, err
}
if len(carried) > 0 {
body.Items = carried
view.Carried(w, len(carried), sourceDate)
}

if err := logfile.Update(dir, "", body); err != nil {
return nil, err
}
return logfile.Get(dir, "")
}

// carryFromPreviousDay finds the most recent existing log day strictly
// before today, copies its unfinished TODOs forward, and freezes that
// day so it becomes permanently read-only. It is a no-op (nil items,
// empty sourceDate) when no such day exists, or that day is already
// frozen (already carried from).
func carryFromPreviousDay(dir string) (carried []model.Item, sourceDate string, err error) {
refs, err := logfile.List(dir)
if err != nil {
return nil, "", err
}

today := model.Today()
var source *logfile.Ref
for i := range refs {
if refs[i].Name >= today {
break // refs is sorted ascending, so nothing further back stays before today.
}
source = &refs[i]
}
if source == nil {
return nil, "", nil
}

file, err := logfile.Stat(dir, source.Name)
if err != nil {
return nil, "", err
}
if file.Body.Freezed {
return nil, "", nil
}

carried = log.CarryForward(file.Body.Items, source.Name)

file.Body.Freezed = true
if err := logfile.Update(dir, source.Name, file.Body); err != nil {
return nil, "", err
}

return carried, source.Name, nil
}

// TodoOptions controls the todo command behavior.
type TodoOptions struct {
Start bool // mark the task as started right away
Expand All @@ -79,7 +153,7 @@ type TodoOptions struct {

// Todo appends a TODO item (task) to today's log.
func Todo(w io.Writer, dir, content string, opts TodoOptions) error {
file, err := logfile.Get(dir, "")
file, err := ensureToday(w, dir)
if err != nil {
return err
}
Expand All @@ -105,7 +179,7 @@ func Todo(w io.Writer, dir, content string, opts TodoOptions) error {
// Tag adds tags to (or removes them from, when remove is true) the
// item matching hash in today's log, then refreshes the index.
func Tag(w io.Writer, dir, hash string, tags []string, remove bool) error {
file, err := logfile.Get(dir, "")
file, err := ensureToday(w, dir)
if err != nil {
return err
}
Expand Down Expand Up @@ -223,7 +297,7 @@ func elapsedBetween(a, b model.Item) (time.Duration, error) {

// Start marks the task matching hash in today's log as started.
func Start(w io.Writer, dir, hash string) error {
file, err := logfile.Get(dir, "")
file, err := ensureToday(w, dir)
if err != nil {
return err
}
Expand All @@ -247,7 +321,7 @@ func Start(w io.Writer, dir, hash string) error {
// nothing about two concurrent sava processes racing on the same
// file — there is no file locking anywhere in this codebase).
func End(w io.Writer, dir string, hashes []string) error {
file, err := logfile.Get(dir, "")
file, err := ensureToday(w, dir)
if err != nil {
return err
}
Expand Down Expand Up @@ -323,12 +397,19 @@ func Del(w io.Writer, dir, ref string, deep bool) error {
}
}

// deleteOn removes the item matching hash from the log for date.
// deleteOn removes the item matching hash from the log for date. date
// may be any past day, not just today (see Del), so unlike the other
// write commands this must check Freezed itself: logfile.Update no
// longer guards against writing to a frozen day, and a carried-from
// day being permanently read-only is the whole point of freezing it.
func deleteOn(w io.Writer, dir, date, hash string) error {
file, err := logfile.Stat(dir, date)
if err != nil {
return err
}
if file.Body.Freezed {
return fmt.Errorf("%s: %w", date, logfile.ErrFreezed)
}

item, err := log.Delete(&file.Body, hash)
if err != nil {
Expand Down
Loading
Loading