Module: VivlioStarter::CLI::DoctorCommands::ConfigSalvager

Defined in:
lib/vivlio_starter/cli/doctor/config_salvager.rb

Overview

破損した設定ファイルからのサルベージ復元(機能 D / best-effort)

Defined Under Namespace

Classes: Result

Constant Summary collapse

BOOK_SALVAGE_KEYS =

行スキャンで救出を試みる book.yml のトップレベル単一行スカラー。 複数行ブロックスカラー(legal.* 等)は境界判定できないため対象外(spec §3D.3)。

%i[main_title subtitle series release publisher contact author name].freeze
BOOK_PLACEHOLDERS =

scaffold テンプレートのプレースホルダと salvage キーの対応

{
  '{{MAIN_TITLE}}' => :main_title,
  '{{SUBTITLE}}' => :subtitle,
  '{{AUTHOR}}' => :author,
  '{{PUBLISHER}}' => :publisher,
  '{{PROJECT_NAME}}' => :name
}.freeze
BOOK_DEFAULTS =

救出値が無い場合の既定値(vs new の DEFAULT_ANSWERS と同じ非対話既定)

{ main_title: '新しい本', subtitle: '', author: '', publisher: '' }.freeze
CATALOG_SECTIONS =

catalog.yml のセクションと章種別の対応(出力順を兼ねる)

{
  preface: 'PREFACE', chapter: 'CHAPTERS', appendix: 'APPENDICES', postface: 'POSTFACE'
}.freeze

Class Method Summary collapse

Class Method Details

.chapter_entries_from_contentsObject

contents/ の章ファイルを [basename, kind] の配列で返す(章番号昇順)。 アンダースコア始まりのシステムページと、章番号レンジ外のファイルは除外する。



125
126
127
128
129
130
131
132
# File 'lib/vivlio_starter/cli/doctor/config_salvager.rb', line 125

def chapter_entries_from_contents
  Dir.glob(File.join(Common::CONTENTS_DIR, '*.md'))
     .map { File.basename(it, '.md') }
     .reject { it.start_with?('_') }
     .filter_map { |base| (number = base[/\A(\d+)(?=[-_]|\z)/, 1]) && [base, number.to_i] }
     .sort_by { |base, number| [number, base] }
     .filter_map { |base, number| (kind = kind_for(number)) && [base, kind] }
end

.default_book_value(key) ⇒ Object

救出値が無いキーの既定値。プロジェクト名はカレントディレクトリ名を使う (vs new がディレクトリ名を既定のプロジェクト名にするのと同じ流儀)



188
189
190
# File 'lib/vivlio_starter/cli/doctor/config_salvager.rb', line 188

def default_book_value(key)
  key == :name ? File.basename(Dir.pwd) : BOOK_DEFAULTS.fetch(key, '')
end

.extract_book_scalars(corrupt_content) ⇒ Object

破損内容を行単位でスキャンし、救出対象キーの値を集める。 破損行は単にマッチしないだけで処理は継続する(取りこぼし容認)。



158
159
160
161
162
163
164
165
166
167
168
# File 'lib/vivlio_starter/cli/doctor/config_salvager.rb', line 158

def extract_book_scalars(corrupt_content)
  BOOK_SALVAGE_KEYS.each_with_object({}) do |key, found|
    corrupt_content.each_line do |line|
      next unless (value = scalar_value_from(line, key))

      # プレースホルダが残っている値は「利用者の入力」ではないため救出しない
      found[key] = value unless value.empty? || value.include?('{{')
      break
    end
  end
end

.kind_for(number) ⇒ Object

章番号→セクション判定は TokenResolver の定義を再利用する(判定ロジックを複製しない)



135
136
137
# File 'lib/vivlio_starter/cli/doctor/config_salvager.rb', line 135

def kind_for(number)
  TokenResolver::Resolver::KIND_RANGES.find { |_, range| range.cover?(number) }&.first
end

.render_book_yml(scaffold_path, salvaged = {}) ⇒ String

scaffold の book.yml テンプレートを展開する。salvaged の値を優先し、 無いキーは既定値で埋める(機能 A の「欠落 book.yml の素の復元」でも使用)。

Parameters:

  • scaffold_path (String)
  • salvaged (Hash{Symbol => String}) (defaults to: {})

    救出した値(省略時は全て既定値)

Returns:

  • (String)

    展開後の book.yml 全文



81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/vivlio_starter/cli/doctor/config_salvager.rb', line 81

def render_book_yml(scaffold_path, salvaged = {})
  # --- Phase: プレースホルダ展開 ---
  # 単一パス置換で値中の別プレースホルダ文字列の二重展開を防ぐ(vs new と同じ手法)
  substitutions = BOOK_PLACEHOLDERS.to_h do |placeholder, key|
    [placeholder, yaml_escape(salvaged.fetch(key) { default_book_value(key) })]
  end
  pattern = Regexp.union(substitutions.keys)
  content = File.read(scaffold_path, encoding: 'utf-8').gsub(pattern) { substitutions[it] }

  # --- Phase: プレースホルダの無いキーの行置換 ---
  # series / release / contact は scaffold に実値が入っているため、
  # 救出値がある場合のみ該当行の値部分を書き換える
  (salvaged.keys - BOOK_PLACEHOLDERS.values).each do |key|
    content = replace_scalar_line(content, key, salvaged[key])
  end
  content
end

.replace_scalar_line(content, key, value) ⇒ Object

key: "..." 行の値部分だけを救出値で置き換える(コメントは保持)



