Skip to main content

roomrs_migrate/
lib.rs

1//! roomrs-migrate — schema snapshot model shared between the proc-macros
2//! (compile time) and the runtime (명세 §3, §7).
3//!
4//! Internal crate — use the `roomrs` facade instead.
5#![deny(unsafe_code)]
6
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9
10/// 스키마 스냅샷 — 리포에 커밋되는 검증 진실 소스 (명세 §7.1)
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12pub struct SchemaSnapshot {
13    /// 스키마 버전
14    pub version: u32,
15    /// 테이블 목록
16    pub tables: Vec<TableSnapshot>,
17}
18
19/// 테이블 스냅샷
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub struct TableSnapshot {
22    pub name: String,
23    pub columns: Vec<ColumnSnapshot>,
24    /// 테이블·인덱스 DDL — diff 초안·해시에 사용 (M3)
25    #[serde(default)]
26    pub ddl: Vec<String>,
27}
28
29/// 컬럼 스냅샷
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
31pub struct ColumnSnapshot {
32    pub name: String,
33    /// SQLite 타입 (빈 문자열 = typeless)
34    pub sql_type: String,
35    pub not_null: bool,
36    pub pk: bool,
37    /// rename 힌트 (명세 §8.3) — diff 초안 전용, 해시 제외
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub renamed_from: Option<String>,
40}
41
42impl SchemaSnapshot {
43    /// 파일에서 로드
44    pub fn read_from(path: &Path) -> std::io::Result<Self> {
45        let raw = std::fs::read_to_string(path)?;
46        serde_json::from_str(&raw).map_err(std::io::Error::other)
47    }
48
49    /// Parse a snapshot from raw JSON bytes (e.g. a decompressed embedded blob).
50    pub fn from_slice(bytes: &[u8]) -> std::io::Result<Self> {
51        serde_json::from_slice(bytes).map_err(std::io::Error::other)
52    }
53
54    /// Serialize to the pretty JSON format used by snapshot files.
55    pub fn to_json(&self) -> std::io::Result<String> {
56        serde_json::to_string_pretty(self).map_err(std::io::Error::other)
57    }
58
59    /// 파일로 저장 (pretty JSON — 리뷰 가능한 diff)
60    pub fn write_to(&self, path: &Path) -> std::io::Result<()> {
61        if let Some(parent) = path.parent() {
62            std::fs::create_dir_all(parent)?;
63        }
64        std::fs::write(path, self.to_json()?)
65    }
66
67    /// 테이블 조회
68    pub fn table(&self, name: &str) -> Option<&TableSnapshot> {
69        self.tables.iter().find(|t| t.name == name)
70    }
71
72    /// 정준 문자열 — 해시 입력. 테이블은 이름순 정렬, 컬럼은 선언 순서 유지.
73    /// 각 가변 필드는 길이 접두사(`{len}:{값}`)로 인코딩해 필드 경계 충돌을
74    /// 원천 차단한다 (L-14).
75    pub fn canonical_string(&self) -> String {
76        // 길이 접두사 필드 인코딩 — 구분자가 값 안에 있어도 모호하지 않다 (L-14)
77        fn push_field(out: &mut String, s: &str) {
78            out.push_str(&format!("{}:{}", s.len(), s));
79        }
80        let mut tables: Vec<&TableSnapshot> = self.tables.iter().collect();
81        tables.sort_by(|a, b| a.name.cmp(&b.name));
82        let mut out = format!("v{};", self.version);
83        for t in tables {
84            out.push('t');
85            push_field(&mut out, &t.name);
86            out.push('(');
87            for c in &t.columns {
88                out.push('c');
89                push_field(&mut out, &c.name);
90                out.push('y');
91                push_field(&mut out, &c.sql_type);
92                out.push_str(&format!("n{}p{},", c.not_null as u8, c.pk as u8));
93            }
94            out.push_str(");");
95            // 인덱스 등 DDL 변경도 스테일로 감지되도록 해시에 포함 (M3)
96            for d in &t.ddl {
97                out.push('d');
98                push_field(&mut out, d);
99                out.push(';');
100            }
101        }
102        out
103    }
104
105    /// FNV-1a 64 해시 — 스테일 감지용 (명세 §7.4). 암호학적 강도 불필요.
106    pub fn hash(&self) -> u64 {
107        fnv1a64(self.canonical_string().as_bytes())
108    }
109}
110
111impl TableSnapshot {
112    /// 컬럼 존재 확인
113    pub fn has_column(&self, name: &str) -> bool {
114        self.columns.iter().any(|c| c.name == name)
115    }
116}
117
118/// FNV-1a 64비트 해시
119fn fnv1a64(bytes: &[u8]) -> u64 {
120    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
121    for b in bytes {
122        hash ^= u64::from(*b);
123        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
124    }
125    hash
126}
127
128// ─────────────────────── 압축 (결정 21c) ───────────────────────
129
130/// 압축 해제 상한 — 스냅샷 JSON이 이 크기를 넘으면 손상으로 간주 (64MB)
131const DECOMPRESS_LIMIT: usize = 64 * 1024 * 1024;
132
133/// Compress snapshot JSON bytes with miniz_oxide (raw deflate, level 8).
134///
135/// Used by `#[database]` to embed every committed snapshot into the binary
136/// (spec §8.4, decision 21c). Reverse with [`decompress_snapshot`].
137pub fn compress_snapshot(bytes: &[u8]) -> Vec<u8> {
138    miniz_oxide::deflate::compress_to_vec(bytes, 8)
139}
140
141/// Decompress bytes produced by [`compress_snapshot`].
142///
143/// Fails when the stream is corrupt or the decompressed size exceeds the
144/// 64 MiB sanity limit.
145pub fn decompress_snapshot(bytes: &[u8]) -> std::io::Result<Vec<u8>> {
146    miniz_oxide::inflate::decompress_to_vec_with_limit(bytes, DECOMPRESS_LIMIT)
147        .map_err(|_| std::io::Error::other("스냅샷 압축 해제 실패: 스트림 손상 또는 64MB 초과"))
148}
149
150// ─────────────────────── 스냅샷 파일 경로 (결정 21) ───────────────────────
151
152/// Standard snapshot directory relative to the crate manifest (spec §7.2).
153pub const SCHEMA_DIR_RELATIVE: &str = "migrations/schema";
154
155/// Resolve the snapshot directory: the `ROOMRS_SCHEMA_DIR` environment
156/// variable wins when set and non-empty, otherwise
157/// `{manifest_dir}/migrations/schema` (spec §7.2, decision 21).
158///
159/// A relative `ROOMRS_SCHEMA_DIR` is joined onto `manifest_dir`, so that
160/// compile-time file reads and the `include_bytes!` dependency paths the
161/// macros emit resolve to the same files regardless of the working
162/// directory (M-8). The variable must be set identically for build and
163/// test runs — otherwise the embedded snapshot chain and the exported
164/// files silently diverge.
165pub fn resolve_schema_dir(manifest_dir: &str) -> PathBuf {
166    match std::env::var("ROOMRS_SCHEMA_DIR") {
167        Ok(p) if !p.is_empty() => {
168            let p = PathBuf::from(p);
169            // 상대 경로 = manifest 기준 절대화 — fs::read(CWD 기준)와
170            // include_bytes!(소스 기준)가 서로 다른 파일을 보는 이중 해석 차단 (M-8)
171            if p.is_relative() {
172                Path::new(manifest_dir).join(p)
173            } else {
174                p
175            }
176        }
177        _ => Path::new(manifest_dir).join(SCHEMA_DIR_RELATIVE),
178    }
179}
180
181/// File name of a versioned snapshot: `{db_name}.{version}.json`
182/// (decision 21 — the `current.json` single-file model is gone).
183pub fn snapshot_file_name(db_name: &str, version: u32) -> String {
184    format!("{db_name}.{version}.json")
185}
186
187/// Full path of a versioned snapshot inside a schema directory.
188pub fn snapshot_path(dir: &Path, db_name: &str, version: u32) -> PathBuf {
189    dir.join(snapshot_file_name(db_name, version))
190}
191
192/// List every `{db_name}.{N}.json` snapshot in `dir`, sorted ascending by
193/// version. `N` is parsed strictly (digits only, no leading zeros), so
194/// duplicate versions are impossible by file name. A missing directory
195/// yields an empty list.
196pub fn list_snapshot_versions(dir: &Path, db_name: &str) -> std::io::Result<Vec<(u32, PathBuf)>> {
197    let mut out: Vec<(u32, PathBuf)> = Vec::new();
198    let rd = match std::fs::read_dir(dir) {
199        Ok(rd) => rd,
200        // 디렉토리 부재 = 스냅샷 없음 (온보딩 마찰 방지, 명세 §7.4c)
201        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
202        Err(e) => return Err(e),
203    };
204    let prefix = format!("{db_name}.");
205    for entry in rd {
206        let entry = entry?;
207        let name = entry.file_name();
208        let Some(name) = name.to_str() else { continue };
209        let Some(v) = parse_snapshot_version(name, &prefix)? else {
210            continue;
211        };
212        out.push((v, entry.path()));
213    }
214    out.sort_by_key(|(v, _)| *v);
215    Ok(out)
216}
217
218/// `{prefix}{N}.json` 파일명에서 버전 N 엄격 파스 — 숫자만, 선행 0 금지.
219/// 무관 파일 = Ok(None), 숫자 형식인데 u32 범위 초과 = 침묵 무시 대신
220/// 하드 에러 — 해당 버전이 조용히 빠지는 것을 차단한다 (L-14)
221fn parse_snapshot_version(name: &str, prefix: &str) -> std::io::Result<Option<u32>> {
222    let Some(rest) = name.strip_prefix(prefix) else {
223        return Ok(None);
224    };
225    let Some(num) = rest.strip_suffix(".json") else {
226        return Ok(None);
227    };
228    if num.is_empty() || !num.bytes().all(|b| b.is_ascii_digit()) {
229        return Ok(None);
230    }
231    // 선행 0 금지 — "01"과 "1"이 같은 버전으로 중복되는 것을 차단
232    if num.len() > 1 && num.starts_with('0') {
233        return Ok(None);
234    }
235    match num.parse() {
236        Ok(v) => Ok(Some(v)),
237        // 전부 숫자인데 파스 실패 = u32 오버플로 (L-14)
238        Err(_) => Err(std::io::Error::other(format!(
239            "스냅샷 파일명 버전이 u32 범위를 넘습니다: {name}"
240        ))),
241    }
242}
243
244// ─────────────────────── 구조화 diff (H-10/H-11) ───────────────────────
245
246/// Structured migration plan between two snapshots (spec §8.1/§8.4).
247///
248/// `safe` holds executable SQL statements (CREATE TABLE, nullable ADD
249/// COLUMN, valid RENAME COLUMN, CREATE INDEX). `destructive` holds
250/// human-review items that roomrs never runs automatically (DROP TABLE,
251/// DROP COLUMN, column definition changes, NOT NULL ADD COLUMN without a
252/// default, DROP INDEX). `warnings` reports ignored or invalid rename
253/// hints.
254#[derive(Debug, Clone, Default, PartialEq, Eq)]
255pub struct DiffPlan {
256    /// Executable safe statements, in order.
257    pub safe: Vec<String>,
258    /// Destructive items requiring human review.
259    pub destructive: Vec<String>,
260    /// Non-fatal diagnostics (e.g. invalid rename hints).
261    pub warnings: Vec<String>,
262}
263
264/// Compute a structured diff plan from `old` to `new` (spec §8.1, §8.4).
265///
266/// Rename hints (`renamed_from`) produce a safe RENAME COLUMN only when the
267/// old column exists, the new schema no longer has it, and the column
268/// definition is unchanged; otherwise they degrade to a warning or a
269/// destructive rewrite item (H-10/H-11). A rename source column can be
270/// consumed only once — a second hint pointing at the same source degrades
271/// to ADD COLUMN with a warning (M-9).
272///
273/// Constraint-level `CREATE TABLE` changes (UNIQUE/DEFAULT/CHECK …) are not
274/// visible in the column snapshots, so when the column definitions of a
275/// same-name table are identical but its `CREATE TABLE` statement text
276/// differs (whitespace-normalized), a destructive table-rewrite item is
277/// emitted (H-5). Limitation: when column changes and constraint changes
278/// happen in the same version step, the DDL text difference cannot be
279/// attributed, so only the column-level items are reported.
280pub fn diff_plan(old: &SchemaSnapshot, new: &SchemaSnapshot) -> DiffPlan {
281    let mut plan = DiffPlan::default();
282
283    // 새 테이블 — DDL 전체가 안전 연산
284    for nt in &new.tables {
285        if old.table(&nt.name).is_none() {
286            plan.safe.extend(nt.ddl.iter().cloned());
287        }
288    }
289
290    // 삭제된 테이블 — 파괴적
291    for ot in &old.tables {
292        if new.table(&ot.name).is_none() {
293            plan.destructive.push(format!("DROP TABLE \"{}\"", ot.name));
294        }
295    }
296
297    // 기존 테이블의 컬럼·인덱스 변경
298    for nt in &new.tables {
299        let Some(ot) = old.table(&nt.name) else {
300            continue;
301        };
302        diff_columns(ot, nt, &mut plan);
303        diff_indexes(ot, nt, &mut plan);
304        diff_table_constraints(ot, nt, &mut plan);
305    }
306
307    plan
308}
309
310/// 동일 이름 테이블의 CREATE TABLE 문 비교 — 제약(UNIQUE/DEFAULT/CHECK 등)
311/// 변경 감지 (H-5). 컬럼 스냅샷에는 제약 정보가 없어 컬럼 diff만으로는
312/// 빈 계획이 나와 auto_migrate가 조용히 스키마 분기를 방치한다.
313///
314/// 한계(의도된 보수 정책): 컬럼 정의(이름/타입/not_null/pk)가 **완전히 동일**할
315/// 때만 비교한다 — 컬럼 추가/삭제/rename이 있으면 CREATE TABLE 문은 그로
316/// 인해 당연히 달라지므로, 그 차이를 재작성 필요로 오탐하지 않기 위해
317/// 건너뛴다. 컬럼 변경과 제약 변경이 한 버전에서 동시에 일어나면 제약
318/// 변경은 감지되지 않는다 (rustdoc의 diff_plan 한계 참조).
319fn diff_table_constraints(ot: &TableSnapshot, nt: &TableSnapshot, plan: &mut DiffPlan) {
320    // 컬럼 집합 동일 검사 — 이름 기준 매칭(순서 무관), 정의 필드 전부 일치
321    let same_columns = ot.columns.len() == nt.columns.len()
322        && nt.columns.iter().all(|n| {
323            ot.columns.iter().any(|o| {
324                o.name == n.name
325                    && o.sql_type == n.sql_type
326                    && o.not_null == n.not_null
327                    && o.pk == n.pk
328            })
329        });
330    if !same_columns {
331        return;
332    }
333    // 구형 스냅샷(serde default)은 ddl 이 빈 벡터 — 한쪽만 비면 [] vs [CREATE …]
334    // 차이를 재작성 필요로 오탐한다. 비교 스킵 + 경고로 강등 (D-2b)
335    if ot.ddl.is_empty() || nt.ddl.is_empty() {
336        if ot.ddl.is_empty() != nt.ddl.is_empty() {
337            plan.warnings.push(format!(
338                "제약 비교 스킵: \"{}\" — 한쪽 스냅샷에 ddl 정보가 없습니다(구형 스냅샷) — write_schema_snapshot 재생성 권장",
339                nt.name
340            ));
341        }
342        return;
343    }
344    if create_table_entries(ot) != create_table_entries(nt) {
345        plan.destructive.push(format!(
346            "테이블 재작성 필요(제약/DDL 변경): \"{}\" — 컬럼 정의는 동일하나 CREATE TABLE 문이 다릅니다",
347            nt.name
348        ));
349    }
350}
351
352/// 인덱스 외 DDL(CREATE TABLE 문)만 공백 정규화해 추출 (H-5).
353/// 정규화는 인용 구간 **밖**의 공백만 접는다 (D-2a)
354fn create_table_entries(t: &TableSnapshot) -> Vec<String> {
355    t.ddl
356        .iter()
357        .filter(|d| index_name(d).is_none())
358        .map(|d| normalize_ws_outside_quotes(d))
359        .collect()
360}
361
362/// 인용 구간('…', "…") 밖의 연속 공백을 1개로 접고 선두/말미 공백을 제거한다.
363/// split_whitespace 전면 접기는 `DEFAULT 'a  b'` → `DEFAULT 'a b'` 리터럴
364/// 변경을 지워 미탐이었다 (D-2a). 인용 안 `''`/`""` 이스케이프는 상태를
365/// 껐다 켜도 사이에 공백이 없어 결과가 동일하다.
366fn normalize_ws_outside_quotes(s: &str) -> String {
367    let mut out = String::with_capacity(s.len());
368    // 현재 인용 문자 — None = 인용 밖
369    let mut quote: Option<char> = None;
370    // 인용 밖에서 만난 공백 대기 플래그 — 다음 비공백 앞에 1개만 삽입
371    let mut pending_ws = false;
372    for c in s.chars() {
373        match quote {
374            Some(q) => {
375                out.push(c);
376                if c == q {
377                    quote = None;
378                }
379            }
380            None if c.is_whitespace() => pending_ws = true,
381            None => {
382                if pending_ws && !out.is_empty() {
383                    out.push(' ');
384                }
385                pending_ws = false;
386                if c == '\'' || c == '"' {
387                    quote = Some(c);
388                }
389                out.push(c);
390            }
391        }
392    }
393    out
394}
395
396/// 동일 이름 테이블의 컬럼 diff — 정의 변경 감지(H-10)·rename 힌트 검증(H-11)·
397/// rename 원본 중복 소비 차단(M-9)
398fn diff_columns(ot: &TableSnapshot, nt: &TableSnapshot, plan: &mut DiffPlan) {
399    // rename 힌트가 이미 소비한 원본 컬럼 — 같은 원본을 두 번 RENAME 하면
400    // 두 번째 문이 런타임에 실패한다(safe 계약 위반, M-9)
401    let mut consumed_sources: Vec<&str> = Vec::new();
402    for nc in &nt.columns {
403        // 동명 컬럼 — sql_type/not_null/pk 변경 = 테이블 재작성 필요 (H-10)
404        if let Some(oc) = ot.columns.iter().find(|c| c.name == nc.name) {
405            let mut changes: Vec<String> = Vec::new();
406            if oc.sql_type != nc.sql_type {
407                changes.push(format!(
408                    "sql_type \"{}\" -> \"{}\"",
409                    oc.sql_type, nc.sql_type
410                ));
411            }
412            if oc.not_null != nc.not_null {
413                changes.push(format!("not_null {} -> {}", oc.not_null, nc.not_null));
414            }
415            if oc.pk != nc.pk {
416                changes.push(format!("pk {} -> {}", oc.pk, nc.pk));
417            }
418            if !changes.is_empty() {
419                plan.destructive.push(format!(
420                    "테이블 재작성 필요: \"{}\".\"{}\" 정의 변경 ({})",
421                    nt.name,
422                    nc.name,
423                    changes.join(", ")
424                ));
425            }
426            continue;
427        }
428
429        // 신규 컬럼 — rename 힌트 우선 (명세 §8.3)
430        if let Some(from) = &nc.renamed_from {
431            if consumed_sources.contains(&from.as_str()) {
432                // 같은 원본을 이미 다른 컬럼이 rename으로 소비 — 두 번째 RENAME은
433                // 런타임 실패이므로 경고 + ADD COLUMN 강등 (M-9)
434                plan.warnings.push(format!(
435                    "rename 힌트 중복: \"{}\".\"{from}\" 은 이미 다른 컬럼의 rename 원본으로 소비됨 — \"{}\"는 ADD COLUMN으로 처리",
436                    nt.name, nc.name
437                ));
438            } else if nt.has_column(from) {
439                // 새 스키마에 원본 컬럼이 여전히 존재 = 힌트 무효 (H-11) — ADD로 강등
440                plan.warnings.push(format!(
441                    "rename 힌트 무시: 새 스키마 \"{}\"에 \"{from}\" 컬럼이 여전히 존재 — \"{}\"는 ADD COLUMN으로 처리",
442                    nt.name, nc.name
443                ));
444            } else if let Some(of) = ot.columns.iter().find(|c| &c.name == from) {
445                // 원본 소비 기록 — safe RENAME이든 파괴적 재작성이든 원본은 소비됨 (M-9)
446                consumed_sources.push(from);
447                if of.sql_type == nc.sql_type && of.not_null == nc.not_null && of.pk == nc.pk {
448                    plan.safe.push(format!(
449                        "ALTER TABLE \"{}\" RENAME COLUMN \"{from}\" TO \"{}\"",
450                        nt.name, nc.name
451                    ));
452                } else {
453                    // rename + 정의 변경 동시 = RENAME COLUMN으로 불가 (H-10)
454                    plan.destructive.push(format!(
455                        "테이블 재작성 필요: \"{}\".\"{from}\" -> \"{}\" rename에 정의 변경이 동반됨",
456                        nt.name, nc.name
457                    ));
458                }
459                continue;
460            } else {
461                plan.warnings.push(format!(
462                    "잘못된 rename 힌트: 옛 스키마 \"{}\"에 \"{from}\" 컬럼 없음 — \"{}\"는 ADD COLUMN으로 처리",
463                    nt.name, nc.name
464                ));
465            }
466        }
467
468        // 일반 ADD COLUMN
469        let mut col = format!("\"{}\"", nc.name);
470        if !nc.sql_type.is_empty() {
471            col.push_str(&format!(" {}", nc.sql_type));
472        }
473        if nc.pk {
474            plan.destructive.push(format!(
475                "테이블 재작성 필요: \"{}\".\"{}\" — PK 컬럼은 ADD COLUMN 불가",
476                nt.name, nc.name
477            ));
478        } else if nc.not_null {
479            // NOT NULL ADD COLUMN은 기존 행 때문에 DEFAULT 필요 — 스냅샷에
480            // DEFAULT 정보가 없으므로 파괴적(수동 검토)으로 분류
481            plan.destructive.push(format!(
482                "ALTER TABLE \"{}\" ADD COLUMN {col} NOT NULL — DEFAULT 필요(기존 행)",
483                nt.name
484            ));
485        } else {
486            plan.safe
487                .push(format!("ALTER TABLE \"{}\" ADD COLUMN {col}", nt.name));
488        }
489    }
490
491    // 삭제된 컬럼 — 유효한 rename으로 소비된 컬럼은 제외
492    for oc in &ot.columns {
493        let renamed_away = nt
494            .columns
495            .iter()
496            .any(|nc| nc.renamed_from.as_deref() == Some(oc.name.as_str()));
497        if !nt.has_column(&oc.name) && !renamed_away {
498            plan.destructive.push(format!(
499                "ALTER TABLE \"{}\" DROP COLUMN \"{}\"",
500                nt.name, oc.name
501            ));
502        }
503    }
504}
505
506/// 동일 이름 테이블의 인덱스 diff — 이름 기준 추가/삭제/변경 감지 (H-10)
507fn diff_indexes(ot: &TableSnapshot, nt: &TableSnapshot, plan: &mut DiffPlan) {
508    let old_idx: Vec<(String, &String)> = index_entries(ot);
509    let new_idx: Vec<(String, &String)> = index_entries(nt);
510
511    for (name, ddl) in &new_idx {
512        match old_idx.iter().find(|(n, _)| n == name) {
513            // 신규 인덱스 = 안전
514            None => plan.safe.push((*ddl).clone()),
515            // 동명 인덱스 정의 변경 = 재생성 필요(파괴적 검토)
516            Some((_, old_ddl))
517                if normalize_ws_outside_quotes(old_ddl) != normalize_ws_outside_quotes(ddl) =>
518            {
519                plan.destructive.push(format!(
520                    "인덱스 재생성 필요: DROP INDEX \"{name}\" 후 재생성 ({ddl})"
521                ))
522            }
523            Some(_) => {}
524        }
525    }
526    for (name, _) in &old_idx {
527        if !new_idx.iter().any(|(n, _)| n == name) {
528            plan.destructive.push(format!("DROP INDEX \"{name}\""));
529        }
530    }
531}
532
533/// 테이블 DDL에서 (인덱스명, DDL) 목록 추출
534fn index_entries(t: &TableSnapshot) -> Vec<(String, &String)> {
535    t.ddl
536        .iter()
537        .filter_map(|d| index_name(d).map(|n| (n, d)))
538        .collect()
539}
540
541/// `CREATE [UNIQUE] INDEX [IF NOT EXISTS] name …` DDL에서 인덱스명 추출.
542/// 인용 식별자(`"…"` / `` `…` `` / `[…]`) 안의 공백·이스케이프를 처리한다 —
543/// split_whitespace 기반 추출은 `CREATE INDEX "my idx"` 를 `"my` 로 절단했다 (M-12)
544fn index_name(ddl: &str) -> Option<String> {
545    let mut rest = strip_keyword(ddl, "CREATE")?;
546    if let Some(r) = strip_keyword(rest, "UNIQUE") {
547        rest = r;
548    }
549    rest = strip_keyword(rest, "INDEX")?;
550    if let Some(r) = strip_keyword(rest, "IF") {
551        // IF NOT EXISTS 건너뛰기
552        rest = strip_keyword(strip_keyword(r, "NOT")?, "EXISTS")?;
553    }
554    parse_leading_identifier(rest)
555}
556
557/// 선두 키워드 소비(대소문자 무시) — 매치하면 키워드 뒤 나머지 반환 (M-12).
558/// 비교는 바이트 단위 — `&s[..kw.len()]` 문자열 슬라이스는 멀티바이트
559/// 식별자(예: 한글 인덱스명)에서 char boundary panic 이었다 (D-1).
560/// kw 는 전부 ASCII 이므로 바이트 매치 성공 = `kw.len()` 이 char boundary 보장.
561fn strip_keyword<'a>(s: &'a str, kw: &str) -> Option<&'a str> {
562    let s = s.trim_start();
563    if s.len() < kw.len() || !s.as_bytes()[..kw.len()].eq_ignore_ascii_case(kw.as_bytes()) {
564        return None;
565    }
566    let rest = &s[kw.len()..];
567    // 키워드 경계 — 바로 뒤가 식별자 문자면 키워드가 아니다 (예: INDEXED)
568    match rest.chars().next() {
569        Some(c) if c.is_ascii_alphanumeric() || c == '_' => None,
570        _ => Some(rest),
571    }
572}
573
574/// 선두 식별자 파스 — 인용부호별 종료·이스케이프(`""` / ```` `` ````) 처리 (M-12)
575fn parse_leading_identifier(s: &str) -> Option<String> {
576    let s = s.trim_start();
577    let mut chars = s.chars();
578    match chars.next()? {
579        // "…" 또는 `…` — 짝 인용부호 2연속 = 이스케이프
580        q @ ('"' | '`') => {
581            let mut out = String::new();
582            let mut iter = chars.peekable();
583            while let Some(c) = iter.next() {
584                if c == q {
585                    if iter.peek() == Some(&q) {
586                        iter.next();
587                        out.push(q);
588                        continue;
589                    }
590                    return Some(out);
591                }
592                out.push(c);
593            }
594            // 닫히지 않은 인용부 = 파스 실패
595            None
596        }
597        // […] — 이스케이프 없음, `]` 가 종료
598        '[' => s[1..].find(']').map(|end| s[1..1 + end].to_string()),
599        _ => {
600            // 비인용 — 공백 또는 여는 괄호(`name(col)` 표기) 앞까지
601            let end = s
602                .find(|c: char| c.is_whitespace() || c == '(')
603                .unwrap_or(s.len());
604            if end == 0 {
605                None
606            } else {
607                Some(s[..end].to_string())
608            }
609        }
610    }
611}
612
613/// 자동 diff **초안** SQL 생성 (명세 §8.1) — 절대 자동 실행하지 않는다(비목표).
614/// [`diff_plan`] 위에 렌더: 안전 연산은 실행문, 파괴적 항목은 `-- TODO(파괴적)`
615/// 주석, 경고는 `-- 경고` 주석으로 출력한다.
616pub fn diff_sql(old: &SchemaSnapshot, new: &SchemaSnapshot) -> String {
617    let plan = diff_plan(old, new);
618    let mut out = format!(
619        "-- roomrs 자동 diff 초안: v{} -> v{}\n-- 검토 후 사용하세요. 파괴적 변경은 주석 처리되어 있습니다.\n",
620        old.version, new.version
621    );
622    for s in &plan.safe {
623        out.push_str(&format!("{s};\n"));
624    }
625    for d in &plan.destructive {
626        out.push_str(&format!("-- TODO(파괴적): {d}\n"));
627    }
628    for w in &plan.warnings {
629        out.push_str(&format!("-- 경고: {w}\n"));
630    }
631    out.push_str(&format!("PRAGMA user_version = {};\n", new.version));
632    out
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638
639    /// 컬럼 스냅샷 생성 헬퍼
640    fn col(name: &str, sql_type: &str, not_null: bool, pk: bool) -> ColumnSnapshot {
641        ColumnSnapshot {
642            name: name.into(),
643            sql_type: sql_type.into(),
644            not_null,
645            pk,
646            renamed_from: None,
647        }
648    }
649
650    /// 테이블 스냅샷 생성 헬퍼
651    fn table(name: &str, columns: Vec<ColumnSnapshot>, ddl: Vec<&str>) -> TableSnapshot {
652        TableSnapshot {
653            name: name.into(),
654            columns,
655            ddl: ddl.into_iter().map(String::from).collect(),
656        }
657    }
658
659    /// 단일 테이블 스냅샷 생성 헬퍼
660    fn snap(version: u32, tables: Vec<TableSnapshot>) -> SchemaSnapshot {
661        SchemaSnapshot { version, tables }
662    }
663
664    /// 정준 문자열이 테이블 순서에 불변인지
665    #[test]
666    fn canonical_is_order_invariant() {
667        let a = snap(
668            1,
669            vec![table("b", vec![], vec![]), table("a", vec![], vec![])],
670        );
671        let mut b = a.clone();
672        b.tables.reverse();
673        assert_eq!(a.hash(), b.hash());
674    }
675
676    /// 컬럼 변경 = 해시 변경
677    #[test]
678    fn hash_changes_on_column_change() {
679        let a = snap(
680            1,
681            vec![table("t", vec![col("id", "INTEGER", true, true)], vec![])],
682        );
683        let mut b = a.clone();
684        b.tables[0].columns[0].name = "id2".into();
685        assert_ne!(a.hash(), b.hash());
686    }
687
688    /// 정준 문자열 필드 경계 충돌 불가 (L-14) — 구분자 포함 값도 구별된다
689    #[test]
690    fn canonical_no_field_boundary_collision() {
691        // 옛 포맷("name:type:nn:pk,")에서는 두 스냅샷 모두 "a:X:1:0:0," 로 충돌
692        let a = snap(
693            1,
694            vec![table("t", vec![col("a", "X:1", false, false)], vec![])],
695        );
696        let b = snap(
697            1,
698            vec![table("t", vec![col("a:X", "1", false, false)], vec![])],
699        );
700        assert_ne!(a.canonical_string(), b.canonical_string());
701        assert_ne!(a.hash(), b.hash());
702    }
703
704    /// 압축/해제 왕복
705    #[test]
706    fn compress_roundtrip() {
707        let json = snap(
708            3,
709            vec![table("t", vec![col("id", "INTEGER", true, true)], vec![])],
710        )
711        .to_json()
712        .unwrap();
713        let comp = compress_snapshot(json.as_bytes());
714        let back = decompress_snapshot(&comp).unwrap();
715        assert_eq!(back, json.as_bytes());
716        // 손상 스트림 = 에러
717        assert!(decompress_snapshot(&[0xFF, 0x00, 0x12]).is_err());
718    }
719
720    /// 버전 파일 스캔 — 엄격 파스·정렬·무관 파일 무시
721    #[test]
722    fn list_snapshot_versions_strict() {
723        let dir = tempfile::tempdir().unwrap();
724        let s = snap(1, vec![]);
725        for name in [
726            "app.2.json",
727            "app.1.json",
728            "app.10.json",
729            "app.01.json",  // 선행 0 — 무시
730            "app.x.json",   // 숫자 아님 — 무시
731            "app..json",    // 빈 버전 — 무시
732            "other.3.json", // 다른 db — 무시
733            "app.1.sql",    // 확장자 다름 — 무시
734        ] {
735            s.write_to(&dir.path().join(name)).unwrap();
736        }
737        let got = list_snapshot_versions(dir.path(), "app").unwrap();
738        let versions: Vec<u32> = got.iter().map(|(v, _)| *v).collect();
739        assert_eq!(versions, vec![1, 2, 10], "오름차순 + 엄격 파스");
740
741        // 디렉토리 부재 = 빈 목록
742        let missing = dir.path().join("없는-디렉토리");
743        assert!(list_snapshot_versions(&missing, "app").unwrap().is_empty());
744    }
745
746    /// 스냅샷 경로 규칙
747    #[test]
748    fn snapshot_path_rule() {
749        assert_eq!(snapshot_file_name("app_db", 3), "app_db.3.json");
750        assert_eq!(
751            snapshot_path(Path::new("/x"), "app_db", 3),
752            Path::new("/x").join("app_db.3.json")
753        );
754    }
755
756    /// diff_plan — 동명 컬럼 타입 변경 = 파괴적 재작성 항목 (H-10)
757    #[test]
758    fn diff_plan_type_change_is_destructive() {
759        let old = snap(
760            1,
761            vec![table("t", vec![col("c", "TEXT", true, false)], vec![])],
762        );
763        let new = snap(
764            2,
765            vec![table("t", vec![col("c", "INTEGER", true, false)], vec![])],
766        );
767        let plan = diff_plan(&old, &new);
768        assert!(plan.safe.is_empty(), "{plan:?}");
769        assert_eq!(plan.destructive.len(), 1);
770        assert!(
771            plan.destructive[0].contains("테이블 재작성 필요"),
772            "{plan:?}"
773        );
774        assert!(plan.destructive[0].contains("sql_type"), "{plan:?}");
775    }
776
777    /// diff_plan — 유효한 rename 힌트 = 안전 RENAME COLUMN
778    #[test]
779    fn diff_plan_valid_rename_is_safe() {
780        let old = snap(
781            1,
782            vec![table("t", vec![col("title", "TEXT", true, false)], vec![])],
783        );
784        let mut renamed = col("subject", "TEXT", true, false);
785        renamed.renamed_from = Some("title".into());
786        let new = snap(2, vec![table("t", vec![renamed], vec![])]);
787        let plan = diff_plan(&old, &new);
788        assert_eq!(
789            plan.safe,
790            vec![r#"ALTER TABLE "t" RENAME COLUMN "title" TO "subject""#.to_string()]
791        );
792        assert!(plan.destructive.is_empty(), "{plan:?}");
793        assert!(plan.warnings.is_empty(), "{plan:?}");
794    }
795
796    /// diff_plan — 새 스키마에 원본 컬럼이 남아 있는 rename 힌트 = 무시 + 경고 (H-11)
797    #[test]
798    fn diff_plan_rename_hint_ignored_when_source_still_exists() {
799        let old = snap(
800            1,
801            vec![table("t", vec![col("title", "TEXT", false, false)], vec![])],
802        );
803        let mut copied = col("subject", "TEXT", false, false);
804        copied.renamed_from = Some("title".into());
805        let new = snap(
806            2,
807            vec![table(
808                "t",
809                vec![col("title", "TEXT", false, false), copied],
810                vec![],
811            )],
812        );
813        let plan = diff_plan(&old, &new);
814        assert_eq!(plan.warnings.len(), 1, "{plan:?}");
815        assert!(plan.warnings[0].contains("rename 힌트 무시"), "{plan:?}");
816        // ADD COLUMN으로 강등 (nullable = 안전)
817        assert_eq!(
818            plan.safe,
819            vec![r#"ALTER TABLE "t" ADD COLUMN "subject" TEXT"#.to_string()]
820        );
821    }
822
823    /// diff_plan — 옛 스키마에 원본이 없는 rename 힌트 = 경고 + ADD 강등
824    #[test]
825    fn diff_plan_rename_hint_missing_source_warns() {
826        let old = snap(
827            1,
828            vec![table("t", vec![col("id", "INTEGER", true, true)], vec![])],
829        );
830        let mut renamed = col("subject", "TEXT", false, false);
831        renamed.renamed_from = Some("ghost".into());
832        let new = snap(
833            2,
834            vec![table(
835                "t",
836                vec![col("id", "INTEGER", true, true), renamed],
837                vec![],
838            )],
839        );
840        let plan = diff_plan(&old, &new);
841        assert_eq!(plan.warnings.len(), 1, "{plan:?}");
842        assert!(plan.warnings[0].contains("잘못된 rename 힌트"), "{plan:?}");
843        assert_eq!(
844            plan.safe,
845            vec![r#"ALTER TABLE "t" ADD COLUMN "subject" TEXT"#.to_string()]
846        );
847    }
848
849    /// diff_plan — rename 힌트 + 정의 변경 동반 = 파괴적
850    #[test]
851    fn diff_plan_rename_with_type_change_is_destructive() {
852        let old = snap(
853            1,
854            vec![table("t", vec![col("title", "TEXT", true, false)], vec![])],
855        );
856        let mut renamed = col("subject", "INTEGER", true, false);
857        renamed.renamed_from = Some("title".into());
858        let new = snap(2, vec![table("t", vec![renamed], vec![])]);
859        let plan = diff_plan(&old, &new);
860        assert!(plan.safe.is_empty(), "{plan:?}");
861        assert_eq!(plan.destructive.len(), 1, "{plan:?}");
862        assert!(
863            plan.destructive[0].contains("테이블 재작성 필요"),
864            "{plan:?}"
865        );
866    }
867
868    /// diff_plan — 인덱스 추가 = 안전, 삭제 = 파괴적 (H-10)
869    #[test]
870    fn diff_plan_index_add_remove() {
871        let ct = r#"CREATE TABLE "t" ("id" INTEGER PRIMARY KEY)"#;
872        let idx_a = r#"CREATE INDEX IF NOT EXISTS "idx_t_a" ON "t"("a")"#;
873        let idx_b = r#"CREATE UNIQUE INDEX "idx_t_b" ON "t"("b")"#;
874        let cols = vec![col("id", "INTEGER", true, true)];
875        let old = snap(1, vec![table("t", cols.clone(), vec![ct, idx_a])]);
876        let new = snap(2, vec![table("t", cols, vec![ct, idx_b])]);
877        let plan = diff_plan(&old, &new);
878        assert_eq!(plan.safe, vec![idx_b.to_string()], "신규 인덱스 = 안전");
879        assert_eq!(
880            plan.destructive,
881            vec![r#"DROP INDEX "idx_t_a""#.to_string()]
882        );
883    }
884
885    /// diff_plan — NOT NULL ADD COLUMN(DEFAULT 정보 없음) = 파괴적
886    #[test]
887    fn diff_plan_not_null_add_is_destructive() {
888        let old = snap(
889            1,
890            vec![table("t", vec![col("id", "INTEGER", true, true)], vec![])],
891        );
892        let new = snap(
893            2,
894            vec![table(
895                "t",
896                vec![
897                    col("id", "INTEGER", true, true),
898                    col("note", "TEXT", true, false),
899                ],
900                vec![],
901            )],
902        );
903        let plan = diff_plan(&old, &new);
904        assert!(plan.safe.is_empty(), "{plan:?}");
905        assert_eq!(plan.destructive.len(), 1, "{plan:?}");
906        assert!(plan.destructive[0].contains("DEFAULT 필요"), "{plan:?}");
907        assert!(
908            plan.destructive[0].contains(r#"ADD COLUMN "note""#),
909            "{plan:?}"
910        );
911    }
912
913    /// diff_plan — 테이블 추가/삭제 분류
914    #[test]
915    fn diff_plan_table_add_remove() {
916        let ct = r#"CREATE TABLE "b" ("id" INTEGER PRIMARY KEY)"#;
917        let old = snap(1, vec![table("a", vec![], vec![])]);
918        let new = snap(
919            2,
920            vec![table("b", vec![col("id", "INTEGER", true, true)], vec![ct])],
921        );
922        let plan = diff_plan(&old, &new);
923        assert_eq!(plan.safe, vec![ct.to_string()]);
924        assert_eq!(plan.destructive, vec![r#"DROP TABLE "a""#.to_string()]);
925    }
926
927    /// diff_plan — 컬럼 동일 + CREATE TABLE 문 변경(UNIQUE 추가) = 파괴적 재작성 (H-5)
928    #[test]
929    fn diff_plan_constraint_change_is_destructive() {
930        let cols = vec![
931            col("id", "INTEGER", true, true),
932            col("email", "TEXT", true, false),
933        ];
934        let ct_old = r#"CREATE TABLE "t" ("id" INTEGER PRIMARY KEY, "email" TEXT NOT NULL)"#;
935        let ct_new = r#"CREATE TABLE "t" ("id" INTEGER PRIMARY KEY, "email" TEXT NOT NULL UNIQUE)"#;
936        let old = snap(1, vec![table("t", cols.clone(), vec![ct_old])]);
937        let new = snap(2, vec![table("t", cols, vec![ct_new])]);
938        let plan = diff_plan(&old, &new);
939        assert!(plan.safe.is_empty(), "{plan:?}");
940        assert_eq!(plan.destructive.len(), 1, "{plan:?}");
941        assert!(plan.destructive[0].contains("제약/DDL 변경"), "{plan:?}");
942    }
943
944    /// diff_plan — DDL 동일(공백만 다름) = 재작성 항목 없음 (H-5)
945    #[test]
946    fn diff_plan_constraint_same_ddl_no_item() {
947        let cols = vec![col("id", "INTEGER", true, true)];
948        let ct = r#"CREATE TABLE "t" ("id" INTEGER PRIMARY KEY)"#;
949        let ct_ws = "CREATE TABLE  \"t\"\n(\"id\" INTEGER  PRIMARY KEY)";
950        let old = snap(1, vec![table("t", cols.clone(), vec![ct])]);
951        let new = snap(2, vec![table("t", cols, vec![ct_ws])]);
952        let plan = diff_plan(&old, &new);
953        assert!(plan.destructive.is_empty(), "공백 정규화: {plan:?}");
954        assert!(plan.safe.is_empty(), "{plan:?}");
955    }
956
957    /// diff_plan — 컬럼 추가(DDL도 당연히 변경) = 안전 ADD만, 재작성 오탐 없음 (H-5)
958    #[test]
959    fn diff_plan_column_add_no_false_rewrite() {
960        let ct_old = r#"CREATE TABLE "t" ("id" INTEGER PRIMARY KEY)"#;
961        let ct_new = r#"CREATE TABLE "t" ("id" INTEGER PRIMARY KEY, "note" TEXT)"#;
962        let old = snap(
963            1,
964            vec![table(
965                "t",
966                vec![col("id", "INTEGER", true, true)],
967                vec![ct_old],
968            )],
969        );
970        let new = snap(
971            2,
972            vec![table(
973                "t",
974                vec![
975                    col("id", "INTEGER", true, true),
976                    col("note", "TEXT", false, false),
977                ],
978                vec![ct_new],
979            )],
980        );
981        let plan = diff_plan(&old, &new);
982        assert_eq!(
983            plan.safe,
984            vec![r#"ALTER TABLE "t" ADD COLUMN "note" TEXT"#.to_string()]
985        );
986        assert!(plan.destructive.is_empty(), "재작성 오탐: {plan:?}");
987    }
988
989    /// diff_plan — 같은 rename 원본 2회 소비 = 첫 힌트만 RENAME, 둘째는 경고+ADD (M-9)
990    #[test]
991    fn diff_plan_duplicate_rename_source_degrades() {
992        let old = snap(
993            1,
994            vec![table("t", vec![col("a", "TEXT", false, false)], vec![])],
995        );
996        let mut b = col("b", "TEXT", false, false);
997        b.renamed_from = Some("a".into());
998        let mut c = col("c", "TEXT", false, false);
999        c.renamed_from = Some("a".into());
1000        let new = snap(2, vec![table("t", vec![b, c], vec![])]);
1001        let plan = diff_plan(&old, &new);
1002        assert_eq!(
1003            plan.safe,
1004            vec![
1005                r#"ALTER TABLE "t" RENAME COLUMN "a" TO "b""#.to_string(),
1006                r#"ALTER TABLE "t" ADD COLUMN "c" TEXT"#.to_string(),
1007            ],
1008            "{plan:?}"
1009        );
1010        assert_eq!(plan.warnings.len(), 1, "{plan:?}");
1011        assert!(plan.warnings[0].contains("rename 힌트 중복"), "{plan:?}");
1012        // 원본 a 는 첫 rename 이 소비 — DROP 오탐 없음
1013        assert!(plan.destructive.is_empty(), "{plan:?}");
1014    }
1015
1016    /// index_name — 멀티바이트(한글) 이름 panic 없이 파스 (D-1)
1017    #[test]
1018    fn index_name_multibyte_no_panic() {
1019        // 인용 한글 — 종전 &s[..kw.len()] 슬라이스가 char boundary panic
1020        assert_eq!(
1021            index_name(r#"CREATE INDEX "할일_idx" ON t(a)"#),
1022            Some("할일_idx".to_string())
1023        );
1024        // 비인용 한글
1025        assert_eq!(
1026            index_name("CREATE INDEX 할일 ON t(a)"),
1027            Some("할일".to_string())
1028        );
1029        // 한글 UNIQUE 인덱스 + IF NOT EXISTS 조합
1030        assert_eq!(
1031            index_name(r#"CREATE UNIQUE INDEX IF NOT EXISTS "메모 색인" ON t(b)"#),
1032            Some("메모 색인".to_string())
1033        );
1034    }
1035
1036    /// diff_plan — 문자열 리터럴 내부 공백 변경도 제약 변경으로 감지 (D-2a)
1037    #[test]
1038    fn diff_plan_constraint_literal_space_change_detected() {
1039        let cols = vec![col("a", "TEXT", false, false)];
1040        let ct_old = r#"CREATE TABLE "t" ("a" TEXT DEFAULT 'a  b')"#;
1041        let ct_new = r#"CREATE TABLE "t" ("a" TEXT DEFAULT 'a b')"#;
1042        let old = snap(1, vec![table("t", cols.clone(), vec![ct_old])]);
1043        let new = snap(2, vec![table("t", cols, vec![ct_new])]);
1044        let plan = diff_plan(&old, &new);
1045        assert_eq!(
1046            plan.destructive.len(),
1047            1,
1048            "리터럴 내부 공백 = 실 변경: {plan:?}"
1049        );
1050        assert!(plan.destructive[0].contains("제약/DDL 변경"), "{plan:?}");
1051    }
1052
1053    /// diff_plan — 한쪽 ddl 빈(구형 스냅샷) = 재작성 오탐 대신 경고 강등 (D-2b)
1054    #[test]
1055    fn diff_plan_constraint_empty_ddl_degrades_to_warning() {
1056        let cols = vec![col("a", "TEXT", false, false)];
1057        let ct = r#"CREATE TABLE "t" ("a" TEXT)"#;
1058        let old = snap(1, vec![table("t", cols.clone(), vec![])]);
1059        let new = snap(2, vec![table("t", cols, vec![ct])]);
1060        let plan = diff_plan(&old, &new);
1061        assert!(plan.destructive.is_empty(), "재작성 오탐: {plan:?}");
1062        assert_eq!(plan.warnings.len(), 1, "{plan:?}");
1063        assert!(plan.warnings[0].contains("제약 비교 스킵"), "{plan:?}");
1064    }
1065
1066    /// normalize_ws_outside_quotes — 인용 밖 접기·인용 안 보존·이스케이프
1067    #[test]
1068    fn normalize_ws_quote_aware() {
1069        assert_eq!(
1070            normalize_ws_outside_quotes("CREATE  TABLE \"t\"\n(\"a  b\" TEXT DEFAULT 'x  y')"),
1071            r#"CREATE TABLE "t" ("a  b" TEXT DEFAULT 'x  y')"#
1072        );
1073        // '' 이스케이프 사이엔 공백이 없어 결과 동일
1074        assert_eq!(
1075            normalize_ws_outside_quotes("DEFAULT  'o''clock  x'"),
1076            "DEFAULT 'o''clock  x'"
1077        );
1078    }
1079
1080    /// 인덱스 DDL의 인용 밖 공백 차이는 정의 변경이 아니다.
1081    #[test]
1082    fn index_diff_ignores_whitespace_outside_quotes() {
1083        let old = snap(
1084            1,
1085            vec![table(
1086                "items",
1087                vec![],
1088                vec!["CREATE  INDEX idx_name\n ON items (name)"],
1089            )],
1090        );
1091        let new = snap(
1092            2,
1093            vec![table(
1094                "items",
1095                vec![],
1096                vec!["CREATE INDEX idx_name ON items (name)"],
1097            )],
1098        );
1099        assert!(diff_plan(&old, &new).destructive.is_empty());
1100    }
1101
1102    /// 인용 내부 공백 차이는 실제 인덱스 표현식 변경으로 유지한다.
1103    #[test]
1104    fn index_diff_preserves_whitespace_inside_quotes() {
1105        let old = snap(
1106            1,
1107            vec![table(
1108                "items",
1109                vec![],
1110                vec!["CREATE INDEX idx_name ON items ('a  b')"],
1111            )],
1112        );
1113        let new = snap(
1114            2,
1115            vec![table(
1116                "items",
1117                vec![],
1118                vec!["CREATE INDEX idx_name ON items ('a b')"],
1119            )],
1120        );
1121        assert_eq!(diff_plan(&old, &new).destructive.len(), 1);
1122    }
1123
1124    /// index_name — 공백 포함 인용 이름·이스케이프·백틱·대괄호 (M-12)
1125    #[test]
1126    fn index_name_quoted_variants() {
1127        assert_eq!(
1128            index_name(r#"CREATE INDEX "my idx" ON "t"("a")"#),
1129            Some("my idx".to_string())
1130        );
1131        assert_eq!(
1132            index_name(r#"CREATE UNIQUE INDEX IF NOT EXISTS "a""b" ON t(x)"#),
1133            Some("a\"b".to_string())
1134        );
1135        assert_eq!(
1136            index_name("CREATE INDEX `sp ace` ON t(a)"),
1137            Some("sp ace".to_string())
1138        );
1139        assert_eq!(
1140            index_name("CREATE INDEX [br idx] ON t(a)"),
1141            Some("br idx".to_string())
1142        );
1143        // 비인용 + `name(col)` 붙은 표기
1144        assert_eq!(
1145            index_name("create unique index idx_t_a(a)"),
1146            Some("idx_t_a".to_string())
1147        );
1148        // CREATE TABLE = 인덱스 아님, 닫히지 않은 인용부 = None
1149        assert_eq!(index_name(r#"CREATE TABLE "t" ("id" INTEGER)"#), None);
1150        assert_eq!(index_name(r#"CREATE INDEX "broken ON t(a)"#), None);
1151        // INDEXED 같은 접두 확장 키워드 오인 금지
1152        assert_eq!(index_name("CREATE INDEXED_VIEW x"), None);
1153    }
1154
1155    /// list_snapshot_versions — u32 오버플로 파일명 = 침묵 무시 대신 에러 (L-14)
1156    #[test]
1157    fn list_snapshot_versions_overflow_is_error() {
1158        let dir = tempfile::tempdir().unwrap();
1159        let s = snap(1, vec![]);
1160        s.write_to(&dir.path().join("app.1.json")).unwrap();
1161        // u32::MAX = 4294967295 — 그보다 큰 순수 숫자 버전
1162        s.write_to(&dir.path().join("app.99999999999.json"))
1163            .unwrap();
1164        let err = list_snapshot_versions(dir.path(), "app").unwrap_err();
1165        assert!(err.to_string().contains("u32 범위"), "{err}");
1166    }
1167
1168    /// resolve_schema_dir — 상대 env 경로는 manifest 기준 절대화 (M-8)
1169    #[test]
1170    fn resolve_schema_dir_relative_env() {
1171        // SAFETY: env는 프로세스 전역 — 이 크레이트 테스트 중 ROOMRS_SCHEMA_DIR을
1172        // 조작하는 테스트는 이것뿐이라 경합 없음. 종료 시 원복.
1173        #[allow(unsafe_code)]
1174        unsafe {
1175            std::env::set_var("ROOMRS_SCHEMA_DIR", "custom/schema");
1176        }
1177        let got = resolve_schema_dir("/proj");
1178        #[allow(unsafe_code)]
1179        unsafe {
1180            std::env::remove_var("ROOMRS_SCHEMA_DIR");
1181        }
1182        assert_eq!(got, Path::new("/proj").join("custom/schema"));
1183    }
1184
1185    /// diff_sql — 안전=실행문, 파괴적=TODO 주석, 경고=주석, user_version 마무리
1186    #[test]
1187    fn diff_sql_renders_plan() {
1188        let old = snap(
1189            1,
1190            vec![table(
1191                "t",
1192                vec![
1193                    col("id", "INTEGER", true, true),
1194                    col("gone", "TEXT", false, false),
1195                ],
1196                vec![],
1197            )],
1198        );
1199        let new = snap(
1200            2,
1201            vec![table(
1202                "t",
1203                vec![
1204                    col("id", "INTEGER", true, true),
1205                    col("name", "TEXT", false, false),
1206                ],
1207                vec![],
1208            )],
1209        );
1210        let sql = diff_sql(&old, &new);
1211        assert!(
1212            sql.contains("ALTER TABLE \"t\" ADD COLUMN \"name\" TEXT;"),
1213            "{sql}"
1214        );
1215        assert!(
1216            sql.contains("-- TODO(파괴적): ALTER TABLE \"t\" DROP COLUMN \"gone\""),
1217            "{sql}"
1218        );
1219        assert!(sql.contains("PRAGMA user_version = 2;"), "{sql}");
1220    }
1221}