Skip to main content

specfold_core/authoring/
set.rs

1//! Changing frontmatter through assignments (`key=value`, `key+=value`, `key-=value`), with the
2//! lifecycle enforced and `updated` kept current.
3
4use super::plan::Plan;
5use crate::corpus::Corpus;
6use crate::markdown::frontmatter::edit;
7use crate::model::stamp;
8use anyhow::{Result, anyhow, bail};
9use serde_json::{Map, Value};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum Op {
13    Set,
14    Add,
15    Remove,
16}
17
18#[derive(Debug, Clone)]
19pub struct Assign {
20    /// `status`, `phase`, `tags`, `rel.implements` …
21    pub key: String,
22    pub op: Op,
23    pub value: String,
24}
25
26impl Assign {
27    pub fn parse(s: &str) -> Result<Self> {
28        for (token, op) in [("+=", Op::Add), ("-=", Op::Remove), ("=", Op::Set)] {
29            if let Some((k, v)) = s.split_once(token) {
30                if k.is_empty() {
31                    break;
32                }
33                return Ok(Assign { key: k.trim().to_string(), op, value: v.trim().to_string() });
34            }
35        }
36        bail!("'{s}' is not key=value, key+=value or key-=value")
37    }
38}
39
40const PROTECTED: &[&str] = &["id", "created", "files"];
41const LIST_KEYS: &[&str] = &["tags", "owners", "aliases", "verify", "check_skip", "screens"];
42
43fn target<'a>(map: &'a mut Map<String, Value>, key: &str) -> (&'a mut Map<String, Value>, String) {
44    match key.split_once('.') {
45        Some((parent, child)) => {
46            let entry = map.entry(parent.to_string()).or_insert_with(|| Value::Object(Map::new()));
47            if !entry.is_object() {
48                *entry = Value::Object(Map::new());
49            }
50            (entry.as_object_mut().expect("entry was just made an object"), child.to_string())
51        }
52        None => (map, key.to_string()),
53    }
54}
55
56/// Frontmatter keys that hold one value.
57const SCALAR_KEYS: &[&str] = &["id", "title", "status", "owner", "created", "updated", "source_rev"];
58
59fn apply_one(map: &mut Map<String, Value>, a: &Assign) {
60    // C109: `key=[a, b]` is a list, written the way YAML writes one.
61    // C200: a single-value key keeps `[x, y]` as text (the writer quotes it), not a list.
62    let scalar = SCALAR_KEYS.contains(&a.key.as_str());
63    let bracketed = a.value.trim().strip_prefix('[').and_then(|v| v.strip_suffix(']')).filter(|_| !scalar);
64    let is_list = bracketed.is_some() || a.key.starts_with("rel.") || LIST_KEYS.contains(&a.key.as_str());
65    let value = if let (Op::Set, Some(inner)) = (&a.op, bracketed) {
66        if inner.trim().is_empty() {
67            let (m, k) = target(map, &a.key);
68            m.insert(k, Value::Array(vec![]));
69            return;
70        }
71        inner.to_string()
72    } else if a.key == "updated" && a.value == "today" {
73        stamp::now_value()
74    } else {
75        a.value.clone()
76    };
77    let nested = a.key.contains('.');
78    let (m, k) = target(map, &a.key);
79    // `key=` with nothing after it deletes the key, so an unknown relation can be taken out.
80    if matches!(a.op, Op::Set) && value.is_empty() {
81        m.remove(&k);
82        if nested {
83            let parent = a.key.split('.').next().unwrap_or_default();
84            if map.get(parent).is_some_and(|v| v.as_object().is_some_and(|o| o.is_empty())) {
85                map.remove(parent);
86            }
87        }
88        return;
89    }
90    match (&a.op, is_list) {
91        (Op::Set, true) => {
92            m.insert(
93                k,
94                Value::Array(
95                    value
96                        .split(',')
97                        .map(|s| Value::String(s.trim().into()))
98                        .filter(|v| v.as_str() != Some(""))
99                        .collect(),
100                ),
101            );
102        }
103        (Op::Set, false) => {
104            m.insert(k, Value::String(value));
105        }
106        (Op::Add, _) => {
107            let list = m.entry(k).or_insert_with(|| Value::Array(vec![]));
108            if let Value::Array(items) = list
109                && !items.iter().any(|x| x.as_str() == Some(value.as_str()))
110            {
111                items.push(Value::String(value));
112            }
113        }
114        (Op::Remove, _) => match m.get_mut(&k) {
115            Some(Value::Array(items)) => items.retain(|x| x.as_str() != Some(value.as_str())),
116            Some(Value::Object(entries)) => {
117                entries.remove(&value);
118                if entries.is_empty() {
119                    m.remove(&k);
120                }
121            }
122            _ => {}
123        },
124    }
125}
126
127/// What to run instead of a refused status move: never the command that was just refused.
128fn move_hint(ty: &crate::model::doctype::TypeDef, from: &str, to: &str, id: &str) -> String {
129    let next: Vec<&str> =
130        ty.statuses.iter().map(String::as_str).filter(|s| *s != from && ty.transition_allowed(from, s)).collect();
131    if to == "superseded" && from == "draft" {
132        return format!(
133            "a draft has not taken effect, so nothing supersedes it: make it active first with `spec set {} status=active --write`, then run `spec new {} --title '…' --from {} --write`; or change the draft itself with `spec edit {} --text '…' --write`",
134            crate::shell::word(id),
135            ty.name,
136            crate::shell::word(id),
137            crate::shell::word(id)
138        );
139    }
140    if to == "superseded" && ty.transition_allowed("active", "superseded") && from != "active" {
141        return format!("only an active {} can be superseded, and this one is {from}", ty.name);
142    }
143    if next.is_empty() {
144        format!("'{from}' is final")
145    } else {
146        format!("from '{from}' it can move to: {}", next.join(", "))
147    }
148}
149
150/// The status a word for "finished" most likely means: types name the end of their work
151/// differently (`archived`, `done`, `closed`), so a near miss gets the real one suggested.
152fn finishing_status<'a>(asked: &str, reachable: &[&'a String]) -> Option<&'a str> {
153    const FINISHED: &[&str] = &["done", "complete", "completed", "finished", "closed", "resolved", "archived"];
154    if !FINISHED.contains(&asked.to_lowercase().as_str()) {
155        return None;
156    }
157    FINISHED.iter().find_map(|f| reachable.iter().find(|s| s.as_str() == *f).map(|s| s.as_str()))
158}
159
160pub fn plan(c: &Corpus, idx: usize, assigns: &[Assign]) -> Result<Plan> {
161    plan_with(c, idx, assigns, false)
162}
163
164/// The old document's half of `spec new --from`: its successor is written in the same change.
165pub fn plan_superseded_by_new(c: &Corpus, idx: usize) -> Result<Plan> {
166    plan_with(c, idx, &[Assign::parse("status=superseded")?], true)
167}
168
169fn plan_with(c: &Corpus, idx: usize, assigns: &[Assign], successor_pending: bool) -> Result<Plan> {
170    let d = &c.docs[idx];
171    let ty = c.cfg.registry.get(&d.id.ty).ok_or_else(|| anyhow!("unknown type {}", d.id.ty))?;
172    for a in assigns {
173        if PROTECTED.contains(&a.key.as_str()) {
174            bail!(
175                "{} cannot be set by hand: id comes from the path (spec mv), created is fixed, files is managed by spec attach",
176                a.key
177            );
178        }
179        // C128: title is required (S1), so it can be changed but not emptied.
180        if a.key == "title" && matches!(a.op, Op::Set) && a.value.trim().is_empty() {
181            bail!("title is required and cannot be emptied; give a new one: spec set {} title='…' --write", d.id);
182        }
183        // C223: updated is required (S1) as well; `updated=` would delete it.
184        if a.key == "updated" && matches!(a.op, Op::Set) && a.value.trim().is_empty() {
185            bail!("updated is required and cannot be emptied; give a date: spec set {} updated=today --write", d.id);
186        }
187        if a.key == "rel.supersedes" && !matches!(a.op, Op::Remove) {
188            for v in a.value.split(',').map(|v| v.trim().trim_matches(['[', ']'])).filter(|v| !v.is_empty()) {
189                if let Some(old) = c.docs.iter().position(|o| o.id.to_string() == v && o.id.ty == d.id.ty) {
190                    super::successor::refuse_second(c, old, Some(idx))?;
191                }
192            }
193        }
194        if a.key == "status" {
195            if !ty.statuses.contains(&a.value) {
196                let from = d.status().unwrap_or_else(|| ty.initial.clone());
197                let reachable: Vec<&String> =
198                    ty.statuses.iter().filter(|s| **s != from && ty.transition_allowed(&from, s)).collect();
199                let pick = finishing_status(&a.value, &reachable);
200                let next = pick
201                    .map(|s| format!("; next: spec set {} status={s} --write", crate::shell::word(&d.id)))
202                    .unwrap_or_default();
203                bail!(
204                    "status '{}' is not allowed for {} (allowed: {}; from '{from}': {}){next}",
205                    a.value,
206                    ty.name,
207                    ty.statuses.join(", "),
208                    reachable.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
209                );
210            }
211            let from = d.status().unwrap_or_else(|| ty.initial.clone());
212            if !ty.transition_allowed(&from, &a.value) {
213                bail!(
214                    "status cannot move from '{from}' to '{}'; {}",
215                    a.value,
216                    move_hint(ty, &from, &a.value, &d.id.to_string())
217                );
218            }
219            // superseded without a successor is a T1 error the moment it is written.
220            let me = d.id.to_string();
221            let succeeded = c
222                .docs
223                .iter()
224                .filter(|o| o.id.ty == d.id.ty) // C204: L3 refuses a cross-type supersedes
225                .any(|o| o.rel().get("supersedes").is_some_and(|l| l.iter().any(|x| x.trim_matches(['[', ']']) == me)));
226            if a.value == "superseded" && !succeeded && !successor_pending {
227                let title = d.title.clone().unwrap_or_else(|| d.slug.replace('-', " ")).replace('\'', "");
228                bail!(
229                    "nothing supersedes {me} yet, so status=superseded would fail T1; write the successor, which marks this one superseded (edit the title); next: spec new {} --from {me} --title '{title}' --write",
230                    ty.name
231                );
232            }
233        }
234    }
235    // A pin names a commit; HEAD is resolved now, since HEAD moves.
236    let pinned: Vec<Assign>;
237    let assigns = if assigns.iter().any(|a| a.key == "verified_at" && a.value == "HEAD") {
238        let head = crate::workspace::vcs::head(&c.cfg.root)
239            .ok_or_else(|| anyhow!("verified_at=HEAD needs a git repository with a commit"))?;
240        pinned = assigns
241            .iter()
242            .map(|a| {
243                let mut a = a.clone();
244                if a.key == "verified_at" && a.value == "HEAD" {
245                    a.value = head.chars().take(12).collect();
246                }
247                a
248            })
249            .collect();
250        &pinned[..]
251    } else {
252        assigns
253    };
254    let text = crate::corpus::overlay::read(&c.cfg.root.join(&d.path))?;
255    // C117: values that are already there change nothing, so nothing is written and `updated` stays.
256    let order = |m: &mut Map<String, Value>| {
257        let ordered = edit::ordered(std::mem::take(m));
258        *m = ordered;
259    };
260    let same = edit::rewrite(&text, |m| {
261        for a in assigns {
262            apply_one(m, a);
263        }
264        order(m);
265    })? == edit::rewrite(&text, order)?;
266    if same {
267        return Ok(Plan::default());
268    }
269    let new = edit::rewrite(&text, |m| {
270        for a in assigns {
271            apply_one(m, a);
272        }
273        if !assigns.iter().any(|a| a.key == "updated") {
274            m.insert("updated".into(), Value::String(stamp::now_value()));
275        }
276        let ordered = edit::ordered(std::mem::take(m));
277        *m = ordered;
278    })?;
279    let mut p = Plan::default();
280    p.write(&d.path, new, false);
281    Ok(p)
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn parses_and_applies() {
290        let mut m = Map::new();
291        apply_one(&mut m, &Assign::parse("rel.implements+=sot:a/b").unwrap());
292        apply_one(&mut m, &Assign::parse("rel.implements+=sot:a/b").unwrap());
293        apply_one(&mut m, &Assign::parse("approvers=[kim, lee]").unwrap());
294        assert_eq!(m["approvers"], serde_json::json!(["kim", "lee"]), "C109");
295        apply_one(&mut m, &Assign::parse("tags=x, y").unwrap());
296        apply_one(&mut m, &Assign::parse("phase=P3").unwrap());
297        assert_eq!(m["rel"]["implements"], serde_json::json!(["sot:a/b"]));
298        assert_eq!(m["tags"], serde_json::json!(["x", "y"]));
299        apply_one(&mut m, &Assign::parse("tags-=x").unwrap());
300        apply_one(&mut m, &Assign::parse("rel.affects=a").unwrap());
301        apply_one(&mut m, &Assign::parse("rel.affects=").unwrap());
302        assert!(m["rel"].get("affects").is_none());
303        assert_eq!(m["tags"], serde_json::json!(["y"]));
304        assert!(Assign::parse("=x").is_err());
305    }
306}