193
194
195
196
197
# File 'lib/vivlio_starter/cli/doctor/config_salvager.rb', line 193

def replace_scalar_line(content, key, value)
  content.sub(/^(\s{2}#{Regexp.escape(key.to_s)}:\s*)(?:"(?:[^"\\]|\\.)*"|'[^']*'|[^#\n]*?)(\s*#[^\n]*)?$/) do
    %(#{Regexp.last_match(1)}"#{yaml_escape(value)}"#{Regexp.last_match(2)})
  end
end

.salvage(path, corrupt_content, scaffold_path) ⇒ Result?

破損ファイルから復元内容を生成する。 救出できない・対象外のファイルなら nil(素の scaffold 復元へフォールバック)。

Parameters:

  • path (String)

    破損した設定ファイルのパス(config/book.yml 等)

  • corrupt_content (String)

    .bak 退避前に読み取った破損ファイルの中身

  • scaffold_path (String)

    scaffold 側の同名ファイル

Returns:



64
65
66
67
68
69
70
71
72
73
74
# File 'lib/vivlio_starter/cli/doctor/config_salvager.rb', line 64

def salvage(path, corrupt_content, scaffold_path)
  # 破損ファイルは不正な UTF-8 バイト列を含み得る(ファズテスト FZ-01 で検出)。
  # 行スキャンの正規表現が ArgumentError で落ちないよう不正バイトを除去する。
  # 拾えない行が増えるだけで best-effort の範囲(§3D.1)
  content = corrupt_content.to_s.dup.force_encoding(Encoding::UTF_8).scrub('')

  case File.basename(path)
  when 'catalog.yml' then salvage_catalog
  when 'book.yml' then salvage_book(content, scaffold_path)
  end
end

.salvage_book(corrupt_content, scaffold_path) ⇒ Object

破損 book.yml から単一行スカラーを抽出し、テンプレートへ書き戻す。 1 件も救出できなければ nil(素の scaffold 復元へ)。



143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/vivlio_starter/cli/doctor/config_salvager.rb', line 143

def salvage_book(corrupt_content, scaffold_path)
  salvaged = extract_book_scalars(corrupt_content)
  return nil if salvaged.empty?

  notes = salvaged.map { |key, value| "- #{key}: #{value}" }
  notes << '🟡 免責・商標などの複数行設定は復元されません。必要なら上記バックアップから書き戻してください'
  Result.new(
    content: render_book_yml(scaffold_path, salvaged),
    summary: 'config/book.yml を復元し、以下の値を救出しました(要確認)',
    notes:
  )
end

.salvage_catalogResult?

破損した catalog.yml は解析せず、contents/*.md の命名規約 (NN-slug.md)と章番号レンジから目録を組み立て直す。 部タイトルと意図的な除外(コメントアウト)は catalog.yml にしか 存在しないため構造上復元できない(notes で利用者に明示する)。

Returns:

  • (Result, nil)

    contents/ に章が 1 つも無ければ nil



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/vivlio_starter/cli/doctor/config_salvager.rb', line 106

def salvage_catalog
  entries = chapter_entries_from_contents
  return nil if entries.empty?

  grouped = entries.group_by { |_basename, kind| kind }
  body = CATALOG_SECTIONS.map do |kind, section|
    lines = (grouped[kind] || []).map { |basename, _| "  - #{basename}\n" }.join
    "#{section}:\n#{lines}"
  end.join("\n")

  Result.new(
    content: "# vs doctor --fix が contents/ から再構築した目録です(要確認)\n#{body}",
    summary: "config/catalog.yml を contents/ から再構築しました(#{entries.size} 章)",
    notes: ['🟡 部タイトル・除外設定は復元されません。必要なら上記バックアップから書き戻してください']
  )
end

.scalar_value_from(line, key) ⇒ String?

1 行から key: value 形式の値を取り出す。 引用符付き(" / ')と裸の値に対応し、行末コメントは無視する。 閉じ引用符の無い行(破損行)はマッチさせない。

Returns:

  • (String, nil)


174
175
176
177
178
179
180
181
182
# File 'lib/vivlio_starter/cli/doctor/config_salvager.rb', line 174

def scalar_value_from(line, key)
  pattern = /\A\s{2}#{Regexp.escape(key.to_s)}:\s*
             (?:"((?:[^"\\]|\\.)*)"|'([^']*)'|([^"'#\s][^#\n]*?))
             \s*(?:\#.*)?\z/x
  match = line.match(pattern)
  return nil unless match

  match[1] ? unescape_double_quoted(match[1]) : (match[2] || match[3]&.strip)
end

.unescape_double_quoted(raw) ⇒ Object

double-quoted 値のエスケープを復元する(best-effort: 主要シーケンスのみ)



208
209
210
# File 'lib/vivlio_starter/cli/doctor/config_salvager.rb', line 208

def unescape_double_quoted(raw)
  raw.gsub(/\\(.)/) { { 'n' => "\n", 't' => "\t", 'r' => "\r" }.fetch(Regexp.last_match(1), Regexp.last_match(1)) }
end

.yaml_escape(value) ⇒ Object

YAML double-quoted string 内で安全になるよう値をエスケープする (NewCommands.yaml_escape_double_quoted と同等の最小実装)



201
202
203
204
205
# File 'lib/vivlio_starter/cli/doctor/config_salvager.rb', line 201

def yaml_escape(value)
  value.to_s.gsub(/[\\"\n\r\t]/) do |c|
    { "\\" => '\\\\', '"' => '\\"', "\n" => '\\n', "\r" => '\\r', "\t" => '\\t' }.fetch(c)
  end
end