specfold_core/authoring/
set.rs1use super::plan::Plan;
5use crate::corpus::Corpus;
6use crate::markdown::frontmatter::edit;
7use crate::model::{lifecycle, 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 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().unwrap(), child.to_string())
51 }
52 None => (map, key.to_string()),
53 }
54}
55
56fn apply_one(map: &mut Map<String, Value>, a: &Assign) {
57 let is_list = a.key.starts_with("rel.") || LIST_KEYS.contains(&a.key.as_str());
58 let value = if a.key == "updated" && a.value == "today" { stamp::today() } else { a.value.clone() };
59 let (m, k) = target(map, &a.key);
60 match (&a.op, is_list) {
61 (Op::Set, true) => {
62 m.insert(
63 k,
64 Value::Array(
65 value
66 .split(',')
67 .map(|s| Value::String(s.trim().into()))
68 .filter(|v| v.as_str() != Some(""))
69 .collect(),
70 ),
71 );
72 }
73 (Op::Set, false) => {
74 m.insert(k, Value::String(value));
75 }
76 (Op::Add, _) => {
77 let list = m.entry(k).or_insert_with(|| Value::Array(vec![]));
78 if let Value::Array(items) = list
79 && !items.iter().any(|x| x.as_str() == Some(value.as_str()))
80 {
81 items.push(Value::String(value));
82 }
83 }
84 (Op::Remove, _) => {
85 if let Some(Value::Array(items)) = m.get_mut(&k) {
86 items.retain(|x| x.as_str() != Some(value.as_str()));
87 }
88 }
89 }
90}
91
92pub fn plan(c: &Corpus, idx: usize, assigns: &[Assign]) -> Result<Plan> {
93 let d = &c.docs[idx];
94 let ty = c.cfg.registry.get(&d.id.ty).ok_or_else(|| anyhow!("unknown type {}", d.id.ty))?;
95 for a in assigns {
96 if PROTECTED.contains(&a.key.as_str()) {
97 bail!(
98 "{} cannot be set by hand: id comes from the path (spec mv), created is fixed, files is managed by spec attach",
99 a.key
100 );
101 }
102 if a.key == "status" {
103 if !ty.statuses.contains(&a.value) {
104 bail!("status '{}' is not allowed for {} (allowed: {})", a.value, ty.name, ty.statuses.join(", "));
105 }
106 let from = d.status().unwrap_or_else(|| ty.initial.clone());
107 if !lifecycle::transition_allowed(&from, &a.value) {
108 bail!(
109 "status cannot move from '{from}' to '{}'; supersede it with spec new {} --from {}",
110 a.value,
111 ty.name,
112 d.id
113 );
114 }
115 }
116 }
117 let text = std::fs::read_to_string(c.cfg.root.join(&d.path))?;
118 let new = edit::rewrite(&text, |m| {
119 for a in assigns {
120 apply_one(m, a);
121 }
122 if !assigns.iter().any(|a| a.key == "updated") {
123 m.insert("updated".into(), Value::String(stamp::today()));
124 }
125 let ordered = edit::ordered(std::mem::take(m));
126 *m = ordered;
127 })?;
128 let mut p = Plan::default();
129 p.write(&d.path, new, false);
130 Ok(p)
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 #[test]
138 fn parses_and_applies() {
139 let mut m = Map::new();
140 apply_one(&mut m, &Assign::parse("rel.implements+=sot:a/b").unwrap());
141 apply_one(&mut m, &Assign::parse("rel.implements+=sot:a/b").unwrap());
142 apply_one(&mut m, &Assign::parse("tags=x, y").unwrap());
143 apply_one(&mut m, &Assign::parse("phase=P3").unwrap());
144 assert_eq!(m["rel"]["implements"], serde_json::json!(["sot:a/b"]));
145 assert_eq!(m["tags"], serde_json::json!(["x", "y"]));
146 apply_one(&mut m, &Assign::parse("tags-=x").unwrap());
147 assert_eq!(m["tags"], serde_json::json!(["y"]));
148 assert!(Assign::parse("=x").is_err());
149 }
150}