Skip to main content

specfold_core/model/
doc.rs

1//! A loaded document: where it lives, what its frontmatter says, and what its body points at.
2
3use super::id::DocId;
4use serde::{Deserialize, Serialize};
5use serde_json::{Map, Value};
6use std::collections::BTreeMap;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
9#[serde(rename_all = "lowercase")]
10pub enum Tier {
11    /// Committed to the repository.
12    Committed,
13    /// In the git-ignored local directory.
14    Local,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
18pub struct FileEntry {
19    /// Relative to the document's bundle directory.
20    pub path: String,
21    pub sha256: String,
22    pub bytes: u64,
23    /// `repo` or `store`.
24    pub store: String,
25}
26
27/// Something found in a document's files, with the file and line it came from.
28#[derive(Debug, Clone, Serialize)]
29pub struct Located<T> {
30    pub path: String,
31    pub line: usize,
32    pub item: T,
33}
34
35#[derive(Debug, Clone, Default, Serialize, Deserialize)]
36pub struct Frontmatter {
37    /// Present when the file has a frontmatter block.
38    pub map: Option<Map<String, Value>>,
39    /// A frontmatter block that does not parse.
40    pub error: Option<String>,
41}
42
43#[derive(Debug, Clone)]
44pub struct Doc {
45    pub id: DocId,
46    /// The main Markdown file, relative to the repository root.
47    pub path: String,
48    /// The bundle directory of a directory-form document.
49    pub dir: Option<String>,
50    pub tier: Tier,
51    /// The slug part of the file name.
52    pub slug: String,
53    pub frontmatter: Frontmatter,
54    pub title: Option<String>,
55    /// Chapter files inside the bundle.
56    pub chapters: Vec<String>,
57    /// Non-Markdown files inside the bundle, relative to the repository root.
58    pub bundle_files: Vec<String>,
59    pub links: Vec<Located<String>>,
60    pub anchors: Vec<Located<String>>,
61    pub path_cites: Vec<Located<String>>,
62    pub body_lines: usize,
63    /// Hash of the main file's body, for sealed documents.
64    pub body_hash: String,
65}
66
67impl Doc {
68    /// `plan:root/payment-rollout`: the id with the slug in place of a timestamp key, the name
69    /// people write when they remember the title. None when the key is the slug already.
70    pub fn slug_id(&self) -> Option<String> {
71        (!self.slug.is_empty() && self.slug != self.id.key)
72            .then(|| format!("{}:{}/{}", self.id.ty, self.id.owner, self.slug))
73    }
74
75    pub fn fm(&self) -> Option<&Map<String, Value>> {
76        self.frontmatter.map.as_ref()
77    }
78
79    pub fn str_field(&self, key: &str) -> Option<String> {
80        match self.fm()?.get(key)? {
81            Value::String(s) => Some(s.clone()),
82            Value::Number(n) => Some(n.to_string()),
83            Value::Bool(b) => Some(b.to_string()),
84            _ => None,
85        }
86    }
87
88    pub fn list_field(&self, key: &str) -> Vec<String> {
89        list_of(self.fm().and_then(|m| m.get(key)))
90    }
91
92    pub fn status(&self) -> Option<String> {
93        self.str_field("status")
94    }
95
96    /// Typed relations: `rel.<kind>` lists.
97    pub fn rel(&self) -> BTreeMap<String, Vec<String>> {
98        let mut out = BTreeMap::new();
99        if let Some(Value::Object(m)) = self.fm().and_then(|m| m.get("rel")) {
100            for (k, v) in m {
101                out.insert(k.clone(), list_of(Some(v)));
102            }
103        }
104        out
105    }
106
107    pub fn files(&self) -> Vec<FileEntry> {
108        let Some(Value::Array(items)) = self.fm().and_then(|m| m.get("files")) else { return vec![] };
109        items
110            .iter()
111            .filter_map(|v| {
112                let o = v.as_object()?;
113                Some(FileEntry {
114                    path: o.get("path")?.as_str()?.to_string(),
115                    sha256: o.get("sha256").and_then(Value::as_str).unwrap_or("").to_string(),
116                    bytes: o.get("bytes").and_then(Value::as_u64).unwrap_or(0),
117                    store: o.get("store").and_then(Value::as_str).unwrap_or("repo").to_string(),
118                })
119            })
120            .collect()
121    }
122
123    /// The directory attachments are relative to: the bundle, or the main file's directory.
124    pub fn base_dir(&self) -> String {
125        match &self.dir {
126            Some(d) => d.clone(),
127            None => self.path.rsplit_once('/').map(|(d, _)| d.to_string()).unwrap_or_default(),
128        }
129    }
130}
131
132pub fn list_of(v: Option<&Value>) -> Vec<String> {
133    match v {
134        Some(Value::Array(a)) => a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect(),
135        Some(Value::String(s)) if !s.is_empty() => vec![s.clone()],
136        _ => vec![],
137    }
138}