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/// One line when `age` is not installed. The seal is skipped; the rest of
286/// the sync still succeeds. The next finish tries again.
287fn age_missing_line() -> String {
288    "sync: age is not installed, so the log was not sealed; install age to sync it\n".into()
289}
290
291fn age_missing(err: &anyhow::Error) -> bool {
292    err.chain().any(|cause| {
293        cause
294            .downcast_ref::<std::io::Error>()
295            .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
296    })
297}
298
299/// Write this machine's log for the repository's scope when its atoms
300/// changed since the last write. Returns the line that says what happened.
301/// A missing `age` skips the seal and is not an error.
302///
303/// # Errors
304///
305/// The pack not answering, or age refusing to seal for a reason other than
306/// being absent.
307pub fn export(root: &Path, scope: &Scope, atoms: &[Value]) -> Result<String> {
308    let host = host();
309    let plain = log_plaintext(atoms, &scope.name, &default_scope(&scope.name));
310    let count = plain.lines().count();
311    let dir = log_dir(root);
312    std::fs::create_dir_all(&dir)?;
313    let stamp = dir.join(format!("{host}.digest"));
314    let digest = format!(
315        "{} {}\n",
316        crate::work_id(&plain),
317        crate::work_id(&scope.recipients.join(","))
318    );
319    if std::fs::read_to_string(&stamp).is_ok_and(|d| d == digest) {
320        return Ok(format!(
321            "sync: {count} {} atoms unchanged since the last export\n",
322            scope.name
323        ));
324    }
325    if !crate::on_path("age") {
326        return Ok(age_missing_line());
327    }
328    if let Err(err) = seal(
329        &plain,
330        &scope.recipients,
331        &dir.join(format!("{host}.jsonl.age")),
332    ) {
333        if age_missing(&err) {
334            return Ok(age_missing_line());
335        }
336        return Err(err);
337    }
338    std::fs::write(&stamp, digest)?;
339    Ok(format!(
340        "sync: exported {count} {} atoms to .ljos/atoms/{host}.jsonl.age, sealed to {} recipient{}\n",
341        scope.name,
342        scope.recipients.len(),
343        if scope.recipients.len() == 1 { "" } else { "s" }
344    ))
345}
346
347/// What one host's log asks of this pack: the atoms to post, and the texts
348/// it sent last time and sends no longer.
349#[derive(Debug, Default, PartialEq)]
350pub struct Incoming {
351    pub post: Vec<Value>,
352    pub retired: Vec<String>,
353}
354
355/// Read one host's log against the texts taken from it last time
356/// (`before`, for what it retired) and the texts from it this pack
357/// already holds (`held`, which are not posted again).
358#[must_use]
359pub fn incoming(
360    plain: &str,
361    host: &str,
362    before: &BTreeSet<String>,
363    held: &BTreeSet<String>,
364) -> Incoming {
365    let mut post = Vec::new();
366    let mut now = BTreeSet::new();
367    for line in plain.lines().filter(|l| !l.trim().is_empty()) {
368        let Ok(mut atom) = serde_json::from_str::<Value>(line) else {
369            continue;
370        };
371        let text = atom["text"].as_str().unwrap_or("").to_string();
372        if text.is_empty() {
373            continue;
374        }
375        let id = crate::work_id(&text);
376        now.insert(id.clone());
377        if held.contains(&id) {
378            continue;
379        }
380        if let Some(map) = atom.as_object_mut() {
381            let mut entities: Vec<Value> = map
382                .get("entities")
383                .and_then(Value::as_array)
384                .cloned()
385                .unwrap_or_default();
386            let tag = sender_entity(host);
387            if !entities.iter().any(|e| e.as_str() == Some(tag.as_str())) {
388                entities.push(Value::String(tag));
389            }
390            map.insert("entities".into(), Value::Array(entities));
391            map.insert(
392                "origin".into(),
393                Value::String(crate::admit::ORIGIN_PEER.to_string()),
394            );
395            map.remove("id");
396        }
397        post.push(atom);
398    }
399    let retired = before.difference(&now).cloned().collect();
400    Incoming { post, retired }
401}
402
403fn taken_path(scope: &str, host: &str) -> PathBuf {
404    state_dir().join(scope).join(format!("{host}.taken"))
405}
406
407fn read_taken(scope: &str, host: &str) -> BTreeSet<String> {
408    std::fs::read_to_string(taken_path(scope, host))
409        .map(|t| t.lines().map(str::to_string).collect())
410        .unwrap_or_default()
411}
412
413fn write_taken(scope: &str, host: &str, plain: &str) -> Result<()> {
414    let path = taken_path(scope, host);
415    if let Some(dir) = path.parent() {
416        std::fs::create_dir_all(dir)?;
417    }
418    let ids: BTreeSet<String> = plain
419        .lines()
420        .filter_map(|l| serde_json::from_str::<Value>(l).ok())
421        .filter_map(|a| a["text"].as_str().map(crate::work_id))
422        .collect();
423    std::fs::write(path, ids.into_iter().map(|i| i + "\n").collect::<String>())?;
424    Ok(())
425}
426
427/// Import every other machine's log of the repository's scope into this
428/// seat's pack. Returns one line per host.
429///
430/// # Errors
431///
432/// The pack not answering, or a log this machine should open failing to.
433pub fn import(root: &Path, scope: &Scope) -> Result<String> {
434    let me = host();
435    let identity = identity_path();
436    if !identity.exists() {
437        return Ok("sync: no age identity on this machine; `ljos sync --key` makes one\n".into());
438    }
439    let dir = log_dir(root);
440    let Ok(entries) = std::fs::read_dir(&dir) else {
441        return Ok(format!("sync: no {} logs yet\n", scope.name));
442    };
443    let client = crate::pack()?;
444    let mut out = String::new();
445    let mut paths: Vec<PathBuf> = entries
446        .flatten()
447        .map(|e| e.path())
448        .filter(|p| p.to_string_lossy().ends_with(".jsonl.age"))
449        .collect();
450    paths.sort();
451    for path in paths {
452        let host = path
453            .file_name()
454            .and_then(|n| n.to_str())
455            .and_then(|n| n.strip_suffix(".jsonl.age"))
456            .unwrap_or("")
457            .to_string();
458        if host.is_empty() || host == me {
459            continue;
460        }
461        let Some(plain) = open(&path, &identity)? else {
462            out.push_str(&format!(
463                "sync: {host}'s {} log is not sealed to this machine\n",
464                scope.name
465            ));
466            continue;
467        };
468        let workspace = client.workspace();
469        // The pack names a posted atom afresh, so a text already taken
470        // from this host would be held twice; what is held is skipped.
471        let tag = sender_entity(&host);
472        let held: BTreeSet<String> = crate::atoms_lean(&client, &workspace)?
473            .iter()
474            .filter(|a| {
475                a["entities"]
476                    .as_array()
477                    .into_iter()
478                    .flatten()
479                    .any(|e| e.as_str() == Some(tag.as_str()))
480            })
481            .filter_map(|a| a["text"].as_str().map(crate::work_id))
482            .collect();
483        let got = incoming(&plain, &host, &read_taken(&scope.name, &host), &held);
484        let (mut kept, mut refused) = (0usize, 0usize);
485        for mut atom in got.post {
486            if let Some(map) = atom.as_object_mut() {
487                map.insert("workspace".into(), Value::String(workspace.clone()));
488            }
489            match crate::admit::post_kept(&client, &atom) {
490                Ok(_) => kept += 1,
491                Err(_) => refused += 1,
492            }
493        }
494        let mut retired = 0usize;
495        if !got.retired.is_empty() {
496            for atom in crate::atoms_lean(&client, &workspace)? {
497                let text = atom["text"].as_str().unwrap_or("");
498                let from_host = atom["entities"]
499                    .as_array()
500                    .into_iter()
501                    .flatten()
502                    .any(|e| e.as_str() == Some(tag.as_str()));
503                if from_host && got.retired.contains(&crate::work_id(text)) {
504                    if let Some(id) = atom["id"].as_str() {
505                        if crate::packset_forget(id, None).is_ok() {
506                            retired += 1;
507                        }
508                    }
509                }
510            }
511        }
512        write_taken(&scope.name, &host, &plain)?;
513        out.push_str(&format!(
514            "sync: {host}: {kept} {} atoms taken, {refused} refused, {retired} retired\n",
515            scope.name
516        ));
517    }
518    Ok(out)
519}
520
521/// The scope of the repository that holds an issue, when it declares one.
522#[must_use]
523pub fn scope_for_issue(issue: &str) -> Option<String> {
524    let hit = vissue_core::Layout::resolve(None, None)
525        .and_then(vissue_core::Router::load)
526        .and_then(|router| router.find_by_id(issue))
527        .ok()?;
528    let root = hit
529        .path
530        .ancestors()
531        .find(|d| d.join(".git").exists())?
532        .to_path_buf();
533    scope_of_repo(&root)
534        .ok()
535        .flatten()
536        .map(|s| s.for_project(&hit.project))
537}
538
539/// The tracker repository this seat writes, its root directory.
540fn tracker_root() -> Result<PathBuf> {
541    let layout = vissue_core::Layout::resolve(None, None).map_err(anyhow::Error::from)?;
542    Ok(layout.root().to_path_buf())
543}
544
545fn git(root: &Path, args: &[&str]) -> std::io::Result<std::process::Output> {
546    Command::new("git")
547        .arg("-C")
548        .arg(root)
549        .args(args)
550        .stdin(Stdio::null())
551        .output()
552}
553
554/// Commit the log after an export. A skipped seal with no file yet has
555/// nothing to stage, and that is not a failure.
556fn commit_if_sealed(root: &Path, scope: &str, exported: &str) -> String {
557    let age = log_dir(root).join(format!("{}.jsonl.age", host()));
558    if exported.contains("age is not installed") && !age.is_file() {
559        return String::new();
560    }
561    commit_log(root, scope)
562}
563
564/// Push commits this checkout has not sent. Atoms that did not change used
565/// to return before this, so a `.gitattributes` commit sat unpushed.
566/// Nothing to send, and a checkout with no remote, stay quiet.
567fn push_pending(root: &Path) -> String {
568    let remotes = git(root, &["remote"])
569        .ok()
570        .filter(|o| o.status.success())
571        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
572        .unwrap_or_default();
573    if remotes.is_empty() || !commits_to_push(root) {
574        return String::new();
575    }
576    match crate::tracker_push::push_checkout(root) {
577        Ok(()) => "sync: pushed pending commits\n".into(),
578        Err(e) => format!("sync: push refused: {e}\n"),
579    }
580}
581
582/// Whether `HEAD` has a commit the upstream does not. No upstream, with a
583/// remote, is a branch that has never been pushed.
584fn commits_to_push(root: &Path) -> bool {
585    let ahead = |spec: &str| -> Option<u64> {
586        let out = git(root, &["rev-list", "--count", spec]).ok()?;
587        if !out.status.success() {
588            return None;
589        }
590        String::from_utf8_lossy(&out.stdout).trim().parse().ok()
591    };
592    if ahead("@{upstream}..HEAD").is_some_and(|n| n > 0) {
593        return true;
594    }
595    if git(root, &["rev-parse", "--abbrev-ref", "@{upstream}"]).is_ok_and(|o| o.status.success()) {
596        return false;
597    }
598    let branch = git(root, &["rev-parse", "--abbrev-ref", "HEAD"])
599        .ok()
600        .filter(|o| o.status.success())
601        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
602        .unwrap_or_default();
603    if branch.is_empty() || branch == "HEAD" {
604        return git(root, &["rev-parse", "--verify", "HEAD"]).is_ok_and(|o| o.status.success());
605    }
606    let remote_ref = format!("origin/{branch}");
607    match ahead(&format!("{remote_ref}..HEAD")) {
608        Some(n) => n > 0,
609        None => git(root, &["rev-parse", "--verify", "HEAD"]).is_ok_and(|o| o.status.success()),
610    }
611}
612
613/// Commit this host's log alone and push it to the upstream and every other
614/// remote that carries the branch.
615fn commit_log(root: &Path, scope: &str) -> String {
616    let host = host();
617    let files = [
618        format!(".ljos/atoms/{host}.jsonl.age"),
619        format!(".ljos/atoms/{host}.digest"),
620    ];
621    // The same lock the tracker commits take: one checkout, many seats.
622    let common = git(root, &["rev-parse", "--git-common-dir"])
623        .ok()
624        .filter(|o| o.status.success())
625        .map(|o| root.join(String::from_utf8_lossy(&o.stdout).trim()))
626        .unwrap_or_else(|| root.join(".git"));
627    let held = crate::CommitLock::acquire(&common.join("ljos-commit.lock"));
628    let mut add = vec!["add", "--"];
629    add.extend(files.iter().map(String::as_str));
630    if git(root, &add).map_or(true, |o| !o.status.success()) {
631        return "sync: could not stage the log\n".into();
632    }
633    let staged = git(
634        root,
635        &["diff", "--cached", "--quiet", "--", &files[0], &files[1]],
636    );
637    if staged.is_ok_and(|o| o.status.success()) {
638        // Atoms did not change. A commit already made in this checkout,
639        // such as the tracker's merge attributes, still has to leave.
640        drop(held);
641        return push_pending(root);
642    }
643    let message = format!("chore(sync): {host} {scope} atoms");
644    let mut commit = vec!["commit", "-q", "--only", "-m", message.as_str(), "--"];
645    commit.extend(files.iter().map(String::as_str));
646    match git(root, &commit) {
647        Ok(o) if o.status.success() => {}
648        Ok(o) => {
649            return format!(
650                "sync: commit refused: {}\n",
651                String::from_utf8_lossy(&o.stderr)
652                    .lines()
653                    .next()
654                    .unwrap_or("")
655            )
656        }
657        Err(e) => return format!("sync: git: {e}\n"),
658    }
659    drop(held);
660    let remotes = git(root, &["remote"])
661        .ok()
662        .filter(|o| o.status.success())
663        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
664        .unwrap_or_default();
665    if remotes.is_empty() {
666        return format!(
667            "sync: committed {message}; no remote is configured, so nothing was pushed. \
668             Set one with `git -C {} remote add origin URL` and `git push -u origin HEAD`.\n",
669            root.display()
670        );
671    }
672    // No upstream yet: push_checkout sets it with `-u origin <branch>`.
673    // A sibling bot's push is rebased, and an org conflict keeps both
674    // headings. Failure stays a failure; the line does not say the log was
675    // pushed.
676    match crate::tracker_push::push_checkout(root) {
677        Ok(()) => format!("sync: committed {message}; pushed\n"),
678        Err(e) => format!("sync: committed {message}; push refused: {e}\n"),
679    }
680}
681
682/// Fetch every remote and fast-forward to each one that is ahead. Machines
683/// that push to different remotes of one tracker then read each other's
684/// logs whenever the history is linear; a real divergence stays for the
685/// doctor's split row and a person.
686fn catch_up(root: &Path) -> String {
687    if git(root, &["fetch", "-q", "--all"]).map_or(true, |o| !o.status.success()) {
688        return "sync: fetch failed; reading the logs as they are\n".into();
689    }
690    let Some(up) = crate::tracker_upstream(root) else {
691        return String::new();
692    };
693    let mut refs = vec![up.clone()];
694    refs.extend(
695        crate::tracker_mirrors(root, &up)
696            .unwrap_or_default()
697            .into_iter()
698            .map(|(r, b)| format!("{r}/{b}")),
699    );
700    let mut stuck = Vec::new();
701    for r in refs {
702        let forward = git(root, &["merge", "-q", "--ff-only", &r]);
703        if forward.is_ok_and(|o| o.status.success()) {
704            continue;
705        }
706        // Diverged: this host committed while another pushed. Merge, as the
707        // tracker's own push does; a merge that stops is undone and named.
708        let merged = git(root, &["merge", "-q", "--no-edit", &r]);
709        if !merged.is_ok_and(|o| o.status.success()) {
710            let _ = git(root, &["merge", "--abort"]);
711            stuck.push(r);
712        }
713    }
714    if stuck.is_empty() {
715        String::new()
716    } else {
717        format!(
718            "sync: could not merge {}; `ljos doctor` names the split\n",
719            stuck.join(", ")
720        )
721    }
722}
723
724/// One pass over the tracker repository's scope: pull, take every other
725/// machine's log, write and push this machine's. `pull_import` and `export`
726/// pick the halves; the sitting takes the first, finish the second.
727///
728/// # Errors
729///
730/// A scope file that does not parse, the pack not answering, or age
731/// refusing to seal.
732pub fn sync_repo(pull_import: bool, export_log: bool) -> Result<String> {
733    sync_repos(pull_import, export_log, true)
734}
735
736/// [`sync_repo`] as the sitting and finish run it: a repository with no
737/// scope file is skipped without a word. Sync is opt-in, and a hint on
738/// every sitting pointed agents at a remote the person had not asked for.
739/// `ljos sync` by hand still names the missing file.
740///
741/// # Errors
742///
743/// As [`sync_repo`].
744pub fn sync_repo_in_passing(pull_import: bool, export_log: bool) -> Result<String> {
745    sync_repos(pull_import, export_log, false)
746}
747
748fn sync_repos(pull_import: bool, export_log: bool, asked: bool) -> Result<String> {
749    let listed = local().repos;
750    let roots: Vec<PathBuf> = if listed.is_empty() {
751        vec![tracker_root()?]
752    } else {
753        listed.iter().map(|r| expand(r)).collect()
754    };
755    let mut out = String::new();
756    for root in roots {
757        out.push_str(&sync_one(&root, pull_import, export_log, asked)?);
758    }
759    Ok(out)
760}
761
762fn sync_one(root: &Path, pull_import: bool, export_log: bool, asked: bool) -> Result<String> {
763    let root = root.to_path_buf();
764    let mut out = String::new();
765    // Every sync writes the tracker's merge attributes and registers the
766    // atoms driver. A later conflict then unions org files and re-exports
767    // this machine's atoms.
768    if let Err(e) = crate::tracker_push::prepare_tracker(&root, false, true) {
769        out.push_str(&format!("sync: merge setup: {e}\n"));
770    }
771    let Some(scope) = scope_of_repo(&root)? else {
772        if !asked {
773            return Ok(String::new());
774        }
775        out.push_str(&format!(
776            "sync: {} names no scope; write .ljos/sync.toml with name and recipients (`ljos sync --key` prints this machine's)\n",
777            root.display()
778        ));
779        return Ok(out);
780    };
781    if pull_import {
782        out.push_str(&catch_up(&root));
783        out.push_str(&import(&root, &scope)?);
784    }
785    if export_log {
786        let client = crate::pack()?;
787        let atoms = crate::atoms_lean(&client, &client.workspace())?;
788        let said = export(&root, &scope, &atoms)?;
789        out.push_str(&said);
790        out.push_str(&commit_if_sealed(&root, &scope.name, &said));
791    }
792    Ok(out)
793}
794
795/// Tracker checkouts that carry user rules. A guessed working directory is
796/// not one: that is the tree the seat was started in, not the repository
797/// that travels between machines. `LJOS_USER_RULES_SYNC=off` names none.
798#[must_use]
799pub fn user_rule_trackers() -> Vec<PathBuf> {
800    if std::env::var("LJOS_USER_RULES_SYNC").ok().as_deref() == Some("off") {
801        return Vec::new();
802    }
803    if let Some(one) = std::env::var("LJOS_USER_RULES_TRACKER")
804        .ok()
805        .filter(|path| !path.trim().is_empty())
806    {
807        return vec![expand(one.trim())];
808    }
809    let listed = local().repos;
810    if !listed.is_empty() {
811        return listed.iter().map(|path| expand(path)).collect();
812    }
813    if let Some(root) = std::env::var_os("ISSUE_ROOT")
814        .or_else(|| std::env::var_os("VISSUE_ROOT"))
815        .filter(|root| !root.is_empty())
816    {
817        return vec![PathBuf::from(root)];
818    }
819    if let Some(root) = seat_tracker() {
820        return vec![root];
821    }
822    if let Ok(cwd) = std::env::current_dir() {
823        if cwd.join("vissue.toml").is_file() {
824            return vec![cwd];
825        }
826    }
827    Vec::new()
828}
829
830fn seat_tracker() -> Option<PathBuf> {
831    #[derive(serde::Deserialize)]
832    struct Seat {
833        root: Option<String>,
834    }
835    let path = std::env::var_os("VISSUE_CONFIG")
836        .filter(|raw| !raw.is_empty())
837        .map(PathBuf::from)
838        .or_else(|| Some(config_dir().parent()?.join("vissue").join("config.toml")))?;
839    let text = std::fs::read_to_string(path).ok()?;
840    let root = toml::from_str::<Seat>(&text).ok()?.root?;
841    let path = expand(root.trim());
842    path.is_dir().then_some(path)
843}
844
845fn machine_name(host: &str) -> String {
846    let clean: String = host
847        .chars()
848        .map(|c| {
849            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
850                c
851            } else {
852                '-'
853            }
854        })
855        .collect();
856    let clean = clean.trim_matches('.').to_string();
857    if clean.is_empty() {
858        "host".into()
859    } else {
860        clean
861    }
862}
863
864fn looks_like_git(root: &Path) -> bool {
865    let git = root.join(".git");
866    git.is_dir() || git.is_file()
867}
868
869fn repo_rows(root: &Path) -> Vec<crate::user_rules::UserRule> {
870    let dir = root.join(".ljos").join("user-rules");
871    let Ok(read) = std::fs::read_dir(&dir) else {
872        return Vec::new();
873    };
874    let mut paths: Vec<PathBuf> = read
875        .filter_map(|entry| entry.ok())
876        .map(|entry| entry.path())
877        .filter(|path| path.extension().is_some_and(|ext| ext == "json"))
878        .collect();
879    paths.sort();
880    let mut rows = Vec::new();
881    for path in paths {
882        let Ok(text) = std::fs::read_to_string(&path) else {
883            continue;
884        };
885        if let Ok(parsed) = crate::user_rules::parse_rules(&text) {
886            rows.extend(parsed);
887        }
888    }
889    rows
890}
891
892fn git_within(
893    root: &Path,
894    args: &[&str],
895    deadline: std::time::Instant,
896) -> Result<std::process::Output> {
897    use std::os::unix::process::CommandExt;
898    let budget = deadline.saturating_duration_since(std::time::Instant::now());
899    if budget.is_zero() {
900        bail!(
901            "sync: user rules: no time left for git {}",
902            args.first().copied().unwrap_or("")
903        );
904    }
905    let mut cmd = Command::new("git");
906    cmd.arg("-C")
907        .arg(root)
908        .args(args)
909        .env("GIT_TERMINAL_PROMPT", "0")
910        .stdin(Stdio::null())
911        .stdout(Stdio::piped())
912        .stderr(Stdio::piped());
913    // SAFETY: the closure runs in the forked child before exec. setpgid
914    // makes that child a group leader so a timed-out fetch can be killed
915    // with the helpers it started. The child has no other threads.
916    unsafe {
917        cmd.pre_exec(|| {
918            libc::setpgid(0, 0);
919            Ok(())
920        });
921    }
922    let child = cmd.spawn().with_context(|| {
923        format!(
924            "sync: user rules: git {}",
925            args.first().copied().unwrap_or("")
926        )
927    })?;
928    let pid = child.id();
929    let (tx, rx) = std::sync::mpsc::channel();
930    std::thread::spawn(move || {
931        let _ = tx.send(child.wait_with_output());
932    });
933    match rx.recv_timeout(budget) {
934        Ok(Ok(out)) => Ok(out),
935        Ok(Err(err)) => Err(err).context("sync: user rules: git"),
936        Err(_) => {
937            // SAFETY: signal 9 to the child's process group, whose id is
938            // the child's pid after setpgid, and to the child itself.
939            unsafe {
940                libc::kill(-(pid as i32), libc::SIGKILL);
941                libc::kill(pid as i32, libc::SIGKILL);
942            }
943            bail!(
944                "sync: user rules: the tracker did not answer within {} ms",
945                budget.as_millis()
946            )
947        }
948    }
949}
950
951fn git_succeeded(out: &std::process::Output) -> bool {
952    out.status.success()
953}
954
955fn one_line(out: &std::process::Output) -> String {
956    let text = String::from_utf8_lossy(&out.stderr);
957    let text = if text.trim().is_empty() {
958        String::from_utf8_lossy(&out.stdout)
959    } else {
960        text
961    };
962    text.lines()
963        .find(|line| !line.trim().is_empty())
964        .unwrap_or("")
965        .trim()
966        .to_string()
967}
968
969fn integrate_remote(root: &Path, deadline: std::time::Instant) -> Result<()> {
970    let fetched = git_within(root, &["fetch", "-q", "--all"], deadline)?;
971    if !git_succeeded(&fetched) {
972        bail!("sync: user rules: fetch failed: {}", one_line(&fetched));
973    }
974    let Some(up) = crate::tracker_upstream(root) else {
975        return Ok(());
976    };
977    let forward = git_within(root, &["merge", "-q", "--ff-only", &up], deadline)?;
978    if git_succeeded(&forward) {
979        return Ok(());
980    }
981    let merged = git_within(root, &["merge", "-q", "--no-edit", &up], deadline)?;
982    if git_succeeded(&merged) {
983        return Ok(());
984    }
985    let _ = git_within(
986        root,
987        &["merge", "--abort"],
988        std::time::Instant::now() + std::time::Duration::from_secs(2),
989    );
990    bail!(
991        "sync: user rules: could not merge {up}: {}",
992        one_line(&merged)
993    )
994}
995
996fn commit_host(root: &Path, rel: &str, host: &str, deadline: std::time::Instant) -> Result<bool> {
997    let common = git(root, &["rev-parse", "--git-common-dir"])
998        .ok()
999        .filter(|out| out.status.success())
1000        .map(|out| {
1001            let rel_git = String::from_utf8_lossy(&out.stdout).trim().to_string();
1002            let path = PathBuf::from(&rel_git);
1003            if path.is_absolute() {
1004                path
1005            } else {
1006                root.join(path)
1007            }
1008        })
1009        .unwrap_or_else(|| root.join(".git"));
1010    let held = crate::CommitLock::acquire(&common.join("ljos-commit.lock"));
1011    let added = git_within(root, &["add", "--", rel], deadline)?;
1012    if !git_succeeded(&added) {
1013        bail!(
1014            "sync: user rules: could not stage {rel}: {}",
1015            one_line(&added)
1016        );
1017    }
1018    let staged = git_within(root, &["diff", "--cached", "--quiet", "--", rel], deadline)?;
1019    if git_succeeded(&staged) {
1020        drop(held);
1021        return Ok(false);
1022    }
1023    let message = format!("chore(sync): {host} user rules");
1024    let committed = git_within(
1025        root,
1026        &["commit", "-q", "--only", "-m", &message, "--", rel],
1027        deadline,
1028    )?;
1029    drop(held);
1030    if git_succeeded(&committed) {
1031        Ok(true)
1032    } else {
1033        bail!("sync: user rules: commit refused: {}", one_line(&committed))
1034    }
1035}
1036
1037fn push_host(root: &Path, deadline: std::time::Instant) -> Result<()> {
1038    let upstream = git(root, &["rev-parse", "--abbrev-ref", "@{upstream}"])
1039        .ok()
1040        .is_some_and(|out| out.status.success());
1041    let args: &[&str] = if upstream {
1042        &["push", "-q"]
1043    } else {
1044        &["push", "-q", "-u", "origin", "HEAD"]
1045    };
1046    let pushed = git_within(root, args, deadline)?;
1047    if git_succeeded(&pushed) {
1048        return Ok(());
1049    }
1050    let said = format!(
1051        "{}\n{}",
1052        one_line(&pushed),
1053        String::from_utf8_lossy(&pushed.stderr)
1054    );
1055    if !crate::tracker_push::rejected(&said) {
1056        bail!("sync: user rules: push refused: {}", one_line(&pushed));
1057    }
1058    integrate_remote(root, deadline)?;
1059    let again = git_within(root, args, deadline)?;
1060    if git_succeeded(&again) {
1061        Ok(())
1062    } else {
1063        bail!("sync: user rules: push refused: {}", one_line(&again))
1064    }
1065}
1066
1067/// Fetch the tracker's user-rule files, merge them with the local file by
1068/// id, and push this machine's file. `budget` bounds every git call. A
1069/// fetch that does not return leaves the local file as it was.
1070///
1071/// # Errors
1072///
1073/// The checkout is not git, the fetch did not answer, or the local file
1074/// cannot be written.
1075pub fn exchange_user_rules(root: &Path, host: &str, budget: std::time::Duration) -> Result<String> {
1076    if !root.is_dir() {
1077        bail!("sync: user rules: {} is not a directory", root.display());
1078    }
1079    if !looks_like_git(root) {
1080        bail!("sync: user rules: {} is not a git checkout", root.display());
1081    }
1082    let deadline = std::time::Instant::now() + budget;
1083    let remote = git(root, &["remote"])
1084        .ok()
1085        .filter(|out| out.status.success())
1086        .is_some_and(|out| !String::from_utf8_lossy(&out.stdout).trim().is_empty());
1087    if remote {
1088        integrate_remote(root, deadline)?;
1089    } else {
1090        let local_empty = crate::user_rules::read_all().is_ok_and(|rows| rows.is_empty());
1091        if local_empty && repo_rows(root).is_empty() {
1092            return Ok(String::new());
1093        }
1094    }
1095    let mut rows = repo_rows(root);
1096    rows.extend(crate::user_rules::read_all()?);
1097    let merged = crate::user_rules::merge_rows(rows);
1098    crate::user_rules::replace_stored(&merged)?;
1099    let name = machine_name(host);
1100    let rel = format!(".ljos/user-rules/{name}.json");
1101    let path = root.join(&rel);
1102    if let Some(parent) = path.parent() {
1103        std::fs::create_dir_all(parent)?;
1104    }
1105    std::fs::write(&path, crate::user_rules::render_rules(&merged)?)?;
1106    let committed = commit_host(root, &rel, &name, deadline)?;
1107    let mut out = String::new();
1108    if remote {
1109        out.push_str("sync: user rules: fetched\n");
1110    }
1111    if committed || (remote && commits_to_push(root)) {
1112        push_host(root, deadline)?;
1113        out.push_str("sync: user rules: pushed\n");
1114    }
1115    let live = merged.iter().filter(|rule| !rule.deleted).count();
1116    out.push_str(&format!("sync: user rules: {live} on this machine\n"));
1117    Ok(out)
1118}
1119
1120#[cfg(test)]
1121mod tests {
1122    use super::*;
1123
1124    fn atom(id: &str, text: &str, entities: &[&str]) -> Value {
1125        serde_json::json!({"id": id, "text": text, "kind": "lesson",
1126            "entities": entities, "embedding": [0.1, 0.2]})
1127    }
1128
1129    #[test]
1130    fn a_repository_with_no_scope_is_named_only_when_sync_is_asked_for() {
1131        let _scratch = tempfile::tempdir().unwrap();
1132        let root = _scratch.path().to_path_buf();
1133        let asked = sync_one(&root, true, false, true).unwrap();
1134        assert!(asked.contains("names no scope"), "{asked}");
1135        assert_eq!(sync_one(&root, true, false, false).unwrap(), "");
1136        assert_eq!(sync_one(&root, false, true, false).unwrap(), "");
1137        let _ = std::fs::remove_dir(&root);
1138    }
1139
1140    /// A tracker with no remote used to say the log was pushed to nothing.
1141    #[test]
1142    fn a_log_with_no_remote_says_how_to_set_one() {
1143        let _g = crate::test_env_lock();
1144        let dir = tempfile::tempdir().unwrap();
1145        let root = dir.path();
1146        let git = |args: &[&str]| {
1147            let out = std::process::Command::new("git")
1148                .arg("-C")
1149                .arg(root)
1150                .args(args)
1151                .output()
1152                .unwrap();
1153            assert!(
1154                out.status.success(),
1155                "git {args:?}: {}",
1156                String::from_utf8_lossy(&out.stderr)
1157            );
1158        };
1159        git(&["init", "-q"]);
1160        git(&["config", "user.email", "seat@example.invalid"]);
1161        git(&["config", "user.name", "seat"]);
1162        git(&["config", "core.hooksPath", "/dev/null"]);
1163        let atoms = root.join(".ljos/atoms");
1164        std::fs::create_dir_all(&atoms).unwrap();
1165        let name = host();
1166        std::fs::write(atoms.join(format!("{name}.jsonl.age")), "sealed\n").unwrap();
1167        std::fs::write(atoms.join(format!("{name}.digest")), "digest\n").unwrap();
1168        let said = commit_log(root, "surf");
1169        assert!(said.contains("no remote is configured"), "{said}");
1170        assert!(said.contains("remote add origin"), "{said}");
1171        assert!(said.contains("git push -u origin HEAD"), "{said}");
1172        assert!(!said.contains("pushed to nothing"), "{said}");
1173        assert_eq!(commit_log(root, "surf"), "");
1174    }
1175
1176    /// Atoms that did not change still push a commit already waiting,
1177    /// such as the tracker's merge attributes.
1178    #[test]
1179    fn unchanged_atoms_still_push_a_pending_commit() {
1180        let _g = crate::test_env_lock();
1181        let dir = tempfile::tempdir().unwrap();
1182        let remote = dir.path().join("remote.git");
1183        let root = dir.path().join("local");
1184        let git = |cwd: &std::path::Path, args: &[&str]| {
1185            let out = std::process::Command::new("git")
1186                .arg("-C")
1187                .arg(cwd)
1188                .args(args)
1189                .output()
1190                .unwrap();
1191            assert!(
1192                out.status.success(),
1193                "git {args:?}: {}",
1194                String::from_utf8_lossy(&out.stderr)
1195            );
1196        };
1197        std::fs::create_dir_all(&remote).unwrap();
1198        git(&remote, &["init", "-q", "--bare"]);
1199        std::fs::create_dir_all(&root).unwrap();
1200        git(&root, &["init", "-q"]);
1201        git(&root, &["config", "user.email", "seat@example.invalid"]);
1202        git(&root, &["config", "user.name", "seat"]);
1203        git(&root, &["config", "core.hooksPath", "/dev/null"]);
1204        let atoms = root.join(".ljos/atoms");
1205        std::fs::create_dir_all(&atoms).unwrap();
1206        let name = host();
1207        std::fs::write(atoms.join(format!("{name}.jsonl.age")), "sealed\n").unwrap();
1208        std::fs::write(atoms.join(format!("{name}.digest")), "digest\n").unwrap();
1209        std::fs::write(root.join("README"), "seed\n").unwrap();
1210        git(&root, &["add", "."]);
1211        git(&root, &["commit", "-q", "-m", "seed"]);
1212        git(
1213            &root,
1214            &["remote", "add", "origin", remote.to_str().unwrap()],
1215        );
1216        git(&root, &["push", "-q", "-u", "origin", "HEAD"]);
1217        std::fs::write(root.join(".gitattributes"), "*.org merge=union\n").unwrap();
1218        git(&root, &["add", ".gitattributes"]);
1219        git(
1220            &root,
1221            &[
1222                "commit",
1223                "-q",
1224                "-m",
1225                "chore(tracker): union org files and re-export atoms on merge",
1226            ],
1227        );
1228        let said = commit_log(&root, "surf");
1229        assert!(
1230            said.contains("pushed pending commits"),
1231            "an unchanged atom log must still push: {said}"
1232        );
1233        let remote_log = std::process::Command::new("git")
1234            .arg("-C")
1235            .arg(&remote)
1236            .args(["log", "--format=%s"])
1237            .output()
1238            .unwrap();
1239        let subjects = String::from_utf8_lossy(&remote_log.stdout);
1240        assert!(
1241            subjects.contains("union org files"),
1242            "the pending commit was not pushed: {subjects}"
1243        );
1244        assert_eq!(commit_log(&root, "surf"), "");
1245    }
1246
1247    /// Finish's sync used to print an os error when `age` was not installed.
1248    #[test]
1249    fn a_missing_age_skips_the_seal_without_an_os_error() {
1250        let _g = crate::test_env_lock();
1251        struct Restore(Option<std::ffi::OsString>);
1252        impl Drop for Restore {
1253            fn drop(&mut self) {
1254                match self.0.take() {
1255                    Some(path) => unsafe { std::env::set_var("PATH", path) },
1256                    None => unsafe { std::env::remove_var("PATH") },
1257                }
1258            }
1259        }
1260        let _path = Restore(std::env::var_os("PATH"));
1261        let empty = tempfile::tempdir().unwrap();
1262        unsafe { std::env::set_var("PATH", empty.path()) };
1263        let root = tempfile::tempdir().unwrap();
1264        let scope: Scope =
1265            toml::from_str("name = \"surf\"\nrecipients = [\"age1test\"]\n").unwrap();
1266        let said = export(root.path(), &scope, &[atom("a", "kept", &[])]).unwrap();
1267        assert!(said.contains("age is not installed"), "{said}");
1268        assert!(said.contains("install age"), "{said}");
1269        assert!(!said.contains("os error"), "{said}");
1270        assert!(!said.to_lowercase().contains("not found"), "{said}");
1271        assert!(!log_dir(root.path())
1272            .join(format!("{}.jsonl.age", host()))
1273            .is_file());
1274        assert_eq!(commit_if_sealed(root.path(), "surf", &said), "");
1275    }
1276
1277    #[test]
1278    fn a_log_carries_own_atoms_of_its_scope_without_vectors() {
1279        let atoms = vec![
1280            atom("b", "second", &["seat:x"]),
1281            atom("a", "first", &[]),
1282            atom("c", "personal", &["scope:personal"]),
1283            atom("d", "imported", &["sync:otherhost"]),
1284        ];
1285        let plain = log_plaintext(&atoms, "surf", "surf");
1286        let lines: Vec<Value> = plain
1287            .lines()
1288            .map(|l| serde_json::from_str(l).unwrap())
1289            .collect();
1290        assert_eq!(lines.len(), 2, "{plain}");
1291        assert_eq!(lines[0]["id"], "a");
1292        assert_eq!(lines[1]["id"], "b");
1293        assert!(lines.iter().all(|a| a.get("embedding").is_none()));
1294        assert_eq!(log_plaintext(&atoms, "personal", "surf").lines().count(), 1);
1295    }
1296
1297    #[test]
1298    fn a_project_named_in_the_scope_file_takes_its_own_scope() {
1299        let scope: Scope = toml::from_str(
1300            "name = \"personal\"\nrecipients = [\"age1x\"]\n[projects]\ntools = \"shared\"\n",
1301        )
1302        .unwrap();
1303        assert_eq!(scope.for_project("tools"), "shared");
1304        assert_eq!(scope.for_project("garden"), "personal");
1305        let bare: Scope = toml::from_str("name = \"shared\"\nrecipients = []\n").unwrap();
1306        assert!(bare.projects.is_empty());
1307    }
1308
1309    #[test]
1310    fn an_import_tags_the_sender_and_names_what_it_retired() {
1311        let plain = format!(
1312            "{}\n{}\n",
1313            atom("a", "kept", &["seat:x"]),
1314            atom("b", "new", &[])
1315        );
1316        let before: BTreeSet<String> = [crate::work_id("kept"), crate::work_id("dropped")].into();
1317        let got = incoming(&plain, "rglat", &before, &BTreeSet::new());
1318        assert_eq!(got.post.len(), 2);
1319        let again = incoming(&plain, "rglat", &before, &[crate::work_id("kept")].into());
1320        assert_eq!(
1321            again.post.len(),
1322            1,
1323            "a text already held is not posted again"
1324        );
1325        assert_eq!(again.post[0]["text"], "new");
1326        assert!(got.post.iter().all(|a| a.get("id").is_none()));
1327        assert!(got.post.iter().all(|a| a["entities"]
1328            .as_array()
1329            .unwrap()
1330            .iter()
1331            .any(|e| e == "sync:rglat")));
1332        assert!(got.post.iter().all(|a| a["origin"] == "peer"));
1333        assert_eq!(got.retired, vec![crate::work_id("dropped")]);
1334        let claimed = "{\"id\":\"c\",\"text\":\"claimed\",\"kind\":\"lesson\",\"origin\":\"user-declared\",\"entities\":[]}\n";
1335        let overwrote = incoming(claimed, "rglat", &BTreeSet::new(), &BTreeSet::new());
1336        assert_eq!(overwrote.post[0]["origin"], "peer");
1337    }
1338
1339    #[test]
1340    fn a_sealed_log_opens_for_a_recipient_and_not_for_another() {
1341        let _g = crate::test_env_lock();
1342        let dir = tempfile::tempdir().unwrap();
1343        let key = |name: &str| {
1344            let path = dir.path().join(name);
1345            let made = Command::new("age-keygen")
1346                .arg("-o")
1347                .arg(&path)
1348                .output()
1349                .unwrap();
1350            assert!(made.status.success(), "age-keygen must be installed");
1351            let public = Command::new("age-keygen")
1352                .arg("-y")
1353                .arg(&path)
1354                .output()
1355                .unwrap();
1356            (
1357                path,
1358                String::from_utf8_lossy(&public.stdout).trim().to_string(),
1359            )
1360        };
1361        let (mine, my_public) = key("mine.key");
1362        let (other, _) = key("other.key");
1363        let out = dir.path().join("host.jsonl.age");
1364        seal("{\"text\":\"x\"}\n", &[my_public], &out).unwrap();
1365        assert_eq!(
1366            open(&out, &mine).unwrap().as_deref(),
1367            Some("{\"text\":\"x\"}\n")
1368        );
1369        assert_eq!(open(&out, &other).unwrap(), None);
1370        assert!(seal("x", &[], &out).is_err(), "no recipients is refused");
1371    }
1372
1373    #[test]
1374    fn an_atoms_scope_is_its_entity_or_the_default() {
1375        assert_eq!(
1376            atom_scope(&atom("a", "t", &["scope:personal"]), "surf"),
1377            "personal"
1378        );
1379        assert_eq!(atom_scope(&atom("a", "t", &["seat:x"]), "surf"), "surf");
1380    }
1381}