1use 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 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
56const SCALAR_KEYS: &[&str] = &["id", "title", "status", "owner", "created", "updated", "source_rev"];
58
59fn apply_one(map: &mut Map<String, Value>, a: &Assign) {
60 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 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
127fn 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
150fn 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
164pub 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 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 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 == "status" {
188 if !ty.statuses.contains(&a.value) {
189 let from = d.status().unwrap_or_else(|| ty.initial.clone());
190 let reachable: Vec<&String> =
191 ty.statuses.iter().filter(|s| **s != from && ty.transition_allowed(&from, s)).collect();
192 let pick = finishing_status(&a.value, &reachable);
193 let next = pick
194 .map(|s| format!("; next: spec set {} status={s} --write", crate::shell::word(&d.id)))
195 .unwrap_or_default();
196 bail!(
197 "status '{}' is not allowed for {} (allowed: {}; from '{from}': {}){next}",
198 a.value,
199 ty.name,
200 ty.statuses.join(", "),
201 reachable.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
202 );
203 }
204 let from = d.status().unwrap_or_else(|| ty.initial.clone());
205 if !ty.transition_allowed(&from, &a.value) {
206 bail!(
207 "status cannot move from '{from}' to '{}'; {}",
208 a.value,
209 move_hint(ty, &from, &a.value, &d.id.to_string())
210 );
211 }
212 let me = d.id.to_string();
214 let succeeded = c
215 .docs
216 .iter()
217 .filter(|o| o.id.ty == d.id.ty) .any(|o| o.rel().get("supersedes").is_some_and(|l| l.iter().any(|x| x.trim_matches(['[', ']']) == me)));
219 if a.value == "superseded" && !succeeded && !successor_pending {
220 let title = d.title.clone().unwrap_or_else(|| d.slug.replace('-', " ")).replace('\'', "");
221 bail!(
222 "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",
223 ty.name
224 );
225 }
226 }
227 }
228 let pinned: Vec<Assign>;
230 let assigns = if assigns.iter().any(|a| a.key == "verified_at" && a.value == "HEAD") {
231 let head = crate::workspace::vcs::head(&c.cfg.root)
232 .ok_or_else(|| anyhow!("verified_at=HEAD needs a git repository with a commit"))?;
233 pinned = assigns
234 .iter()
235 .map(|a| {
236 let mut a = a.clone();
237 if a.key == "verified_at" && a.value == "HEAD" {
238 a.value = head.chars().take(12).collect();
239 }
240 a
241 })
242 .collect();
243 &pinned[..]
244 } else {
245 assigns
246 };
247 let text = crate::corpus::overlay::read(&c.cfg.root.join(&d.path))?;
248 let order = |m: &mut Map<String, Value>| {
250 let ordered = edit::ordered(std::mem::take(m));
251 *m = ordered;
252 };
253 let same = edit::rewrite(&text, |m| {
254 for a in assigns {
255 apply_one(m, a);
256 }
257 order(m);
258 })? == edit::rewrite(&text, order)?;
259 if same {
260 return Ok(Plan::default());
261 }
262 let new = edit::rewrite(&text, |m| {
263 for a in assigns {
264 apply_one(m, a);
265 }
266 if !assigns.iter().any(|a| a.key == "updated") {
267 m.insert("updated".into(), Value::String(stamp::now_value()));
268 }
269 let ordered = edit::ordered(std::mem::take(m));
270 *m = ordered;
271 })?;
272 let mut p = Plan::default();
273 p.write(&d.path, new, false);
274 Ok(p)
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 #[test]
282 fn parses_and_applies() {
283 let mut m = Map::new();
284 apply_one(&mut m, &Assign::parse("rel.implements+=sot:a/b").unwrap());
285 apply_one(&mut m, &Assign::parse("rel.implements+=sot:a/b").unwrap());
286 apply_one(&mut m, &Assign::parse("approvers=[kim, lee]").unwrap());
287 assert_eq!(m["approvers"], serde_json::json!(["kim", "lee"]), "C109");
288 apply_one(&mut m, &Assign::parse("tags=x, y").unwrap());
289 apply_one(&mut m, &Assign::parse("phase=P3").unwrap());
290 assert_eq!(m["rel"]["implements"], serde_json::json!(["sot:a/b"]));
291 assert_eq!(m["tags"], serde_json::json!(["x", "y"]));
292 apply_one(&mut m, &Assign::parse("tags-=x").unwrap());
293 apply_one(&mut m, &Assign::parse("rel.affects=a").unwrap());
294 apply_one(&mut m, &Assign::parse("rel.affects=").unwrap());
295 assert!(m["rel"].get("affects").is_none());
296 assert_eq!(m["tags"], serde_json::json!(["y"]));
297 assert!(Assign::parse("=x").is_err());
298 }
299}