grove
at @9
1//! The store: objects, the log, and the state the log adds up to.
2//!
3//! A write is: check the referenced objects exist, apply the record to the in-memory state
4//! (which validates it and changes nothing if it's invalid), then append it to the log. If
5//! the append fails, the state is rebuilt from the log on disk, so memory never claims
6//! something the disk doesn't hold.
7
8use crate::json::{self, Value};
9use crate::log::{Log, OpenError, Recovery};
10use crate::model::{id_to_json, step_to_json, Actor, ChangeState, Project, State, Step};
11use crate::objects::{self, Id, Objects};
12use std::path::{Path, PathBuf};
13use std::time::{SystemTime, UNIX_EPOCH};
14
15#[derive(Debug)]
16pub enum Error {
17 Open(OpenError),
18 Objects(objects::Error),
19 Io(std::io::Error),
20 /// The log holds a record that doesn't fit the state before it.
21 Damaged { record: u64, reason: String },
22 /// The request isn't allowed or doesn't make sense.
23 Refused(String),
24 /// The change was built on an older point. grove doesn't merge: the agent rebuilds the
25 /// change on the tip and proposes a new revision.
26 Behind { base: u64, tip: u64 },
27}
28
29impl std::fmt::Display for Error {
30 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
31 match self {
32 Error::Open(e) => write!(f, "{e}"),
33 Error::Objects(e) => write!(f, "{e}"),
34 Error::Io(e) => write!(f, "{e}"),
35 Error::Damaged { record, reason } => write!(f, "log record {record} is damaged: {reason}"),
36 Error::Refused(r) => write!(f, "{r}"),
37 Error::Behind { base, tip } => write!(f, "built on @{base}, but main is at @{tip}; the change needs a new revision built on @{tip}"),
38 }
39 }
40}
41
42impl From<OpenError> for Error {
43 fn from(e: OpenError) -> Self {
44 Error::Open(e)
45 }
46}
47impl From<objects::Error> for Error {
48 fn from(e: objects::Error) -> Self {
49 Error::Objects(e)
50 }
51}
52impl From<std::io::Error> for Error {
53 fn from(e: std::io::Error) -> Self {
54 Error::Io(e)
55 }
56}
57
58pub type Result<T> = std::result::Result<T, Error>;
59
60fn refused<T>(msg: impl Into<String>) -> Result<T> {
61 Err(Error::Refused(msg.into()))
62}
63
64/// What a piece of evidence holds.
65pub enum Content<'a> {
66 Bytes(&'a [u8]),
67 /// A blob already stored (uploaded beforehand).
68 Blob(Id),
69 Url(&'a str),
70}
71
72pub fn token_hash(secret: &str) -> String {
73 crate::hex::encode(&crate::blake3::hash(secret.as_bytes()))
74}
75
76pub fn now_ms() -> i64 {
77 SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)
78}
79
80pub struct Store {
81 dir: PathBuf,
82 log: Log,
83 pub objects: Objects,
84 state: State,
85 pub recovery: Recovery,
86}
87
88fn replay(records: &[Vec<u8>]) -> Result<State> {
89 let mut state = State::default();
90 for (i, bytes) in records.iter().enumerate() {
91 let damaged = |reason: String| Error::Damaged { record: i as u64, reason };
92 let text = std::str::from_utf8(bytes).map_err(|_| damaged("not UTF-8".into()))?;
93 let rec = json::parse(text).map_err(|e| damaged(e.to_string()))?;
94 state.apply(&rec).map_err(damaged)?;
95 }
96 Ok(state)
97}
98
99impl Store {
100 pub fn open(dir: &Path) -> Result<Store> {
101 std::fs::create_dir_all(dir)?;
102 let objects = Objects::open(&dir.join("objects"))?;
103 let (log, records, recovery) = Log::open(&dir.join("log"))?;
104 let state = replay(&records)?;
105 Ok(Store { dir: dir.to_path_buf(), log, objects, state, recovery })
106 }
107
108 pub fn dir(&self) -> &Path {
109 &self.dir
110 }
111
112 pub fn state(&self) -> &State {
113 &self.state
114 }
115
116 /// Validate, apply, append. `rec` gets its time and actor here.
117 fn write(&mut self, by: &Actor, mut rec: Vec<(String, Value)>) -> Result<()> {
118 rec.insert(1, ("at".into(), now_ms().into()));
119 rec.insert(2, ("by".into(), by.to_json()));
120 let rec = Value::Object(rec);
121 self.state.apply(&rec).map_err(Error::Refused)?;
122 if let Err(e) = self.log.append(rec.encode().as_bytes()) {
123 // The state now holds a record the log doesn't. Rebuild it from the disk.
124 let (log, records, _) = Log::open(&self.dir.join("log")).map_err(|_| Error::Io(e))?;
125 self.log = log;
126 self.state = replay(&records)?;
127 return Err(Error::Refused("couldn't write to the log".into()));
128 }
129 Ok(())
130 }
131
132 fn rec(t: &str, fields: Vec<(&str, Value)>) -> Vec<(String, Value)> {
133 let mut out = vec![("t".to_string(), Value::from(t))];
134 out.extend(fields.into_iter().map(|(k, v)| (k.to_string(), v)));
135 out
136 }
137
138 fn require_tree(&self, id: &Id) -> Result<()> {
139 if !self.objects.has(id) {
140 return refused(format!("snapshot {} isn't stored", crate::hex::encode(id)));
141 }
142 Ok(())
143 }
144
145 pub fn project(&self, owner: &str, name: &str) -> Result<&Project> {
146 self.state.project(owner, name).ok_or_else(|| Error::Refused(format!("no project {owner}/{name}")))
147 }
148
149 // ── People and agents ──────────────────────────────────
150
151 pub fn add_person(&mut self, username: &str, display_name: &str) -> Result<()> {
152 if !username.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') || username.is_empty() {
153 return refused("usernames are letters, digits, - and _");
154 }
155 self.write(&Actor::person(username), Self::rec("person", vec![("username", username.into()), ("display_name", display_name.into())]))
156 }
157
158 /// Register an agent acting for `person`. Returns its id.
159 pub fn add_agent(&mut self, person: &str, name: &str) -> Result<String> {
160 let id = crate::random::hex(6);
161 self.write(&Actor::person(person), Self::rec("agent", vec![("id", id.clone().into()), ("name", name.into())]))?;
162 Ok(id)
163 }
164
165 /// Issue a token acting as `person`, or as one of their agents. Returns the secret, which
166 /// is shown once; only its hash is kept.
167 pub fn issue_token(&mut self, person: &str, agent: Option<&str>) -> Result<String> {
168 let secret = format!("grove_{}", crate::random::hex(24));
169 let hash = token_hash(&secret);
170 self.write(&Actor::person(person), Self::rec("token", vec![("hash", hash.into()), ("agent", agent.map(str::to_string).into())]))?;
171 Ok(secret)
172 }
173
174 /// Create an invite (in person). With a project, it also makes whoever uses it a
175 /// contributor. Returns the code, shown once; only its hash is kept.
176 pub fn create_invite(&mut self, by: &Actor, project: Option<(&str, &str)>) -> Result<String> {
177 let code = format!("invite_{}", crate::random::hex(16));
178 let mut fields = vec![("hash", token_hash(&code).into())];
179 if let Some((o, n)) = project {
180 fields.push(("owner", o.into()));
181 fields.push(("project", n.into()));
182 }
183 self.write(by, Self::rec("invite", fields))?;
184 Ok(code)
185 }
186
187 /// Is this invite code usable?
188 pub fn invite_open(&self, code: &str) -> bool {
189 self.state.open_invite(&token_hash(code)).is_some()
190 }
191
192 /// Use an invite as `person`. Returns the project it grants, if any.
193 pub fn use_invite(&mut self, person: &str, code: &str) -> Result<Option<(String, String)>> {
194 let hash = token_hash(code);
195 let project = self.state.open_invite(&hash).ok_or_else(|| Error::Refused("that invite doesn't exist or was already used".into()))?.project.clone();
196 self.write(&Actor::person(person), Self::rec("invite.use", vec![("hash", hash.into())]))?;
197 Ok(project)
198 }
199
200 /// Who a token's secret acts as.
201 pub fn authenticate(&self, secret: &str) -> Option<Actor> {
202 self.state.token_actor(&token_hash(secret))
203 }
204
205 pub fn revoke_agent(&mut self, person: &str, id: &str) -> Result<()> {
206 self.write(&Actor::person(person), Self::rec("agent.revoke", vec![("id", id.into())]))
207 }
208
209 // ── Projects and main ──────────────────────────────────
210
211 /// Create a project owned by `owner`: the actor themselves or a group they're in.
212 pub fn create_project(&mut self, by: &Actor, owner: &str, name: &str, public: bool) -> Result<()> {
213 if !objects::valid_name(name) || name.contains(char::is_whitespace) {
214 return refused(format!("{name:?} can't be a project name"));
215 }
216 self.write(by, Self::rec("project", vec![("owner", owner.into()), ("name", name.into()), ("public", public.into())]))
217 }
218
219 /// Give a project to another owner. History goes with it.
220 pub fn move_project(&mut self, by: &Actor, owner: &str, name: &str, to: &str) -> Result<()> {
221 self.write(by, Self::rec("project.move", vec![("owner", owner.into()), ("project", name.into()), ("to", to.into())]))
222 }
223
224 pub fn create_group(&mut self, by: &Actor, name: &str) -> Result<()> {
225 if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') || name.is_empty() {
226 return refused("group names are letters, digits, - and _");
227 }
228 self.write(by, Self::rec("group", vec![("name", name.into())]))
229 }
230
231 pub fn add_member(&mut self, by: &Actor, group: &str, person: &str) -> Result<()> {
232 self.write(by, Self::rec("group.add", vec![("group", group.into()), ("person", person.into())]))
233 }
234
235 /// Record a point directly on main: a project's first snapshot, or imported history.
236 pub fn record_point(&mut self, by: &Actor, owner: &str, name: &str, snapshot: &Id, message: &str, author: Option<&Actor>) -> Result<u64> {
237 self.require_tree(snapshot)?;
238 let n = self.project(owner, name)?.tip() + 1;
239 let mut fields = vec![
240 ("owner", owner.into()),
241 ("project", name.into()),
242 ("n", n.into()),
243 ("snapshot", id_to_json(snapshot)),
244 ("message", message.into()),
245 ];
246 if let Some(a) = author {
247 fields.push(("author", a.to_json()));
248 }
249 self.write(by, Self::rec("point", fields))?;
250 Ok(n)
251 }
252
253 // ── Changes ────────────────────────────────────────────
254
255 /// Open a change as an idea: an intent, no code yet. Its first revision makes it a draft.
256 pub fn open_change(&mut self, by: &Actor, owner: &str, name: &str, intent: &str) -> Result<u64> {
257 let number = self.project(owner, name)?.changes.len() as u64 + 1;
258 self.write(by, Self::rec("change", vec![("owner", owner.into()), ("project", name.into()), ("number", number.into()), ("intent", intent.into()), ("state", "idea".into())]))?;
259 Ok(number)
260 }
261
262 /// Add a revision: the whole stack of steps, built on main point `base`.
263 pub fn revise(&mut self, by: &Actor, owner: &str, name: &str, change: u64, base: u64, steps: &[Step], note: Option<&str>) -> Result<u32> {
264 for s in steps {
265 self.require_tree(&s.snapshot)?;
266 }
267 let p = self.project(owner, name)?;
268 let c = p.change(change).ok_or_else(|| Error::Refused(format!("no change #{change}")))?;
269 if by.person != c.proposer.person && !self.state.owns(&p.owner, &by.person) {
270 return refused("only the proposer or the owner can revise a change");
271 }
272 let number = c.revisions.len() as u32 + 1;
273 let steps: Vec<Value> = steps.iter().map(step_to_json).collect();
274 self.write(
275 by,
276 Self::rec("revision", vec![
277 ("owner", owner.into()),
278 ("project", name.into()),
279 ("change", change.into()),
280 ("number", (number as u64).into()),
281 ("base", base.into()),
282 ("steps", steps.into()),
283 ("note", note.map(str::to_string).into()),
284 ]),
285 )?;
286 Ok(number)
287 }
288
289 pub fn set_state(&mut self, by: &Actor, owner: &str, name: &str, change: u64, to: ChangeState) -> Result<()> {
290 if to == ChangeState::Landed {
291 return refused("changes land through land()");
292 }
293 self.write(by, Self::rec("state", vec![("owner", owner.into()), ("project", name.into()), ("change", change.into()), ("state", to.name().into())]))
294 }
295
296 /// Land a change's latest revision. Its steps become the next points on main, all at once.
297 pub fn land(&mut self, by: &Actor, owner: &str, name: &str, change: u64) -> Result<(u64, u64)> {
298 let p = self.project(owner, name)?;
299 if !self.state.owns(&p.owner, &by.person) || by.agent.is_some() {
300 return refused("only the owner, in person, lands changes");
301 }
302 let c = p.change(change).ok_or_else(|| Error::Refused(format!("no change #{change}")))?;
303 let rev = c.latest().ok_or_else(|| Error::Refused(format!("#{change} has no revisions")))?;
304 let tip = p.tip();
305 // No merging: a change lands only on the point it was built on.
306 if rev.base != tip {
307 return Err(Error::Behind { base: rev.base, tip });
308 }
309 let snapshots: Vec<Id> = rev.steps.iter().map(|s| s.snapshot).collect();
310 let first = tip + 1;
311 let count = snapshots.len() as u64;
312 let snapshots: Vec<Value> = snapshots.iter().map(id_to_json).collect();
313 let revision = rev.number as u64;
314 self.write(
315 by,
316 Self::rec("land", vec![
317 ("owner", owner.into()),
318 ("project", name.into()),
319 ("change", change.into()),
320 ("revision", revision.into()),
321 ("first", first.into()),
322 ("snapshots", snapshots.into()),
323 ]),
324 )?;
325 Ok((first, first + count - 1))
326 }
327
328 pub fn say(&mut self, by: &Actor, owner: &str, name: &str, change: u64, body: &str) -> Result<()> {
329 self.write(by, Self::rec("say", vec![("owner", owner.into()), ("project", name.into()), ("change", change.into()), ("body", body.into())]))
330 }
331
332 /// Attach evidence to a revision: stored bytes of a media type, or a URL.
333 pub fn attach(&mut self, by: &Actor, owner: &str, name: &str, change: u64, revision: u32, title: &str, media_type: &str, content: Content) -> Result<()> {
334 let (blob, url) = match content {
335 Content::Bytes(bytes) => (Value::from(crate::hex::encode(&self.objects.put_blob(bytes)?)), Value::Null),
336 Content::Blob(id) => {
337 self.objects.get_blob(&id)?;
338 (Value::from(crate::hex::encode(&id)), Value::Null)
339 }
340 Content::Url(url) => (Value::Null, Value::from(url)),
341 };
342 self.write(
343 by,
344 Self::rec("evidence", vec![
345 ("owner", owner.into()),
346 ("project", name.into()),
347 ("change", change.into()),
348 ("revision", (revision as u64).into()),
349 ("title", title.into()),
350 ("type", media_type.into()),
351 ("blob", blob),
352 ("url", url),
353 ]),
354 )
355 }
356
357 /// Check that every object the state refers to exists and matches its id.
358 pub fn verify(&self) -> Result<usize> {
359 let mut count = 0;
360 let mut seen = std::collections::HashSet::new();
361 let mut check_tree = |id: &Id, count: &mut usize| -> Result<()> {
362 if seen.insert(*id) {
363 *count += self.objects.verify_tree(id)?;
364 }
365 Ok(())
366 };
367 for p in &self.state.projects {
368 for pt in &p.points {
369 check_tree(&pt.snapshot, &mut count)?;
370 }
371 for c in &p.changes {
372 for r in &c.revisions {
373 for s in &r.steps {
374 check_tree(&s.snapshot, &mut count)?;
375 }
376 }
377 for e in &c.evidence {
378 if let Some(b) = &e.blob {
379 self.objects.get_blob(b)?;
380 count += 1;
381 }
382 }
383 }
384 }
385 Ok(count)
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392 use crate::objects::{Entry, Kind};
393 use crate::testdir::TestDir;
394
395 fn tree(store: &Store, files: &[(&str, &str)]) -> Id {
396 let entries: Vec<Entry> = files
397 .iter()
398 .map(|(n, c)| Entry { name: n.to_string(), kind: Kind::File, id: store.objects.put_blob(c.as_bytes()).unwrap() })
399 .collect();
400 store.objects.put_tree(&entries).unwrap()
401 }
402
403 fn setup(dir: &TestDir) -> (Store, Actor, String) {
404 let mut store = Store::open(dir.path()).unwrap();
405 store.add_person("anton", "Anton").unwrap();
406 let agent = store.add_agent("anton", "claude").unwrap();
407 let me = Actor::agent("anton", &agent);
408 store.create_project(&Actor::person("anton"), "anton", "grove", true).unwrap();
409 let t = tree(&store, &[("README", "hello")]);
410 store.record_point(&Actor::person("anton"), "anton", "grove", &t, "Start", None).unwrap();
411 (store, me, agent)
412 }
413
414 #[test]
415 fn change_lifecycle_and_replay() {
416 let dir = TestDir::new("store-lifecycle");
417 {
418 let (mut store, me, _) = setup(&dir);
419 let owner = Actor::person("anton");
420 let c = store.open_change(&me, "anton", "grove", "Say more in the README").unwrap();
421 assert_eq!(store.project("anton", "grove").unwrap().change(c).unwrap().state, ChangeState::Idea);
422
423 let t1 = tree(&store, &[("README", "hello there")]);
424 let t2 = tree(&store, &[("README", "hello there"), ("NOTES", "x")]);
425 let steps = vec![
426 Step { id: "s1".into(), snapshot: t1, message: "Longer greeting".into() },
427 Step { id: "s2".into(), snapshot: t2, message: "Add notes".into() },
428 ];
429 assert_eq!(store.revise(&me, "anton", "grove", c, 1, &steps, None).unwrap(), 1);
430 store.set_state(&me, "anton", "grove", c, ChangeState::Proposed).unwrap();
431 store.say(&owner, "anton", "grove", c, "Looks good").unwrap();
432 store.attach(&me, "anton", "grove", c, 1, "notes", "text/plain", Content::Bytes(b"checked by hand")).unwrap();
433
434 // Only the owner lands.
435 assert!(matches!(store.land(&me, "anton", "grove", c), Err(Error::Refused(_))));
436 assert_eq!(store.land(&owner, "anton", "grove", c).unwrap(), (2, 3));
437 let p = store.project("anton", "grove").unwrap();
438 assert_eq!(p.tip(), 3);
439 assert_eq!(p.point(3).unwrap().snapshot, t2);
440 assert_eq!(p.change(c).unwrap().state, ChangeState::Landed);
441 assert_eq!(store.verify().unwrap() > 0, true);
442 }
443 // Everything comes back from the log.
444 let store = Store::open(dir.path()).unwrap();
445 let p = store.project("anton", "grove").unwrap();
446 assert_eq!(p.tip(), 3);
447 let c = p.change(1).unwrap();
448 assert_eq!(c.state, ChangeState::Landed);
449 assert_eq!(c.messages[0].body, "Looks good");
450 assert_eq!(c.evidence.len(), 1);
451 assert_eq!(p.point(2).unwrap().message, "Longer greeting");
452 assert!(p.point(2).unwrap().author.agent.is_some());
453 }
454
455 #[test]
456 fn landing_behind_the_tip_is_refused() {
457 let dir = TestDir::new("store-behind");
458 let (mut store, me, _) = setup(&dir);
459 let owner = Actor::person("anton");
460 let t = tree(&store, &[("README", "a")]);
461 let step = |id: &str| vec![Step { id: id.into(), snapshot: t, message: "m".into() }];
462 let a = store.open_change(&me, "anton", "grove", "a").unwrap();
463 let b = store.open_change(&me, "anton", "grove", "b").unwrap();
464 store.revise(&me, "anton", "grove", a, 1, &step("x"), None).unwrap();
465 store.revise(&me, "anton", "grove", b, 1, &step("y"), None).unwrap();
466 store.set_state(&me, "anton", "grove", a, ChangeState::Proposed).unwrap();
467 store.set_state(&me, "anton", "grove", b, ChangeState::Proposed).unwrap();
468 store.land(&owner, "anton", "grove", a).unwrap();
469 assert!(matches!(store.land(&owner, "anton", "grove", b), Err(Error::Behind { base: 1, tip: 2 })));
470 assert_eq!(store.project("anton", "grove").unwrap().change(b).unwrap().state, ChangeState::Proposed);
471 // A new revision built on the tip lands.
472 let t2 = tree(&store, &[("README", "rebuilt")]);
473 store.revise(&me, "anton", "grove", b, 2, &[Step { id: "y".into(), snapshot: t2, message: "m".into() }], None).unwrap();
474 assert_eq!(store.land(&owner, "anton", "grove", b).unwrap(), (3, 3));
475 }
476
477 #[test]
478 fn refusals_leave_no_trace() {
479 let dir = TestDir::new("store-refusals");
480 let (mut store, me, agent) = setup(&dir);
481 let before = store.state().records;
482 // Unstored snapshot, unknown project, bad transition, revoked agent.
483 let ghost = crate::objects::blob_id(b"not stored");
484 assert!(store.record_point(&Actor::person("anton"), "anton", "grove", &ghost, "x", None).is_err());
485 assert!(store.open_change(&me, "anton", "nope", "x").is_err());
486 let c = store.open_change(&me, "anton", "grove", "idea").unwrap();
487 assert!(store.set_state(&me, "anton", "grove", c, ChangeState::Proposed).is_err(), "an idea can't be proposed");
488 store.revoke_agent("anton", &agent).unwrap();
489 assert!(store.say(&me, "anton", "grove", c, "still here?").is_err());
490 assert_eq!(store.state().records, before + 2, "only the change and the revocation were written");
491 drop(store);
492 assert_eq!(Store::open(dir.path()).unwrap().state().records, before + 2);
493 }
494
495 #[test]
496 fn groups_own_projects() {
497 let dir = TestDir::new("store-groups");
498 let (mut store, me, _) = setup(&dir);
499 let anton = Actor::person("anton");
500 store.add_person("bea", "Bea").unwrap();
501 let bea = Actor::person("bea");
502 assert!(store.create_group(&me, "letterpress").is_err(), "groups are created in person");
503 store.create_group(&anton, "letterpress").unwrap();
504 assert!(store.create_group(&anton, "bea").is_err(), "names are shared with people");
505 // Move grove to the group; its history goes with it.
506 assert!(store.move_project(&bea, "anton", "grove", "letterpress").is_err());
507 store.move_project(&anton, "anton", "grove", "letterpress").unwrap();
508 assert!(store.project("anton", "grove").is_err());
509 assert_eq!(store.project("letterpress", "grove").unwrap().tip(), 1);
510 // Members decide for the group; others don't. Bea needs an invite to take part at all.
511 let t = tree(&store, &[("README", "group")]);
512 assert!(store.open_change(&bea, "letterpress", "grove", "from bea").is_err());
513 let code = store.create_invite(&anton, Some(("letterpress", "grove"))).unwrap();
514 store.use_invite("bea", &code).unwrap();
515 let c = store.open_change(&bea, "letterpress", "grove", "from bea").unwrap();
516 store.revise(&bea, "letterpress", "grove", c, 1, &[Step { id: "s".into(), snapshot: t, message: "m".into() }], None).unwrap();
517 store.set_state(&bea, "letterpress", "grove", c, ChangeState::Proposed).unwrap();
518 assert!(store.land(&bea, "letterpress", "grove", c).is_err());
519 store.add_member(&anton, "letterpress", "bea").unwrap();
520 store.land(&bea, "letterpress", "grove", c).unwrap();
521 drop(store);
522 let store = Store::open(dir.path()).unwrap();
523 assert_eq!(store.project("letterpress", "grove").unwrap().tip(), 2);
524 assert_eq!(store.state().group("letterpress").unwrap().members, ["anton", "bea"]);
525 }
526
527 #[test]
528 fn strangers_and_private_projects() {
529 let dir = TestDir::new("store-strangers");
530 let (mut store, _, _) = setup(&dir);
531 store.add_person("visitor", "Visitor").unwrap();
532 let visitor = Actor::person("visitor");
533 // Without an invite, a stranger can't even open a change.
534 assert!(store.open_change(&visitor, "anton", "grove", "uninvited").is_err());
535 // Agents can't send invites; the owner, in person, can.
536 let agent = Actor::agent("anton", &store.state().agents[0].id.clone());
537 assert!(store.create_invite(&agent, Some(("anton", "grove"))).is_err());
538 let code = store.create_invite(&Actor::person("anton"), Some(("anton", "grove"))).unwrap();
539 assert_eq!(store.use_invite("visitor", &code).unwrap(), Some(("anton".into(), "grove".into())));
540 assert!(store.use_invite("visitor", &code).is_err(), "an invite works once");
541 // An invited contributor can propose, but not land.
542 let c = store.open_change(&visitor, "anton", "grove", "An idea from outside").unwrap();
543 let t = tree(&store, &[("README", "outside")]);
544 store.revise(&visitor, "anton", "grove", c, 1, &[Step { id: "s".into(), snapshot: t, message: "m".into() }], None).unwrap();
545 store.set_state(&visitor, "anton", "grove", c, ChangeState::Proposed).unwrap();
546 assert!(store.land(&visitor, "anton", "grove", c).is_err());
547 store.set_state(&Actor::person("anton"), "anton", "grove", c, ChangeState::Declined).unwrap();
548 // Private projects take changes only from the owner.
549 store.create_project(&Actor::person("anton"), "anton", "secret", false).unwrap();
550 assert!(store.open_change(&visitor, "anton", "secret", "let me in").is_err());
551 }
552}