| 1 | //! The model as JSON, for the API: what the server sends and the CLI reads back. |
| 2 | |
| 3 | use crate::hex; |
| 4 | use crate::json::{obj, Value}; |
| 5 | use crate::model::{Actor, Change, ChangeState, Event, Evidence, Message, Point, Project, Revision, State, Step}; |
| 6 | use crate::objects::Id; |
| 7 | |
| 8 | fn id(i: &Id) -> Value { |
| 9 | Value::Str(hex::encode(i)) |
| 10 | } |
| 11 | |
| 12 | fn actor(a: &Actor) -> Value { |
| 13 | a.to_json() |
| 14 | } |
| 15 | |
| 16 | fn step(s: &Step) -> Value { |
| 17 | crate::model::step_to_json(s) |
| 18 | } |
| 19 | |
| 20 | pub fn project_json(state: &State, p: &Project) -> Value { |
| 21 | // Names of every agent that appears, so clients can say "claude for anton". |
| 22 | let mut agents: Vec<(String, Value)> = Vec::new(); |
| 23 | let mut note = |a: &Actor| { |
| 24 | if let Some(aid) = &a.agent { |
| 25 | if !agents.iter().any(|(k, _)| k == aid) { |
| 26 | let name = state.agent(aid).map(|g| g.name.clone()).unwrap_or_else(|| aid.clone()); |
| 27 | agents.push((aid.clone(), name.into())); |
| 28 | } |
| 29 | } |
| 30 | }; |
| 31 | for pt in &p.points { |
| 32 | note(&pt.author); |
| 33 | note(&pt.landed_by); |
| 34 | } |
| 35 | for c in &p.changes { |
| 36 | for (_, by, _) in &c.timeline { |
| 37 | note(by); |
| 38 | } |
| 39 | } |
| 40 | obj([ |
| 41 | ("owner", p.owner.clone().into()), |
| 42 | ("name", p.name.clone().into()), |
| 43 | ("public", p.public.into()), |
| 44 | ("contributors", Value::Array(p.contributors.iter().map(|c| Value::from(c.as_str())).collect())), |
| 45 | ("at", p.at.into()), |
| 46 | ("points", Value::Array(p.points.iter().map(point).collect())), |
| 47 | ("changes", Value::Array(p.changes.iter().map(change).collect())), |
| 48 | ("agents", Value::Object(agents)), |
| 49 | ]) |
| 50 | } |
| 51 | |
| 52 | fn point(p: &Point) -> Value { |
| 53 | obj([ |
| 54 | ("n", p.n.into()), |
| 55 | ("snapshot", id(&p.snapshot)), |
| 56 | ("message", p.message.clone().into()), |
| 57 | ("author", actor(&p.author)), |
| 58 | ("landed_by", actor(&p.landed_by)), |
| 59 | ("change", p.change.into()), |
| 60 | ("step", p.step.clone().into()), |
| 61 | ("at", p.at.into()), |
| 62 | ]) |
| 63 | } |
| 64 | |
| 65 | fn change(c: &Change) -> Value { |
| 66 | obj([ |
| 67 | ("number", c.number.into()), |
| 68 | ("intent", c.intent.clone().into()), |
| 69 | ("proposer", actor(&c.proposer)), |
| 70 | ("state", c.state.name().into()), |
| 71 | ("at", c.at.into()), |
| 72 | ( |
| 73 | "revisions", |
| 74 | Value::Array( |
| 75 | c.revisions |
| 76 | .iter() |
| 77 | .map(|r| { |
| 78 | obj([ |
| 79 | ("number", (r.number as u64).into()), |
| 80 | ("base", r.base.into()), |
| 81 | ("steps", Value::Array(r.steps.iter().map(step).collect())), |
| 82 | ("note", r.note.clone().into()), |
| 83 | ("by", actor(&r.by)), |
| 84 | ("at", r.at.into()), |
| 85 | ]) |
| 86 | }) |
| 87 | .collect(), |
| 88 | ), |
| 89 | ), |
| 90 | ( |
| 91 | "messages", |
| 92 | Value::Array(c.messages.iter().map(|m| obj([("id", (m.id as u64).into()), ("body", m.body.clone().into()), ("by", actor(&m.by)), ("at", m.at.into())])).collect()), |
| 93 | ), |
| 94 | ( |
| 95 | "evidence", |
| 96 | Value::Array( |
| 97 | c.evidence |
| 98 | .iter() |
| 99 | .map(|e| { |
| 100 | obj([ |
| 101 | ("revision", (e.revision as u64).into()), |
| 102 | ("title", e.title.clone().into()), |
| 103 | ("type", e.media_type.clone().into()), |
| 104 | ("blob", e.blob.as_ref().map(|b| hex::encode(b)).into()), |
| 105 | ("url", e.url.clone().into()), |
| 106 | ("by", actor(&e.by)), |
| 107 | ("at", e.at.into()), |
| 108 | ]) |
| 109 | }) |
| 110 | .collect(), |
| 111 | ), |
| 112 | ), |
| 113 | ("timeline", Value::Array(c.timeline.iter().map(|(at, by, ev)| event(*at, by, ev)).collect())), |
| 114 | ]) |
| 115 | } |
| 116 | |
| 117 | fn event(at: i64, by: &Actor, ev: &Event) -> Value { |
| 118 | let (kind, detail): (&str, Value) = match ev { |
| 119 | Event::Opened => ("opened", Value::Null), |
| 120 | Event::Revised(n) => ("revised", (*n as u64).into()), |
| 121 | Event::State(s) => ("state", s.name().into()), |
| 122 | Event::Said(n) => ("said", (*n as u64).into()), |
| 123 | Event::Attached(i) => ("attached", (*i as u64).into()), |
| 124 | Event::Landed { revision, points } => ( |
| 125 | "landed", |
| 126 | obj([("revision", (*revision as u64).into()), ("first", points.0.into()), ("last", points.1.into())]), |
| 127 | ), |
| 128 | }; |
| 129 | obj([("at", at.into()), ("by", actor(by)), ("event", kind.into()), ("detail", detail)]) |
| 130 | } |
| 131 | |
| 132 | // ── Reading it back ──────────────────────────────────────── |
| 133 | |
| 134 | type R<T> = Result<T, String>; |
| 135 | |
| 136 | fn s(v: &Value, k: &str) -> R<String> { |
| 137 | v.get(k).and_then(Value::as_str).map(str::to_string).ok_or_else(|| format!("missing {k}")) |
| 138 | } |
| 139 | fn os(v: &Value, k: &str) -> Option<String> { |
| 140 | v.get(k).and_then(Value::as_str).map(str::to_string) |
| 141 | } |
| 142 | fn n(v: &Value, k: &str) -> R<i64> { |
| 143 | v.get(k).and_then(Value::as_i64).ok_or_else(|| format!("missing {k}")) |
| 144 | } |
| 145 | fn arr<'a>(v: &'a Value, k: &str) -> &'a [Value] { |
| 146 | v.get(k).and_then(Value::as_array).unwrap_or(&[]) |
| 147 | } |
| 148 | fn id_of(v: &Value, k: &str) -> R<Id> { |
| 149 | hex::decode(&s(v, k)?).and_then(|b| b.try_into().ok()).ok_or_else(|| format!("bad id in {k}")) |
| 150 | } |
| 151 | fn actor_of(v: &Value, k: &str) -> R<Actor> { |
| 152 | let a = v.get(k).ok_or_else(|| format!("missing {k}"))?; |
| 153 | Ok(Actor { person: s(a, "person")?, agent: os(a, "agent") }) |
| 154 | } |
| 155 | |
| 156 | /// A project as sent by `project_json`, plus agent names by id. |
| 157 | pub fn project_from_json(v: &Value) -> R<(Project, Vec<(String, String)>)> { |
| 158 | let points = arr(v, "points") |
| 159 | .iter() |
| 160 | .map(|p| { |
| 161 | Ok(Point { |
| 162 | n: n(p, "n")? as u64, |
| 163 | snapshot: id_of(p, "snapshot")?, |
| 164 | message: s(p, "message")?, |
| 165 | author: actor_of(p, "author")?, |
| 166 | landed_by: actor_of(p, "landed_by")?, |
| 167 | change: p.get("change").and_then(Value::as_i64).map(|x| x as u64), |
| 168 | step: os(p, "step"), |
| 169 | at: n(p, "at")?, |
| 170 | }) |
| 171 | }) |
| 172 | .collect::<R<Vec<_>>>()?; |
| 173 | let changes = arr(v, "changes") |
| 174 | .iter() |
| 175 | .map(|c| { |
| 176 | let revisions = arr(c, "revisions") |
| 177 | .iter() |
| 178 | .map(|r| { |
| 179 | Ok(Revision { |
| 180 | number: n(r, "number")? as u32, |
| 181 | base: n(r, "base")? as u64, |
| 182 | steps: arr(r, "steps") |
| 183 | .iter() |
| 184 | .map(|st| Ok(Step { id: s(st, "id")?, snapshot: id_of(st, "snapshot")?, message: s(st, "message")? })) |
| 185 | .collect::<R<Vec<_>>>()?, |
| 186 | note: os(r, "note"), |
| 187 | by: actor_of(r, "by")?, |
| 188 | at: n(r, "at")?, |
| 189 | }) |
| 190 | }) |
| 191 | .collect::<R<Vec<_>>>()?; |
| 192 | let messages = arr(c, "messages") |
| 193 | .iter() |
| 194 | .map(|m| Ok(Message { id: n(m, "id")? as u32, body: s(m, "body")?, by: actor_of(m, "by")?, at: n(m, "at")? })) |
| 195 | .collect::<R<Vec<_>>>()?; |
| 196 | let evidence = arr(c, "evidence") |
| 197 | .iter() |
| 198 | .map(|e| { |
| 199 | Ok(Evidence { |
| 200 | revision: n(e, "revision")? as u32, |
| 201 | title: s(e, "title")?, |
| 202 | media_type: s(e, "type")?, |
| 203 | blob: os(e, "blob").and_then(|h| hex::decode(&h)).and_then(|b| b.try_into().ok()), |
| 204 | url: os(e, "url"), |
| 205 | by: actor_of(e, "by")?, |
| 206 | at: n(e, "at")?, |
| 207 | }) |
| 208 | }) |
| 209 | .collect::<R<Vec<_>>>()?; |
| 210 | let timeline = arr(c, "timeline") |
| 211 | .iter() |
| 212 | .map(|t| { |
| 213 | let d = t.get("detail").cloned().unwrap_or(Value::Null); |
| 214 | let num = |v: &Value| v.as_i64().unwrap_or(0); |
| 215 | let ev = match s(t, "event")?.as_str() { |
| 216 | "opened" => Event::Opened, |
| 217 | "revised" => Event::Revised(num(&d) as u32), |
| 218 | "state" => Event::State(ChangeState::parse(d.as_str().unwrap_or("")).ok_or("bad state")?), |
| 219 | "said" => Event::Said(num(&d) as u32), |
| 220 | "attached" => Event::Attached(num(&d) as usize), |
| 221 | "landed" => Event::Landed { |
| 222 | revision: d.get("revision").map(num).unwrap_or(0) as u32, |
| 223 | points: (d.get("first").map(num).unwrap_or(0) as u64, d.get("last").map(num).unwrap_or(0) as u64), |
| 224 | }, |
| 225 | other => return Err(format!("unknown event {other}")), |
| 226 | }; |
| 227 | Ok((n(t, "at")?, actor_of(t, "by")?, ev)) |
| 228 | }) |
| 229 | .collect::<R<Vec<_>>>()?; |
| 230 | Ok(Change { |
| 231 | number: n(c, "number")? as u64, |
| 232 | intent: s(c, "intent")?, |
| 233 | proposer: actor_of(c, "proposer")?, |
| 234 | state: ChangeState::parse(&s(c, "state")?).ok_or("bad state")?, |
| 235 | revisions, |
| 236 | messages, |
| 237 | evidence, |
| 238 | timeline, |
| 239 | at: n(c, "at")?, |
| 240 | }) |
| 241 | }) |
| 242 | .collect::<R<Vec<_>>>()?; |
| 243 | let agents = match v.get("agents") { |
| 244 | Some(Value::Object(f)) => f.iter().map(|(k, v)| (k.clone(), v.as_str().unwrap_or(k).to_string())).collect(), |
| 245 | _ => Vec::new(), |
| 246 | }; |
| 247 | let public = matches!(v.get("public"), Some(Value::Bool(true))); |
| 248 | let contributors = arr(v, "contributors").iter().filter_map(|c| c.as_str().map(str::to_string)).collect(); |
| 249 | Ok((Project { owner: s(v, "owner")?, name: s(v, "name")?, public, points, changes, contributors, at: n(v, "at")? }, agents)) |
| 250 | } |
| 251 | |
| 252 | /// Objects in transit: each is `length (u32 LE), stored bytes`. |
| 253 | pub fn frame_objects(objects: &[Vec<u8>]) -> Vec<u8> { |
| 254 | let mut out = Vec::new(); |
| 255 | for o in objects { |
| 256 | out.extend_from_slice(&(o.len() as u32).to_le_bytes()); |
| 257 | out.extend_from_slice(o); |
| 258 | } |
| 259 | out |
| 260 | } |
| 261 | |
| 262 | pub fn unframe_objects(mut bytes: &[u8]) -> R<Vec<&[u8]>> { |
| 263 | let mut out = Vec::new(); |
| 264 | while !bytes.is_empty() { |
| 265 | let len = u32::from_le_bytes(bytes.get(..4).ok_or("short frame")?.try_into().unwrap()) as usize; |
| 266 | let body = bytes.get(4..4 + len).ok_or("short object")?; |
| 267 | out.push(body); |
| 268 | bytes = &bytes[4 + len..]; |
| 269 | } |
| 270 | Ok(out) |
| 271 | } |
| 272 | |
| 273 | #[cfg(test)] |
| 274 | mod tests { |
| 275 | use super::*; |
| 276 | use crate::model::Actor; |
| 277 | use crate::objects::{Entry, Kind}; |
| 278 | use crate::store::{Content, Store}; |
| 279 | use crate::testdir::TestDir; |
| 280 | |
| 281 | #[test] |
| 282 | fn project_round_trips() { |
| 283 | let dir = TestDir::new("wire"); |
| 284 | let mut store = Store::open(dir.path()).unwrap(); |
| 285 | store.add_person("a", "A").unwrap(); |
| 286 | let agent = store.add_agent("a", "claude").unwrap(); |
| 287 | let me = Actor::agent("a", &agent); |
| 288 | store.create_project(&Actor::person("a"), "a", "p", true).unwrap(); |
| 289 | let blob = store.objects.put_blob(b"x").unwrap(); |
| 290 | let t = store.objects.put_tree(&[Entry { name: "f".into(), kind: Kind::File, id: blob }]).unwrap(); |
| 291 | store.record_point(&Actor::person("a"), "a", "p", &t, "start", None).unwrap(); |
| 292 | let c = store.open_change(&me, "a", "p", "do it").unwrap(); |
| 293 | store.revise(&me, "a", "p", c, 1, &[Step { id: "s".into(), snapshot: t, message: "m".into() }], Some("note")).unwrap(); |
| 294 | store.set_state(&me, "a", "p", c, ChangeState::Proposed).unwrap(); |
| 295 | store.say(&me, "a", "p", c, "hi").unwrap(); |
| 296 | store.attach(&me, "a", "p", c, 1, "proof", "text/plain", Content::Bytes(b"ok")).unwrap(); |
| 297 | store.land(&Actor::person("a"), "a", "p", c).unwrap(); |
| 298 | |
| 299 | let p = store.project("a", "p").unwrap(); |
| 300 | let json = project_json(store.state(), p).encode(); |
| 301 | let (back, agents) = project_from_json(&crate::json::parse(&json).unwrap()).unwrap(); |
| 302 | assert_eq!(agents, vec![(agent.clone(), "claude".to_string())]); |
| 303 | assert_eq!(back.points.len(), 2); |
| 304 | assert_eq!(back.changes[0].state, ChangeState::Landed); |
| 305 | assert_eq!(back.changes[0].revisions[0].steps, p.changes[0].revisions[0].steps); |
| 306 | assert_eq!(back.changes[0].timeline.len(), p.changes[0].timeline.len()); |
| 307 | // Encoding the decoded project gives the same JSON. |
| 308 | assert_eq!(project_json(store.state(), &back).encode(), json); |
| 309 | } |
| 310 | |
| 311 | #[test] |
| 312 | fn frames() { |
| 313 | let objs = vec![b"one".to_vec(), Vec::new(), b"three".to_vec()]; |
| 314 | let framed = frame_objects(&objs); |
| 315 | assert_eq!(unframe_objects(&framed).unwrap(), vec![&b"one"[..], &b""[..], &b"three"[..]]); |
| 316 | assert!(unframe_objects(&framed[..framed.len() - 1]).is_err()); |
| 317 | } |
| 318 | } |