1use 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#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
28pub struct Scope {
29 pub name: String,
31 pub recipients: Vec<String>,
33 #[serde(default)]
37 pub projects: BTreeMap<String, String>,
38}
39
40impl Scope {
41 #[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#[derive(Debug, Clone, Default, serde::Deserialize)]
53struct Local {
54 #[serde(default)]
55 default_scope: Option<String>,
56 #[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#[must_use]
80pub fn sender_entity(host: &str) -> String {
81 format!("sync:{host}")
82}
83
84#[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#[must_use]
113pub fn identity_path() -> PathBuf {
114 config_dir().join("age.key")
115}
116
117pub 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
156pub 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#[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#[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#[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#[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
229fn 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
261fn 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
285fn 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
299pub 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#[derive(Debug, Default, PartialEq)]
350pub struct Incoming {
351 pub post: Vec<Value>,
352 pub retired: Vec<String>,
353}
354
355#[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
427pub 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 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#[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
539fn 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
554fn 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
564fn 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
582fn 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
613fn 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 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 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 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
682fn 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 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
724pub fn sync_repo(pull_import: bool, export_log: bool) -> Result<String> {
733 sync_repos(pull_import, export_log, true)
734}
735
736pub 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 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#[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 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 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
1067pub 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 #[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 #[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 #[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}