1use std::collections::{BTreeMap, BTreeSet};
51use std::path::Path;
52
53use okf_core::{
54 Bundle, Concept, Document, Frontmatter, PREFERRED_KEY_ORDER, Status, TrustTier, Value,
55};
56use serde::Serialize;
57
58use super::inspect::InspectError;
59
60#[derive(Debug, Clone, Serialize)]
62pub struct Finding {
63 pub severity: &'static str,
65 pub code: Option<&'static str>,
71 pub concept: Option<String>,
73 pub path: Option<String>,
75 pub message: String,
77}
78
79#[derive(Debug, Clone, Serialize)]
81pub struct CheckReport {
82 pub root: String,
84 pub check: &'static str,
86 pub concepts: usize,
89 pub findings: Vec<Finding>,
92 pub errors: usize,
94 pub warnings: usize,
96}
97
98impl CheckReport {
99 #[must_use]
108 pub const fn passed(&self) -> bool {
109 self.errors == 0
110 }
111}
112
113struct Cx<'a> {
116 findings: Vec<Finding>,
117 concept: Option<String>,
118 path: Option<String>,
119 root: &'a Path,
120}
121
122impl<'a> Cx<'a> {
123 fn new(root: &'a Path) -> Self {
124 Self {
125 findings: Vec::new(),
126 concept: None,
127 path: None,
128 root,
129 }
130 }
131
132 fn at(&mut self, concept: &Concept) {
134 self.concept = Some(concept.id.to_string());
135 self.path = Some(self.relative(&concept.path));
136 }
137
138 fn at_file(&mut self, path: &Path) {
141 self.concept = None;
142 self.path = Some(self.relative(path));
143 }
144
145 fn relative(&self, path: &Path) -> String {
148 path.strip_prefix(self.root)
149 .unwrap_or(path)
150 .display()
151 .to_string()
152 }
153
154 fn push(&mut self, severity: &'static str, code: Option<&'static str>, message: String) {
155 self.findings.push(Finding {
156 severity,
157 code,
158 concept: self.concept.clone(),
159 path: self.path.clone(),
160 message,
161 });
162 }
163
164 fn err(&mut self, message: impl Into<String>) {
165 self.push("error", None, message.into());
166 }
167
168 fn warn(&mut self, message: impl Into<String>) {
169 self.push("warning", None, message.into());
170 }
171
172 fn info(&mut self, message: impl Into<String>) {
173 self.push("info", None, message.into());
174 }
175
176 fn lint(&mut self, severity: &'static str, code: &'static str, message: impl Into<String>) {
178 self.push(severity, Some(code), message.into());
179 }
180
181 fn finish(self, root: &Path, check: &'static str, concepts: usize) -> CheckReport {
182 let mut findings = self.findings;
183 findings.sort_by_key(|f| match f.severity {
188 "error" => 0u8,
189 "warning" => 1,
190 _ => 2,
191 });
192 CheckReport {
193 root: root.display().to_string(),
194 check,
195 concepts,
196 errors: findings.iter().filter(|f| f.severity == "error").count(),
197 warnings: findings.iter().filter(|f| f.severity == "warning").count(),
198 findings,
199 }
200 }
201}
202
203pub fn validate_report(root: &Path) -> Result<CheckReport, InspectError> {
211 let bundle = super::inspect::load(root)?;
212 Ok(validate_bundle(&bundle, root))
213}
214
215#[must_use]
218pub fn validate_bundle(bundle: &Bundle, root: &Path) -> CheckReport {
219 let mut cx = Cx::new(root);
220
221 for (path, error) in bundle.parse_errors() {
225 cx.at_file(path);
226 cx.err(format!("not a readable OKF document: {error}"));
227 }
228
229 for concept in bundle.concepts() {
230 cx.at(concept);
231 let doc = &concept.document;
232 let fm = &doc.frontmatter;
233
234 check_type(&mut cx, concept, fm);
235 check_recommended(&mut cx, doc);
236 check_empty_body(&mut cx, doc);
237 check_tags(&mut cx, fm);
238 check_trust(&mut cx, fm);
239 check_lifecycle(&mut cx, fm);
240 check_usage_window(&mut cx, fm);
241 check_attribution(&mut cx, doc);
242 check_legacy(&mut cx, doc, fm);
243 check_computation(&mut cx, concept);
244 check_resources(&mut cx, bundle, concept);
245 check_link_targets(&mut cx, bundle, concept);
246 check_reserved_filename(&mut cx, concept);
247 }
248
249 check_declared_version(&mut cx, bundle);
250 check_duplicate_titles(&mut cx, bundle);
251 check_circular_derivation(&mut cx, bundle);
252 check_stale_indexes(&mut cx, bundle);
253
254 cx.finish(root, "validate", bundle.concepts().len())
255}
256
257pub fn lint_report(root: &Path) -> Result<CheckReport, InspectError> {
268 let bundle = super::inspect::load(root)?;
269 Ok(lint_bundle(&bundle, root))
270}
271
272#[must_use]
274pub fn lint_bundle(bundle: &Bundle, root: &Path) -> CheckReport {
275 let mut cx = Cx::new(root);
276 let indexed = indexed_concepts(bundle);
277
278 for concept in bundle.concepts() {
279 cx.at(concept);
280 let doc = &concept.document;
281 let fm = &doc.frontmatter;
282
283 lint_headings(&mut cx, doc);
284 lint_key_order(&mut cx, fm);
285 lint_unused_sources(&mut cx, concept, doc);
286 lint_actor_convention(&mut cx, concept);
287 lint_computation_block(&mut cx, doc);
288 lint_whitespace(&mut cx, doc);
289 lint_orphan(&mut cx, concept, &indexed);
290 lint_portable_id(&mut cx, concept);
291 lint_self_link(&mut cx, bundle, concept);
292 lint_unverified(&mut cx, concept);
293 lint_draft(&mut cx, concept);
294 }
295
296 cx.finish(root, "lint", bundle.concepts().len())
297}
298
299fn check_type(cx: &mut Cx<'_>, concept: &Concept, fm: &Frontmatter) {
305 if concept.type_().is_none_or(|t| t.trim().is_empty()) {
306 cx.err("`type` is missing or empty; §4.1 requires one on every concept");
307 }
308 if let Some(t) = fm.type_()
312 && !t.trim().is_empty()
313 && t.trim() != t
314 {
315 cx.info(format!(
316 "`type` has surrounding whitespace (`{t}`); consumers that compare it literally will not match"
317 ));
318 }
319}
320
321fn check_recommended(cx: &mut Cx<'_>, doc: &Document) {
324 for key in doc.missing_recommended() {
325 cx.warn(format!("recommended key `{key}` is missing"));
326 }
327}
328
329fn check_empty_body(cx: &mut Cx<'_>, doc: &Document) {
330 if doc.body.trim().is_empty() {
331 cx.warn("body is empty; a concept should carry at least one line of prose or code");
332 }
333}
334
335fn check_tags(cx: &mut Cx<'_>, fm: &Frontmatter) {
342 match fm.get("tags") {
343 Some(Value::String(_)) => cx.warn(
344 "`tags` should be a list of short strings, found a string; \
345 a strict consumer reads no tags from it",
346 ),
347 Some(Value::Sequence(items)) => {
348 if let Some(bad) = items.iter().find(|v| !matches!(v, Value::String(_))) {
349 cx.warn(format!(
350 "`tags` contains a non-string entry ({}); §4.1 asks for short strings",
351 kind_of(bad)
352 ));
353 }
354 }
355 Some(other) => cx.warn(format!(
356 "`tags` should be a list of short strings, found {}",
357 kind_of(other)
358 )),
359 None => {}
360 }
361}
362
363fn check_trust(cx: &mut Cx<'_>, fm: &Frontmatter) {
365 if let Some(generated) = fm.generated() {
366 if generated.by.is_none() {
367 cx.warn("`generated.by` is required within `generated`");
368 }
369 match &generated.at {
370 None => cx.warn("`generated.at` is required within `generated`"),
371 Some(at) if at.datetime.is_none() => cx.warn(format!(
372 "`generated.at` is not an ISO-8601 datetime (`{}`)",
373 at.raw
374 )),
375 Some(_) => {}
376 }
377 }
378
379 if fm.contains_key("verified") {
382 let events = fm.verified();
383 if events.is_empty() {
384 cx.warn("`verified` is present but contains no `{ by, at }` events");
385 }
386 for (i, event) in events.iter().enumerate() {
387 if event.by.is_none() {
388 cx.warn(format!("`verified[{i}].by` is missing"));
389 }
390 match &event.at {
391 None => cx.warn(format!("`verified[{i}].at` is missing")),
392 Some(at) if at.datetime.is_none() => cx.warn(format!(
393 "`verified[{i}].at` is not an ISO-8601 datetime (`{}`)",
394 at.raw
395 )),
396 Some(_) => {}
397 }
398 }
399 }
400}
401
402fn check_lifecycle(cx: &mut Cx<'_>, fm: &Frontmatter) {
404 if let Some(Status::Other(value)) = Some(Status::parse(fm.get("status").and_then(as_str))) {
405 cx.info(format!(
406 "`status: {value}` is outside §5.4's `draft | stable | deprecated`; \
407 consumers must tolerate it, but few will act on it"
408 ));
409 }
410 if let Some(raw) = fm.get("stale_after").and_then(as_str)
411 && okf_core::DateTime::parse(raw).is_none()
412 {
413 cx.warn(format!(
414 "`stale_after` is not an ISO-8601 datetime (`{raw}`), so no consumer can act on it"
415 ));
416 }
417}
418
419fn check_usage_window(cx: &mut Cx<'_>, fm: &Frontmatter) {
421 if fm.usage_window().is_some() && fm.sources().is_empty() {
422 cx.warn("`usage_window` is present without `sources` to frame");
423 }
424}
425
426fn check_attribution(cx: &mut Cx<'_>, doc: &Document) {
429 for attribution in doc.attributions() {
430 if attribution.source.is_none() {
431 cx.warn(format!(
432 "footnote [^{}] matches no `sources[].id`; the label is the join key for attribution",
433 attribution.label
434 ));
435 }
436 }
437}
438
439fn check_legacy(cx: &mut Cx<'_>, doc: &Document, fm: &Frontmatter) {
441 if fm.timestamp().is_some() {
442 cx.warn("`timestamp` is superseded by `generated.at` (§13.1)");
443 }
444 if doc.has_legacy_citations() {
445 cx.warn("the body `# Citations` list is superseded by `sources` (§13.1)");
446 }
447}
448
449fn check_computation(cx: &mut Cx<'_>, concept: &Concept) {
451 let Some(computation) = concept.attested_computation() else {
452 return;
453 };
454
455 if computation.runtime.as_deref().is_none_or(str::is_empty) {
456 cx.warn("`runtime` is missing; without it nothing knows how to run the computation");
457 }
458 for (i, parameter) in computation.parameters.iter().enumerate() {
459 if parameter.name.is_none() {
460 cx.warn(format!("`parameters[{i}].name` is missing"));
461 }
462 if parameter.type_.is_none() {
463 cx.warn(format!("`parameters[{i}].type` is missing"));
464 }
465 }
466 match &computation.executor {
467 None => cx.warn("missing `executor`: nothing says how to run the computation"),
468 Some(e) if e.resource.is_none() => {
469 cx.warn("`executor.resource` is missing; it names the run instructions or code");
470 }
471 Some(_) => {}
472 }
473 match &computation.attester {
474 None => cx.warn("missing `attester`: nothing can check a run's receipt"),
475 Some(a) if a.resource.is_none() => {
476 cx.warn("`attester.resource` is missing; it names the deterministic check");
477 }
478 Some(_) => {}
479 }
480 if computation.computation.is_missing() {
481 cx.warn(
482 "no computation: neither a `# Computation` block nor a `computation:` path is present, \
483 so there is nothing for an executor to run or an attester to check",
484 );
485 }
486 if computation.has_redundant_inline {
487 cx.warn(
488 "both a `# Computation` block and a `computation:` path are present; \
489 §10 asks for one or the other, and two copies can disagree",
490 );
491 }
492}
493
494fn check_resources(cx: &mut Cx<'_>, bundle: &Bundle, concept: &Concept) {
499 let check = |label: &str, raw: &str, cx: &mut Cx<'_>| {
500 if let Some(rel) = bundle_relative(raw)
501 && !bundle.root().join(&rel).exists()
502 {
503 cx.warn(format!(
504 "{label} names `{raw}`, which the bundle does not contain"
505 ));
506 }
507 };
508
509 if let Some(resource) = concept.document.frontmatter.resource() {
510 check("`resource`", &resource, cx);
511 }
512 for source in concept.sources() {
513 if let Some(resource) = &source.resource {
514 check("a `sources` entry", resource, cx);
515 }
516 }
517 if let Some(computation) = concept.attested_computation() {
518 if let Some(e) = computation
519 .executor
520 .as_ref()
521 .and_then(|e| e.resource.clone())
522 {
523 check("`executor.resource`", &e, cx);
524 }
525 if let Some(a) = computation
526 .attester
527 .as_ref()
528 .and_then(|a| a.resource.clone())
529 {
530 check("`attester.resource`", &a, cx);
531 }
532 if let Some(path) = computation.computation.path() {
533 check("`computation`", path, cx);
534 }
535 }
536}
537
538fn check_link_targets(cx: &mut Cx<'_>, bundle: &Bundle, concept: &Concept) {
544 let mut deprecated: BTreeSet<String> = BTreeSet::new();
548 for link in bundle.links_from(&concept.id) {
549 if !link.exists {
550 if bundle.resolve_path_field(&concept.id, &link.raw).is_none() {
558 cx.info(format!(
559 "link `{}` names `{}`, which the bundle does not contain; \
560 §6 tells a consumer to tolerate this",
561 link.raw, link.target
562 ));
563 }
564 continue;
565 }
566 if let Some(target) = bundle.get(&link.target)
567 && target.status().is_deprecated()
568 {
569 deprecated.insert(link.target.to_string());
570 }
571 }
572 for target in deprecated {
573 cx.warn(format!("links to deprecated concept `{target}`"));
574 }
575}
576
577fn check_reserved_filename(cx: &mut Cx<'_>, concept: &Concept) {
582 let name = concept
583 .path
584 .file_name()
585 .and_then(|n| n.to_str())
586 .unwrap_or_default();
587 if okf_core::RESERVED_FILENAMES.contains(&name) {
588 cx.err(format!(
589 "`{name}` is a reserved filename and §3.1 forbids using it for a concept document"
590 ));
591 }
592}
593
594fn check_declared_version(cx: &mut Cx<'_>, bundle: &Bundle) {
608 if let Some(version) = bundle.okf_version()
609 && version != super::OKF_VERSION
610 {
611 cx.at_file(&bundle.root().join("index.md"));
612 cx.info(format!(
613 "the bundle declares `okf_version: {version}`; this reader implements {}, \
614 so it is read best-effort (§12)",
615 super::OKF_VERSION
616 ));
617 cx.concept = None;
618 cx.path = None;
619 }
620}
621
622fn check_duplicate_titles(cx: &mut Cx<'_>, bundle: &Bundle) {
624 let mut by_title: BTreeMap<String, Vec<&Concept>> = BTreeMap::new();
625 for concept in bundle.concepts() {
626 by_title
627 .entry(concept.display_title())
628 .or_default()
629 .push(concept);
630 }
631 for (title, concepts) in by_title {
632 if concepts.len() < 2 {
633 continue;
634 }
635 let others: Vec<String> = concepts.iter().map(|c| c.id.to_string()).collect();
636 for concept in &concepts {
637 cx.at(concept);
638 let self_id = concept.id.to_string();
640 let siblings: Vec<&String> = others.iter().filter(|id| **id != self_id).collect();
641 cx.warn(format!(
642 "title `{title}` is shared with {}; \
643 the two are indistinguishable in any listing that shows titles",
644 siblings
645 .iter()
646 .map(|s| format!("`{s}`"))
647 .collect::<Vec<_>>()
648 .join(", ")
649 ));
650 }
651 }
652}
653
654fn check_circular_derivation(cx: &mut Cx<'_>, bundle: &Bundle) {
659 let mut edges: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
661 for concept in bundle.concepts() {
662 let from = concept.id.to_string();
663 for source in concept.sources() {
664 let Some(resource) = &source.resource else {
665 continue;
666 };
667 let Some(rel) = bundle_relative(resource) else {
668 continue;
669 };
670 if let Some(id) = okf_core::links::concept_id_for_path(&rel)
675 && bundle.contains(&id)
676 {
677 edges
678 .entry(from.clone())
679 .or_default()
680 .insert(id.to_string());
681 }
682 }
683 }
684
685 let mut seen: BTreeSet<String> = BTreeSet::new();
688 let mut reported: BTreeSet<Vec<String>> = BTreeSet::new();
692 for start in edges.keys() {
693 let mut stack = vec![(start.clone(), vec![start.clone()])];
694 while let Some((node, trail)) = stack.pop() {
695 for next in edges.get(&node).into_iter().flatten() {
696 if next == start {
697 let ring = trail.join(" → ");
698 let mut members = trail.clone();
699 members.sort();
700 members.dedup();
701 if reported.insert(members) {
702 if let Some(concept) = bundle
703 .concepts()
704 .iter()
705 .find(|c| c.id.to_string() == *start)
706 {
707 cx.at(concept);
708 }
709 cx.err(format!(
710 "circular derivation: {ring} → {start}; \
711 no reader can establish where this claim came from"
712 ));
713 }
714 continue;
715 }
716 if seen.insert(format!("{start}\u{0}{next}")) {
717 let mut trail = trail.clone();
718 trail.push(next.clone());
719 stack.push((next.clone(), trail));
720 }
721 }
722 }
723 }
724}
725
726fn check_stale_indexes(cx: &mut Cx<'_>, bundle: &Bundle) {
731 for index in bundle.index_files() {
732 cx.at_file(index);
733 for (target, resolved) in index_listings(bundle, index) {
734 if !resolved.exists() {
735 cx.warn(format!(
736 "index lists `{target}`, which no longer exists; \
737 a reader following the listing lands on nothing"
738 ));
739 }
740 }
741 }
742 cx.concept = None;
743 cx.path = None;
744}
745
746fn lint_headings(cx: &mut Cx<'_>, doc: &Document) {
756 let headings = okf_core::markdown::extract_headings(&doc.body);
757 if headings.is_empty() {
758 cx.lint(
759 "warning",
760 "L1",
761 "body has no top-level `#` heading; OKF docs conventionally open with one",
762 );
763 return;
764 }
765
766 let mut top = 0usize;
767 let mut previous = 0usize;
768 for (i, heading) in headings.iter().enumerate() {
769 if heading.level == 1 {
770 top += 1;
771 if top > 1 {
772 cx.lint(
773 "warning",
774 "L3",
775 format!(
776 "multiple top-level `#` headings found (heading `{}` at line {})",
777 heading.text, heading.line_num
778 ),
779 );
780 }
781 }
782 if previous > 0 && heading.level > previous + 1 {
783 cx.lint(
784 "warning",
785 "L3",
786 format!(
787 "heading level skipped: `{}` jumps from h{previous} to h{}",
788 heading.text, heading.level
789 ),
790 );
791 }
792 previous = heading.level;
793
794 let starts = heading.line_index + 1;
800 let ends = headings
801 .get(i + 1)
802 .map_or_else(|| doc.body.lines().count(), |h| h.line_index);
803 let contains_a_deeper_heading = headings
804 .get(i + 1)
805 .is_some_and(|next| next.level > heading.level);
806 let empty = !contains_a_deeper_heading
807 && doc
808 .body
809 .lines()
810 .skip(starts)
811 .take(ends.saturating_sub(starts))
812 .all(|l| l.trim().is_empty());
813 if empty {
814 cx.lint(
815 "warning",
816 "L4",
817 format!("heading `{}` has no content", heading.text),
818 );
819 }
820 }
821
822 if top == 0 {
823 cx.lint(
824 "warning",
825 "L1",
826 "body has no top-level `#` heading; OKF docs conventionally open with one",
827 );
828 }
829}
830
831fn lint_key_order(cx: &mut Cx<'_>, fm: &Frontmatter) {
836 let rank: BTreeMap<&str, usize> = PREFERRED_KEY_ORDER
837 .iter()
838 .enumerate()
839 .map(|(i, k)| (*k, i))
840 .collect();
841 let ranked: Vec<usize> = fm.keys().filter_map(|k| rank.get(k).copied()).collect();
842 if ranked.windows(2).any(|w| w[0] > w[1]) {
843 cx.lint(
844 "info",
845 "L2",
846 "frontmatter keys are not in canonical order (§5's reading order)",
847 );
848 }
849}
850
851fn lint_unused_sources(cx: &mut Cx<'_>, concept: &Concept, doc: &Document) {
853 let cited: BTreeSet<String> = doc
854 .footnote_refs()
855 .into_iter()
856 .map(|r| r.label)
857 .chain(doc.footnote_definitions().into_iter().map(|d| d.label))
858 .collect();
859 for source in concept.sources() {
860 let Some(id) = &source.id else { continue };
861 if !cited.contains(id) {
862 cx.lint(
863 "warning",
864 "L5",
865 format!(
866 "source `{id}` is declared in frontmatter but never cited with footnote `[^{id}]`"
867 ),
868 );
869 }
870 }
871}
872
873fn lint_actor_convention(cx: &mut Cx<'_>, concept: &Concept) {
875 for source in concept.sources() {
876 let Some(author) = &source.author else {
877 continue;
878 };
879 if author.kind() == okf_core::ActorKind::Other {
880 cx.lint(
881 "info",
882 "L6",
883 format!(
884 "author `{}` in `sources.author` does not follow §7's `human:<id>`, \
885 `process:<id>` or `<producer>/<version>` convention",
886 author.as_str()
887 ),
888 );
889 }
890 }
891}
892
893fn lint_computation_block(cx: &mut Cx<'_>, doc: &Document) {
899 if let Some(inline) = doc.inline_computation()
900 && inline.fenced
901 && inline.language.is_none()
902 {
903 cx.lint(
904 "warning",
905 "L7",
906 "`# Computation` code block carries no language tag \
907 (e.g. ```sql), so no syntax check can read it",
908 );
909 }
910}
911
912fn lint_whitespace(cx: &mut Cx<'_>, doc: &Document) {
914 let offending: Vec<usize> = doc
915 .body
916 .lines()
917 .enumerate()
918 .filter(|(_, l)| !l.is_empty() && l.trim_end() != *l)
919 .map(|(i, _)| i + 1)
920 .collect();
921 if let Some(first) = offending.first() {
922 cx.lint(
923 "info",
924 "L8",
925 format!(
926 "trailing whitespace found on {} line(s) in markdown body (first at line {first})",
927 offending.len()
928 ),
929 );
930 }
931}
932
933fn lint_orphan(cx: &mut Cx<'_>, concept: &Concept, indexed: &BTreeSet<String>) {
935 if !indexed.contains(&concept.id.to_string()) {
936 cx.lint(
937 "warning",
938 "L9",
939 "no `index.md` lists this concept, so nothing walking the bundle's \
940 listings will reach it",
941 );
942 }
943}
944
945fn lint_portable_id(cx: &mut Cx<'_>, concept: &Concept) {
953 for segment in concept.id.segments() {
954 if !okf_core::concept_id::is_portable_segment(segment) {
955 cx.lint(
956 "warning",
957 "R1",
958 format!(
959 "concept-id segment `{segment}` may not survive a checkout on every \
960 filesystem; the specification does not forbid it, but a consumer on \
961 a case-insensitive or restricted filesystem cannot read the bundle"
962 ),
963 );
964 }
965 }
966}
967
968fn lint_self_link(cx: &mut Cx<'_>, bundle: &Bundle, concept: &Concept) {
970 if bundle
971 .links_from(&concept.id)
972 .iter()
973 .any(|l| l.target == concept.id)
974 {
975 cx.lint(
976 "warning",
977 "L10",
978 "self-link; a concept that links to itself usually signals a stray reference",
979 );
980 }
981}
982
983fn lint_unverified(cx: &mut Cx<'_>, concept: &Concept) {
985 if concept.trust_tier() == TrustTier::Unverified {
986 cx.lint(
987 "info",
988 "L11",
989 "no `verified` events; trust tier is `unverified`",
990 );
991 }
992}
993
994fn lint_draft(cx: &mut Cx<'_>, concept: &Concept) {
996 if concept.status() == Status::Draft {
997 cx.lint(
998 "warning",
999 "L12",
1000 "`status: draft`; a draft concept is not ready for production consumption",
1001 );
1002 }
1003}
1004
1005fn indexed_concepts(bundle: &Bundle) -> BTreeSet<String> {
1011 let mut listed = BTreeSet::new();
1012 for index in bundle.index_files() {
1013 for (_, resolved) in index_listings(bundle, index) {
1014 if let Ok(id) = okf_core::ConceptId::from_path(bundle.root(), &resolved) {
1015 listed.insert(id.to_string());
1016 }
1017 }
1018 }
1019 listed
1020}
1021
1022fn index_listings(bundle: &Bundle, index: &Path) -> Vec<(String, std::path::PathBuf)> {
1029 let Ok(text) = std::fs::read_to_string(index) else {
1030 return Vec::new();
1031 };
1032 let parent = index.parent().unwrap_or_else(|| bundle.root());
1033 okf_core::links::extract_links(&text)
1034 .into_iter()
1035 .filter_map(|link| {
1036 let target = link.target_without_anchor().to_owned();
1037 if target.contains("://")
1038 || !Path::new(&target)
1039 .extension()
1040 .is_some_and(|e| e.eq_ignore_ascii_case("md"))
1041 {
1042 return None;
1043 }
1044 let resolved = target
1045 .strip_prefix('/')
1046 .map_or_else(|| parent.join(&target), |rooted| bundle.root().join(rooted));
1047 Some((target, resolved))
1048 })
1049 .collect()
1050}
1051
1052fn bundle_relative(raw: &str) -> Option<String> {
1055 if raw.contains("://") || raw.starts_with("mailto:") {
1056 return None;
1057 }
1058 let trimmed = raw.trim_start_matches('/');
1059 if trimmed.is_empty() {
1060 return None;
1061 }
1062
1063 if trimmed
1071 .split(['/', '\\'])
1072 .any(|segment| segment == ".." || segment == "." || segment.is_empty())
1073 {
1074 return None;
1075 }
1076 if trimmed.contains(':') {
1079 return None;
1080 }
1081 if Path::new(trimmed)
1084 .components()
1085 .any(|c| !matches!(c, std::path::Component::Normal(_)))
1086 {
1087 return None;
1088 }
1089 Some(trimmed.to_owned())
1090}
1091
1092fn as_str(value: &Value) -> Option<&str> {
1093 match value {
1094 Value::String(s) => Some(s.as_str()),
1095 _ => None,
1096 }
1097}
1098
1099const fn kind_of(value: &Value) -> &'static str {
1102 match value {
1103 Value::Null => "null",
1104 Value::Bool(_) => "a boolean",
1105 Value::Int(_) => "an integer",
1106 Value::Float(_) => "a number",
1107 Value::String(_) => "a string",
1108 Value::Sequence(_) => "a list",
1109 Value::Mapping(_) => "a mapping",
1110 }
1111}