Skip to main content

specfold_core/import/
map.rs

1//! The import map: how files from another layout become typed documents. A project writes it
2//! once for its migration; nothing in it is specific to specfold's own layout.
3
4use anyhow::{Context, Result};
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7use std::path::Path;
8
9#[derive(Debug, Clone, Deserialize, Serialize)]
10#[serde(deny_unknown_fields)]
11pub struct ImportMap {
12    #[serde(default)]
13    pub sources: Vec<SourceRule>,
14    /// Old status → new status, for every source.
15    #[serde(default)]
16    pub status: BTreeMap<String, String>,
17    /// Old frontmatter key → new key, or "-" to drop it. `rel.<kind>` moves a value into rel.
18    /// A trailing `?` (`check = "verify?"`) sets the key only when no other old key filled it.
19    #[serde(default)]
20    pub keys: BTreeMap<String, String>,
21    /// Also record each source's bare name (file name without `.md`, or directory name) as an
22    /// alias, for layouts whose documents referred to each other by name.
23    #[serde(default)]
24    pub stem_aliases: bool,
25    /// Drop a leading record number from titles taken from the heading (`# 2. Use X` → `Use X`).
26    #[serde(default)]
27    pub strip_title_number: bool,
28    /// Status values that carry more than a word (`superseded by ADR-0123`): a regex, tried
29    /// case-insensitively in order after `[status]`, and the status it means. A `by` group names
30    /// the record that replaced it, shown in the plan for review.
31    #[serde(default)]
32    pub status_patterns: Vec<StatusPattern>,
33    /// Status, date and links read from the body, for layouts that keep them there.
34    #[serde(default)]
35    pub body: BodyMap,
36}
37
38#[derive(Debug, Clone, Deserialize, Serialize)]
39#[serde(deny_unknown_fields)]
40pub struct StatusPattern {
41    pub pattern: String,
42    pub status: String,
43}
44
45/// Where the body keeps metadata. `status` and `date` take `section:<heading>` (first line of
46/// that section), `bullet:<label>` (`* Label: value`) or `line:<label>` (`Label: value`).
47/// Frontmatter wins when both have a value.
48#[derive(Debug, Clone, Default, Deserialize, Serialize)]
49#[serde(deny_unknown_fields)]
50pub struct BodyMap {
51    pub status: Option<String>,
52    pub date: Option<String>,
53    /// A line opening with the phrase (`Supercedes [title](file)`) → relation kind, or "-" to
54    /// skip it (the other side of a link the other document already records).
55    #[serde(default)]
56    pub links: BTreeMap<String, String>,
57    /// Remove the `section:` status heading from the body once its value is in frontmatter.
58    /// Off by default: the plan only notes the duplicate.
59    #[serde(default)]
60    pub drop_status_section: bool,
61}
62
63#[derive(Debug, Clone, Deserialize, Serialize)]
64#[serde(deny_unknown_fields)]
65pub struct SourceRule {
66    /// Files to import; a pattern ending in `/` matches directories, imported as bundles.
67    pub glob: String,
68    #[serde(default)]
69    pub exclude: Vec<String>,
70    /// The document type. Required unless `redirect` is set.
71    #[serde(rename = "type")]
72    pub ty: Option<String>,
73    /// `auto` (from governs, then from the code paths the text mentions) or a fixed owner id.
74    #[serde(default = "auto")]
75    pub owner: String,
76    /// How the key is chosen for timestamp types: `name` (from `name`'s `key` group),
77    /// `first-commit` (the file's first commit time) or `created` (frontmatter created date).
78    #[serde(default = "first_commit")]
79    pub key: String,
80    /// A regex over the file or directory name with optional named groups `key`, `slug`, `alias`.
81    pub name: Option<String>,
82    /// Status for documents without one.
83    pub default_status: Option<String>,
84    /// Status overrides for this source only.
85    #[serde(default)]
86    pub status: BTreeMap<String, String>,
87    /// The file is a "moved" stub: a regex with a `target` group naming the new path. Stubs become
88    /// alias rows and are removed.
89    pub redirect: Option<String>,
90    /// Extra frontmatter fields whose values become aliases (old ids).
91    #[serde(default)]
92    pub alias_fields: Vec<String>,
93    /// For files: also take the sibling directory `<stem>/` as the bundle.
94    #[serde(default)]
95    pub sibling_dir: bool,
96    /// Files with no Markdown (raw output, data, boards) become attachments of a generated
97    /// document titled after them: one per matched file, or one per matched directory.
98    #[serde(default)]
99    pub wrap: bool,
100    /// The matched files belong to the old layout only (indexes, generated graphs, retired rule
101    /// pages) and are removed without becoming documents.
102    #[serde(default)]
103    pub drop: bool,
104}
105
106fn auto() -> String {
107    "auto".into()
108}
109
110fn first_commit() -> String {
111    "first-commit".into()
112}
113
114impl ImportMap {
115    pub fn load(path: &Path) -> Result<Self> {
116        let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
117        let map: Self = toml::from_str(&text).with_context(|| format!("invalid import map {}", path.display()))?;
118        for how in map.body.status.iter().chain(&map.body.date) {
119            if !super::body_meta::valid(how) {
120                anyhow::bail!(
121                    "invalid import map {}: body '{how}' must be section:<heading>, bullet:<label> or line:<label>",
122                    path.display()
123                );
124            }
125        }
126        for p in &map.status_patterns {
127            if let Err(e) = regex::Regex::new(&p.pattern) {
128                anyhow::bail!("invalid import map {}: status pattern '{}': {e}", path.display(), p.pattern);
129            }
130        }
131        Ok(map)
132    }
133}