タイムスタンプを見ず、中身が変わっていたらビルドする

# これは何？

先日のツイート

https://twitter.com/Nabetani/status/1539858949079322624

の実装。

# 状況

タイムスタンプが信用できないファイルが絡むビルドをしたいことがある。

* 手元で生成できるファイルが、 git でも管理されている
* 自動更新されるデータ（スクレイピングの結果からの抽出物とか）をソースにする

など。

具体的には、たとえばとこんな rakefile（抜粋）があったとしよう。

```ruby:rakefile
file "generated/cooked.json" => "hoge.json" do |a|
  cook_json( a.sources, a.name )
end
```

`hoge.json` をなんか処理して、 `cooked.json` を作っている。
`cooked.json` 自体はそんなに大きくはない。

しかし、なにかわからない事情によって `hoge.json` のタイムスタンプは信用できないので、上記の `rakefile` はうまく機能しない。

`touch hoge.json` などとした上で rake するとビルドされるが、 `cooked.json` を生成する処理は割と重いので、`hoge.json` が変わっていないのであればそっとしておきたい。

# 解決方法

```ruby:rakefile
file digest_filename_of("generated/cooked.json") => digest_filename_of("hoge.json") do |a|
  cook_json( "hoge.json", a.name )
end
```

まずは、こんな風にして、解決を狙う。
依存ファイルにタイムスタンプが信用できないファイルがある場合、 `digest_filename_of` でくるむと解決するように `digest_filename_of` を定義しておく。
中身はこんな感じ。

```ruby:ruby
def digest_filename_of(fn)
  FileUtils.mkdir_p( INTERMEDIATE_DIR )
  digest = ->(x){ Digest::SHA256.hexdigest(x).strip }
  fread = ->(x){ File.open(x,&:read) rescue "" }
  dfn = File.join(INTERMEDIATE_DIR, digest[fn])
  d = digest[fread[fn]]
  if ! File.exist?(dfn) || fread[dfn] != d
    File.open( dfn, "w" ) do |f|
      f.write(d)
    end
  end
  dfn
end
```

まあ見ての通り。

雑に説明すると、ダイジェスト（ハッシュ）をおぼえておいて、前回と同じかどうかを利用する。
ダイジェストが一致していればファイルを更新しない。

### この作戦の欠点

見ての通り、

```ruby:rakefile
file "generated/cooked.json" => "hoge.json" do |a|
  cook_json( a.sources, a.name )
end
```

でやっていたような `a.sources` が使えないのが弱点。

あと、生成されるファイルのタイムスタンプが信用できない場合はうまく行かない。
この記事を公開した当初はうまくいくと思っていたんだけど、駄目っぽい。

# もっといい解決案

Qiita に書いたおかげで地味に広まり

https://twitter.com/amasawa_seiji/status/1541682656810676224

と、[task](https://taskfile.dev/) というツールを教えていただいた。

このツール、タイムスタンプではなくチェックサム（「サム」と単に加算していそうな名前だけど、おそらくダイジェスト）を見るようになっているので、なにもしなくてもこの記事のタイトルのような動作になる。
素晴らしい。

amasawa_seiji 様、ありがとうございます。

# rake に手を入れる

rake に手を入れれば解決できるので、手を入れてみた。

## ユーザーコード側

```ruby:rakefile(抜粋)
require_relative "./contents_task.rb"

contents "generated/cooked.json" => "hoge.json" do |a|
  cook_json( a.sources, a.name )
end
```

`file` の代わりに `contents` と書くとタイムスタンプではなく中身の比較でビルドするかどうかを判断する。

`a.sources` や `a.name` も普通に使える。

## 実装側

問題の `contents_task.rb` は、以下の通り：

```ruby:contents_task.rb
# frozen_string_literal: true
require "rake/task"
require "digest/sha2"
require "fileutils"
require "json"

module Rake
  class ContentsTask < Task
    def initialize(*args)
      super
      @intermediate_dir = ".intermediate"
    end

    attr_accessor :intermediate_dir

    def execute(args=nil)
      super
      update_state
    end

    def needed?
      should_build?(name) || @application.options.build_all
    end

    private

    def state_fn(files)
      File.join(intermediate_dir, Digest::SHA256.hexdigest(files.sort.to_json))
    end

    def diget_files(files)
      a=Digest::SHA256.new
      files.sort.each do |fn|
        return nil unless File.exist?(fn)
        a.file(fn)
      end
      a.hexdigest
    end

    def should_build?(name)
      files = ([name]+prerequisites).flatten
      dfn = state_fn(files)
      return true unless File.exist?(dfn)
      digest_val = diget_files(files)
      return true unless digest_val
      return digest_val != File.open(dfn, &:read).strip
    end

    def update_state
      files = ([name]+prerequisites).flatten
      dfn = state_fn(files)
      FileUtils.mkdir_p( File.split(dfn)[0])
      File.open(dfn, "w") do |f|
        f.write(diget_files(files))
      end
    end

    class << self
      def scope_name(scope, task_name)
        Rake.from_pathname(task_name)
      end
    end
  end
end

module Rake
  module DSL
    def contents(*args, &block)
      Rake::ContentsTask.define_task(*args, &block)
    end
  end
end
```

七十余行。

そんなに真剣にテストしてなけど、うまく行っている気がする。
どうだろう。