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 cx.info(format!(
551 "link `{}` names `{}`, which the bundle does not contain; \
552 §6 tells a consumer to tolerate this",
553 link.raw, link.target
554 ));
555 continue;
556 }
557 if let Some(target) = bundle.get(&link.target)
558 && target.status().is_deprecated()
559 {
560 deprecated.insert(link.target.to_string());
561 }
562 }
563 for target in deprecated {
564 cx.warn(format!("links to deprecated concept `{target}`"));
565 }
566}
567
568fn check_reserved_filename(cx: &mut Cx<'_>, concept: &Concept) {
573 let name = concept
574 .path
575 .file_name()
576 .and_then(|n| n.to_str())
577 .unwrap_or_default();
578 if okf_core::RESERVED_FILENAMES.contains(&name) {
579 cx.err(format!(
580 "`{name}` is a reserved filename and §3.1 forbids using it for a concept document"
581 ));
582 }
583}
584
585fn check_declared_version(cx: &mut Cx<'_>, bundle: &Bundle) {
599 if let Some(version) = bundle.okf_version()
600 && version != super::OKF_VERSION
601 {
602 cx.at_file(&bundle.root().join("index.md"));
603 cx.info(format!(
604 "the bundle declares `okf_version: {version}`; this reader implements {}, \
605 so it is read best-effort (§12)",
606 super::OKF_VERSION
607 ));
608 cx.concept = None;
609 cx.path = None;
610 }
611}
612
613fn check_duplicate_titles(cx: &mut Cx<'_>, bundle: &Bundle) {
615 let mut by_title: BTreeMap<String, Vec<&Concept>> = BTreeMap::new();
616 for concept in bundle.concepts() {
617 by_title
618 .entry(concept.display_title())
619 .or_default()
620 .push(concept);
621 }
622 for (title, concepts) in by_title {
623 if concepts.len() < 2 {
624 continue;
625 }
626 let others: Vec<String> = concepts.iter().map(|c| c.id.to_string()).collect();
627 for concept in &concepts {
628 cx.at(concept);
629 let self_id = concept.id.to_string();
631 let siblings: Vec<&String> = others.iter().filter(|id| **id != self_id).collect();
632 cx.warn(format!(
633 "title `{title}` is shared with {}; \
634 the two are indistinguishable in any listing that shows titles",
635 siblings
636 .iter()
637 .map(|s| format!("`{s}`"))
638 .collect::<Vec<_>>()
639 .join(", ")
640 ));
641 }
642 }
643}
644
645fn check_circular_derivation(cx: &mut Cx<'_>, bundle: &Bundle) {
650 let mut edges: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
652 for concept in bundle.concepts() {
653 let from = concept.id.to_string();
654 for source in concept.sources() {
655 let Some(resource) = &source.resource else {
656 continue;
657 };
658 let Some(rel) = bundle_relative(resource) else {
659 continue;
660 };
661 if let Some(id) = okf_core::links::concept_id_for_path(&rel)
666 && bundle.contains(&id)
667 {
668 edges
669 .entry(from.clone())
670 .or_default()
671 .insert(id.to_string());
672 }
673 }
674 }
675
676 let mut seen: BTreeSet<String> = BTreeSet::new();
679 let mut reported: BTreeSet<Vec<String>> = BTreeSet::new();
683 for start in edges.keys() {
684 let mut stack = vec![(start.clone(), vec![start.clone()])];
685 while let Some((node, trail)) = stack.pop() {
686 for next in edges.get(&node).into_iter().flatten() {
687 if next == start {
688 let ring = trail.join(" → ");
689 let mut members = trail.clone();
690 members.sort();
691 members.dedup();
692 if reported.insert(members) {
693 if let Some(concept) = bundle
694 .concepts()
695 .iter()
696 .find(|c| c.id.to_string() == *start)
697 {
698 cx.at(concept);
699 }
700 cx.err(format!(
701 "circular derivation: {ring} → {start}; \
702 no reader can establish where this claim came from"
703 ));
704 }
705 continue;
706 }
707 if seen.insert(format!("{start}\u{0}{next}")) {
708 let mut trail = trail.clone();
709 trail.push(next.clone());
710 stack.push((next.clone(), trail));
711 }
712 }
713 }
714 }
715}
716
717fn check_stale_indexes(cx: &mut Cx<'_>, bundle: &Bundle) {
722 for index in bundle.index_files() {
723 cx.at_file(index);
724 for (target, resolved) in index_listings(bundle, index) {
725 if !resolved.exists() {
726 cx.warn(format!(
727 "index lists `{target}`, which no longer exists; \
728 a reader following the listing lands on nothing"
729 ));
730 }
731 }
732 }
733 cx.concept = None;
734 cx.path = None;
735}
736
737fn lint_headings(cx: &mut Cx<'_>, doc: &Document) {
747 let headings = okf_core::markdown::extract_headings(&doc.body);
748 if headings.is_empty() {
749 cx.lint(
750 "warning",
751 "L1",
752 "body has no top-level `#` heading; OKF docs conventionally open with one",
753 );
754 return;
755 }
756
757 let mut top = 0usize;
758 let mut previous = 0usize;
759 for (i, heading) in headings.iter().enumerate() {
760 if heading.level == 1 {
761 top += 1;
762 if top > 1 {
763 cx.lint(
764 "warning",
765 "L3",
766 format!(
767 "multiple top-level `#` headings found (heading `{}` at line {})",
768 heading.text, heading.line_num
769 ),
770 );
771 }
772 }
773 if previous > 0 && heading.level > previous + 1 {
774 cx.lint(
775 "warning",
776 "L3",
777 format!(
778 "heading level skipped: `{}` jumps from h{previous} to h{}",
779 heading.text, heading.level
780 ),
781 );
782 }
783 previous = heading.level;
784
785 let starts = heading.line_index + 1;
791 let ends = headings
792 .get(i + 1)
793 .map_or_else(|| doc.body.lines().count(), |h| h.line_index);
794 let contains_a_deeper_heading = headings
795 .get(i + 1)
796 .is_some_and(|next| next.level > heading.level);
797 let empty = !contains_a_deeper_heading
798 && doc
799 .body
800 .lines()
801 .skip(starts)
802 .take(ends.saturating_sub(starts))
803 .all(|l| l.trim().is_empty());
804 if empty {
805 cx.lint(
806 "warning",
807 "L4",
808 format!("heading `{}` has no content", heading.text),
809 );
810 }
811 }
812
813 if top == 0 {
814 cx.lint(
815 "warning",
816 "L1",
817 "body has no top-level `#` heading; OKF docs conventionally open with one",
818 );
819 }
820}
821
822fn lint_key_order(cx: &mut Cx<'_>, fm: &Frontmatter) {
827 let rank: BTreeMap<&str, usize> = PREFERRED_KEY_ORDER
828 .iter()
829 .enumerate()
830 .map(|(i, k)| (*k, i))
831 .collect();
832 let ranked: Vec<usize> = fm.keys().filter_map(|k| rank.get(k).copied()).collect();
833 if ranked.windows(2).any(|w| w[0] > w[1]) {
834 cx.lint(
835 "info",
836 "L2",
837 "frontmatter keys are not in canonical order (§5's reading order)",
838 );
839 }
840}
841
842fn lint_unused_sources(cx: &mut Cx<'_>, concept: &Concept, doc: &Document) {
844 let cited: BTreeSet<String> = doc
845 .footnote_refs()
846 .into_iter()
847 .map(|r| r.label)
848 .chain(doc.footnote_definitions().into_iter().map(|d| d.label))
849 .collect();
850 for source in concept.sources() {
851 let Some(id) = &source.id else { continue };
852 if !cited.contains(id) {
853 cx.lint(
854 "warning",
855 "L5",
856 format!(
857 "source `{id}` is declared in frontmatter but never cited with footnote `[^{id}]`"
858 ),
859 );
860 }
861 }
862}
863
864fn lint_actor_convention(cx: &mut Cx<'_>, concept: &Concept) {
866 for source in concept.sources() {
867 let Some(author) = &source.author else {
868 continue;
869 };
870 if author.kind() == okf_core::ActorKind::Other {
871 cx.lint(
872 "info",
873 "L6",
874 format!(
875 "author `{}` in `sources.author` does not follow §7's `human:<id>`, \
876 `process:<id>` or `<producer>/<version>` convention",
877 author.as_str()
878 ),
879 );
880 }
881 }
882}
883
884fn lint_computation_block(cx: &mut Cx<'_>, doc: &Document) {
890 if let Some(inline) = doc.inline_computation()
891 && inline.fenced
892 && inline.language.is_none()
893 {
894 cx.lint(
895 "warning",
896 "L7",
897 "`# Computation` code block carries no language tag \
898 (e.g. ```sql), so no syntax check can read it",
899 );
900 }
901}
902
903fn lint_whitespace(cx: &mut Cx<'_>, doc: &Document) {
905 let offending: Vec<usize> = doc
906 .body
907 .lines()
908 .enumerate()
909 .filter(|(_, l)| !l.is_empty() && l.trim_end() != *l)
910 .map(|(i, _)| i + 1)
911 .collect();
912 if let Some(first) = offending.first() {
913 cx.lint(
914 "info",
915 "L8",
916 format!(
917 "trailing whitespace found on {} line(s) in markdown body (first at line {first})",
918 offending.len()
919 ),
920 );
921 }
922}
923
924fn lint_orphan(cx: &mut Cx<'_>, concept: &Concept, indexed: &BTreeSet<String>) {
926 if !indexed.contains(&concept.id.to_string()) {
927 cx.lint(
928 "warning",
929 "L9",
930 "no `index.md` lists this concept, so nothing walking the bundle's \
931 listings will reach it",
932 );
933 }
934}
935
936fn lint_portable_id(cx: &mut Cx<'_>, concept: &Concept) {
944 for segment in concept.id.segments() {
945 if !okf_core::concept_id::is_portable_segment(segment) {
946 cx.lint(
947 "warning",
948 "R1",
949 format!(
950 "concept-id segment `{segment}` may not survive a checkout on every \
951 filesystem; the specification does not forbid it, but a consumer on \
952 a case-insensitive or restricted filesystem cannot read the bundle"
953 ),
954 );
955 }
956 }
957}
958
959fn lint_self_link(cx: &mut Cx<'_>, bundle: &Bundle, concept: &Concept) {
961 if bundle
962 .links_from(&concept.id)
963 .iter()
964 .any(|l| l.target == concept.id)
965 {
966 cx.lint(
967 "warning",
968 "L10",
969 "self-link; a concept that links to itself usually signals a stray reference",
970 );
971 }
972}
973
974fn lint_unverified(cx: &mut Cx<'_>, concept: &Concept) {
976 if concept.trust_tier() == TrustTier::Unverified {
977 cx.lint(
978 "info",
979 "L11",
980 "no `verified` events; trust tier is `unverified`",
981 );
982 }
983}
984
985fn lint_draft(cx: &mut Cx<'_>, concept: &Concept) {
987 if concept.status() == Status::Draft {
988 cx.lint(
989 "warning",
990 "L12",
991 "`status: draft`; a draft concept is not ready for production consumption",
992 );
993 }
994}
995
996fn indexed_concepts(bundle: &Bundle) -> BTreeSet<String> {
1002 let mut listed = BTreeSet::new();
1003 for index in bundle.index_files() {
1004 for (_, resolved) in index_listings(bundle, index) {
1005 if let Ok(id) = okf_core::ConceptId::from_path(bundle.root(), &resolved) {
1006 listed.insert(id.to_string());
1007 }
1008 }
1009 }
1010 listed
1011}
1012
1013fn index_listings(bundle: &Bundle, index: &Path) -> Vec<(String, std::path::PathBuf)> {
1020 let Ok(text) = std::fs::read_to_string(index) else {
1021 return Vec::new();
1022 };
1023 let parent = index.parent().unwrap_or_else(|| bundle.root());
1024 okf_core::links::extract_links(&text)
1025 .into_iter()
1026 .filter_map(|link| {
1027 let target = link.target_without_anchor().to_owned();
1028 if target.contains("://")
1029 || !Path::new(&target)
1030 .extension()
1031 .is_some_and(|e| e.eq_ignore_ascii_case("md"))
1032 {
1033 return None;
1034 }
1035 let resolved = target
1036 .strip_prefix('/')
1037 .map_or_else(|| parent.join(&target), |rooted| bundle.root().join(rooted));
1038 Some((target, resolved))
1039 })
1040 .collect()
1041}
1042
1043fn bundle_relative(raw: &str) -> Option<String> {
1046 if raw.contains("://") || raw.starts_with("mailto:") {
1047 return None;
1048 }
1049 let trimmed = raw.trim_start_matches('/');
1050 if trimmed.is_empty() {
1051 return None;
1052 }
1053
1054 if trimmed
1062 .split(['/', '\\'])
1063 .any(|segment| segment == ".." || segment == "." || segment.is_empty())
1064 {
1065 return None;
1066 }
1067 if trimmed.contains(':') {
1070 return None;
1071 }
1072 if Path::new(trimmed)
1075 .components()
1076 .any(|c| !matches!(c, std::path::Component::Normal(_)))
1077 {
1078 return None;
1079 }
1080 Some(trimmed.to_owned())
1081}
1082
1083fn as_str(value: &Value) -> Option<&str> {
1084 match value {
1085 Value::String(s) => Some(s.as_str()),
1086 _ => None,
1087 }
1088}
1089
1090const fn kind_of(value: &Value) -> &'static str {
1093 match value {
1094 Value::Null => "null",
1095 Value::Bool(_) => "a boolean",
1096 Value::Int(_) => "an integer",
1097 Value::Float(_) => "a number",
1098 Value::String(_) => "a string",
1099 Value::Sequence(_) => "a list",
1100 Value::Mapping(_) => "a mapping",
1101 }
1102}