Skip to main content

ljos_cli/
sync.rs

1//! Sharing the seat's memory across machines through the tracker repository
2//! that already travels between them.
3//!
4//! Each tracker repository names one scope in `.ljos/sync.toml`: the scope's
5//! name and the age recipients allowed to read it. Every machine writes only
6//! its own log, `.ljos/atoms/<host>.jsonl.age`, sealed to those recipients,
7//! so two machines never touch one file and git never has to merge a log.
8//! A log carries the machine's live atoms of that scope without their
9//! vectors; the receiving pack encodes them again. An atom's scope is its
10//! `scope:NAME` entity, else the machine's default scope.
11//!
12//! Import posts every atom of every other machine's log to this seat's pack,
13//! which answers a text it already holds with the claim it has, so an import
14//! twice is an import once. Retirement travels by difference: the texts an
15//! import took from a host are remembered, and a text that host no longer
16//! sends retires the local copy that came from it, never a claim this
17//! machine wrote itself.
18
19use std::collections::{BTreeMap, BTreeSet};
20use std::path::{Path, PathBuf};
21use std::process::{Command, Stdio};
22
23use anyhow::{bail, Context, Result};
24use serde_json::Value;
25
26/// The scope a tracker repository shares: `.ljos/sync.toml`.
27#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
28pub struct Scope {
29    /// The scope's name; an atom carrying `scope:NAME` belongs to it.
30    pub name: String,
31    /// The age public keys of the machines allowed to read this scope.
32    pub recipients: Vec<String>,
33    /// Projects of this repository whose lessons belong to another scope:
34    /// `[projects] tools = "shared"` sends a lesson written on a `tools`
35    /// issue with the `shared` scope's log instead of this one.
36    #[serde(default)]
37    pub projects: BTreeMap<String, String>,
38}
39
40impl Scope {
41    /// The scope a lesson on an issue of `project` belongs to.
42    #[must_use]
43    pub fn for_project(&self, project: &str) -> String {
44        self.projects
45            .get(project)
46            .cloned()
47            .unwrap_or_else(|| self.name.clone())
48    }
49}
50
51/// This machine's defaults: `~/.config/ljos/sync.toml`, `default_scope`.
52#[derive(Debug, Clone, Default, serde::Deserialize)]
53struct Local {
54    #[serde(default)]
55    default_scope: Option<String>,
56    /// The tracker repositories this machine shares, when more than the
57    /// tracker root: a machine can hold one scope's repository beside
58    /// another's.
59    #[serde(default)]
60    repos: Vec<String>,
61}
62
63fn local() -> Local {
64    std::fs::read_to_string(config_dir().join("sync.toml"))
65        .ok()
66        .and_then(|t| toml::from_str::<Local>(&t).ok())
67        .unwrap_or_default()
68}
69
70fn expand(path: &str) -> PathBuf {
71    match path.strip_prefix("~/") {
72        Some(rest) => std::env::var_os("HOME")
73            .map_or_else(|| PathBuf::from(path), |h| PathBuf::from(h).join(rest)),
74        None => PathBuf::from(path),
75    }
76}
77
78/// The entity an imported atom carries: which machine sent it.
79#[must_use]
80pub fn sender_entity(host: &str) -> String {
81    format!("sync:{host}")
82}
83
84/// This machine's short host name.
85#[must_use]
86pub fn host() -> String {
87    std::fs::read_to_string("/proc/sys/kernel/hostname")
88        .map(|h| h.trim().to_string())
89        .ok()
90        .filter(|h| !h.is_empty())
91        .unwrap_or_else(|| "host".into())
92}
93
94fn config_dir() -> PathBuf {
95    std::env::var_os("XDG_CONFIG_HOME")
96        .map(PathBuf::from)
97        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
98        .unwrap_or_else(|| PathBuf::from(".config"))
99        .join("ljos")
100}
101
102fn state_dir() -> PathBuf {
103    std::env::var_os("XDG_STATE_HOME")
104        .map(PathBuf::from)
105        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
106        .unwrap_or_else(|| PathBuf::from(".local/state"))
107        .join("ljos")
108        .join("sync")
109}
110
111/// This machine's age identity, made on first use.
112#[must_use]
113pub fn identity_path() -> PathBuf {
114    config_dir().join("age.key")
115}
116
117/// The public key of this machine's identity, making the identity first when
118/// there is none. This is what goes into a scope's `recipients`.
119///
120/// # Errors
121///
122/// `age-keygen` missing or refusing.
123pub fn public_key() -> Result<String> {
124    let key = identity_path();
125    if !key.exists() {
126        if let Some(dir) = key.parent() {
127            std::fs::create_dir_all(dir)?;
128        }
129        let made = Command::new("age-keygen")
130            .arg("-o")
131            .arg(&key)
132            .stdin(Stdio::null())
133            .output()
134            .context("age-keygen not on PATH; install age")?;
135        if !made.status.success() {
136            bail!(
137                "age-keygen: {}",
138                String::from_utf8_lossy(&made.stderr).trim()
139            );
140        }
141    }
142    let out = Command::new("age-keygen")
143        .arg("-y")
144        .arg(&key)
145        .output()
146        .context("age-keygen not on PATH; install age")?;
147    if !out.status.success() {
148        bail!(
149            "age-keygen -y: {}",
150            String::from_utf8_lossy(&out.stderr).trim()
151        );
152    }
153    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
154}
155
156/// The scope a tracker repository declares, when it declares one.
157///
158/// # Errors
159///
160/// A `.ljos/sync.toml` that is present and does not parse.
161pub fn scope_of_repo(root: &Path) -> Result<Option<Scope>> {
162    let path = root.join(".ljos").join("sync.toml");
163    match std::fs::read_to_string(&path) {
164        Ok(text) => Ok(Some(
165            toml::from_str(&text).with_context(|| path.display().to_string())?,
166        )),
167        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
168        Err(e) => Err(e).with_context(|| path.display().to_string()),
169    }
170}
171
172fn default_scope(repo_scope: &str) -> String {
173    local()
174        .default_scope
175        .unwrap_or_else(|| repo_scope.to_string())
176}
177
178/// The scope an atom belongs to: its `scope:NAME` entity, else `default`.
179#[must_use]
180pub fn atom_scope(atom: &Value, default: &str) -> String {
181    atom["entities"]
182        .as_array()
183        .into_iter()
184        .flatten()
185        .filter_map(Value::as_str)
186        .find_map(|e| e.strip_prefix("scope:"))
187        .map_or_else(|| default.to_string(), str::to_string)
188}
189
190/// An atom as it travels: without its vector, and without a sender tag of
191/// another machine, since a host sends only what it holds as its own.
192#[must_use]
193pub fn travelling(atom: &Value) -> Value {
194    let mut a = atom.clone();
195    if let Some(map) = a.as_object_mut() {
196        map.remove("embedding");
197    }
198    a
199}
200
201/// Whether this machine holds an atom as its own rather than as an import.
202#[must_use]
203pub fn is_own(atom: &Value) -> bool {
204    !atom["entities"]
205        .as_array()
206        .into_iter()
207        .flatten()
208        .filter_map(Value::as_str)
209        .any(|e| e.starts_with("sync:"))
210}
211
212/// The plaintext of this machine's log for a scope: its own live atoms of
213/// that scope, sorted by id, one JSON object a line.
214#[must_use]
215pub fn log_plaintext(atoms: &[Value], scope: &str, default: &str) -> String {
216    let mut mine: Vec<Value> = atoms
217        .iter()
218        .filter(|a| is_own(a) && atom_scope(a, default) == scope)
219        .map(travelling)
220        .collect();
221    mine.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str()));
222    mine.iter().map(|a| format!("{a}\n")).collect()
223}
224
225fn log_dir(root: &Path) -> PathBuf {
226    root.join(".ljos").join("atoms")
227}
228
229/// Seal `plain` to the recipients into `out`.
230fn seal(plain: &str, recipients: &[String], out: &Path) -> Result<()> {
231    if recipients.is_empty() {
232        bail!("sync: the scope names no recipients; add this machine's `ljos sync --key` to .ljos/sync.toml");
233    }
234    let mut cmd = Command::new("age");
235    for r in recipients {
236        cmd.arg("-r").arg(r);
237    }
238    let tmp = out.with_extension("age.tmp");
239    let mut child = cmd
240        .arg("-o")
241        .arg(&tmp)
242        .stdin(Stdio::piped())
243        .stderr(Stdio::piped())
244        .spawn()
245        .context("age not on PATH; install age")?;
246    use std::io::Write;
247    child
248        .stdin
249        .take()
250        .context("age: no stdin")?
251        .write_all(plain.as_bytes())?;
252    let done = child.wait_with_output()?;
253    if !done.status.success() {
254        let _ = std::fs::remove_file(&tmp);
255        bail!("age: {}", String::from_utf8_lossy(&done.stderr).trim());
256    }
257    std::fs::rename(&tmp, out)?;
258    Ok(())
259}
260
261/// Open a sealed log with this machine's identity; none when this machine
262/// is not one of its recipients.
263fn open(path: &Path, identity: &Path) -> Result<Option<String>> {
264    let out = Command::new("age")
265        .arg("-d")
266        .arg("-i")
267        .arg(identity)
268        .arg(path)
269        .stdin(Stdio::null())
270        .output()
271        .context("age not on PATH; install age")?;
272    if out.status.success() {
273        Ok(Some(String::from_utf8_lossy(&out.stdout).into_owned()))
274    } else if String::from_utf8_lossy(&out.stderr).contains("no identity matched") {
275        Ok(None)
276    } else {
277        bail!(
278            "age -d {}: {}",
279            path.display(),
280            String::from_utf8_lossy(&out.stderr).trim()
281        )
282    }
283}
284
285/// Write this machine's log for the repository's scope when its atoms
286/// changed since the last write. Returns the line that says what happened.
287///
288/// # Errors
289///
290/// The pack not answering, or age refusing to seal.
291pub fn export(root: &Path, scope: &Scope, atoms: &[Value]) -> Result<String> {
292    let host = host();
293    let plain = log_plaintext(atoms, &scope.name, &default_scope(&scope.name));
294    let count = plain.lines().count();
295    let dir = log_dir(root);
296    std::fs::create_dir_all(&dir)?;
297    let stamp = dir.join(format!("{host}.digest"));
298    let digest = format!(
299        "{} {}\n",
300        crate::work_id(&plain),
301        crate::work_id(&scope.recipients.join(","))
302    );
303    if std::fs::read_to_string(&stamp).is_ok_and(|d| d == digest) {
304        return Ok(format!(
305            "sync: {count} {} atoms unchanged since the last export\n",
306            scope.name
307        ));
308    }
309    seal(
310        &plain,
311        &scope.recipients,
312        &dir.join(format!("{host}.jsonl.age")),
313    )?;
314    std::fs::write(&stamp, digest)?;
315    Ok(format!(
316        "sync: exported {count} {} atoms to .ljos/atoms/{host}.jsonl.age, sealed to {} recipient{}\n",
317        scope.name,
318        scope.recipients.len(),
319        if scope.recipients.len() == 1 { "" } else { "s" }
320    ))
321}
322
323/// What one host's log asks of this pack: the atoms to post, and the texts
324/// it sent last time and sends no longer.
325#[derive(Debug, Default, PartialEq)]
326pub struct Incoming {
327    pub post: Vec<Value>,
328    pub retired: Vec<String>,
329}
330
331/// Read one host's log against the texts taken from it last time
332/// (`before`, for what it retired) and the texts from it this pack
333/// already holds (`held`, which are not posted again).
334#[must_use]
335pub fn incoming(
336    plain: &str,
337    host: &str,
338    before: &BTreeSet<String>,
339    held: &BTreeSet<String>,
340) -> Incoming {
341    let mut post = Vec::new();
342    let mut now = BTreeSet::new();
343    for line in plain.lines().filter(|l| !l.trim().is_empty()) {
344        let Ok(mut atom) = serde_json::from_str::<Value>(line) else {
345            continue;
346        };
347        let text = atom["text"].as_str().unwrap_or("").to_string();
348        if text.is_empty() {
349            continue;
350        }
351        let id = crate::work_id(&text);
352        now.insert(id.clone());
353        if held.contains(&id) {
354            continue;
355        }
356        if let Some(map) = atom.as_object_mut() {
357            let mut entities: Vec<Value> = map
358                .get("entities")
359                .and_then(Value::as_array)
360                .cloned()
361                .unwrap_or_default();
362            let tag = sender_entity(host);
363            if !entities.iter().any(|e| e.as_str() == Some(tag.as_str())) {
364                entities.push(Value::String(tag));
365            }
366            map.insert("entities".into(), Value::Array(entities));
367            map.insert(
368                "origin".into(),
369                Value::String(crate::admit::ORIGIN_PEER.to_string()),
370            );
371            map.remove("id");
372        }
373        post.push(atom);
374    }
375    let retired = before.difference(&now).cloned().collect();
376    Incoming { post, retired }
377}
378
379fn taken_path(scope: &str, host: &str) -> PathBuf {
380    state_dir().join(scope).join(format!("{host}.taken"))
381}
382
383fn read_taken(scope: &str, host: &str) -> BTreeSet<String> {
384    std::fs::read_to_string(taken_path(scope, host))
385        .map(|t| t.lines().map(str::to_string).collect())
386        .unwrap_or_default()
387}
388
389fn write_taken(scope: &str, host: &str, plain: &str) -> Result<()> {
390    let path = taken_path(scope, host);
391    if let Some(dir) = path.parent() {
392        std::fs::create_dir_all(dir)?;
393    }
394    let ids: BTreeSet<String> = plain
395        .lines()
396        .filter_map(|l| serde_json::from_str::<Value>(l).ok())
397        .filter_map(|a| a["text"].as_str().map(crate::work_id))
398        .collect();
399    std::fs::write(path, ids.into_iter().map(|i| i + "\n").collect::<String>())?;
400    Ok(())
401}
402
403/// Import every other machine's log of the repository's scope into this
404/// seat's pack. Returns one line per host.
405///
406/// # Errors
407///
408/// The pack not answering, or a log this machine should open failing to.
409pub fn import(root: &Path, scope: &Scope) -> Result<String> {
410    let me = host();
411    let identity = identity_path();
412    if !identity.exists() {
413        return Ok("sync: no age identity on this machine; `ljos sync --key` makes one\n".into());
414    }
415    let dir = log_dir(root);
416    let Ok(entries) = std::fs::read_dir(&dir) else {
417        return Ok(format!("sync: no {} logs yet\n", scope.name));
418    };
419    let client = crate::pack()?;
420    let mut out = String::new();
421    let mut paths: Vec<PathBuf> = entries
422        .flatten()
423        .map(|e| e.path())
424        .filter(|p| p.to_string_lossy().ends_with(".jsonl.age"))
425        .collect();
426    paths.sort();
427    for path in paths {
428        let host = path
429            .file_name()
430            .and_then(|n| n.to_str())
431            .and_then(|n| n.strip_suffix(".jsonl.age"))
432            .unwrap_or("")
433            .to_string();
434        if host.is_empty() || host == me {
435            continue;
436        }
437        let Some(plain) = open(&path, &identity)? else {
438            out.push_str(&format!(
439                "sync: {host}'s {} log is not sealed to this machine\n",
440                scope.name
441            ));
442            continue;
443        };
444        let workspace = client.workspace();
445        // The pack names a posted atom afresh, so a text already taken
446        // from this host would be held twice; what is held is skipped.
447        let tag = sender_entity(&host);
448        let held: BTreeSet<String> = crate::atoms_lean(&client, &workspace)?
449            .iter()
450            .filter(|a| {
451                a["entities"]
452                    .as_array()
453                    .into_iter()
454                    .flatten()
455                    .any(|e| e.as_str() == Some(tag.as_str()))
456            })
457            .filter_map(|a| a["text"].as_str().map(crate::work_id))
458            .collect();
459        let got = incoming(&plain, &host, &read_taken(&scope.name, &host), &held);
460        let (mut kept, mut refused) = (0usize, 0usize);
461        for mut atom in got.post {
462            if let Some(map) = atom.as_object_mut() {
463                map.insert("workspace".into(), Value::String(workspace.clone()));
464            }
465            match crate::admit::post_kept(&client, &atom) {
466                Ok(_) => kept += 1,
467                Err(_) => refused += 1,
468            }
469        }
470        let mut retired = 0usize;
471        if !got.retired.is_empty() {
472            for atom in crate::atoms_lean(&client, &workspace)? {
473                let text = atom["text"].as_str().unwrap_or("");
474                let from_host = atom["entities"]
475                    .as_array()
476                    .into_iter()
477                    .flatten()
478                    .any(|e| e.as_str() == Some(tag.as_str()));
479                if from_host && got.retired.contains(&crate::work_id(text)) {
480                    if let Some(id) = atom["id"].as_str() {
481                        if crate::packset_forget(id, None).is_ok() {
482                            retired += 1;
483                        }
484                    }
485                }
486            }
487        }
488        write_taken(&scope.name, &host, &plain)?;
489        out.push_str(&format!(
490            "sync: {host}: {kept} {} atoms taken, {refused} refused, {retired} retired\n",
491            scope.name
492        ));
493    }
494    Ok(out)
495}
496
497/// The scope of the repository that holds an issue, when it declares one.
498#[must_use]
499pub fn scope_for_issue(issue: &str) -> Option<String> {
500    let hit = vissue_core::Layout::resolve(None, None)
501        .and_then(vissue_core::Router::load)
502        .and_then(|router| router.find_by_id(issue))
503        .ok()?;
504    let root = hit
505        .path
506        .ancestors()
507        .find(|d| d.join(".git").exists())?
508        .to_path_buf();
509    scope_of_repo(&root)
510        .ok()
511        .flatten()
512        .map(|s| s.for_project(&hit.project))
513}
514
515/// The tracker repository this seat writes, its root directory.
516fn tracker_root() -> Result<PathBuf> {
517    let layout = vissue_core::Layout::resolve(None, None).map_err(anyhow::Error::from)?;
518    Ok(layout.root().to_path_buf())
519}
520
521fn git(root: &Path, args: &[&str]) -> std::io::Result<std::process::Output> {
522    Command::new("git")
523        .arg("-C")
524        .arg(root)
525        .args(args)
526        .stdin(Stdio::null())
527        .output()
528}
529
530/// Commit this host's log alone and push it to the upstream and every other
531/// remote that carries the branch.
532fn commit_log(root: &Path, scope: &str) -> String {
533    let host = host();
534    let files = [
535        format!(".ljos/atoms/{host}.jsonl.age"),
536        format!(".ljos/atoms/{host}.digest"),
537    ];
538    // The same lock the tracker commits take: one checkout, many seats.
539    let common = git(root, &["rev-parse", "--git-common-dir"])
540        .ok()
541        .filter(|o| o.status.success())
542        .map(|o| root.join(String::from_utf8_lossy(&o.stdout).trim()))
543        .unwrap_or_else(|| root.join(".git"));
544    let held = crate::CommitLock::acquire(&common.join("ljos-commit.lock"));
545    let mut add = vec!["add", "--"];
546    add.extend(files.iter().map(String::as_str));
547    if git(root, &add).map_or(true, |o| !o.status.success()) {
548        return "sync: could not stage the log\n".into();
549    }
550    let staged = git(
551        root,
552        &["diff", "--cached", "--quiet", "--", &files[0], &files[1]],
553    );
554    if staged.is_ok_and(|o| o.status.success()) {
555        return String::new();
556    }
557    let message = format!("chore(sync): {host} {scope} atoms");
558    let mut commit = vec!["commit", "-q", "--only", "-m", message.as_str(), "--"];
559    commit.extend(files.iter().map(String::as_str));
560    match git(root, &commit) {
561        Ok(o) if o.status.success() => {}
562        Ok(o) => {
563            return format!(
564                "sync: commit refused: {}\n",
565                String::from_utf8_lossy(&o.stderr)
566                    .lines()
567                    .next()
568                    .unwrap_or("")
569            )
570        }
571        Err(e) => return format!("sync: git: {e}\n"),
572    }
573    drop(held);
574    let mut pushed = vec![];
575    // A push another host beat is merged and tried once more; left ahead,
576    // the next catch-up could only fast-forward and never would.
577    let pushed_first = git(root, &["push", "-q"]).is_ok_and(|o| o.status.success());
578    let pushed_after_merge = !pushed_first
579        && git(root, &["pull", "-q", "--no-rebase", "--no-edit"]).is_ok_and(|o| o.status.success())
580        && git(root, &["push", "-q"]).is_ok_and(|o| o.status.success());
581    if pushed_first || pushed_after_merge {
582        pushed.push("upstream".to_string());
583    }
584    if let Some(up) = crate::tracker_upstream(root) {
585        for (remote, branch) in crate::tracker_mirrors(root, &up).unwrap_or_default() {
586            let refspec = format!("HEAD:refs/heads/{branch}");
587            if git(root, &["push", "-q", &remote, &refspec]).is_ok_and(|o| o.status.success()) {
588                pushed.push(remote);
589            }
590        }
591    }
592    format!(
593        "sync: committed {message}; pushed to {}\n",
594        if pushed.is_empty() {
595            "nothing".to_string()
596        } else {
597            pushed.join(", ")
598        }
599    )
600}
601
602/// Fetch every remote and fast-forward to each one that is ahead. Machines
603/// that push to different remotes of one tracker then read each other's
604/// logs whenever the history is linear; a real divergence stays for the
605/// doctor's split row and a person.
606fn catch_up(root: &Path) -> String {
607    if git(root, &["fetch", "-q", "--all"]).map_or(true, |o| !o.status.success()) {
608        return "sync: fetch failed; reading the logs as they are\n".into();
609    }
610    let Some(up) = crate::tracker_upstream(root) else {
611        return String::new();
612    };
613    let mut refs = vec![up.clone()];
614    refs.extend(
615        crate::tracker_mirrors(root, &up)
616            .unwrap_or_default()
617            .into_iter()
618            .map(|(r, b)| format!("{r}/{b}")),
619    );
620    let mut stuck = Vec::new();
621    for r in refs {
622        let forward = git(root, &["merge", "-q", "--ff-only", &r]);
623        if forward.is_ok_and(|o| o.status.success()) {
624            continue;
625        }
626        // Diverged: this host committed while another pushed. Merge, as the
627        // tracker's own push does; a merge that stops is undone and named.
628        let merged = git(root, &["merge", "-q", "--no-edit", &r]);
629        if !merged.is_ok_and(|o| o.status.success()) {
630            let _ = git(root, &["merge", "--abort"]);
631            stuck.push(r);
632        }
633    }
634    if stuck.is_empty() {
635        String::new()
636    } else {
637        format!(
638            "sync: could not merge {}; `ljos doctor` names the split\n",
639            stuck.join(", ")
640        )
641    }
642}
643
644/// One pass over the tracker repository's scope: pull, take every other
645/// machine's log, write and push this machine's. `pull_import` and `export`
646/// pick the halves; the sitting takes the first, finish the second.
647///
648/// # Errors
649///
650/// A scope file that does not parse, the pack not answering, or age
651/// refusing to seal.
652pub fn sync_repo(pull_import: bool, export_log: bool) -> Result<String> {
653    let listed = local().repos;
654    let roots: Vec<PathBuf> = if listed.is_empty() {
655        vec![tracker_root()?]
656    } else {
657        listed.iter().map(|r| expand(r)).collect()
658    };
659    let mut out = String::new();
660    for root in roots {
661        out.push_str(&sync_one(&root, pull_import, export_log)?);
662    }
663    Ok(out)
664}
665
666fn sync_one(root: &Path, pull_import: bool, export_log: bool) -> Result<String> {
667    let root = root.to_path_buf();
668    let Some(scope) = scope_of_repo(&root)? else {
669        return Ok(format!(
670            "sync: {} names no scope; write .ljos/sync.toml with name and recipients (`ljos sync --key` prints this machine's)\n",
671            root.display()
672        ));
673    };
674    let mut out = String::new();
675    if pull_import {
676        out.push_str(&catch_up(&root));
677        out.push_str(&import(&root, &scope)?);
678    }
679    if export_log {
680        let client = crate::pack()?;
681        let atoms = crate::atoms_lean(&client, &client.workspace())?;
682        out.push_str(&export(&root, &scope, &atoms)?);
683        out.push_str(&commit_log(&root, &scope.name));
684    }
685    Ok(out)
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691
692    fn atom(id: &str, text: &str, entities: &[&str]) -> Value {
693        serde_json::json!({"id": id, "text": text, "kind": "lesson",
694            "entities": entities, "embedding": [0.1, 0.2]})
695    }
696
697    #[test]
698    fn a_log_carries_own_atoms_of_its_scope_without_vectors() {
699        let atoms = vec![
700            atom("b", "second", &["seat:x"]),
701            atom("a", "first", &[]),
702            atom("c", "personal", &["scope:personal"]),
703            atom("d", "imported", &["sync:otherhost"]),
704        ];
705        let plain = log_plaintext(&atoms, "surf", "surf");
706        let lines: Vec<Value> = plain
707            .lines()
708            .map(|l| serde_json::from_str(l).unwrap())
709            .collect();
710        assert_eq!(lines.len(), 2, "{plain}");
711        assert_eq!(lines[0]["id"], "a");
712        assert_eq!(lines[1]["id"], "b");
713        assert!(lines.iter().all(|a| a.get("embedding").is_none()));
714        assert_eq!(log_plaintext(&atoms, "personal", "surf").lines().count(), 1);
715    }
716
717    #[test]
718    fn a_project_named_in_the_scope_file_takes_its_own_scope() {
719        let scope: Scope = toml::from_str(
720            "name = \"personal\"\nrecipients = [\"age1x\"]\n[projects]\ntools = \"shared\"\n",
721        )
722        .unwrap();
723        assert_eq!(scope.for_project("tools"), "shared");
724        assert_eq!(scope.for_project("garden"), "personal");
725        let bare: Scope = toml::from_str("name = \"shared\"\nrecipients = []\n").unwrap();
726        assert!(bare.projects.is_empty());
727    }
728
729    #[test]
730    fn an_import_tags_the_sender_and_names_what_it_retired() {
731        let plain = format!(
732            "{}\n{}\n",
733            atom("a", "kept", &["seat:x"]),
734            atom("b", "new", &[])
735        );
736        let before: BTreeSet<String> = [crate::work_id("kept"), crate::work_id("dropped")].into();
737        let got = incoming(&plain, "rglat", &before, &BTreeSet::new());
738        assert_eq!(got.post.len(), 2);
739        let again = incoming(&plain, "rglat", &before, &[crate::work_id("kept")].into());
740        assert_eq!(
741            again.post.len(),
742            1,
743            "a text already held is not posted again"
744        );
745        assert_eq!(again.post[0]["text"], "new");
746        assert!(got.post.iter().all(|a| a.get("id").is_none()));
747        assert!(got.post.iter().all(|a| a["entities"]
748            .as_array()
749            .unwrap()
750            .iter()
751            .any(|e| e == "sync:rglat")));
752        assert!(got.post.iter().all(|a| a["origin"] == "peer"));
753        assert_eq!(got.retired, vec![crate::work_id("dropped")]);
754        let claimed = "{\"id\":\"c\",\"text\":\"claimed\",\"kind\":\"lesson\",\"origin\":\"user-declared\",\"entities\":[]}\n";
755        let overwrote = incoming(claimed, "rglat", &BTreeSet::new(), &BTreeSet::new());
756        assert_eq!(overwrote.post[0]["origin"], "peer");
757    }
758
759    #[test]
760    fn a_sealed_log_opens_for_a_recipient_and_not_for_another() {
761        let dir = tempfile::tempdir().unwrap();
762        let key = |name: &str| {
763            let path = dir.path().join(name);
764            let made = Command::new("age-keygen")
765                .arg("-o")
766                .arg(&path)
767                .output()
768                .unwrap();
769            assert!(made.status.success(), "age-keygen must be installed");
770            let public = Command::new("age-keygen")
771                .arg("-y")
772                .arg(&path)
773                .output()
774                .unwrap();
775            (
776                path,
777                String::from_utf8_lossy(&public.stdout).trim().to_string(),
778            )
779        };
780        let (mine, my_public) = key("mine.key");
781        let (other, _) = key("other.key");
782        let out = dir.path().join("host.jsonl.age");
783        seal("{\"text\":\"x\"}\n", &[my_public], &out).unwrap();
784        assert_eq!(
785            open(&out, &mine).unwrap().as_deref(),
786            Some("{\"text\":\"x\"}\n")
787        );
788        assert_eq!(open(&out, &other).unwrap(), None);
789        assert!(seal("x", &[], &out).is_err(), "no recipients is refused");
790    }
791
792    #[test]
793    fn an_atoms_scope_is_its_entity_or_the_default() {
794        assert_eq!(
795            atom_scope(&atom("a", "t", &["scope:personal"]), "surf"),
796            "personal"
797        );
798        assert_eq!(atom_scope(&atom("a", "t", &["seat:x"]), "surf"), "surf");
799    }
800}