grove
at @13
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 /// Revoke one of `person`'s tokens by its secret.
206 pub fn revoke_token(&mut self, person: &str, secret: &str) -> Result<()> {
207 self.write(&Actor::person(person), Self::rec("token.revoke", vec![("hash", token_hash(secret).into())]))
208 }
209
210 pub fn revoke_agent(&mut self, person: &str, id: &str) -> Result<()> {
211 self.write(&Actor::person(person), Self::rec("agent.revoke", vec![("id", id.into())]))
212 }
213
214 // ── Projects and main ──────────────────────────────────
215
216 /// Create a project owned by `owner`: the actor themselves or a group they're in.
217 pub fn create_project(&mut self, by: &Actor, owner: &str, name: &str, public: bool) -> Result<()> {
218 if !objects::valid_name(name) || name.contains(char::is_whitespace) {
219 return refused(format!("{name:?} can't be a project name"));
220 }
221 self.write(by, Self::rec("project", vec![("owner", owner.into()), ("name", name.into()), ("public", public.into())]))
222 }
223
224 /// Give a project to another owner. History goes with it.
225 pub fn move_project(&mut self, by: &Actor, owner: &str, name: &str, to: &str) -> Result<()> {
226 self.write(by, Self::rec("project.move", vec![("owner", owner.into()), ("project", name.into()), ("to", to.into())]))
227 }
228
229 pub fn create_group(&mut self, by: &Actor, name: &str) -> Result<()> {
230 if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') || name.is_empty() {
231 return refused("group names are letters, digits, - and _");
232 }
233 self.write(by, Self::rec("group", vec![("name", name.into())]))
234 }
235
236 pub fn add_member(&mut self, by: &Actor, group: &str, person: &str) -> Result<()> {
237 self.write(by, Self::rec("group.add", vec![("group", group.into()), ("person", person.into())]))
238 }
239
240 /// Record a point directly on main: a project's first snapshot, or imported history.
241 pub fn record_point(&mut self, by: &Actor, owner: &str, name: &str, snapshot: &Id, message: &str, author: Option<&Actor>) -> Result<u64> {
242 self.require_tree(snapshot)?;
243 let n = self.project(owner, name)?.tip() + 1;
244 let mut fields = vec![
245 ("owner", owner.into()),
246 ("project", name.into()),
247 ("n", n.into()),
248 ("snapshot", id_to_json(snapshot)),
249 ("message", message.into()),
250 ];
251 if let Some(a) = author {
252 fields.push(("author", a.to_json()));
253 }
254 self.write(by, Self::rec("point", fields))?;
255 Ok(n)
256 }
257
258 // ── Changes ────────────────────────────────────────────
259
260 /// Open a change as an idea: an intent, no code yet. Its first revision makes it a draft.
261 pub fn open_change(&mut self, by: &Actor, owner: &str, name: &str, intent: &str) -> Result<u64> {
262 let number = self.project(owner, name)?.changes.len() as u64 + 1;
263 self.write(by, Self::rec("change", vec![("owner", owner.into()), ("project", name.into()), ("number", number.into()), ("intent", intent.into()), ("state", "idea".into())]))?;
264 Ok(number)
265 }
266
267 /// Add a revision: the whole stack of steps, built on main point `base`.
268 pub fn revise(&mut self, by: &Actor, owner: &str, name: &str, change: u64, base: u64, steps: &[Step], note: Option<&str>) -> Result<u32> {
269 for s in steps {
270 self.require_tree(&s.snapshot)?;
271 }
272 let p = self.project(owner, name)?;
273 let c = p.change(change).ok_or_else(|| Error::Refused(format!("no change #{change}")))?;
274 if by.person != c.proposer.person && !self.state.owns(&p.owner, &by.person) {
275 return refused("only the proposer or the owner can revise a change");
276 }
277 let number = c.revisions.len() as u32 + 1;
278 let steps: Vec<Value> = steps.iter().map(step_to_json).collect();
279 self.write(
280 by,
281 Self::rec("revision", vec![
282 ("owner", owner.into()),
283 ("project", name.into()),
284 ("change", change.into()),
285 ("number", (number as u64).into()),
286 ("base", base.into()),
287 ("steps", steps.into()),
288 ("note", note.map(str::to_string).into()),
289 ]),
290 )?;
291 Ok(number)
292 }
293
294 pub fn set_state(&mut self, by: &Actor, owner: &str, name: &str, change: u64, to: ChangeState) -> Result<()> {
295 if to == ChangeState::Landed {
296 return refused("changes land through land()");
297 }
298 self.write(by, Self::rec("state", vec![("owner", owner.into()), ("project", name.into()), ("change", change.into()), ("state", to.name().into())]))
299 }
300
301 /// Land a change's latest revision. Its steps become the next points on main, all at once.
302 pub fn land(&mut self, by: &Actor, owner: &str, name: &str, change: u64) -> Result<(u64, u64)> {
303 let p = self.project(owner, name)?;
304 if !self.state.owns(&p.owner, &by.person) || by.agent.is_some() {
305 return refused("only the owner, in person, lands changes");
306 }
307 let c = p.change(change).ok_or_else(|| Error::Refused(format!("no change #{change}")))?;
308 let rev = c.latest().ok_or_else(|| Error::Refused(format!("#{change} has no revisions")))?;
309 let tip = p.tip();
310 // No merging: a change lands only on the point it was built on.
311 if rev.base != tip {
312 return Err(Error::Behind { base: rev.base, tip });
313 }
314 let snapshots: Vec<Id> = rev.steps.iter().map(|s| s.snapshot).collect();
315 let first = tip + 1;
316 let count = snapshots.len() as u64;
317 let snapshots: Vec<Value> = snapshots.iter().map(id_to_json).collect();
318 let revision = rev.number as u64;
319 self.write(
320 by,
321 Self::rec("land", vec![
322 ("owner", owner.into()),
323 ("project", name.into()),
324 ("change", change.into()),
325 ("revision", revision.into()),
326 ("first", first.into()),
327 ("snapshots", snapshots.into()),
328 ]),
329 )?;
330 Ok((first, first + count - 1))
331 }
332
333 pub fn say(&mut self, by: &Actor, owner: &str, name: &str, change: u64, body: &str) -> Result<()> {
334 self.write(by, Self::rec("say", vec![("owner", owner.into()), ("project", name.into()), ("change", change.into()), ("body", body.into())]))
335 }
336
337 /// Attach evidence to a revision: stored bytes of a media type, or a URL.
338 pub fn attach(&mut self, by: &Actor, owner: &str, name: &str, change: u64, revision: u32, title: &str, media_type: &str, content: Content) -> Result<()> {
339 let (blob, url) = match content {
340 Content::Bytes(bytes) => (Value::from(crate::hex::encode(&self.objects.put_blob(bytes)?)), Value::Null),
341 Content::Blob(id) => {
342 self.objects.get_blob(&id)?;
343 (Value::from(crate::hex::encode(&id)), Value::Null)
344 }
345 Content::Url(url) => (Value::Null, Value::from(url)),
346 };
347 self.write(
348 by,
349 Self::rec("evidence", vec![
350 ("owner", owner.into()),
351 ("project", name.into()),
352 ("change", change.into()),
353 ("revision", (revision as u64).into()),
354 ("title", title.into()),
355 ("type", media_type.into()),
356 ("blob", blob),
357 ("url", url),
358 ]),
359 )
360 }
361
362 /// Check that every object the state refers to exists and matches its id.
363 pub fn verify(&self) -> Result<usize> {
364 let mut count = 0;
365 let mut seen = std::collections::HashSet::new();
366 let mut check_tree = |id: &Id, count: &mut usize| -> Result<()> {
367 if seen.insert(*id) {
368 *count += self.objects.verify_tree(id)?;
369 }
370 Ok(())
371 };
372 for p in &self.state.projects {
373 for pt in &p.points {
374 check_tree(&pt.snapshot, &mut count)?;
375 }
376 for c in &p.changes {
377 for r in &c.revisions {
378 for s in &r.steps {
379 check_tree(&s.snapshot, &mut count)?;
380 }
381 }
382 for e in &c.evidence {
383 if let Some(b) = &e.blob {
384 self.objects.get_blob(b)?;
385 count += 1;
386 }
387 }
388 }
389 }
390 Ok(count)
391 }
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use crate::objects::{Entry, Kind};
398 use crate::testdir::TestDir;
399
400 fn tree(store: &Store, files: &[(&str, &str)]) -> Id {
401 let entries: Vec<Entry> = files
402 .iter()
403 .map(|(n, c)| Entry { name: n.to_string(), kind: Kind::File, id: store.objects.put_blob(c.as_bytes()).unwrap() })
404 .collect();
405 store.objects.put_tree(&entries).unwrap()
406 }
407
408 fn setup(dir: &TestDir) -> (Store, Actor, String) {
409 let mut store = Store::open(dir.path()).unwrap();
410 store.add_person("anton", "Anton").unwrap();
411 let agent = store.add_agent("anton", "claude").unwrap();
412 let me = Actor::agent("anton", &agent);
413 store.create_project(&Actor::person("anton"), "anton", "grove", true).unwrap();
414 let t = tree(&store, &[("README", "hello")]);
415 store.record_point(&Actor::person("anton"), "anton", "grove", &t, "Start", None).unwrap();
416 (store, me, agent)
417 }
418
419 #[test]
420 fn change_lifecycle_and_replay() {
421 let dir = TestDir::new("store-lifecycle");
422 {
423 let (mut store, me, _) = setup(&dir);
424 let owner = Actor::person("anton");
425 let c = store.open_change(&me, "anton", "grove", "Say more in the README").unwrap();
426 assert_eq!(store.project("anton", "grove").unwrap().change(c).unwrap().state, ChangeState::Idea);
427
428 let t1 = tree(&store, &[("README", "hello there")]);
429 let t2 = tree(&store, &[("README", "hello there"), ("NOTES", "x")]);
430 let steps = vec![
431 Step { id: "s1".into(), snapshot: t1, message: "Longer greeting".into() },
432 Step { id: "s2".into(), snapshot: t2, message: "Add notes".into() },
433 ];
434 assert_eq!(store.revise(&me, "anton", "grove", c, 1, &steps, None).unwrap(), 1);
435 store.set_state(&me, "anton", "grove", c, ChangeState::Proposed).unwrap();
436 store.say(&owner, "anton", "grove", c, "Looks good").unwrap();
437 store.attach(&me, "anton", "grove", c, 1, "notes", "text/plain", Content::Bytes(b"checked by hand")).unwrap();
438
439 // Only the owner lands.
440 assert!(matches!(store.land(&me, "anton", "grove", c), Err(Error::Refused(_))));
441 assert_eq!(store.land(&owner, "anton", "grove", c).unwrap(), (2, 3));
442 let p = store.project("anton", "grove").unwrap();
443 assert_eq!(p.tip(), 3);
444 assert_eq!(p.point(3).unwrap().snapshot, t2);
445 assert_eq!(p.change(c).unwrap().state, ChangeState::Landed);
446 assert_eq!(store.verify().unwrap() > 0, true);
447 }
448 // Everything comes back from the log.
449 let store = Store::open(dir.path()).unwrap();
450 let p = store.project("anton", "grove").unwrap();
451 assert_eq!(p.tip(), 3);
452 let c = p.change(1).unwrap();
453 assert_eq!(c.state, ChangeState::Landed);
454 assert_eq!(c.messages[0].body, "Looks good");
455 assert_eq!(c.evidence.len(), 1);
456 assert_eq!(p.point(2).unwrap().message, "Longer greeting");
457 assert!(p.point(2).unwrap().author.agent.is_some());
458 }
459
460 #[test]
461 fn landing_behind_the_tip_is_refused() {
462 let dir = TestDir::new("store-behind");
463 let (mut store, me, _) = setup(&dir);
464 let owner = Actor::person("anton");
465 let t = tree(&store, &[("README", "a")]);
466 let step = |id: &str| vec![Step { id: id.into(), snapshot: t, message: "m".into() }];
467 let a = store.open_change(&me, "anton", "grove", "a").unwrap();
468 let b = store.open_change(&me, "anton", "grove", "b").unwrap();
469 store.revise(&me, "anton", "grove", a, 1, &step("x"), None).unwrap();
470 store.revise(&me, "anton", "grove", b, 1, &step("y"), None).unwrap();
471 store.set_state(&me, "anton", "grove", a, ChangeState::Proposed).unwrap();
472 store.set_state(&me, "anton", "grove", b, ChangeState::Proposed).unwrap();
473 store.land(&owner, "anton", "grove", a).unwrap();
474 assert!(matches!(store.land(&owner, "anton", "grove", b), Err(Error::Behind { base: 1, tip: 2 })));
475 assert_eq!(store.project("anton", "grove").unwrap().change(b).unwrap().state, ChangeState::Proposed);
476 // A new revision built on the tip lands.
477 let t2 = tree(&store, &[("README", "rebuilt")]);
478 store.revise(&me, "anton", "grove", b, 2, &[Step { id: "y".into(), snapshot: t2, message: "m".into() }], None).unwrap();
479 assert_eq!(store.land(&owner, "anton", "grove", b).unwrap(), (3, 3));
480 }
481
482 #[test]
483 fn refusals_leave_no_trace() {
484 let dir = TestDir::new("store-refusals");
485 let (mut store, me, agent) = setup(&dir);
486 let before = store.state().records;
487 // Unstored snapshot, unknown project, bad transition, revoked agent.
488 let ghost = crate::objects::blob_id(b"not stored");
489 assert!(store.record_point(&Actor::person("anton"), "anton", "grove", &ghost, "x", None).is_err());
490 assert!(store.open_change(&me, "anton", "nope", "x").is_err());
491 let c = store.open_change(&me, "anton", "grove", "idea").unwrap();
492 assert!(store.set_state(&me, "anton", "grove", c, ChangeState::Proposed).is_err(), "an idea can't be proposed");
493 store.revoke_agent("anton", &agent).unwrap();
494 assert!(store.say(&me, "anton", "grove", c, "still here?").is_err());
495 assert_eq!(store.state().records, before + 2, "only the change and the revocation were written");
496 drop(store);
497 assert_eq!(Store::open(dir.path()).unwrap().state().records, before + 2);
498 }
499
500 #[test]
501 fn groups_own_projects() {
502 let dir = TestDir::new("store-groups");
503 let (mut store, me, _) = setup(&dir);
504 let anton = Actor::person("anton");
505 store.add_person("bea", "Bea").unwrap();
506 let bea = Actor::person("bea");
507 assert!(store.create_group(&me, "letterpress").is_err(), "groups are created in person");
508 store.create_group(&anton, "letterpress").unwrap();
509 assert!(store.create_group(&anton, "bea").is_err(), "names are shared with people");
510 // Move grove to the group; its history goes with it.
511 assert!(store.move_project(&bea, "anton", "grove", "letterpress").is_err());
512 store.move_project(&anton, "anton", "grove", "letterpress").unwrap();
513 assert!(store.project("anton", "grove").is_err());
514 assert_eq!(store.project("letterpress", "grove").unwrap().tip(), 1);
515 // Members decide for the group; others don't. Bea needs an invite to take part at all.
516 let t = tree(&store, &[("README", "group")]);
517 assert!(store.open_change(&bea, "letterpress", "grove", "from bea").is_err());
518 let code = store.create_invite(&anton, Some(("letterpress", "grove"))).unwrap();
519 store.use_invite("bea", &code).unwrap();
520 let c = store.open_change(&bea, "letterpress", "grove", "from bea").unwrap();
521 store.revise(&bea, "letterpress", "grove", c, 1, &[Step { id: "s".into(), snapshot: t, message: "m".into() }], None).unwrap();
522 store.set_state(&bea, "letterpress", "grove", c, ChangeState::Proposed).unwrap();
523 assert!(store.land(&bea, "letterpress", "grove", c).is_err());
524 store.add_member(&anton, "letterpress", "bea").unwrap();
525 store.land(&bea, "letterpress", "grove", c).unwrap();
526 drop(store);
527 let store = Store::open(dir.path()).unwrap();
528 assert_eq!(store.project("letterpress", "grove").unwrap().tip(), 2);
529 assert_eq!(store.state().group("letterpress").unwrap().members, ["anton", "bea"]);
530 }
531
532 #[test]
533 fn strangers_and_private_projects() {
534 let dir = TestDir::new("store-strangers");
535 let (mut store, _, _) = setup(&dir);
536 store.add_person("visitor", "Visitor").unwrap();
537 let visitor = Actor::person("visitor");
538 // Without an invite, a stranger can't even open a change.
539 assert!(store.open_change(&visitor, "anton", "grove", "uninvited").is_err());
540 // Agents can't send invites; the owner, in person, can.
541 let agent = Actor::agent("anton", &store.state().agents[0].id.clone());
542 assert!(store.create_invite(&agent, Some(("anton", "grove"))).is_err());
543 let code = store.create_invite(&Actor::person("anton"), Some(("anton", "grove"))).unwrap();
544 assert_eq!(store.use_invite("visitor", &code).unwrap(), Some(("anton".into(), "grove".into())));
545 assert!(store.use_invite("visitor", &code).is_err(), "an invite works once");
546 // An invited contributor can propose, but not land.
547 let c = store.open_change(&visitor, "anton", "grove", "An idea from outside").unwrap();
548 let t = tree(&store, &[("README", "outside")]);
549 store.revise(&visitor, "anton", "grove", c, 1, &[Step { id: "s".into(), snapshot: t, message: "m".into() }], None).unwrap();
550 store.set_state(&visitor, "anton", "grove", c, ChangeState::Proposed).unwrap();
551 assert!(store.land(&visitor, "anton", "grove", c).is_err());
552 store.set_state(&Actor::person("anton"), "anton", "grove", c, ChangeState::Declined).unwrap();
553 // Private projects take changes only from the owner.
554 store.create_project(&Actor::person("anton"), "anton", "secret", false).unwrap();
555 assert!(store.open_change(&visitor, "anton", "secret", "let me in").is_err());
556 }
557}