Skip to main content

str_format/
validate.rs

1//! 规范第 6 章全部错误码的实现。
2//!
3//! 覆盖四类规则:结构 / 身份、清单一致性、跨枝引用、JSON Schema。
4
5use std::collections::{HashMap, HashSet};
6use std::path::{Path, PathBuf};
7
8use serde_json::Value as JValue;
9
10use crate::bundle::{Bundle, Scan, Visit};
11use crate::error::{Issue, Report, Result, Stats, code};
12use crate::meta::{Entry, Kind, ManifestPolicy, Meta, Policies, ShaPolicy};
13use crate::util::{self, SCHEMA_DIR};
14
15/// 校验入口。
16pub fn validate(bundle: &Bundle) -> Result<Report> {
17    let scan = bundle.scan()?;
18    let mut checker = Checker::new(bundle, &scan);
19    checker.run();
20    checker.check_revision_history();
21    Ok(checker.report)
22}
23
24/// 校验器内部状态。
25struct Checker<'a> {
26    bundle: &'a Bundle,
27    scan: &'a Scan,
28    report: Report,
29    policies: Policies,
30}
31
32impl<'a> Checker<'a> {
33    fn new(bundle: &'a Bundle, scan: &'a Scan) -> Self {
34        let policies = scan
35            .root_index
36            .and_then(|i| scan.visits[i].meta.as_ref())
37            .map(|m| m.policies.clone())
38            .unwrap_or_default();
39        let mut report = Report {
40            bundle: bundle.name(),
41            issues: Vec::new(),
42            stats: Stats::default(),
43        };
44        report.issues.extend(scan.issues.iter().cloned());
45        Self {
46            bundle,
47            scan,
48            report,
49            policies,
50        }
51    }
52
53    // ── 基础输出 ────────────────────────────────────────────
54
55    fn strict(&self) -> bool {
56        self.policies.manifest == ManifestPolicy::Strict
57    }
58
59    fn warn(&mut self, code: &'static str, path: impl Into<String>, msg: impl Into<String>) {
60        self.report.push(Issue::warn(code, path, msg));
61    }
62
63    fn err(&mut self, code: &'static str, path: impl Into<String>, msg: impl Into<String>) {
64        self.report.push(Issue::error(code, path, msg));
65    }
66
67    /// 清单类问题:`manifest = advisory` 时降级为告警(规范 4.8)。
68    fn manifest_issue(
69        &mut self,
70        e_code: &'static str,
71        w_code: &'static str,
72        path: impl Into<String>,
73        msg: impl Into<String>,
74    ) {
75        let path = path.into();
76        let msg = msg.into();
77        if self.strict() {
78            self.err(e_code, path, msg);
79        } else {
80            self.warn(w_code, path, msg);
81        }
82    }
83
84    // ── 主流程 ─────────────────────────────────────────────
85
86    fn run(&mut self) {
87        let scan = self.scan;
88        let name = self.bundle.name();
89        if !name.ends_with(".str") {
90            self.warn(
91                code::BUNDLE_SUFFIX,
92                ".",
93                format!("根目录名 {name:?} 未以 `.str` 结尾"),
94            );
95        }
96
97        let Some(root_idx) = scan.root_index else {
98            return;
99        };
100        self.report.stats.depth = scan.max_depth;
101
102        for v in &scan.visits {
103            match v.depth {
104                0 => {}
105                1 => self.report.stats.nodes += 1,
106                _ => self.report.stats.branches += 1,
107            }
108            if let Some(m) = &v.meta {
109                self.report.stats.entries += m.entries.len();
110            }
111        }
112
113        // 重复 id
114        let dups: Vec<(String, String, usize)> = scan
115            .by_id
116            .iter()
117            .filter(|(_, idxs)| idxs.len() > 1)
118            .map(|(id, idxs)| {
119                (
120                    id.clone(),
121                    scan.visits[idxs[0]].rel.clone(),
122                    idxs.len(),
123                )
124            })
125            .collect();
126        for (id, path, n) in dups {
127            self.err(code::ID_DUP, path, format!("id `{id}` 在 {n} 处重复出现"));
128        }
129
130        // ROOT 自身要求
131        if let Some(m) = scan.visits[root_idx].meta.as_ref() {
132            if m.kind != Some(Kind::Root) {
133                self.err(
134                    code::KIND_DEPTH,
135                    ".",
136                    format!("深度 0 的 `kind` 必须是 `root`,实为 {:?}", m.kind_raw),
137                );
138            }
139            if m.name.is_none() {
140                self.err(code::SCHEMA_FIELD, ".", "root 缺少必填字段 `name`");
141            }
142        }
143
144        for i in 0..scan.visits.len() {
145            self.check_branch(i);
146        }
147
148        self.check_ref_cycles();
149        self.check_meta_schemas();
150    }
151
152    /// 单个分支目录的结构 / 身份 / 引用规则。
153    fn check_branch(&mut self, idx: usize) {
154        let scan = self.scan;
155        let v: &Visit = &scan.visits[idx];
156        let rel = v.rel.clone();
157        let depth = v.depth;
158        let Some(meta) = v.meta.as_ref() else {
159            return; // 解析失败已在扫描期报出
160        };
161
162        // 档位 vs 深度
163        let expected_kind = match depth {
164            0 => Kind::Root,
165            1 => Kind::Node,
166            _ => Kind::Branch,
167        };
168        if let Some(k) = meta.kind {
169            if k != expected_kind {
170                self.err(
171                    code::KIND_DEPTH,
172                    rel.clone(),
173                    format!(
174                        "深度 {depth} 的 `kind` 必须是 `{}`,实为 `{}`",
175                        expected_kind.as_str(),
176                        k.as_str()
177                    ),
178                );
179            }
180        }
181
182        if let Some(ver) = meta.str_version {
183            if ver != crate::STR_MAJOR {
184                self.err(
185                    code::SPEC_UNSUPPORTED,
186                    rel.clone(),
187                    format!("`str` = {ver},本实现只支持主版本 {}", crate::STR_MAJOR),
188                );
189            }
190        }
191
192        // 目录名 vs id
193        let dir_name = v
194            .dir
195            .file_name()
196            .map(|s| s.to_string_lossy().to_string())
197            .unwrap_or_default();
198        if depth >= 1 {
199            if !util::is_uuid(&dir_name) {
200                self.err(
201                    code::ID_NOT_UUID,
202                    rel.clone(),
203                    format!("分支目录名 {dir_name:?} 不是合法 UUID"),
204                );
205            } else if let Some(ver) = util::uuid_version(&dir_name) {
206                if ver != self.policies.id_version {
207                    self.err(
208                        code::ID_VERSION,
209                        rel.clone(),
210                        format!(
211                            "目录名 UUID 版本为 {ver},`policies.id_version` 要求 {}",
212                            self.policies.id_version
213                        ),
214                    );
215                }
216            }
217            if let Some(id) = meta.id.as_deref() {
218                if id != dir_name {
219                    self.err(
220                        code::ID_MISMATCH,
221                        rel.clone(),
222                        format!("`id` = {id:?} 与目录名 {dir_name:?} 不一致"),
223                    );
224                }
225            }
226        }
227
228        // 深度
229        if depth > self.policies.max_depth {
230            self.err(
231                code::DEPTH_EXCEEDED,
232                rel.clone(),
233                format!(
234                    "深度 {depth} 超过 `policies.max_depth` = {}",
235                    self.policies.max_depth
236                ),
237            );
238        } else if depth > self.policies.deep_tree_warn {
239            self.warn(
240                code::DEEP_TREE,
241                rel.clone(),
242                format!(
243                    "深度 {depth} 超过 `policies.deep_tree_warn` = {},建议拆分",
244                    self.policies.deep_tree_warn
245                ),
246            );
247        }
248
249        if depth >= 1 && meta.summary.is_none() {
250            self.warn(
251                code::NO_SUMMARY,
252                rel.clone(),
253                "建议补充 `summary`(AI 检索依据)",
254            );
255        }
256        if depth >= 1 && meta.r#type.is_none() {
257            self.warn(code::NO_TYPE, rel.clone(), "建议补充 `type`");
258        }
259
260        for issue in meta.revision_issues(&rel) {
261            self.report.push(issue);
262        }
263
264        // 跨枝引用
265        let own_id = meta.id.clone();
266        let refs = meta.refs.clone();
267        let mut bad_targets: Vec<(usize, String, String)> = Vec::new();
268        for (n, r) in refs.iter().enumerate() {
269            if Some(r.target.as_str()) == own_id.as_deref() {
270                self.err(
271                    code::REF_SELF,
272                    format!("{rel} #refs[{n}]"),
273                    "关联线指向自身",
274                );
275                continue;
276            }
277            if !r.target.is_empty() && scan.resolve(&r.target).is_none() {
278                bad_targets.push((n, r.target.clone(), rel.clone()));
279            }
280        }
281        for (n, target, rel) in bad_targets {
282            self.err(
283                code::REF_NO_TARGET,
284                format!("{rel} #refs[{n}]"),
285                format!("`target` = {target:?} 无法在本 bundle 内解析到任何分支"),
286            );
287        }
288
289        // 清单
290        self.check_manifest(idx);
291    }
292
293    /// 清单一致性(规范 4.8)与角色 / 深度一致性。
294    fn check_manifest(&mut self, idx: usize) {
295        let scan = self.scan;
296        let bundle = self.bundle;
297        let v: &Visit = &scan.visits[idx];
298        let rel = v.rel.clone();
299        let dir = v.dir.clone();
300        let meta: &Meta = match v.meta.as_ref() {
301            Some(m) => m,
302            None => return,
303        };
304        let entries: Vec<Entry> = meta.entries.clone();
305
306        // path 重复
307        let mut seen: HashSet<String> = HashSet::new();
308        let mut dups: Vec<String> = Vec::new();
309        for e in &entries {
310            if !seen.insert(e.path.clone()) {
311                dups.push(e.path.clone());
312            }
313        }
314        for p in dups {
315            self.err(
316                code::MANIFEST_DUP,
317                format!("{rel} #entries"),
318                format!("`entries[].path` = {p:?} 重复"),
319            );
320        }
321
322        let actual = bundle.list_names(&dir).unwrap_or_default();
323
324        // 实际侧 → 逐条比对
325        for (name, is_dir) in &actual {
326            let ep = format!("{rel}/{name}");
327            if util::is_meta_file(name) || util::is_lock_file(name) {
328                continue; // `._meta` / `.lock` 不参与清单
329            }
330            if util::is_reserved_name(name) {
331                // 保留命名空间判定:
332                // - `._schema` / `._cache` 是已知保留目录 → 放行;
333                // - 业务**目录**以 `._` 开头 → 必然不是 AppleDouble(该机制只产生文件)→ `E_RESERVED_NAME`;
334                // - 已在 `entries` 中登记的 `._*` 条目 → 作者显式声明其为业务内容 → `E_RESERVED_NAME`;
335                // - 其余 `._*` **普通文件** → 视为 macOS AppleDouble 噪声,豁免(规范 3.4)。
336                let known = name == crate::util::SCHEMA_DIR || name == crate::util::CACHE_DIR;
337                let declared_here = entries.iter().any(|e| e.path == *name);
338                if !known && (*is_dir || declared_here) {
339                    self.err(
340                        code::RESERVED_NAME,
341                        ep.clone(),
342                        "业务条目不得以 `._` 开头(`._` 为格式保留命名空间)",
343                    );
344                }
345                continue;
346            }
347            if util::is_os_noise(name) {
348                continue; // `.DS_Store` / `Thumbs.db` / `desktop.ini`
349            }
350            if util::is_other_dotfile(name) {
351                self.warn(
352                    code::DOTFILE,
353                    ep.clone(),
354                    "出现非 `._meta` / `.lock` 的点文件(不计入清单要求)",
355                );
356                continue;
357            }
358            // ROOT 允许承载任意内容(规范 3.3:任意目录可放任意文件),
359            // 但「既非 UUID 命名的分支目录、又未登记进 entries」的条目属散落内容 → 告警。
360            let declared_here = entries.iter().any(|e| e.path == *name);
361            if v.depth == 0 && !util::is_uuid(name) && !declared_here {
362                self.warn(
363                    code::ROOT_STRAY,
364                    ep.clone(),
365                    "ROOT 下出现既非分支目录(UUID 命名)又未登记的条目",
366                );
367            }
368            match entries.iter().find(|e| e.path == *name) {
369                None => {
370                    let what = if *is_dir { "目录" } else { "文件" };
371                    self.manifest_issue(
372                        code::MANIFEST_MISSING,
373                        code::MANIFEST_MISSING_W,
374                        ep.clone(),
375                        format!("磁盘存在{what}但 `entries` 未登记"),
376                    );
377                }
378                Some(e) => {
379                    let e = e.clone();
380                    self.check_entry(&dir, v.depth, meta, &e, &ep, *is_dir);
381                }
382            }
383        }
384
385        // 声明侧 → 幽灵条目
386        let mut ghosts: Vec<(String, bool)> = Vec::new();
387        for e in &entries {
388            let path: PathBuf = dir.join(&e.path);
389            if path.exists() {
390                continue;
391            }
392            if e.optional {
393                self.warn(
394                    code::OPTIONAL_MISSING,
395                    format!("{rel}/{}", e.path),
396                    "`optional = true` 的条目当前缺失(允许)",
397                );
398            } else {
399                ghosts.push((format!("{rel}/{}", e.path), false));
400            }
401        }
402        for (ep, _) in ghosts {
403            self.manifest_issue(
404                code::MANIFEST_GHOST,
405                code::MANIFEST_GHOST_W,
406                ep,
407                "`entries` 已登记但磁盘不存在",
408            );
409        }
410    }
411
412    /// 单条已登记条目 vs 磁盘实际。
413    fn check_entry(
414        &mut self,
415        dir: &Path,
416        parent_depth: usize,
417        meta: &Meta,
418        e: &Entry,
419        ep: &str,
420        is_dir: bool,
421    ) {
422        let bundle = self.bundle;
423        let path: PathBuf = dir.join(&e.path);
424
425        match e.role.as_str() {
426            "node" | "branch" | "dir" => {
427                if !is_dir {
428                    self.err(
429                        code::ENTRY_ROLE_DEPTH,
430                        ep,
431                        format!("`role = {:?}` 要求目录,但磁盘上是文件", e.role),
432                    );
433                    return;
434                }
435                let dir_name = path
436                    .file_name()
437                    .map(|s| s.to_string_lossy().to_string())
438                    .unwrap_or_default();
439                let child_has_meta = bundle.has_meta(&path);
440                if e.role == "dir" {
441                    if util::is_sub_bundle(&dir_name) {
442                        self.err(
443                            code::ENTRY_ROLE_DEPTH,
444                            ep,
445                            "该目录名以 `.str` 结尾(独立子 bundle),应登记为 `role = \"bundle\"`",
446                        );
447                        return;
448                    }
449                    if child_has_meta {
450                        self.err(
451                            code::ENTRY_ROLE_DEPTH,
452                            ep,
453                            "该子目录含 `._meta`(已是分支),却被登记为 `role = \"dir\"`",
454                        );
455                    }
456                    return;
457                }
458                if !child_has_meta {
459                    self.err(code::META_MISSING, ep, "登记为分支但目录内缺少 `._meta`");
460                    return;
461                }
462                let want_node = e.role == "node";
463                if parent_depth == 0 && !want_node {
464                    self.err(
465                        code::ENTRY_ROLE_DEPTH,
466                        ep,
467                        "ROOT 的直接子分支必须是 `role = \"node\"`",
468                    );
469                } else if parent_depth >= 1 && want_node {
470                    self.err(
471                        code::ENTRY_ROLE_DEPTH,
472                        ep,
473                        "深度 ≥1 的子分支必须是 `role = \"branch\"`",
474                    );
475                }
476                if let Some(id) = &e.id {
477                    let dir_name = path
478                        .file_name()
479                        .map(|s| s.to_string_lossy().to_string())
480                        .unwrap_or_default();
481                    if *id != dir_name {
482                        self.err(
483                            code::ENTRY_ID_MISMATCH,
484                            ep,
485                            format!("`entries[].id` = {id:?} 与子目录名 {dir_name:?} 不一致"),
486                        );
487                    }
488                }
489                // 子分支自身的 `kind` 是否与父级 `role` 相符
490                let child_kind = match bundle.read_meta(&path) {
491                    Ok(crate::meta::MetaLoad::Ok(child, _)) => child.kind,
492                    _ => None,
493                };
494                if let Some(k) = child_kind {
495                    let expect = if want_node { Kind::Node } else { Kind::Branch };
496                    if k != expect {
497                        self.err(
498                            code::ENTRY_ROLE_DEPTH,
499                            ep,
500                            format!(
501                                "父级登记 `role = {:?}`,但子分支 `kind` = `{}`",
502                                e.role,
503                                k.as_str()
504                            ),
505                        );
506                    }
507                }
508            }
509            "payload" | "asset" => {
510                if is_dir {
511                    self.err(
512                        code::ENTRY_ROLE_DEPTH,
513                        ep,
514                        format!("`role = {:?}` 要求文件,但磁盘上是目录", e.role),
515                    );
516                    return;
517                }
518                self.check_file_digest(e, ep, &path);
519                if e.role == "payload" {
520                    self.check_payload_schema(meta, e, ep, &path);
521                }
522            }
523            "schema" | "cache" => {
524                if !is_dir {
525                    self.err(
526                        code::ENTRY_ROLE_DEPTH,
527                        ep,
528                        format!("`role = {:?}` 要求目录", e.role),
529                    );
530                }
531            }
532            "bundle" => {
533                // 独立子 bundle:目录名须以 `.str` 结尾;父 bundle 不进入其内部
534                let dir_name = path
535                    .file_name()
536                    .map(|s| s.to_string_lossy().to_string())
537                    .unwrap_or_default();
538                if !is_dir {
539                    self.err(
540                        code::ENTRY_ROLE_DEPTH,
541                        ep,
542                        "`role = \"bundle\"` 要求目录",
543                    );
544                } else if !util::is_sub_bundle(&dir_name) {
545                    self.err(
546                        code::ENTRY_ROLE_DEPTH,
547                        ep,
548                        format!("`role = \"bundle\"` 要求目录名以 `.str` 结尾,实为 {dir_name:?}"),
549                    );
550                }
551            }
552            _ => {}
553        }
554    }
555
556    /// 文件类条目的 `size` / `sha256` 强制与一致性。
557    fn check_file_digest(&mut self, e: &Entry, ep: &str, path: &Path) {
558        if self.policies.sha256 == ShaPolicy::Required && (e.size.is_none() || e.sha256.is_none()) {
559            self.err(
560                code::MANIFEST_DIGEST_MISSING,
561                ep,
562                "`policies.sha256 = \"required\"`:文件类条目必须同时提供 `size` 与 `sha256`",
563            );
564        }
565        let Ok(md) = std::fs::metadata(path) else {
566            return;
567        };
568        let real_size = md.len();
569        if let Some(s) = e.size {
570            if s < 0 || s as u64 != real_size {
571                self.manifest_issue(
572                    code::MANIFEST_HASH,
573                    code::MANIFEST_HASH_W,
574                    ep,
575                    format!("`size` = {s} 与实际 {real_size} 不符"),
576                );
577            }
578        }
579        if self.policies.sha256 != ShaPolicy::Off {
580            if let Some(declared) = e.sha256.clone() {
581                match util::sha256_file(path) {
582                    Ok(real) if real == declared => {}
583                    Ok(real) => {
584                        let d = &declared[..declared.len().min(12)];
585                        let r = &real[..12];
586                        self.manifest_issue(
587                            code::MANIFEST_HASH,
588                            code::MANIFEST_HASH_W,
589                            ep,
590                            format!("`sha256` 不符:声明 {d}…,实际 {r}…"),
591                        );
592                    }
593                    Err(err) => {
594                        self.manifest_issue(
595                            code::MANIFEST_HASH,
596                            code::MANIFEST_HASH_W,
597                            ep,
598                            format!("无法计算摘要:{err}"),
599                        );
600                    }
601                }
602            }
603        }
604        if real_size > self.policies.large_asset_bytes {
605            self.warn(
606                code::LARGE_ASSET,
607                ep,
608                format!(
609                    "文件 {real_size} 字节,超过 `large_asset_bytes` = {}",
610                    self.policies.large_asset_bytes
611                ),
612            );
613        }
614    }
615
616    /// `E_REVISION_STALE` 的**历史**判定:`updated_at` 变了但 `revision` 没有前进(规范 §6.1)。
617    ///
618    /// 依据 `._cache/revisions.json`(由写入端登记,见 [`crate::baseline`])。无基线时跳过 ——
619    /// 单份 `._meta` 不含历史,无从判定;`str sync` 会把基线刷到当前状态。
620    fn check_revision_history(&mut self) {
621        let baseline = crate::baseline::load(self.bundle);
622        if baseline.branches.is_empty() {
623            return;
624        }
625        let mut found: Vec<(String, String, i64, i64, String)> = Vec::new();
626        for v in &self.scan.visits {
627            let Some(meta) = v.meta.as_ref() else {
628                continue;
629            };
630            let Some(snap) = baseline.branches.get(&v.rel) else {
631                continue;
632            };
633            let (Some(rev), Some(updated)) = (meta.revision, meta.updated_at.as_deref()) else {
634                continue;
635            };
636            let changed = match (
637                util::parse_rfc3339(&snap.updated_at),
638                util::parse_rfc3339(updated),
639            ) {
640                (Some(a), Some(b)) => a != b,
641                _ => snap.updated_at != updated,
642            };
643            if changed && rev <= snap.revision {
644                found.push((
645                    v.rel.clone(),
646                    snap.updated_at.clone(),
647                    snap.revision,
648                    rev,
649                    updated.to_string(),
650                ));
651            }
652        }
653        for (rel, was, old_rev, new_rev, now) in found {
654            self.err(
655                code::REVISION_STALE,
656                rel,
657                format!(
658                    "`updated_at` 已由 {was} 变为 {now},但 `revision` 未前进({old_rev} → {new_rev});\
659                     规范 §7.2 要求每次写入同时 +1 并刷新时间"
660                ),
661            );
662        }
663    }
664
665    /// `refs` 关联图成环检测(迭代式 DFS)。
666    fn check_ref_cycles(&mut self) {
667        let scan = self.scan;
668        let n = scan.visits.len();
669        let mut edges: Vec<Vec<usize>> = vec![Vec::new(); n];
670        for (i, v) in scan.visits.iter().enumerate() {
671            if let Some(m) = &v.meta {
672                for r in &m.refs {
673                    if let Some(j) = scan.resolve(&r.target) {
674                        edges[i].push(j);
675                    }
676                }
677            }
678        }
679
680        let mut color = vec![0u8; n]; // 0 未访问 / 1 在栈 / 2 已完成
681        let mut hits: Vec<String> = Vec::new();
682        for start in 0..n {
683            if color[start] != 0 {
684                continue;
685            }
686            let mut stack: Vec<(usize, usize)> = vec![(start, 0)];
687            color[start] = 1;
688            while !stack.is_empty() {
689                let top = stack.len() - 1;
690                let (node, k) = stack[top];
691                if k < edges[node].len() {
692                    let next = edges[node][k];
693                    stack[top].1 += 1;
694                    match color[next] {
695                        0 => {
696                            color[next] = 1;
697                            stack.push((next, 0));
698                        }
699                        1 => hits.push(scan.visits[next].rel.clone()),
700                        _ => {}
701                    }
702                } else {
703                    color[node] = 2;
704                    stack.pop();
705                }
706            }
707        }
708        hits.sort();
709        hits.dedup();
710        for path in hits {
711            self.err(
712                code::REF_CYCLE,
713                path,
714                "`refs` 关联图成环(追踪终点回到链上已有分支)",
715            );
716        }
717    }
718
719    /// 每个分支的 `._meta` 归一化后过 JSON Schema(规范 4.1 校验链路)。
720    fn check_meta_schemas(&mut self) {
721        let scan = self.scan;
722        let bundle = self.bundle;
723        let items: Vec<(String, JValue, &'static str)> = scan
724            .visits
725            .iter()
726            .filter_map(|v| {
727                let m = v.meta.as_ref()?;
728                let name: &'static str = match v.depth {
729                    0 => "root-meta.schema.json",
730                    1 => "node-meta.schema.json",
731                    _ => "branch-meta.schema.json",
732                };
733                Some((v.rel.clone(), m.to_json(), name))
734            })
735            .collect();
736
737        for (rel, instance, name) in items {
738            let schema = load_meta_schema(bundle, name);
739            let Ok(validator) = jsonschema::validator_for(&schema) else {
740                continue;
741            };
742            let msgs: Vec<String> = validator
743                .iter_errors(&instance)
744                .map(|e| format!("{e}(位于 {})", e.instance_path()))
745                .collect();
746            for m in msgs {
747                self.err(code::SCHEMA_FAIL, rel.clone(), m);
748            }
749        }
750    }
751
752    /// payload 的业务 Schema 校验(`entries[].schema` 或分支级 `schema`)。
753    fn check_payload_schema(&mut self, meta: &Meta, e: &Entry, ep: &str, path: &Path) {
754        let Some(schema_ref) = e.schema.clone().or_else(|| meta.schema.clone()) else {
755            return;
756        };
757        let schema_path = self.bundle.root.join(&schema_ref);
758        let Ok(schema_bytes) = std::fs::read(&schema_path) else {
759            return;
760        };
761        let Ok(schema) = serde_json::from_slice::<JValue>(&schema_bytes) else {
762            return;
763        };
764        let Ok(validator) = jsonschema::validator_for(&schema) else {
765            return;
766        };
767        let Ok(bytes) = std::fs::read(path) else {
768            return;
769        };
770        let Ok(instance) = serde_json::from_slice::<JValue>(&bytes) else {
771            return; // 非 JSON payload 不做 Schema 校验
772        };
773        let msgs: Vec<String> = validator
774            .iter_errors(&instance)
775            .map(|err| format!("{err}(位于 {})", err.instance_path()))
776            .collect();
777        for m in msgs {
778            self.err(
779                code::SCHEMA_FAIL,
780                ep,
781                format!("payload 不满足 `{schema_ref}`:{m}"),
782            );
783        }
784    }
785}
786
787/// 取某档位的 `._meta` Schema:优先 bundle 内 `._schema/`,否则用内嵌兜底。
788fn load_meta_schema(bundle: &Bundle, name: &str) -> JValue {
789    let local = bundle.root.join(SCHEMA_DIR).join(name);
790    if local.is_file() {
791        if let Ok(bytes) = std::fs::read(&local) {
792            if let Ok(v) = serde_json::from_slice::<JValue>(&bytes) {
793                return v;
794            }
795        }
796    }
797    crate::EMBEDDED_SCHEMAS
798        .iter()
799        .find(|(n, _)| *n == name)
800        .and_then(|(_, t)| serde_json::from_str(t).ok())
801        .unwrap_or_else(|| JValue::Object(Default::default()))
802}
803
804/// 统计某目录下的条目数(`str sync` 用)。
805pub fn count_dir_entries(dir: &Path) -> usize {
806    std::fs::read_dir(dir)
807        .map(|rd| rd.flatten().filter(|e| !util::is_meta_file(&e.file_name().to_string_lossy())).count())
808        .unwrap_or(0)
809}
810
811/// 计算目录直接子项数(含点文件,排除 `._meta`)。
812pub fn dir_child_count(dir: &Path) -> Option<i64> {
813    std::fs::read_dir(dir).ok().map(|rd| {
814        rd.flatten()
815            .filter(|e| !util::is_meta_file(&e.file_name().to_string_lossy()))
816            .count() as i64
817    })
818}
819
820/// 收集目录内除了 `._meta` 与 OS 噪声之外的名字(`str sync` 用)。
821pub fn real_entries(dir: &Path) -> Vec<(String, bool)> {
822    let mut out = Vec::new();
823    let Ok(rd) = std::fs::read_dir(dir) else {
824        return out;
825    };
826    for ent in rd.flatten() {
827        let name = ent.file_name().to_string_lossy().to_string();
828        if util::is_meta_file(&name) || util::is_os_noise(&name) || util::is_lock_file(&name) {
829            continue;
830        }
831        let is_dir = ent.file_type().map(|t| t.is_dir()).unwrap_or(false);
832        out.push((name, is_dir));
833    }
834    out.sort();
835    out
836}
837
838/// 供测试与工具使用:一次扫描的 `id` → 相对路径映射。
839pub fn id_index(scan: &Scan) -> HashMap<String, String> {
840    scan.by_id
841        .iter()
842        .filter_map(|(id, idxs)| idxs.first().map(|i| (id.clone(), scan.visits[*i].rel.clone())))
843        .collect()
844}