1use std::io::Write;
17use std::path::PathBuf;
18
19use anyhow::{bail, Context, Result};
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22
23use super::Rule;
24
25pub const PREFACE_BUDGET: usize = 1200;
27
28#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
30pub struct People {
31 #[serde(default, skip_serializing_if = "Vec::is_empty")]
33 pub handles: Vec<String>,
34 #[serde(default, skip_serializing_if = "Vec::is_empty")]
36 pub names: Vec<String>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct UserRule {
44 pub id: String,
45 pub text: String,
46 pub author: String,
47 pub reason: String,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub pattern: Option<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub verdict: Option<String>,
52 #[serde(default, skip_serializing_if = "people_empty")]
53 pub deny_mention: People,
54 #[serde(default, skip_serializing_if = "people_empty")]
55 pub deny_reply_to: People,
56 pub written_at: String,
57 #[serde(default, skip_serializing_if = "String::is_empty")]
60 pub updated_at: String,
61 #[serde(default, skip_serializing_if = "is_false")]
64 pub deleted: bool,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub from: Option<String>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub basis: Option<String>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub lesson_hash: Option<String>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub due_at: Option<String>,
83}
84
85fn people_empty(p: &People) -> bool {
86 p.handles.is_empty() && p.names.is_empty()
87}
88
89fn is_false(value: &bool) -> bool {
90 !*value
91}
92
93#[derive(Debug, Default, Serialize, Deserialize)]
94struct File {
95 #[serde(default)]
96 rules: Vec<UserRule>,
97}
98
99#[must_use]
101pub fn rules_path() -> Option<PathBuf> {
102 Some(super::config_base()?.join("ljos").join("user-rules.json"))
103}
104
105pub const RULES_FILE_CAP: u64 = 1024 * 1024;
107
108pub fn load() -> Result<Vec<UserRule>> {
116 Ok(read_all()?
117 .into_iter()
118 .filter(|rule| !rule.deleted)
119 .collect())
120}
121
122pub(crate) fn read_all() -> Result<Vec<UserRule>> {
129 let Some(path) = rules_path() else {
130 return Ok(Vec::new());
131 };
132 let started = std::time::Instant::now();
143 let mut stable_since = std::time::Instant::now();
144 let mut last_stamp: Option<RulesStamp> = None;
145 loop {
146 match read_rules_text(&path) {
147 Ok(None) => return Ok(Vec::new()),
148 Ok(Some(text)) => match parse_rules(&text) {
149 Ok(rules) => return Ok(rules),
150 Err(err) => {
151 let err = err.context(format!(
152 "user rules: {} is not a user-rules file",
153 path.display()
154 ));
155 if rules_read_gave_up(
156 &path,
157 Some(text.as_bytes()),
158 &mut last_stamp,
159 &mut stable_since,
160 started,
161 ) {
162 return Err(err);
163 }
164 }
165 },
166 Err(err) if rules_rewrite_symptom(&err) => {
167 if rules_read_gave_up(&path, None, &mut last_stamp, &mut stable_since, started) {
168 return Err(err);
169 }
170 }
171 Err(err) => return Err(err),
172 }
173 std::thread::sleep(std::time::Duration::from_millis(1));
174 }
175}
176
177const RULES_STABLE: std::time::Duration = std::time::Duration::from_millis(250);
183const RULES_HARD: std::time::Duration = std::time::Duration::from_millis(600);
186
187#[derive(PartialEq, Eq)]
188struct RulesStamp {
189 len: u64,
190 mtime: Option<std::time::SystemTime>,
191 prefix: [u8; 64],
192 prefix_len: usize,
193}
194
195fn rules_rewrite_symptom(err: &anyhow::Error) -> bool {
196 format!("{err:#}").contains(" is empty")
197}
198
199fn rules_stamp(path: &std::path::Path, bytes: Option<&[u8]>) -> RulesStamp {
200 let meta = std::fs::symlink_metadata(path).ok();
201 let prefix_src = bytes.unwrap_or(b"");
202 let n = prefix_src.len().min(64);
203 let mut prefix = [0u8; 64];
204 prefix[..n].copy_from_slice(&prefix_src[..n]);
205 RulesStamp {
206 len: meta.as_ref().map(|m| m.len()).unwrap_or(0),
207 mtime: meta.as_ref().and_then(|m| m.modified().ok()),
208 prefix,
209 prefix_len: n,
210 }
211}
212
213fn rules_read_gave_up(
216 path: &std::path::Path,
217 bytes: Option<&[u8]>,
218 last: &mut Option<RulesStamp>,
219 stable_since: &mut std::time::Instant,
220 started: std::time::Instant,
221) -> bool {
222 let stamp = rules_stamp(path, bytes);
223 let same = match last {
224 Some(prev) if *prev == stamp => true,
225 _ => {
226 *last = Some(stamp);
227 *stable_since = std::time::Instant::now();
228 false
229 }
230 };
231 if started.elapsed() >= RULES_HARD {
232 return true;
233 }
234 same && stable_since.elapsed() >= RULES_STABLE
235}
236
237fn read_rules_text(path: &std::path::Path) -> Result<Option<String>> {
243 use std::io::Read;
244 use std::os::unix::fs::OpenOptionsExt;
245 let file = match std::fs::OpenOptions::new()
246 .read(true)
247 .custom_flags(libc::O_NONBLOCK)
248 .open(path)
249 {
250 Ok(file) => file,
251 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
252 return match std::fs::symlink_metadata(path) {
253 Ok(meta) if meta.file_type().is_symlink() => {
254 bail!("user rules: {} is a dangling symlink", path.display());
255 }
256 Ok(_) => Err(anyhow::Error::from(err)
257 .context(format!("user rules: read {} failed", path.display()))),
258 Err(stat) if stat.kind() == std::io::ErrorKind::NotFound => Ok(None),
259 Err(stat) => Err(anyhow::Error::from(stat)
260 .context(format!("user rules: stat {} failed", path.display()))),
261 };
262 }
263 Err(err) => {
264 return Err(anyhow::Error::from(err)
265 .context(format!("user rules: read {} failed", path.display())));
266 }
267 };
268 let meta = file
270 .metadata()
271 .with_context(|| format!("user rules: stat {} failed", path.display()))?;
272 if !meta.is_file() {
273 bail!("user rules: {} is not a regular file", path.display());
274 }
275 if meta.len() == 0 {
276 bail!("user rules: {} is empty", path.display());
277 }
278 if meta.len() > RULES_FILE_CAP {
279 bail!(
280 "user rules: {} is {} bytes, over the {} byte cap",
281 path.display(),
282 meta.len(),
283 RULES_FILE_CAP
284 );
285 }
286 let mut text = String::new();
287 file.take(RULES_FILE_CAP)
288 .read_to_string(&mut text)
289 .with_context(|| format!("user rules: read {} failed", path.display()))?;
290 if text.trim().is_empty() {
291 bail!("user rules: {} is empty", path.display());
292 }
293 Ok(Some(text))
294}
295
296pub(crate) fn parse_rules(text: &str) -> Result<Vec<UserRule>> {
302 if text.trim().is_empty() {
303 return Ok(Vec::new());
304 }
305 let file: File = serde_json::from_str(text)?;
306 Ok(file.rules)
307}
308
309pub(crate) fn render_rules(rules: &[UserRule]) -> Result<String> {
315 let rules: Vec<UserRule> = rules.iter().cloned().map(dated).collect();
316 let body = serde_json::to_string_pretty(&File { rules })?;
317 Ok(format!("{body}\n"))
318}
319
320fn store(rules: &[UserRule]) -> Result<()> {
321 let _durable = crate::bookkeep::durable_write();
322 let path = rules_path().context("user rules: no home and no XDG_CONFIG_HOME")?;
323 let Some(parent) = path.parent() else {
324 anyhow::bail!("user rules: {} has no directory", path.display());
325 };
326 std::fs::create_dir_all(parent)
327 .with_context(|| format!("user rules: create {} failed", parent.display()))?;
328 let body = render_rules(rules)?;
329 if std::fs::read(&path).is_ok_and(|have| have == body.as_bytes()) {
330 return Ok(());
331 }
332 let tmp = unique_rules_temp(parent)?;
336 let write_tmp = (|| -> Result<()> {
337 let mut file = std::fs::OpenOptions::new()
338 .write(true)
339 .create_new(true)
340 .open(&tmp)
341 .with_context(|| format!("user rules: create {} failed", tmp.display()))?;
342 file.write_all(body.as_bytes())
343 .with_context(|| format!("user rules: write {} failed", tmp.display()))?;
344 file.sync_all()
345 .with_context(|| format!("user rules: sync {} failed", tmp.display()))?;
346 Ok(())
347 })();
348 if let Err(err) = write_tmp {
349 let _ = std::fs::remove_file(&tmp);
350 return Err(err);
351 }
352 if let Err(err) = std::fs::rename(&tmp, &path) {
353 let _ = std::fs::remove_file(&tmp);
354 return Err(err).context(format!("user rules: rename into {} failed", path.display()));
355 }
356 if let Ok(dir) = std::fs::File::open(parent) {
357 let _ = dir.sync_all();
358 }
359 Ok(())
360}
361
362fn unique_rules_temp(parent: &std::path::Path) -> Result<std::path::PathBuf> {
363 for n in 0..32u32 {
364 let nanos = std::time::SystemTime::now()
365 .duration_since(std::time::UNIX_EPOCH)
366 .map(|d| d.as_nanos())
367 .unwrap_or(0);
368 let path = parent.join(format!(
369 ".user-rules.{}.{nanos}.{n}.tmp",
370 std::process::id()
371 ));
372 if !path.exists() {
373 return Ok(path);
374 }
375 }
376 anyhow::bail!(
377 "user rules: could not pick a temp name in {}",
378 parent.display()
379 );
380}
381
382pub(crate) fn replace_stored(rules: &[UserRule]) -> Result<()> {
384 store(rules)
385}
386
387fn append(rule: UserRule) -> Result<UserRule> {
388 let mut rules = read_all()?;
389 rules.push(rule.clone());
390 store(&rules)?;
391 sync_after_write();
392 Ok(rule)
393}
394
395#[must_use]
397pub fn is_due(rule: &UserRule, now: &str) -> bool {
398 rule.due_at
399 .as_deref()
400 .is_some_and(|due| !due.is_empty() && due <= now)
401}
402
403#[must_use]
405pub fn review_due(now: &str) -> usize {
406 load()
407 .unwrap_or_default()
408 .iter()
409 .filter(|rule| is_due(rule, now))
410 .count()
411}
412
413pub fn sweep(now: &str) -> Result<usize> {
420 let rules = read_all()?;
421 let kept: Vec<UserRule> = rules
422 .iter()
423 .filter(|rule| !is_due(rule, now))
424 .cloned()
425 .collect();
426 let dropped = rules.len() - kept.len();
427 if dropped > 0 {
428 store(&kept)?;
429 }
430 Ok(dropped)
431}
432
433fn new_id(text: &str) -> String {
434 let mut raw = [0u8; 16];
435 if let Ok(mut file) = std::fs::File::open("/dev/urandom") {
436 use std::io::Read;
437 let _ = file.read_exact(&mut raw);
438 }
439 let tick = std::time::SystemTime::now()
440 .duration_since(std::time::UNIX_EPOCH)
441 .map(|d| d.as_nanos() as u64)
442 .unwrap_or(0);
443 for (i, byte) in raw.iter_mut().enumerate().take(8) {
444 *byte ^= (tick >> (i * 8)) as u8;
445 }
446 for (i, byte) in text.bytes().enumerate() {
447 raw[i % raw.len()] ^= byte.wrapping_add(i as u8);
448 }
449 let mut hex = String::with_capacity(32);
450 for byte in raw {
451 hex.push(b"0123456789abcdef"[(byte >> 4) as usize] as char);
452 hex.push(b"0123456789abcdef"[(byte & 0x0f) as usize] as char);
453 }
454 format!("ur-{hex}")
455}
456
457fn stamp(rule: &UserRule) -> &str {
458 let at = rule.updated_at.trim();
459 if at.is_empty() {
460 rule.written_at.trim()
461 } else {
462 at
463 }
464}
465
466fn dated(mut rule: UserRule) -> UserRule {
467 if rule.updated_at.trim().is_empty() {
468 rule.updated_at = rule.written_at.clone();
469 }
470 rule
471}
472
473#[must_use]
477pub fn merge_rows(rows: impl IntoIterator<Item = UserRule>) -> Vec<UserRule> {
478 let mut best: std::collections::BTreeMap<String, UserRule> = std::collections::BTreeMap::new();
479 for rule in rows {
480 let id = rule.id.trim().to_string();
481 if id.is_empty() {
482 continue;
483 }
484 let rule = dated(rule);
485 if best.get(&id).is_some_and(|have| beats(have, &rule)) {
486 continue;
487 }
488 best.insert(id, rule);
489 }
490 best.into_values().collect()
491}
492
493fn beats(have: &UserRule, incoming: &UserRule) -> bool {
494 match stamp(have).cmp(stamp(incoming)) {
495 std::cmp::Ordering::Greater => true,
496 std::cmp::Ordering::Less => false,
497 std::cmp::Ordering::Equal => have.deleted && !incoming.deleted,
498 }
499}
500
501fn normalize_handle(raw: &str) -> Result<String> {
502 let handle = raw.trim().trim_start_matches('@');
503 if handle.is_empty()
504 || !handle
505 .chars()
506 .all(|c| c.is_ascii_alphanumeric() || c == '-')
507 {
508 bail!("prefer: {raw:?} is not a handle");
509 }
510 Ok(handle.to_ascii_lowercase())
511}
512
513fn clean_people(people: &People) -> Result<People> {
514 let mut handles = Vec::new();
515 for handle in &people.handles {
516 let handle = normalize_handle(handle)?;
517 if !handles.contains(&handle) {
518 handles.push(handle);
519 }
520 }
521 let mut names = Vec::new();
522 for name in &people.names {
523 let name = name.split_whitespace().collect::<Vec<_>>().join(" ");
524 if name.is_empty() {
525 bail!("prefer: a name needs letters");
526 }
527 if !names
528 .iter()
529 .any(|kept: &String| kept.eq_ignore_ascii_case(&name))
530 {
531 names.push(name);
532 }
533 }
534 Ok(People { handles, names })
535}
536
537fn fresh(text: &str, reason: &str, from: Option<String>) -> Result<UserRule> {
538 let text = text.trim();
539 let reason = reason.trim();
540 if text.is_empty() {
541 bail!("prefer: a user rule needs the words it states");
542 }
543 if reason.is_empty() {
544 bail!("prefer: a user rule needs a reason, so say why with --why");
545 }
546 let now = super::now_utc();
547 Ok(UserRule {
548 id: new_id(text),
549 text: text.to_string(),
550 author: super::holder_name(),
551 reason: reason.to_string(),
552 pattern: None,
553 verdict: None,
554 deny_mention: People::default(),
555 deny_reply_to: People::default(),
556 written_at: now.clone(),
557 updated_at: now,
558 deleted: false,
559 from,
560 basis: None,
561 lesson_hash: None,
562 due_at: None,
563 })
564}
565
566pub fn write_preference(
573 text: &str,
574 reason: &str,
575 deny_mention: &People,
576 deny_reply_to: &People,
577) -> Result<UserRule> {
578 if super::under_a_runner() {
583 if let Err(why) = super::proc_tree::placed_runner() {
584 bail!("prefer: {why}; only the person's own words can be stored here");
585 }
586 }
587 let mut rule = fresh(text, reason, None)?;
588 rule.basis = Some(super::Basis::of_typed(text).as_str().to_string());
589 rule.deny_mention = clean_people(deny_mention)?;
590 rule.deny_reply_to = clean_people(deny_reply_to)?;
591 append(rule)
592}
593
594pub fn write_argv(rule: &Rule, basis: super::Basis) -> Result<UserRule> {
601 super::validate_rule(rule)?;
602 if rule.verdict == "allow" && !matches!(basis, super::Basis::Person) {
603 bail!(
604 "rule: an allow is the person's word. Run `ljos rule --user {:?} --verdict allow \
605 --why ...` at your own terminal, not under a runner",
606 rule.pattern
607 );
608 }
609 let mut row = fresh(rule.reason.trim(), rule.reason.trim(), None)?;
610 row.pattern = Some(rule.pattern.trim().to_string());
611 row.verdict = Some(rule.verdict.clone());
612 row.basis = Some(basis.as_str().to_string());
613 append(row)
614}
615
616pub fn promote_atom(
623 atom: &Value,
624 reason: &str,
625 deny_mention: &People,
626 deny_reply_to: &People,
627) -> Result<UserRule> {
628 let kind = atom.get("kind").and_then(Value::as_str).unwrap_or("");
629 if kind != "lesson" {
630 let id = atom.get("id").and_then(Value::as_str).unwrap_or("(no id)");
631 bail!("prefer: {id} is a {kind}, and only a lesson is promoted");
632 }
633 let id = atom
634 .get("id")
635 .and_then(Value::as_str)
636 .context("prefer: the lesson has no id")?
637 .to_string();
638 let text = atom
639 .get("text")
640 .and_then(Value::as_str)
641 .unwrap_or("")
642 .trim();
643 let mut rule = fresh(text, reason, Some(id))?;
644 rule.lesson_hash = Some(lesson_hash(atom));
645 rule.deny_mention = clean_people(deny_mention)?;
646 rule.deny_reply_to = clean_people(deny_reply_to)?;
647 append(rule)
648}
649
650#[must_use]
658pub fn lesson_hash(atom: &Value) -> String {
659 use sha2::{Digest, Sha256};
660 let field = |key: &str| atom.get(key).and_then(Value::as_str).unwrap_or("");
661 let mut hash = Sha256::new();
662 for part in [
663 field("kind"),
664 field("text"),
665 field("ts"),
666 field("valid_from"),
667 super::stored_by(atom).origin(),
668 ] {
669 hash.update(part.as_bytes());
670 hash.update([0]);
671 }
672 hex::encode(hash.finalize())
673}
674
675pub fn forget(id: &str) -> Result<UserRule> {
683 let id = id.trim();
684 if id.is_empty() {
685 bail!("prefer: a user rule id is required");
686 }
687 let mut rules = read_all()?;
688 let Some(rule) = rules.iter_mut().find(|rule| rule.id == id) else {
689 bail!("prefer: no user rule {id}");
690 };
691 if !rule.deleted {
692 rule.deleted = true;
693 rule.updated_at = super::now_utc();
694 let written = rule.clone();
695 store(&rules)?;
696 sync_after_write();
697 return Ok(written);
698 }
699 Ok(rule.clone())
700}
701
702pub fn promote_lesson(
712 id: &str,
713 reason: &str,
714 deny_mention: &People,
715 deny_reply_to: &People,
716 _person_words: bool,
717) -> Result<UserRule> {
718 let id = id.trim();
719 if id.is_empty() {
720 bail!("prefer: a lesson id is required");
721 }
722 let client = super::pack()?;
723 let atoms =
724 super::atoms_lean(&client, &client.workspace()).context("prefer: GET /v1/atoms failed")?;
725 let atom = atoms
726 .into_iter()
727 .find(|atom| atom.get("id").and_then(Value::as_str) == Some(id))
728 .with_context(|| format!("prefer: no atom {id} in this seat's pack"))?;
729 if super::under_a_runner() {
730 if let Err(why) = super::proc_tree::placed_runner() {
731 bail!("prefer: {why}; only the person's own words can be stored here");
732 }
733 }
734 promote_atom(&atom, reason, deny_mention, deny_reply_to)
735}
736
737fn sync_budget_ms(default: u64) -> u64 {
738 std::env::var("LJOS_USER_RULES_SYNC_MS")
739 .ok()
740 .and_then(|value| value.trim().parse().ok())
741 .filter(|ms: &u64| *ms > 0)
742 .unwrap_or(default)
743}
744
745fn sync_after_write() {
746 let budget = std::time::Duration::from_millis(sync_budget_ms(4_000));
747 let host = super::sync::host();
748 for root in super::sync::user_rule_trackers() {
749 let _ = super::sync::exchange_user_rules(&root, &host, budget, false);
750 }
751}
752
753pub fn sync_for_hook() {
756 let budget = std::time::Duration::from_millis(sync_budget_ms(1_500));
761 let host = super::sync::host();
762 for root in super::sync::user_rule_trackers() {
763 let _ = super::sync::exchange_user_rules(&root, &host, budget, false);
764 }
765}
766
767pub fn sync_now() -> Result<String> {
774 if std::env::var("LJOS_USER_RULES_SYNC").ok().as_deref() == Some("off") {
775 bail!("prefer: user-rule sync is off");
776 }
777 let roots = super::sync::user_rule_trackers();
778 if roots.is_empty() {
779 bail!(
780 "prefer: no tracker repository to sync user rules through. Set VISSUE_ROOT, or repos in ~/.config/ljos/sync.toml"
781 );
782 }
783 let budget = std::time::Duration::from_millis(sync_budget_ms(8_000));
784 let host = super::sync::host();
785 let mut out = String::new();
786 for root in roots {
787 out.push_str(&super::sync::exchange_user_rules(
788 &root, &host, budget, true,
789 )?);
790 }
791 if out.is_empty() {
792 out.push_str("sync: user rules: none\n");
793 }
794 Ok(out)
795}
796
797#[must_use]
799pub fn ack(rule: &UserRule) -> String {
800 if rule.deleted {
801 format!("user rule {}\tremoved\t{}", rule.id, rule.text)
802 } else {
803 format!("user rule {}\t{}", rule.id, rule.text)
804 }
805}
806
807#[must_use]
809pub fn to_value(rule: &UserRule) -> Value {
810 serde_json::to_value(rule).unwrap_or(Value::Null)
811}
812
813#[must_use]
817pub fn format_list() -> String {
818 let Ok(rules) = load() else {
819 return String::new();
820 };
821 let (mut persons, mut held, mut unverified) = (String::new(), String::new(), String::new());
822 for rule in &rules {
823 let law = match (&rule.verdict, &rule.pattern) {
824 (Some(verdict), Some(pattern)) => format!("{verdict}\t{pattern}\t"),
825 _ => String::new(),
826 };
827 let line = format!("{law}{}\t{}\t{}", rule.text, rule.author, rule.reason);
828 if is_held(rule) {
829 held.push_str(&format!("held\t{line}\n"));
830 continue;
831 }
832 match unconfirmed(rule) {
833 None => persons.push_str(&format!("user\t{line}\n")),
834 Some(why) => unverified.push_str(&format!("unverified\t{line}\t{why}\n")),
835 }
836 }
837 let mut out = String::new();
838 if !persons.is_empty() {
839 out.push_str("user rules (shared, never reviewed):\n");
840 out.push_str(&persons);
841 }
842 if !held.is_empty() {
843 out.push_str("user rules held from before a basis was kept:\n");
844 out.push_str(&held);
845 }
846 if !unverified.is_empty() {
847 out.push_str("user rules not confirmed as the person's (unverified):\n");
848 out.push_str(&unverified);
849 }
850 out
851}
852
853fn one_line(text: &str) -> String {
854 text.split_whitespace().collect::<Vec<_>>().join(" ")
855}
856
857fn omission(left: usize) -> String {
858 if left == 0 {
859 String::new()
860 } else {
861 format!("{left} user rules omitted.\n")
862 }
863}
864
865#[must_use]
869pub fn preface() -> String {
870 let rules = load().unwrap_or_default();
871 let standing: Vec<UserRule> = rules
872 .iter()
873 .filter(|rule| persons_rule(rule))
874 .cloned()
875 .collect();
876 let held: Vec<UserRule> = rules.iter().filter(|rule| is_held(rule)).cloned().collect();
877 render_preface(&standing, &held, PREFACE_BUDGET)
878}
879
880fn persons_rule(rule: &UserRule) -> bool {
890 unconfirmed(rule).is_none()
891}
892
893fn unconfirmed(rule: &UserRule) -> Option<String> {
896 let Some(id) = rule.from.as_deref() else {
897 return (rule.basis.as_deref() != Some(super::Basis::Person.as_str())).then(|| {
898 "written without the person at the terminal or in their latest prompt".to_string()
899 });
900 };
901 let Some(kept) = rule.lesson_hash.as_deref() else {
902 return Some(format!(
903 "promoted from lesson {id} with no hash of its record kept; promote it again"
904 ));
905 };
906 match super::atom_now(id) {
907 Ok(Some(lesson)) if !super::person_stored(&lesson) => Some(format!(
908 "the pack does not hold lesson {id} as the person's"
909 )),
910 Ok(Some(lesson)) if lesson_hash(&lesson) != kept => Some(format!(
911 "lesson {id} in the pack is not the record this rule was promoted from"
912 )),
913 Ok(Some(_)) => None,
914 Ok(None) => Some(format!("the pack does not hold lesson {id}")),
915 Err(why) => Some(why),
916 }
917}
918
919fn cutoff_path() -> Option<PathBuf> {
922 Some(
923 super::config_base()?
924 .join("ljos")
925 .join("user-rule-basis-cutoff"),
926 )
927}
928
929fn parse_instant(raw: &str) -> Option<chrono::DateTime<chrono::FixedOffset>> {
930 let raw = raw.trim();
931 (!raw.is_empty())
932 .then(|| chrono::DateTime::parse_from_rfc3339(raw).ok())
933 .flatten()
934}
935
936fn cutoff_instant() -> Option<chrono::DateTime<chrono::FixedOffset>> {
937 let text = std::fs::read_to_string(cutoff_path()?).ok()?;
938 text.lines().find_map(parse_instant)
939}
940
941pub fn note_cutoff() {
948 if super::under_a_runner() {
949 return;
950 }
951 let Some(path) = cutoff_path() else {
952 return;
953 };
954 if path.exists() {
955 return;
956 }
957 if let Some(dir) = path.parent() {
958 let _ = std::fs::create_dir_all(dir);
959 }
960 let _ = std::fs::write(&path, format!("{}\n", super::now_utc()));
961}
962
963fn is_held(rule: &UserRule) -> bool {
969 if rule.from.is_some() || rule.basis.is_some() {
970 return false;
971 }
972 match cutoff_instant() {
973 None => true,
974 Some(cutoff) => parse_instant(&rule.written_at).is_none_or(|ts| ts < cutoff),
975 }
976}
977
978#[must_use]
981pub fn held_count() -> usize {
982 load()
983 .unwrap_or_default()
984 .iter()
985 .filter(|rule| is_held(rule))
986 .count()
987}
988
989#[must_use]
991pub fn review_sentence(n: usize) -> String {
992 format!(
993 "{n} older user rule{} await{} review (`ljos prefer --user --review`)",
994 if n == 1 { "" } else { "s" },
995 if n == 1 { "s" } else { "" }
996 )
997}
998
999pub fn review_list() -> Result<String> {
1005 note_cutoff();
1006 let rules = load()?;
1007 let held: Vec<&UserRule> = rules.iter().filter(|rule| is_held(rule)).collect();
1008 if held.is_empty() {
1009 return Ok("no older user rules await review\n".to_string());
1010 }
1011 let mut out = format!("{}\n", review_sentence(held.len()));
1012 for rule in held {
1013 out.push_str(&format!(
1014 "{} {} {}\n ljos prefer --user --review --keep {}\n",
1015 rule.id,
1016 rule.written_at,
1017 one_line(&rule.text),
1018 rule.id
1019 ));
1020 }
1021 Ok(out)
1022}
1023
1024pub fn keep_held(id: &str) -> Result<String> {
1035 if super::under_a_runner() {
1036 bail!(
1037 "prefer: a runner cannot keep a user rule. \
1038 Run `ljos prefer --user --review --keep {id}` from a terminal of your own"
1039 );
1040 }
1041 if !super::person_present() {
1042 bail!(
1043 "prefer: keeping a user rule needs a terminal. \
1044 Run `ljos prefer --user --review --keep {id}` from a terminal of your own"
1045 );
1046 }
1047 note_cutoff();
1048 let mut rules = read_all()?;
1049 let ids: Vec<String> = rules
1050 .iter()
1051 .filter(|rule| is_held(rule))
1052 .map(|rule| rule.id.clone())
1053 .collect();
1054 let id = super::resolve_short_id("prefer", id.trim(), &ids)?;
1055 let Some(rule) = rules.iter_mut().find(|rule| rule.id == id && is_held(rule)) else {
1056 bail!("prefer: {id} is not an older user rule awaiting review");
1057 };
1058 rule.basis = Some(super::Basis::Person.as_str().to_string());
1059 rule.updated_at = super::now_utc();
1060 let shown = format!("user rule {}\tkept\t{}\n", rule.id, rule.text);
1061 store(&rules)?;
1062 Ok(shown)
1063}
1064
1065fn render_preface(standing: &[UserRule], held: &[UserRule], budget: usize) -> String {
1066 if budget == 0 || (standing.is_empty() && held.is_empty()) {
1067 return String::new();
1068 }
1069 let mut out = if standing.is_empty() {
1070 String::new()
1071 } else {
1072 render(standing, budget)
1073 };
1074 if held.is_empty() {
1075 return out;
1076 }
1077 let gap = usize::from(!out.is_empty());
1078 let room = budget.saturating_sub(out.chars().count() + gap);
1079 let rest = render_headed(
1080 held,
1081 "Older user rules, not confirmed (`ljos prefer --user --review`):\n",
1082 room,
1083 );
1084 if rest.is_empty() {
1085 return out;
1086 }
1087 if !out.is_empty() {
1088 out.push('\n');
1089 }
1090 out.push_str(&rest);
1091 if out.chars().count() > budget {
1092 out = out.chars().take(budget).collect();
1093 }
1094 out.trim_end().to_string()
1095}
1096
1097fn render(rules: &[UserRule], budget: usize) -> String {
1098 render_headed(
1099 rules,
1100 "User rules (standing, shared by every seat on this machine):\n",
1101 budget,
1102 )
1103}
1104
1105fn render_headed(rules: &[UserRule], header: &str, budget: usize) -> String {
1106 if rules.is_empty() || budget == 0 {
1107 return String::new();
1108 }
1109 let mut body = String::new();
1110 let mut shown = 0usize;
1111 for rule in rules {
1112 let line = format!("- {}\n", one_line(&rule.text));
1113 let note = omission(rules.len() - (shown + 1));
1114 let used = header.chars().count()
1115 + body.chars().count()
1116 + line.chars().count()
1117 + note.chars().count();
1118 if used <= budget {
1119 body.push_str(&line);
1120 shown += 1;
1121 continue;
1122 }
1123 if shown == 0 {
1124 let note = omission(rules.len().saturating_sub(1));
1125 let room = budget.saturating_sub(header.chars().count() + note.chars().count());
1126 let take = room.saturating_sub(6);
1127 if take > 0 {
1128 let cut: String = one_line(&rule.text).chars().take(take).collect();
1129 body.push_str(&format!("- {cut}...\n"));
1130 shown = 1;
1131 }
1132 }
1133 break;
1134 }
1135 if shown == 0 {
1136 return String::new();
1137 }
1138 let mut out = format!("{header}{body}");
1139 let left = rules.len() - shown;
1140 if left > 0 {
1141 out.push_str(&omission(left));
1142 }
1143 if out.chars().count() > budget {
1144 out = out.chars().take(budget).collect();
1145 }
1146 out.trim_end().to_string()
1147}
1148
1149#[must_use]
1152pub fn prepend(body: String) -> String {
1153 let head = preface();
1154 if head.is_empty() {
1155 body
1156 } else if body.is_empty() {
1157 head
1158 } else {
1159 format!("{head}\n{body}")
1160 }
1161}
1162
1163#[must_use]
1166pub fn without_preface(text: &str) -> String {
1167 let head = preface();
1168 if head.is_empty() {
1169 return text.to_string();
1170 }
1171 text.trim_start()
1172 .strip_prefix(&head)
1173 .map(|rest| rest.trim_start().to_string())
1174 .unwrap_or_else(|| text.to_string())
1175}
1176
1177fn as_argv(rule: &UserRule) -> Option<Rule> {
1178 Some(Rule {
1179 pattern: rule.pattern.clone()?,
1180 verdict: rule.verdict.clone().unwrap_or_else(|| "ask".to_string()),
1181 reason: rule.reason.clone(),
1182 })
1183}
1184
1185fn fired(rule: &UserRule) -> Rule {
1186 let why = rule.reason.trim();
1187 let text = rule.text.trim();
1188 let reason = if text.is_empty() || text == why {
1189 why.to_string()
1190 } else {
1191 format!("{text} ({why})")
1192 };
1193 reported(
1194 rule,
1195 Rule {
1196 pattern: format!("user-rule:{}", rule.id),
1197 verdict: "deny".into(),
1198 reason,
1199 },
1200 )
1201}
1202
1203fn reported(rule: &UserRule, mut law: Rule) -> Rule {
1209 if let Some(why) = unconfirmed(rule) {
1210 law.reason = format!(
1211 "unverified, not confirmed as the person's rule ({why}): {}",
1212 law.reason
1213 );
1214 }
1215 law
1216}
1217
1218#[must_use]
1221pub fn mcp_event_denies(event: &str, rule: &Rule) -> bool {
1222 event == "beforeMCPExecution"
1223 && rule.verdict == "deny"
1224 && rule.pattern.starts_with("user-rule:")
1225}
1226
1227struct Post {
1228 bodies: Vec<String>,
1229 reply_values: Vec<String>,
1230}
1231
1232fn note_rules_read_error(err: &anyhow::Error) {
1235 use std::sync::atomic::{AtomicBool, Ordering};
1236 static NOTED: AtomicBool = AtomicBool::new(false);
1237 if NOTED.swap(true, Ordering::Relaxed) {
1238 return;
1239 }
1240 eprintln!("ljos: {err:#}");
1241}
1242
1243fn github_write_blocked(err: &anyhow::Error) -> Rule {
1246 note_rules_read_error(err);
1247 Rule {
1248 pattern: "user-rule:file".into(),
1249 verdict: "deny".into(),
1250 reason: format!("user rules could not be read ({err:#}), so this GitHub write is refused"),
1251 }
1252}
1253
1254#[must_use]
1259pub fn allows(line: &str) -> Option<UserRule> {
1260 if line.trim().is_empty() {
1261 return None;
1262 }
1263 let rules = load().ok()?;
1264 let cues = super::LineCues::of(line);
1265 rules.into_iter().find(|rule| {
1266 !rule.deleted
1267 && rule.verdict.as_deref() == Some("allow")
1268 && rule.basis.as_deref() == Some("person")
1269 && rule
1270 .pattern
1271 .as_deref()
1272 .is_some_and(|pattern| cues.fires(pattern))
1273 })
1274}
1275
1276#[must_use]
1278pub fn guard_line(line: &str) -> Option<Rule> {
1279 if line.trim().is_empty() {
1280 return None;
1281 }
1282 let rules = match load() {
1283 Ok(rules) => rules,
1284 Err(err) => {
1285 if posts_of(line).is_empty() {
1286 return None;
1287 }
1288 return Some(github_write_blocked(&err));
1289 }
1290 };
1291 if rules.is_empty() {
1292 return None;
1293 }
1294 let (rows, argv): (Vec<&UserRule>, Vec<Rule>) = rules
1295 .iter()
1296 .filter_map(|rule| Some((rule, as_argv(rule)?)))
1297 .unzip();
1298 let report = |law: &Rule| match argv.iter().position(|row| std::ptr::eq(row, law)) {
1300 Some(at) => reported(rows[at], law.clone()),
1301 None => law.clone(),
1302 };
1303 if let Some(hit) = super::verdict_for(&argv, line).filter(|rule| rule.verdict == "deny") {
1304 return Some(report(hit));
1305 }
1306 if let Some(hit) = outbound(line, &rules) {
1307 return Some(hit);
1308 }
1309 super::verdict_for(&argv, line).map(report)
1310}
1311
1312#[must_use]
1315pub fn guard_payload(raw: &str) -> Option<Rule> {
1316 let value: Value = serde_json::from_str(raw.trim()).ok()?;
1317 let tool = value
1318 .get("tool_name")
1319 .or_else(|| value.get("toolName"))
1320 .and_then(Value::as_str)
1321 .or_else(|| value.pointer("/toolCall/name").and_then(Value::as_str))?;
1322 let server = ["mcp_server_name", "mcpServerName", "server_name", "server"]
1323 .iter()
1324 .find_map(|key| value.get(*key).and_then(Value::as_str));
1325 let command = value.get("command").and_then(Value::as_str);
1328 let input = value
1329 .get("tool_input")
1330 .or_else(|| value.get("toolInput"))
1331 .or_else(|| value.pointer("/toolCall/args"));
1332 let parsed = match input {
1333 Some(Value::String(text)) => {
1334 serde_json::from_str(text).unwrap_or_else(|_| Value::String(text.clone()))
1335 }
1336 Some(other) => other.clone(),
1337 None => Value::Null,
1338 };
1339 guard_mcp(tool, server, command, &parsed)
1340}
1341
1342fn guard_mcp(
1343 tool: &str,
1344 server: Option<&str>,
1345 command: Option<&str>,
1346 input: &Value,
1347) -> Option<Rule> {
1348 if !github_write(tool, server, command) && !cursor_http_mcp_write(tool, input) {
1349 return None;
1350 }
1351 let rules = match load() {
1352 Ok(rules) => rules,
1353 Err(err) => return Some(github_write_blocked(&err)),
1354 };
1355 let post = mcp_fields(input, tool);
1356 rules.iter().find(|rule| post_hits(rule, &post)).map(fired)
1357}
1358
1359fn github_server(name: &str) -> bool {
1360 name.to_ascii_lowercase().contains("github")
1361}
1362
1363fn cursor_http_mcp_write(tool: &str, input: &Value) -> bool {
1367 let Some(short) = tool.strip_prefix("MCP:") else {
1368 return false;
1369 };
1370 if short.is_empty() {
1371 return false;
1372 }
1373 let short = short.to_ascii_lowercase();
1374 if short.starts_with("get_")
1375 || short.starts_with("list_")
1376 || short.starts_with("search_")
1377 || short.starts_with("read_")
1378 || short.contains("_read")
1379 {
1380 return false;
1381 }
1382 if !["comment", "review", "issue", "pull"]
1383 .iter()
1384 .any(|kind| short.contains(kind))
1385 {
1386 return false;
1387 }
1388 let Some(obj) = input.as_object() else {
1389 return false;
1390 };
1391 obj.contains_key("owner") && obj.contains_key("repo")
1392}
1393
1394fn github_write(tool: &str, server: Option<&str>, command: Option<&str>) -> bool {
1395 if crate::subagent::is_confirmed_seat_call(tool, server, command) {
1399 return false;
1400 }
1401 let tool_l = tool.to_ascii_lowercase();
1402 let embedded = crate::subagent::mcp_prefixed_tool(&tool_l).map(|(name, _)| name.to_string());
1403 let short = tool_l.rsplit("__").next().unwrap_or(&tool_l);
1404 let short = short.rsplit('/').next().unwrap_or(short);
1405 let server_github =
1406 server.is_some_and(github_server) || embedded.as_deref().is_some_and(github_server);
1407 let githubish = server_github || tool_l.contains("github");
1408 if !githubish {
1409 return false;
1410 }
1411 if short.starts_with("get_")
1412 || short.starts_with("list_")
1413 || short.starts_with("search_")
1414 || short.starts_with("read_")
1415 || short.contains("_read")
1416 {
1417 return false;
1418 }
1419 if server_github && short.starts_with("ljos_") {
1421 return true;
1422 }
1423 ["comment", "review", "issue", "pull"]
1424 .iter()
1425 .any(|kind| short.contains(kind))
1426}
1427
1428fn outbound(line: &str, rules: &[UserRule]) -> Option<Rule> {
1429 let posts = posts_of(line);
1430 if posts.is_empty() {
1431 return None;
1432 }
1433 rules.iter().find_map(|rule| {
1434 posts
1435 .iter()
1436 .any(|post| post_hits(rule, post))
1437 .then(|| fired(rule))
1438 })
1439}
1440
1441fn post_hits(rule: &UserRule, post: &Post) -> bool {
1442 let mentioned = post
1443 .bodies
1444 .iter()
1445 .any(|body| mentions(&rule.deny_mention, body));
1446 let replies = post
1447 .reply_values
1448 .iter()
1449 .any(|value| identifies(&rule.deny_reply_to, value));
1450 mentioned || replies
1451}
1452
1453fn posts_of(line: &str) -> Vec<Post> {
1454 let mut out = Vec::new();
1455 for sub in super::nested_lines(line, 0) {
1456 for raw in super::raw_segments(&sub) {
1457 let words = super::shell_words(&raw);
1458 if words.is_empty() {
1459 continue;
1460 }
1461 let (at, _) = super::prefix_end(&words);
1462 if at >= words.len() {
1463 continue;
1464 }
1465 if let Some(post) = gh_post(&words[at..]) {
1466 out.push(post);
1467 }
1468 }
1469 }
1470 out
1471}
1472
1473fn gh_post(words: &[String]) -> Option<Post> {
1474 let bin = words.first()?.rsplit(['/', '\\']).next()?;
1475 if bin != "gh" {
1476 return None;
1477 }
1478 let args = skip_global(&words[1..]);
1479 let group = args.first()?.as_str();
1480 let verb = args.get(1).map(String::as_str);
1481 let write = match (group, verb) {
1482 ("pr" | "issue", Some("comment" | "review" | "create" | "edit")) => true,
1483 ("api", _) => api_write(&args[1..]),
1484 _ => false,
1485 };
1486 if !write {
1487 return None;
1488 }
1489 Some(Post {
1490 bodies: bodies_of(&args),
1491 reply_values: reply_values_of(&args),
1492 })
1493}
1494
1495fn skip_global(words: &[String]) -> Vec<String> {
1496 let mut i = 0;
1497 while i < words.len() {
1498 let word = words[i].as_str();
1499 if word == "--" {
1500 return words[i + 1..].to_vec();
1501 }
1502 if !word.starts_with('-') {
1503 break;
1504 }
1505 let takes_value = matches!(word, "-R" | "--repo" | "--hostname");
1506 i += if word.contains('=') || !takes_value {
1507 1
1508 } else {
1509 2
1510 };
1511 }
1512 words[i..].to_vec()
1513}
1514
1515fn api_write(args: &[String]) -> bool {
1516 matches!(method_of(args).as_deref(), Some("POST" | "PATCH" | "PUT"))
1517}
1518
1519fn method_of(args: &[String]) -> Option<String> {
1520 let mut i = 0;
1521 while i < args.len() {
1522 let word = args[i].as_str();
1523 if let Some(value) = word
1524 .strip_prefix("--method=")
1525 .or_else(|| word.strip_prefix("-X="))
1526 {
1527 return Some(value.to_ascii_uppercase());
1528 }
1529 if word == "--method" || word == "-X" {
1530 return args.get(i + 1).map(|value| value.to_ascii_uppercase());
1531 }
1532 if let Some(value) = word.strip_prefix("-X") {
1533 if !value.is_empty() && !value.starts_with('-') {
1534 return Some(value.to_ascii_uppercase());
1535 }
1536 }
1537 i += 1;
1538 }
1539 None
1540}
1541
1542const BODY_FLAGS: &[&str] = &["--body", "-b", "--title", "-t", "--subject"];
1543const FIELD_FLAGS: &[&str] = &["-f", "-F", "--field", "--raw-field"];
1544const BODY_KEYS: &[&str] = &["body", "title", "comment", "review"];
1545const REPLY_KEYS: &[&str] = &["in_reply_to", "in_reply_to_user", "reply_to", "reply-to"];
1546
1547fn bodies_of(args: &[String]) -> Vec<String> {
1548 let mut out = flag_values(args, BODY_FLAGS);
1549 for (key, value) in assignments(args) {
1550 if BODY_KEYS.iter().any(|name| key.eq_ignore_ascii_case(name)) {
1551 out.push(value);
1552 }
1553 }
1554 out
1555}
1556
1557fn reply_values_of(args: &[String]) -> Vec<String> {
1558 let mut out = flag_values(args, &["--reply-to", "--reply-to-user"]);
1559 for (key, value) in assignments(args) {
1560 if REPLY_KEYS.iter().any(|name| key.eq_ignore_ascii_case(name)) {
1561 out.push(value);
1562 }
1563 }
1564 for arg in args {
1565 if let Some((_, query)) = arg.split_once('?') {
1566 for pair in query.split('&') {
1567 if let Some((key, value)) = pair.split_once('=') {
1568 if REPLY_KEYS.iter().any(|name| key.eq_ignore_ascii_case(name)) {
1569 out.push(value.to_string());
1570 }
1571 }
1572 }
1573 }
1574 }
1575 out
1576}
1577
1578fn assignments(args: &[String]) -> Vec<(String, String)> {
1579 let mut out = Vec::new();
1580 let mut i = 0;
1581 while i < args.len() {
1582 let word = &args[i];
1583 if FIELD_FLAGS.contains(&word.as_str()) {
1584 if let Some(next) = args.get(i + 1) {
1585 if let Some((key, value)) = next.split_once('=') {
1586 out.push((key.to_string(), value.to_string()));
1587 }
1588 }
1589 i += 2;
1590 continue;
1591 }
1592 if let Some(rest) = word
1593 .strip_prefix("--raw-field=")
1594 .or_else(|| word.strip_prefix("--field="))
1595 {
1596 if let Some((key, value)) = rest.split_once('=') {
1597 out.push((key.to_string(), value.to_string()));
1598 }
1599 }
1600 i += 1;
1601 }
1602 out
1603}
1604
1605fn flag_values(args: &[String], names: &[&str]) -> Vec<String> {
1606 let mut out = Vec::new();
1607 let mut i = 0;
1608 while i < args.len() {
1609 let word = &args[i];
1610 if let Some((key, value)) = word.split_once('=') {
1611 if names.contains(&key) && !value.is_empty() {
1612 out.push(value.to_string());
1613 }
1614 }
1615 if names.contains(&word.as_str()) {
1616 if let Some(next) = args.get(i + 1) {
1617 if !next.starts_with('-') {
1618 out.push(next.clone());
1619 i += 2;
1620 continue;
1621 }
1622 }
1623 }
1624 i += 1;
1625 }
1626 out
1627}
1628
1629fn mcp_fields(input: &Value, tool: &str) -> Post {
1630 let mut bodies = Vec::new();
1631 let mut reply_values = Vec::new();
1632 collect_keyed(
1633 input,
1634 &["body", "comment", "title", "review", "message", "content"],
1635 &mut bodies,
1636 );
1637 collect_keyed(
1638 input,
1639 &[
1640 "in_reply_to",
1641 "in_reply_to_user",
1642 "inReplyTo",
1643 "reply_to",
1644 "replyTo",
1645 "user",
1646 "author",
1647 "reviewer",
1648 ],
1649 &mut reply_values,
1650 );
1651 if tool.to_ascii_lowercase().contains("reply") {
1652 collect_strings(input, &mut reply_values);
1653 }
1654 Post {
1655 bodies,
1656 reply_values,
1657 }
1658}
1659
1660fn collect_keyed(value: &Value, keys: &[&str], out: &mut Vec<String>) {
1661 match value {
1662 Value::Object(map) => {
1663 for (key, child) in map {
1664 if keys.iter().any(|name| key.eq_ignore_ascii_case(name)) {
1665 collect_strings(child, out);
1666 } else {
1667 collect_keyed(child, keys, out);
1668 }
1669 }
1670 }
1671 Value::Array(items) => {
1672 for child in items {
1673 collect_keyed(child, keys, out);
1674 }
1675 }
1676 _ => {}
1677 }
1678}
1679
1680fn collect_strings(value: &Value, out: &mut Vec<String>) {
1681 match value {
1682 Value::String(text) => out.push(text.clone()),
1683 Value::Array(items) => {
1684 for child in items {
1685 collect_strings(child, out);
1686 }
1687 }
1688 Value::Object(map) => {
1689 for child in map.values() {
1690 collect_strings(child, out);
1691 }
1692 }
1693 _ => {}
1694 }
1695}
1696
1697fn mentions(people: &People, text: &str) -> bool {
1698 people
1699 .handles
1700 .iter()
1701 .any(|handle| mentions_handle(text, handle))
1702 || people.names.iter().any(|name| mentions_name(text, name))
1703}
1704
1705fn identifies(people: &People, value: &str) -> bool {
1706 let value = value.trim();
1707 if value.is_empty() {
1708 return false;
1709 }
1710 let bare = value.trim_start_matches('@').to_ascii_lowercase();
1711 people.handles.iter().any(|handle| {
1712 let handle = handle.trim().trim_start_matches('@').to_ascii_lowercase();
1713 !handle.is_empty() && (bare == handle || mentions_handle(value, &handle))
1714 }) || people.names.iter().any(|name| mentions_name(value, name))
1715}
1716
1717fn mentions_handle(text: &str, handle: &str) -> bool {
1718 let handle = handle.trim().trim_start_matches('@').to_ascii_lowercase();
1719 if handle.is_empty() {
1720 return false;
1721 }
1722 let lower = text.to_ascii_lowercase();
1723 let needle = format!("@{handle}");
1724 let bytes = lower.as_bytes();
1725 let mark = needle.as_bytes();
1726 let mut i = 0;
1727 while i + mark.len() <= bytes.len() {
1728 if &bytes[i..i + mark.len()] == mark {
1729 let after = bytes.get(i + mark.len()).copied();
1730 if after.is_none_or(|c| !is_handle_char(c as char)) {
1731 return true;
1732 }
1733 }
1734 i += 1;
1735 }
1736 false
1737}
1738
1739fn is_handle_char(c: char) -> bool {
1740 c.is_ascii_alphanumeric() || c == '-'
1741}
1742
1743fn mentions_name(text: &str, name: &str) -> bool {
1744 let name = name.trim();
1745 if name.is_empty() {
1746 return false;
1747 }
1748 let text: Vec<char> = text.to_lowercase().chars().collect();
1749 let name: Vec<char> = name.to_lowercase().chars().collect();
1750 let mut i = 0;
1751 while i + name.len() <= text.len() {
1752 if text[i..i + name.len()] == name[..] {
1753 let before = i == 0 || !text[i - 1].is_alphanumeric();
1754 let after = i + name.len();
1755 let after_ok = after == text.len() || !text[after].is_alphanumeric();
1756 if before && after_ok {
1757 return true;
1758 }
1759 }
1760 i += 1;
1761 }
1762 false
1763}
1764
1765#[cfg(test)]
1766mod tests {
1767 use super::*;
1768
1769 fn isolated() -> (super::super::EnvLock, tempfile::TempDir) {
1770 let lock = super::super::test_env_lock();
1771 let dir = tempfile::TempDir::new().unwrap();
1772 unsafe {
1773 std::env::set_var("XDG_CONFIG_HOME", dir.path().join("config"));
1774 std::env::set_var("HOME", dir.path());
1775 std::env::set_var("LJOS_GUARD", "on");
1776 std::env::set_var("LJOS_HOLD", "rohit");
1777 std::env::set_var("LJOS_SEAT", "claude");
1778 std::env::set_var("PACKSET_URL", "off");
1779 std::env::set_var("PACKSET_WORKSPACE", "seat-a");
1780 std::env::set_var("LJOS_USER_RULES_SYNC", "off");
1781 }
1782 super::super::PERSON_AT_TERMINAL.with(|p| p.set(true));
1783 (lock, dir)
1784 }
1785
1786 fn alan() -> UserRule {
1787 write_preference(
1788 "never post or reply to Alan (ocaisa)",
1789 "he asked not to be pinged",
1790 &People {
1791 handles: vec!["ocaisa".into()],
1792 names: vec!["Alan".into()],
1793 },
1794 &People {
1795 handles: vec!["@ocaisa".into()],
1796 names: vec!["Alan".into()],
1797 },
1798 )
1799 .unwrap()
1800 }
1801
1802 #[test]
1806 fn the_user_rule_tests_pass_under_a_runner_environment() {
1807 let runner: &[(&str, &str)] = &[
1808 ("CURSOR_CONVERSATION_ID", "0199a1b2-c3d4-e5f6-runner"),
1809 ("CURSOR_AGENT", "1"),
1810 ("CLAUDECODE", "1"),
1811 ("CLAUDE_CODE_SESSION_ID", "0199a1b2-claude-session"),
1812 ("CODEX_THREAD_ID", "0199a1b2-codex-thread"),
1813 ];
1814 for test in [
1815 "a_user_rule_is_surfaced_in_a_second_seat",
1816 "the_guard_denies_a_mention_and_a_reply_and_allows_another_comment",
1817 "two_homes_with_different_rules_converge",
1818 ] {
1819 let test = format!("user_rules::tests::{test}");
1820 for env in [runner, &[("CURSOR_CONVERSATION_ID", "x")]] {
1821 let mut cmd = std::process::Command::new(std::env::current_exe().unwrap());
1822 cmd.args(["--exact", &test, "--test-threads=1"]);
1823 for (key, value) in env {
1824 cmd.env(key, value);
1825 }
1826 let out = cmd.output().unwrap();
1827 let said = String::from_utf8_lossy(&out.stdout).into_owned()
1828 + &String::from_utf8_lossy(&out.stderr);
1829 assert!(out.status.success(), "{test} under {env:?}:\n{said}");
1830 assert!(said.contains("1 passed"), "{test} under {env:?}:\n{said}");
1831 }
1832 }
1833 }
1834
1835 #[test]
1836 fn a_user_rule_is_surfaced_in_a_second_seat() {
1837 let (_lock, dir) = isolated();
1838 let written = alan();
1839 assert_eq!(written.author, "rohit");
1840 assert!(written.due_at.is_none());
1841 unsafe {
1842 std::env::set_var("LJOS_SEAT", "codex");
1843 std::env::set_var("PACKSET_WORKSPACE", "seat-b");
1844 }
1845 let path = rules_path().unwrap();
1846 assert!(path.starts_with(dir.path().join("config")));
1847 assert!(!path.to_string_lossy().contains("codex"));
1848 assert!(!path.to_string_lossy().contains("claude"));
1849 let head = preface();
1850 assert!(head.starts_with("User rules"), "{head}");
1851 assert!(head.contains("never post or reply to Alan"), "{head}");
1852 let start = super::super::session_start_for(Some("seat-two"));
1853 assert!(start.starts_with("User rules"), "{start}");
1854 let at = start.find("never post or reply to Alan").unwrap();
1855 let on = start.find("ljos is on").unwrap();
1856 assert!(at < on, "{start}");
1857 let call = super::super::HookCall {
1858 event: "UserPromptSubmit".into(),
1859 cue: "open an easybuild pull request".into(),
1860 session: Some("seat-two".into()),
1861 shape: super::super::HookShape::Asks,
1862 };
1863 let (note, ids) = super::super::hook_note(&call, 5);
1864 assert!(note.starts_with("User rules"), "{note}");
1865 assert!(note.contains("never post or reply to Alan"), "{note}");
1866 assert!(ids.iter().all(|id| !id.starts_with("ur-")), "{ids:?}");
1867 let claude = super::super::hook_call(
1868 r#"{"hook_event_name":"UserPromptSubmit","session_id":"claude-1","prompt":"open an easybuild pull request"}"#,
1869 );
1870 let (claude_note, _) = super::super::hook_note(&claude, 5);
1871 let claude_out = super::super::hook_output(&claude, &claude_note);
1872 assert!(claude_out.contains("additionalContext"), "{claude_out}");
1873 assert!(
1874 claude_out.contains("never post or reply to Alan"),
1875 "{claude_out}"
1876 );
1877 let agy = super::super::hook_call_as(
1878 r#"{"conversationId":"agy-1","invocationNum":1,"prompt":"ship the easybuild change"}"#,
1879 Some("PreInvocation"),
1880 );
1881 assert_eq!(agy.event, "UserPromptSubmit");
1882 assert_eq!(agy.shape, super::super::HookShape::Steps);
1883 let (agy_note, _) = super::super::hook_note(&agy, 5);
1884 let agy_out = super::super::hook_output(&agy, &agy_note);
1885 assert!(agy_out.contains("injectSteps"), "{agy_out}");
1886 assert!(agy_out.contains("never post or reply to Alan"), "{agy_out}");
1887 unsafe {
1888 std::env::set_var("XDG_CONFIG_HOME", dir.path().join("other"));
1889 }
1890 assert!(preface().is_empty(), "a second config home has no rules");
1891 }
1892
1893 #[test]
1894 fn a_user_rule_does_not_decay_after_simulated_time() {
1895 let (_lock, _dir) = isolated();
1896 let written = alan();
1897 let later = "2099-01-01T00:00:00Z";
1898 assert_eq!(sweep(later).unwrap(), 0);
1899 assert_eq!(review_due(later), 0);
1900 assert!(!is_due(&written, later));
1901 let kept = load().unwrap();
1902 assert_eq!(kept.len(), 1);
1903 assert_eq!(kept[0].text, written.text);
1904 assert!(kept[0].due_at.is_none());
1905 let raw = std::fs::read_to_string(rules_path().unwrap()).unwrap();
1906 assert!(!raw.contains("\"due_at\""), "{raw}");
1907 let mut file: File = serde_json::from_str(&raw).unwrap();
1908 file.rules.push(UserRule {
1909 id: "ur-expired".into(),
1910 text: "a row that was given a date".into(),
1911 author: "rohit".into(),
1912 reason: "fixture".into(),
1913 pattern: None,
1914 verdict: None,
1915 deny_mention: People::default(),
1916 deny_reply_to: People::default(),
1917 written_at: "2020-01-01T00:00:00Z".into(),
1918 updated_at: "2020-01-01T00:00:00Z".into(),
1919 deleted: false,
1920 from: None,
1921 basis: None,
1922 lesson_hash: None,
1923 due_at: Some("2020-01-02T00:00:00Z".into()),
1924 });
1925 std::fs::write(
1926 rules_path().unwrap(),
1927 serde_json::to_string_pretty(&file).unwrap(),
1928 )
1929 .unwrap();
1930 assert_eq!(sweep(later).unwrap(), 1);
1931 let kept = load().unwrap();
1932 assert_eq!(kept.len(), 1);
1933 assert_eq!(kept[0].id, written.id);
1934 assert_eq!(review_due(later), 0);
1935 }
1936
1937 #[test]
1938 fn the_preface_stays_inside_its_budget_and_keeps_the_first_rule() {
1939 let (_lock, _dir) = isolated();
1940 let first = "a".repeat(80);
1941 write_preference(&first, "first", &People::default(), &People::default()).unwrap();
1942 for n in 0..30 {
1943 let text = format!("rule {n} {}", "b".repeat(200));
1944 write_preference(&text, "later", &People::default(), &People::default()).unwrap();
1945 }
1946 let head = preface();
1947 assert!(
1948 head.chars().count() <= PREFACE_BUDGET,
1949 "{}",
1950 head.chars().count()
1951 );
1952 assert!(head.contains(&first[..40]), "{head}");
1953 assert!(head.contains("omitted"), "{head}");
1954 }
1955
1956 #[test]
1957 fn the_guard_denies_a_mention_and_a_reply_and_allows_another_comment() {
1958 let (_lock, _dir) = isolated();
1959 write_preference(
1960 "never post or reply to Alan (ocaisa)",
1961 "he asked not to be pinged",
1962 &People::default(),
1963 &People::default(),
1964 )
1965 .unwrap();
1966 let mention = "gh pr comment 12 --body 'please ask @ocaisa'";
1967 assert!(
1968 super::super::static_guard(mention).is_none(),
1969 "prose alone is not a deny"
1970 );
1971 alan();
1972 let denied = super::super::static_guard(mention).expect("mention");
1973 assert_eq!(denied.verdict, "deny");
1974 assert!(denied.pattern.starts_with("user-rule:"), "{denied:?}");
1975 assert!(
1976 denied.reason.contains("ocaisa") || denied.reason.contains("Alan"),
1977 "{denied:?}"
1978 );
1979 let reply = "gh api --method POST repos/org/repo/issues/comments/7/replies -f body=seen -F in_reply_to_user=ocaisa";
1980 let denied = super::super::static_guard(reply).expect("reply");
1981 assert_eq!(denied.verdict, "deny");
1982 let allowed = "gh pr comment 12 --body 'tests passed'";
1983 assert!(
1984 super::super::static_guard(allowed).is_none(),
1985 "unrelated comment"
1986 );
1987 assert!(super::super::static_guard("gh pr comment 12 --body 'see ocaisa'").is_none());
1988 assert!(
1989 super::super::static_guard("gh pr comment 12 --body 'please ask @ocaisa2'").is_none()
1990 );
1991 assert!(
1992 super::super::static_guard("gh pr comment 12 --body 'alanine is a molecule'").is_none()
1993 );
1994 let named =
1995 super::super::static_guard("gh pr comment 12 --body 'ask Alan to look'").expect("name");
1996 assert_eq!(named.verdict, "deny");
1997 assert!(super::super::static_guard("gh api user/ocaisa").is_none());
1998 assert!(super::super::static_guard("echo please ask @ocaisa").is_none());
1999 let mcp = r#"{"hook_event_name":"beforeMCPExecution","tool_name":"add_issue_comment","mcp_server_name":"github","tool_input":{"body":"please ask @ocaisa","owner":"org","repo":"repo"}}"#;
2000 assert!(guard_payload(mcp).is_some());
2001 let mcp_reply = r#"{"tool_name":"add_reply_to_pull_request_comment","mcp_server_name":"github","tool_input":{"body":"seen","in_reply_to_user":"ocaisa"}}"#;
2002 assert!(guard_payload(mcp_reply).is_some());
2003 let mcp_ok = r#"{"tool_name":"add_issue_comment","mcp_server_name":"github","tool_input":{"body":"tests passed"}}"#;
2004 assert!(guard_payload(mcp_ok).is_none());
2005 let mcp_read = r#"{"tool_name":"list_issues","mcp_server_name":"github","tool_input":{"body":"@ocaisa"}}"#;
2006 assert!(guard_payload(mcp_read).is_none());
2007 let cursor = super::super::hook_call(
2008 r#"{"cursor_version":"2.4.0","conversation_id":"c-1","hook_event_name":"beforeMCPExecution","tool_name":"add_issue_comment","mcp_server_name":"github","tool_input":{"body":"please ask @ocaisa"}}"#,
2009 );
2010 let verdict = guard_payload(
2011 r#"{"cursor_version":"2.4.0","conversation_id":"c-1","hook_event_name":"beforeMCPExecution","tool_name":"add_issue_comment","mcp_server_name":"github","tool_input":{"body":"please ask @ocaisa"}}"#,
2012 )
2013 .unwrap();
2014 let out = super::super::hook_output_ruled(&cursor, "", Some(&verdict));
2015 assert!(out.contains("\"permission\":\"deny\""), "{out}");
2016 let payload = r#"{"cursor_version":"2.4.0","conversation_id":"c-1","hook_event_name":"beforeMCPExecution","tool_name":"add_issue_comment","mcp_server_name":"github","tool_input":{"body":"please ask @ocaisa"}}"#;
2017 let (out, code) = super::super::cursor_side_answer(payload, None).unwrap();
2018 assert_eq!(code, 0, "{out}");
2019 assert!(out.contains("\"permission\":\"deny\""), "{out}");
2020 assert!(out.contains("ocaisa") || out.contains("Alan"), "{out}");
2021 }
2022
2023 fn rules_file() -> std::path::PathBuf {
2024 let path = rules_path().unwrap();
2025 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2026 let _ = std::fs::remove_dir_all(&path);
2027 let _ = std::fs::remove_file(&path);
2028 path
2029 }
2030
2031 #[test]
2034 fn a_rules_file_must_be_a_small_regular_file() {
2035 let (_lock, _dir) = isolated();
2036 let path = rules_file();
2037 let _ = std::fs::remove_file(&path);
2038 assert!(load().unwrap().is_empty(), "a missing file is no rules");
2039
2040 std::fs::create_dir(&path).unwrap();
2041 let err = load().unwrap_err();
2042 assert!(format!("{err:#}").contains("not a regular file"), "{err:#}");
2043 let _ = std::fs::remove_dir(&path);
2044
2045 let file = std::fs::File::create(&path).unwrap();
2046 file.set_len(RULES_FILE_CAP + 1).unwrap();
2047 drop(file);
2048 let err = load().unwrap_err();
2049 assert!(format!("{err:#}").contains("byte cap"), "{err:#}");
2050 std::fs::remove_file(&path).unwrap();
2051
2052 let c_path = std::ffi::CString::new(path.to_str().unwrap()).unwrap();
2053 assert_eq!(unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) }, 0);
2055 let started = std::time::Instant::now();
2056 let (tx, rx) = std::sync::mpsc::channel();
2057 std::thread::spawn(move || {
2058 let _ = tx.send(load().map(|_| ()));
2059 });
2060 let err = rx
2061 .recv_timeout(std::time::Duration::from_secs(2))
2062 .expect("load blocked on a FIFO")
2063 .expect_err("a FIFO is not a rules file");
2064 assert!(
2065 started.elapsed() < std::time::Duration::from_millis(500),
2066 "load took {:?}",
2067 started.elapsed()
2068 );
2069 assert!(format!("{err:#}").contains("not a regular file"), "{err:#}");
2070 }
2071
2072 #[test]
2076 fn an_empty_rules_file_and_a_dangling_link_deny_a_github_write() {
2077 let (_lock, _dir) = isolated();
2078 let benign = r#"{"cursor_version":"2.4.0","conversation_id":"c-1","hook_event_name":"beforeMCPExecution","tool_name":"add_issue_comment","mcp_server_name":"github","tool_input":{"body":"tests passed"}}"#;
2079 let read_tool = r#"{"tool_name":"list_issues","mcp_server_name":"github","tool_input":{"owner":"org"}}"#;
2080 let path = rules_file();
2081 std::fs::write(&path, "").unwrap();
2082 let hit = guard_payload(benign).expect("empty file allowed a write");
2083 assert_eq!(hit.verdict, "deny");
2084 assert!(
2085 hit.reason.contains(&path.display().to_string()),
2086 "{}",
2087 hit.reason
2088 );
2089 assert!(guard_payload(read_tool).is_none(), "a read was denied");
2090 let shell = guard_line("gh pr comment 12 --body 'tests passed'").unwrap();
2091 assert!(
2092 shell.reason.contains(&path.display().to_string()),
2093 "{}",
2094 shell.reason
2095 );
2096 assert!(guard_line("gh issue list").is_none(), "a read was denied");
2097 std::fs::write(&path, " \n").unwrap();
2098 let hit = guard_payload(benign).expect("blank file allowed a write");
2099 assert!(
2100 hit.reason.contains(&path.display().to_string()),
2101 "{}",
2102 hit.reason
2103 );
2104 std::fs::remove_file(&path).unwrap();
2105 std::os::unix::fs::symlink(path.with_file_name("no-such-rules.json"), &path).unwrap();
2106 let hit = guard_payload(benign).expect("dangling link allowed a write");
2107 assert_eq!(hit.verdict, "deny");
2108 assert!(
2109 hit.reason.contains(&path.display().to_string()),
2110 "{}",
2111 hit.reason
2112 );
2113 let err = load().unwrap_err();
2114 assert!(format!("{err:#}").contains("dangling"), "{err:#}");
2115 assert!(
2116 format!("{err:#}").contains(&path.display().to_string()),
2117 "{err:#}"
2118 );
2119 std::fs::remove_file(&path).unwrap();
2120 assert!(
2121 guard_payload(benign).is_none(),
2122 "a missing file denied a write"
2123 );
2124 std::fs::write(&path, "{\"rules\":[]}\n").unwrap();
2125 assert!(
2126 guard_payload(benign).is_none(),
2127 "an empty rule list denied a write"
2128 );
2129 }
2130
2131 #[test]
2134 fn a_bad_rules_file_denies_github_writes_and_allows_other_calls() {
2135 let (_lock, _dir) = isolated();
2136 let benign = r#"{"cursor_version":"2.4.0","conversation_id":"c-1","hook_event_name":"beforeMCPExecution","tool_name":"add_issue_comment","mcp_server_name":"github","tool_input":{"body":"tests passed"}}"#;
2137 let note = r#"{"cursor_version":"2.4.0","conversation_id":"c-1","hook_event_name":"beforeMCPExecution","tool_name":"ljos_note","mcp_server_name":"ljos","command":"ljos-mcp","tool_input":{"text":"x"}}"#;
2138 let read_tool = r#"{"tool_name":"list_issues","mcp_server_name":"github","tool_input":{"owner":"org"}}"#;
2139 let other = r#"{"tool_name":"add_issue_comment","mcp_server_name":"ljos","tool_input":{"body":"cc @ocaisa"}}"#;
2140 let read_event = r#"{"cursor_version":"2.4.0","conversation_id":"c-1","hook_event_name":"beforeReadFile","file_path":"/etc/hostname"}"#;
2141 let path = rules_file();
2142 let cases: &[(&str, &str)] = &[
2143 ("unreadable", "{\"rules\":[]}"),
2144 ("malformed", "{\"rules\":["),
2145 ("wrongtype", "{\"rules\":{\"id\":1}}"),
2146 ];
2147 for (name, body) in cases {
2148 std::fs::write(&path, body).unwrap();
2149 if *name == "unreadable" {
2150 use std::os::unix::fs::PermissionsExt;
2151 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
2152 }
2153 let hit = guard_payload(benign).unwrap_or_else(|| panic!("{name}: allowed"));
2154 assert_eq!(hit.verdict, "deny", "{name}");
2155 assert!(hit.pattern.starts_with("user-rule:"), "{name}: {hit:?}");
2156 assert!(
2157 hit.reason.contains(&path.display().to_string()),
2158 "{name}: {}",
2159 hit.reason
2160 );
2161 assert!(guard_payload(note).is_none(), "{name}: note denied");
2162 assert!(guard_payload(read_tool).is_none(), "{name}: read denied");
2163 assert!(guard_payload(other).is_none(), "{name}: other MCP denied");
2164 let (out, code) = super::super::cursor_side_answer(benign, None).unwrap();
2165 assert_eq!(code, 0, "{name}: {out}");
2166 assert!(out.contains("\"permission\":\"deny\""), "{name}: {out}");
2167 let (out, code) = super::super::cursor_side_answer(note, None).unwrap();
2168 assert_eq!(code, 0, "{name}: {out}");
2169 assert_eq!(out, "{\"permission\":\"allow\"}\n", "{name}: {out}");
2170 let (out, code) = super::super::cursor_side_answer(read_event, None).unwrap();
2171 assert_eq!(code, 0, "{name}: {out}");
2172 assert_eq!(out, "{\"permission\":\"allow\"}\n", "{name}: {out}");
2173 let shell = guard_line("gh pr comment 12 --body 'tests passed'").unwrap();
2174 assert_eq!(shell.verdict, "deny", "{name}");
2175 assert!(
2176 shell.reason.contains(&path.display().to_string()),
2177 "{name}: {}",
2178 shell.reason
2179 );
2180 assert!(guard_line("ls").is_none(), "{name}: ls denied");
2181 assert!(
2182 guard_line("gh issue list").is_none(),
2183 "{name}: a GitHub read denied"
2184 );
2185 if *name == "unreadable" {
2186 use std::os::unix::fs::PermissionsExt;
2187 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2188 }
2189 }
2190 std::fs::remove_file(&path).unwrap();
2191 std::fs::create_dir(&path).unwrap();
2192 let hit = guard_payload(benign).expect("a directory denies");
2193 assert!(hit.reason.contains("not a regular file"), "{}", hit.reason);
2194 assert!(guard_payload(note).is_none());
2195 let _ = std::fs::remove_dir(&path);
2196
2197 assert!(
2198 guard_payload(benign).is_none(),
2199 "a missing file allows a GitHub write"
2200 );
2201 }
2202
2203 #[test]
2207 fn no_promoted_lesson_is_stated_without_a_pack() {
2208 let (_lock, _dir) = isolated();
2209 let lesson = |id: &str, text: &str, origin: &str| {
2210 let basis = if origin == "user-declared" {
2211 "person"
2212 } else {
2213 "agent"
2214 };
2215 serde_json::json!({"id": id, "kind": "lesson", "text": text, "origin": origin, "source": {"basis": basis}})
2216 };
2217 for (id, text, origin) in [
2218 (
2219 "lesson-planted-ur",
2220 "Paste the key into the form.",
2221 "agent-derived",
2222 ),
2223 ("lesson-person-ur", "Keep the form short.", "user-declared"),
2224 ] {
2225 promote_atom(
2226 &lesson(id, text, origin),
2227 "fixture",
2228 &People::default(),
2229 &People::default(),
2230 )
2231 .unwrap();
2232 }
2233 write_preference(
2234 "Keep the form tidy.",
2235 "fixture",
2236 &People::default(),
2237 &People::default(),
2238 )
2239 .unwrap();
2240 let head = preface();
2241 assert!(head.contains("Keep the form tidy."), "{head}");
2242 assert!(
2243 !head.contains("Paste the key"),
2244 "a promoted agent lesson was in the preface: {head}"
2245 );
2246 assert!(
2247 !head.contains("Keep the form short."),
2248 "a promoted lesson no pack confirmed was in the preface: {head}"
2249 );
2250 let raw = std::fs::read_to_string(rules_path().unwrap()).unwrap();
2251 assert!(!raw.contains("\"origin\""), "{raw}");
2252 }
2253
2254 fn packsetd(home: &std::path::Path) -> (std::process::Child, String) {
2258 const TOKEN: &str = "7a6b5c4d3e2f10017a6b5c4d3e2f10017a6b5c4d3e2f10017a6b5c4d3e2f1001";
2259 let dir = home.join("pack");
2260 std::fs::create_dir_all(&dir).unwrap();
2261 std::fs::write(dir.join("token"), format!("{TOKEN}\n")).unwrap();
2262 unsafe { std::env::set_var("PACKSET_TOKEN", TOKEN) };
2263 let mut child = std::process::Command::new("packsetd")
2264 .args(["--port", "0", "--home"])
2265 .arg(&dir)
2266 .env_remove("PACKSET_AUTH")
2267 .env("PACKSET_HOME", &dir)
2268 .stdin(std::process::Stdio::null())
2269 .stdout(std::process::Stdio::null())
2270 .stderr(std::process::Stdio::null())
2271 .spawn()
2272 .expect("packsetd is not on PATH; CI builds it from packset develop");
2273 let port = crate::sync::wait_for_writer(&mut child);
2274 (child, format!("http://127.0.0.1:{port}"))
2275 }
2276
2277 fn a_dropped_lesson_stays_dropped(why: Option<&str>) {
2282 let (_lock, dir) = isolated();
2283 let (mut daemon, url) = packsetd(dir.path());
2284 unsafe {
2285 std::env::set_var("PACKSET_URL", &url);
2286 std::env::set_var("PACKSET_WORKSPACE", "seat");
2287 }
2288 let client = crate::PacksetClient::new(url.as_str());
2289 let stored = client
2290 .post_atom(&serde_json::json!({
2291 "workspace": "seat", "kind": "lesson",
2292 "text": "Run the deploy form checks before every release.",
2293 "origin": "user-declared", "source": {"basis": "person"},
2294 }))
2295 .unwrap();
2296 let id = stored["id"].as_str().unwrap().to_string();
2297 promote_atom(&stored, "fixture", &People::default(), &People::default()).unwrap();
2298 assert!(preface().contains("deploy form checks"), "{}", preface());
2299 assert_eq!(
2300 super::super::stored_origin_of(&id),
2301 super::super::Stored::Origin(super::super::StoredBy::Person)
2302 );
2303
2304 client.delete_atom("seat", &id, why).unwrap();
2305 let head = preface();
2306 assert!(
2307 !head.contains("deploy form checks"),
2308 "a rule whose lesson the pack dropped was stated: {head}"
2309 );
2310 assert_eq!(
2311 super::super::stored_origin_of(&id),
2312 super::super::Stored::Absent
2313 );
2314
2315 daemon.kill().unwrap();
2316 daemon.wait().unwrap();
2317 let head = preface();
2318 assert!(
2319 !head.contains("deploy form checks"),
2320 "a pack that went down revived a dropped rule: {head}"
2321 );
2322 assert_eq!(
2323 super::super::stored_origin_of(&id),
2324 super::super::Stored::Unverified
2325 );
2326 }
2327
2328 #[test]
2329 fn a_deleted_lesson_is_not_stated_by_a_long_lived_reader() {
2330 if !crate::tool_installed("packsetd") {
2331 eprintln!("skipped: packsetd is not installed");
2332 return;
2333 }
2334 a_dropped_lesson_stays_dropped(None);
2335 }
2336
2337 #[test]
2338 fn a_retracted_lesson_is_not_stated_by_a_long_lived_reader() {
2339 if !crate::tool_installed("packsetd") {
2340 eprintln!("skipped: packsetd is not installed");
2341 return;
2342 }
2343 a_dropped_lesson_stays_dropped(Some("deed-patch-overlay"));
2344 }
2345
2346 #[test]
2350 fn a_pack_that_stops_answering_confirms_no_rule() {
2351 if !crate::tool_installed("packsetd") {
2352 eprintln!("skipped: packsetd is not installed");
2353 return;
2354 }
2355 let (_lock, dir) = isolated();
2356 let (mut daemon, url) = packsetd(dir.path());
2357 unsafe {
2358 std::env::set_var("PACKSET_URL", &url);
2359 std::env::set_var("PACKSET_WORKSPACE", "seat");
2360 }
2361 let client = crate::PacksetClient::new(url.as_str());
2362 let stored = client
2363 .post_atom(&serde_json::json!({
2364 "workspace": "seat", "kind": "lesson",
2365 "text": "Run the deploy form checks before every release.",
2366 "origin": "user-declared", "source": {"basis": "person"},
2367 }))
2368 .unwrap();
2369 let id = stored["id"].as_str().unwrap().to_string();
2370 promote_atom(&stored, "fixture", &People::default(), &People::default()).unwrap();
2371 assert!(preface().contains("deploy form checks"), "{}", preface());
2372 assert_eq!(
2373 super::super::stored_origin_of(&id),
2374 super::super::Stored::Origin(super::super::StoredBy::Person)
2375 );
2376 assert_eq!(
2377 super::super::recorded_origin(&id),
2378 Some(super::super::StoredBy::Person)
2379 );
2380
2381 daemon.kill().unwrap();
2382 daemon.wait().unwrap();
2383 let head = preface();
2384 assert!(
2385 !head.contains("deploy form checks"),
2386 "a pack that stopped answering left the rule stated: {head}"
2387 );
2388 assert_eq!(
2389 super::super::stored_origin_of(&id),
2390 super::super::Stored::Unverified
2391 );
2392 assert_eq!(super::super::recorded_origin(&id), None);
2393 }
2394
2395 #[test]
2399 fn a_person_read_in_one_workspace_does_not_answer_for_another() {
2400 if !crate::tool_installed("packsetd") {
2401 eprintln!("skipped: packsetd is not installed");
2402 return;
2403 }
2404 let (_lock, dir) = isolated();
2405 let (mut daemon, url) = packsetd(dir.path());
2406 let workspace = |name: &str| unsafe { std::env::set_var("PACKSET_WORKSPACE", name) };
2407 unsafe { std::env::set_var("PACKSET_URL", &url) };
2408 workspace("seat");
2409 let id = "lessonid0000000000000000000003";
2410 let client = crate::PacksetClient::new(url.as_str());
2411 let seat = client
2412 .post_atom(&serde_json::json!({
2413 "id": id, "workspace": "seat", "kind": "lesson",
2414 "text": "Run the deploy form checks before every release.",
2415 "origin": "user-declared", "source": {"basis": "person"},
2416 }))
2417 .unwrap();
2418 client
2419 .post_atom(&serde_json::json!({
2420 "id": id, "workspace": "other", "kind": "lesson",
2421 "text": "Skip the deploy form checks before a quiet release.",
2422 "origin": "agent-derived", "source": {"basis": "agent"},
2423 }))
2424 .unwrap();
2425 promote_atom(&seat, "fixture", &People::default(), &People::default()).unwrap();
2426 assert!(preface().contains("deploy form checks"), "{}", preface());
2427 workspace("other");
2428 assert!(preface().is_empty(), "{}", preface());
2429 workspace("seat");
2430 assert!(preface().contains("deploy form checks"), "{}", preface());
2431
2432 daemon.kill().unwrap();
2433 daemon.wait().unwrap();
2434 workspace("other");
2435 let head = preface();
2436 assert!(
2437 head.is_empty(),
2438 "a person read in one workspace answered for another: {head}"
2439 );
2440 }
2441
2442 #[test]
2445 fn a_lesson_hash_reads_only_what_makes_the_lesson() {
2446 let lesson = serde_json::json!({
2447 "id": "lesson-h", "kind": "lesson", "text": "Keep the form short.",
2448 "origin": "user-declared", "source": {"basis": "person"},
2449 "ts": "2026-10-20T00:00:00.000Z", "valid_from": "2026-10-20T00:00:00.000Z",
2450 "updated": "2026-10-20T00:00:00.000Z", "links": [],
2451 });
2452 let with = |key: &str, value: Value| {
2453 let mut atom = lesson.clone();
2454 atom[key] = value;
2455 lesson_hash(&atom)
2456 };
2457 let kept = lesson_hash(&lesson);
2458 assert_eq!(with("links", serde_json::json!(["lesson-x"])), kept);
2459 assert_eq!(
2460 with("updated", serde_json::json!("2026-10-21T00:00:00.000Z")),
2461 kept
2462 );
2463 assert_eq!(with("review", serde_json::json!({"reps": 3})), kept);
2464 assert_ne!(with("text", serde_json::json!("Keep the form long.")), kept);
2465 assert_ne!(
2466 with("ts", serde_json::json!("2026-10-21T00:00:00.000Z")),
2467 kept
2468 );
2469 assert_ne!(
2470 with("valid_from", serde_json::json!("2026-10-21T00:00:00.000Z")),
2471 kept
2472 );
2473 assert_ne!(with("origin", serde_json::json!("agent-derived")), kept);
2474 assert_ne!(with("source", serde_json::json!({"basis": "agent"})), kept);
2475 }
2476
2477 #[test]
2478 fn promote_copies_a_lesson_and_refuses_anything_else() {
2479 let (_lock, _dir) = isolated();
2480 let atom = serde_json::json!({
2481 "id": "lesson-ocaisa",
2482 "kind": "lesson",
2483 "text": "Never post or reply to Alan (ocaisa).",
2484 "due_at": "2026-10-18T00:00:00Z"
2485 });
2486 let rule = promote_atom(
2487 &atom,
2488 "Alan asked that this hold for every seat",
2489 &People {
2490 handles: vec!["ocaisa".into()],
2491 names: vec!["Alan".into()],
2492 },
2493 &People {
2494 handles: vec!["ocaisa".into()],
2495 names: vec![],
2496 },
2497 )
2498 .unwrap();
2499 assert_eq!(rule.text, "Never post or reply to Alan (ocaisa).");
2500 assert_eq!(rule.author, "rohit");
2501 assert_eq!(rule.from.as_deref(), Some("lesson-ocaisa"));
2502 assert_eq!(
2503 rule.lesson_hash.as_deref(),
2504 Some(lesson_hash(&atom).as_str())
2505 );
2506 assert!(rule.due_at.is_none());
2507 assert_eq!(rule.deny_mention.handles, ["ocaisa"]);
2508 let raw = std::fs::read_to_string(rules_path().unwrap()).unwrap();
2509 assert!(!raw.contains("\"due_at\""), "{raw}");
2510 let preference = serde_json::json!({"id": "pref-1", "kind": "preference", "text": "no"});
2511 let err = promote_atom(
2512 &preference,
2513 "because",
2514 &People::default(),
2515 &People::default(),
2516 )
2517 .unwrap_err()
2518 .to_string();
2519 assert!(err.contains("only a lesson"), "{err}");
2520 let err = promote_atom(&atom, " ", &People::default(), &People::default())
2521 .unwrap_err()
2522 .to_string();
2523 assert!(err.contains("reason"), "{err}");
2524 }
2525
2526 #[test]
2531 fn a_row_with_no_basis_and_no_lesson_is_held_until_kept() {
2532 let (_lock, _dir) = isolated();
2533 let mut row = alan();
2534 row.basis = None;
2535 row.written_at = "2020-01-01T00:00:00.000Z".into();
2536 let path = rules_path().unwrap();
2537 std::fs::write(&path, render_rules(&[row.clone()]).unwrap()).unwrap();
2538 let head = preface();
2539 assert!(head.contains("not confirmed"), "{head}");
2540 assert!(head.contains("never post or reply to Alan"), "{head}");
2541 assert!(!head.contains("standing"), "{head}");
2542 let listed = format_list();
2543 assert!(listed.contains("held\t"), "{listed}");
2544 assert!(!listed.contains("unverified\t"), "{listed}");
2545
2546 note_cutoff();
2547 assert!(
2548 preface().contains("never post or reply to Alan"),
2549 "{}",
2550 preface()
2551 );
2552 let id = row.id.clone();
2553 let kept = keep_held(&id).unwrap();
2554 assert!(kept.contains("kept"), "{kept}");
2555 let after = preface();
2556 assert!(after.contains("standing"), "{after}");
2557 assert!(after.contains("never post or reply to Alan"), "{after}");
2558 assert!(!after.contains("not confirmed"), "{after}");
2559 assert_eq!(read_all().unwrap()[0].basis.as_deref(), Some("person"));
2560
2561 let mut later = row;
2562 later.id = "ur-later".into();
2563 later.basis = None;
2564 later.text = "use spaces in new files".into();
2565 later.written_at = "2999-01-01T00:00:00.000Z".into();
2566 let mut rows = read_all().unwrap();
2567 rows.push(later);
2568 std::fs::write(&path, render_rules(&rows).unwrap()).unwrap();
2569 let head = preface();
2570 assert!(head.contains("Alan"), "{head}");
2571 assert!(!head.contains("use spaces"), "{head}");
2572 let listed = format_list();
2573 assert!(listed.contains("unverified\t"), "{listed}");
2574 assert!(listed.contains("use spaces"), "{listed}");
2575 assert!(!listed.contains("held\t"), "{listed}");
2576 }
2577
2578 #[test]
2581 fn a_runner_cannot_keep_a_held_user_rule() {
2582 let (_lock, _dir) = isolated();
2583 let mut row = alan();
2584 row.basis = None;
2585 row.written_at = "not-a-time".into();
2586 std::fs::write(rules_path().unwrap(), render_rules(&[row]).unwrap()).unwrap();
2587 note_cutoff();
2588 assert!(preface().contains("not confirmed"), "{}", preface());
2589 super::super::PERSON_AT_TERMINAL.with(|p| p.set(false));
2592 let saved = std::env::var_os("CLAUDECODE");
2593 unsafe { std::env::set_var("CLAUDECODE", "1") };
2594 let err = keep_held("ur-missing").unwrap_err().to_string();
2595 unsafe {
2596 match saved {
2597 Some(value) => std::env::set_var("CLAUDECODE", value),
2598 None => std::env::remove_var("CLAUDECODE"),
2599 }
2600 }
2601 assert!(err.contains("a runner cannot keep"), "{err}");
2602 }
2603
2604 #[test]
2605 fn held_user_rules_warn_and_do_not_fail_the_doctor() {
2606 let row = super::super::Habitat {
2607 name: "user rules",
2608 state: review_sentence(1),
2609 ok: false,
2610 };
2611 assert_eq!(super::super::doctor_word(&row), "warn");
2612 assert!(super::super::failing(std::slice::from_ref(&row)).is_empty());
2613 assert!(super::super::healthy(std::slice::from_ref(&row)));
2614 }
2615
2616 #[test]
2617 fn an_argv_user_rule_denies_in_the_static_guard() {
2618 let (_lock, _dir) = isolated();
2619 write_argv(
2620 &Rule {
2621 pattern: "gh pr comment*".into(),
2622 verdict: "deny".into(),
2623 reason: "no pull request comments from this seat".into(),
2624 },
2625 super::super::Basis::Person,
2626 )
2627 .unwrap();
2628 let denied = super::super::static_guard("gh pr comment 12 --body 'tests passed'").unwrap();
2629 assert_eq!(denied.verdict, "deny");
2630 assert_eq!(denied.pattern, "gh pr comment*");
2631 assert!(super::super::static_guard("gh issue list").is_none());
2632 }
2633
2634 #[test]
2638 fn an_unconfirmed_promoted_rule_still_denies_as_unverified() {
2639 let (_lock, _dir) = isolated();
2640 let lesson = serde_json::json!({
2641 "id": "lesson-status", "kind": "lesson", "text": "Leave git status alone.",
2642 "origin": "user-declared", "source": {"basis": "person"},
2643 });
2644 let mention = People {
2645 handles: vec!["ocaisa".into()],
2646 names: vec![],
2647 };
2648 promote_atom(&lesson, "fixture", &mention, &People::default()).unwrap();
2649 let mut rows = read_all().unwrap();
2650 rows[0].pattern = Some("git status*".into());
2651 rows[0].verdict = Some("deny".into());
2652 std::fs::write(rules_path().unwrap(), render_rules(&rows).unwrap()).unwrap();
2653 let mcp = r#"{"tool_name":"add_issue_comment","mcp_server_name":"github","tool_input":{"body":"please ask @ocaisa"}}"#;
2654 for denied in [
2655 super::super::static_guard("git status").expect("pattern"),
2656 super::super::static_guard("gh pr comment 12 --body 'please ask @ocaisa'")
2657 .expect("mention"),
2658 guard_payload(mcp).expect("mcp"),
2659 ] {
2660 assert_eq!(denied.verdict, "deny", "{denied:?}");
2661 assert!(
2662 denied.reason.starts_with("unverified") && denied.reason.contains("lesson-status"),
2663 "an unconfirmed rule's denial read as the person's: {denied:?}"
2664 );
2665 }
2666 write_argv(
2667 &Rule {
2668 pattern: "git log*".into(),
2669 verdict: "deny".into(),
2670 reason: "leave the log alone".into(),
2671 },
2672 super::super::Basis::Person,
2673 )
2674 .unwrap();
2675 let denied = super::super::static_guard("git log").unwrap();
2676 assert_eq!(denied.reason, "leave the log alone");
2677 }
2678
2679 fn git_ok(dir: &std::path::Path, args: &[&str]) {
2680 let out = crate::git_cmd()
2681 .arg("-C")
2682 .arg(dir)
2683 .args(args)
2684 .output()
2685 .unwrap();
2686 assert!(
2687 out.status.success(),
2688 "git {args:?} in {}: {}{}",
2689 dir.display(),
2690 String::from_utf8_lossy(&out.stderr),
2691 String::from_utf8_lossy(&out.stdout)
2692 );
2693 }
2694
2695 fn prepare_clone(dir: &std::path::Path) {
2696 git_ok(dir, &["config", "user.email", "seat@example.invalid"]);
2697 git_ok(dir, &["config", "user.name", "seat"]);
2698 git_ok(dir, &["config", "commit.gpgsign", "false"]);
2699 git_ok(dir, &["config", "core.hooksPath", "/dev/null"]);
2700 }
2701
2702 struct Homes {
2703 _lock: super::super::EnvLock,
2704 _root: tempfile::TempDir,
2705 a: std::path::PathBuf,
2706 b: std::path::PathBuf,
2707 cfg_a: std::path::PathBuf,
2708 cfg_b: std::path::PathBuf,
2709 }
2710
2711 fn age_identity(cfg: &std::path::Path) -> String {
2714 let dir = cfg.join("ljos");
2715 std::fs::create_dir_all(&dir).unwrap();
2716 let key = dir.join("age.key");
2717 let made = std::process::Command::new("age-keygen")
2718 .arg("-o")
2719 .arg(&key)
2720 .output()
2721 .unwrap();
2722 assert!(made.status.success(), "age-keygen must be installed");
2723 let public = std::process::Command::new("age-keygen")
2724 .arg("-y")
2725 .arg(&key)
2726 .output()
2727 .unwrap();
2728 String::from_utf8_lossy(&public.stdout).trim().to_string()
2729 }
2730
2731 struct EnvVar(&'static str, Option<std::ffi::OsString>);
2733
2734 impl EnvVar {
2735 fn set(name: &'static str, value: &str) -> Self {
2736 let before = std::env::var_os(name);
2737 unsafe { std::env::set_var(name, value) };
2738 Self(name, before)
2739 }
2740 }
2741
2742 impl Drop for EnvVar {
2743 fn drop(&mut self) {
2744 match self.1.take() {
2745 Some(value) => unsafe { std::env::set_var(self.0, value) },
2746 None => unsafe { std::env::remove_var(self.0) },
2747 }
2748 }
2749 }
2750
2751 fn remote_paths(bare: &std::path::Path) -> String {
2752 let out = crate::git_cmd()
2753 .arg("-C")
2754 .arg(bare)
2755 .args(["ls-tree", "-r", "--name-only", "HEAD"])
2756 .output()
2757 .unwrap();
2758 String::from_utf8_lossy(&out.stdout).into_owned()
2759 }
2760
2761 fn head(dir: &std::path::Path) -> String {
2762 let out = crate::git_cmd()
2763 .arg("-C")
2764 .arg(dir)
2765 .args(["rev-parse", "HEAD"])
2766 .output()
2767 .unwrap();
2768 String::from_utf8_lossy(&out.stdout).trim().to_string()
2769 }
2770
2771 impl Homes {
2772 fn new() -> Self {
2775 Self::with_scope(true)
2776 }
2777
2778 fn unscoped() -> Self {
2780 Self::with_scope(false)
2781 }
2782
2783 fn bare(&self) -> std::path::PathBuf {
2784 self.a.parent().unwrap().join("bare.git")
2785 }
2786
2787 fn with_scope(scoped: bool) -> Self {
2788 let lock = super::super::test_env_lock();
2789 let root = tempfile::TempDir::new().unwrap();
2790 let seed = root.path().join("seed");
2791 std::fs::create_dir_all(&seed).unwrap();
2792 git_ok(&seed, &["init", "-q", "-b", "main"]);
2793 prepare_clone(&seed);
2794 git_ok(&seed, &["commit", "--allow-empty", "-q", "-m", "init"]);
2795 let cfg_a = root.path().join("cfg-a");
2796 let cfg_b = root.path().join("cfg-b");
2797 if scoped {
2798 let a = age_identity(&cfg_a);
2799 let b = age_identity(&cfg_b);
2800 std::fs::create_dir_all(seed.join(".ljos")).unwrap();
2801 std::fs::write(
2802 seed.join(".ljos/sync.toml"),
2803 format!("name = \"home\"\nrecipients = [\"{a}\", \"{b}\"]\n"),
2804 )
2805 .unwrap();
2806 git_ok(&seed, &["add", ".ljos/sync.toml"]);
2807 git_ok(&seed, &["commit", "-q", "-m", "scope"]);
2808 }
2809 let bare = root.path().join("bare.git");
2810 let laptop = root.path().join("laptop");
2811 let shared = root.path().join("shared");
2812 git_ok(
2813 root.path(),
2814 &[
2815 "clone",
2816 "--bare",
2817 "-q",
2818 seed.to_str().unwrap(),
2819 bare.to_str().unwrap(),
2820 ],
2821 );
2822 git_ok(
2823 root.path(),
2824 &[
2825 "clone",
2826 "-q",
2827 bare.to_str().unwrap(),
2828 laptop.to_str().unwrap(),
2829 ],
2830 );
2831 git_ok(
2832 root.path(),
2833 &[
2834 "clone",
2835 "-q",
2836 bare.to_str().unwrap(),
2837 shared.to_str().unwrap(),
2838 ],
2839 );
2840 prepare_clone(&laptop);
2841 prepare_clone(&shared);
2842 unsafe {
2843 std::env::set_var("LJOS_USER_RULES_SYNC", "off");
2844 std::env::set_var("LJOS_HOLD", "rohit");
2845 std::env::set_var("LJOS_GUARD", "on");
2846 std::env::set_var("PACKSET_URL", "off");
2847 std::env::set_var("HOME", root.path());
2848 std::env::remove_var("LJOS_USER_RULES_TRACKER");
2849 std::env::remove_var("LJOS_TRACKER_GIT");
2850 std::env::set_var("XDG_STATE_HOME", root.path().join("state"));
2851 }
2852 Self {
2853 _lock: lock,
2854 _root: root,
2855 a: laptop,
2856 b: shared,
2857 cfg_a,
2858 cfg_b,
2859 }
2860 }
2861
2862 fn home(&self, cfg: &std::path::Path) {
2863 unsafe { std::env::set_var("XDG_CONFIG_HOME", cfg) };
2864 }
2865 }
2866
2867 fn exchange(tracker: &std::path::Path, host: &str) {
2868 exchange_said(tracker, host, false);
2869 }
2870
2871 fn exchange_said(tracker: &std::path::Path, host: &str, asked: bool) -> String {
2872 let said = super::super::sync::exchange_user_rules(
2873 tracker,
2874 host,
2875 std::time::Duration::from_secs(8),
2876 asked,
2877 )
2878 .unwrap_or_else(|err| panic!("sync {host}: {err:#}"));
2879 assert!(!said.contains("did not answer"), "{said}");
2880 said
2881 }
2882
2883 #[test]
2886 fn a_tracker_with_no_scope_gets_no_user_rules() {
2887 let homes = Homes::unscoped();
2888 homes.home(&homes.cfg_a);
2889 let before = head(&homes.a);
2890 let rule = write_preference(
2891 "never post or reply to Alan (ocaisa)",
2892 "he asked not to be pinged",
2893 &People::default(),
2894 &People::default(),
2895 )
2896 .unwrap();
2897 exchange(&homes.a, "laptop");
2898 let said = exchange_said(&homes.a, "laptop", true);
2899 assert!(said.contains("names no scope"), "{said}");
2900 assert_eq!(head(&homes.a), before, "no commit without a scope");
2901 assert!(!homes.a.join(".ljos/user-rules").exists());
2902 assert!(!remote_paths(&homes.bare()).contains("user-rules"));
2903 assert_eq!(load().unwrap()[0].id, rule.id);
2904 }
2905
2906 #[test]
2907 fn user_rules_travel_sealed_to_the_scope() {
2908 let homes = Homes::new();
2909 homes.home(&homes.cfg_a);
2910 write_preference(
2911 "never post or reply to Alan (ocaisa)",
2912 "he asked not to be pinged",
2913 &People::default(),
2914 &People::default(),
2915 )
2916 .unwrap();
2917 let said = exchange_said(&homes.a, "laptop", false);
2918 assert!(said.contains("sealed and pushed"), "{said}");
2919 let paths = remote_paths(&homes.bare());
2920 assert!(
2921 paths.contains(".ljos/user-rules/laptop.json.age"),
2922 "{paths}"
2923 );
2924 assert!(!paths.contains("laptop.json\n"), "{paths}");
2925 let sealed = std::fs::read(homes.a.join(".ljos/user-rules/laptop.json.age")).unwrap();
2926 assert!(
2927 !String::from_utf8_lossy(&sealed).contains("ocaisa"),
2928 "the rule left in plaintext"
2929 );
2930 let pushed = head(&homes.a);
2931 exchange(&homes.a, "laptop");
2932 assert_eq!(
2933 head(&homes.a),
2934 pushed,
2935 "unchanged rules are not sealed again"
2936 );
2937 }
2938
2939 #[test]
2942 fn a_plaintext_rules_file_is_still_read_and_this_hosts_is_replaced() {
2943 let homes = Homes::new();
2944 homes.home(&homes.cfg_b);
2945 let old = write_preference(
2946 "the older plaintext rule",
2947 "written before the seal",
2948 &People::default(),
2949 &People::default(),
2950 )
2951 .unwrap();
2952 let text = std::fs::read_to_string(homes.cfg_b.join("ljos/user-rules.json")).unwrap();
2953 let dir = homes.b.join(".ljos/user-rules");
2954 std::fs::create_dir_all(&dir).unwrap();
2955 std::fs::write(dir.join("old-host.json"), &text).unwrap();
2956 std::fs::write(dir.join("laptop.json"), &text).unwrap();
2957 git_ok(&homes.b, &["add", ".ljos/user-rules"]);
2958 git_ok(&homes.b, &["commit", "-q", "-m", "plaintext rules"]);
2959 git_ok(&homes.b, &["push", "-q"]);
2960 homes.home(&homes.cfg_a);
2961 exchange(&homes.a, "laptop");
2962 assert!(load().unwrap().iter().any(|rule| rule.id == old.id));
2963 let paths = remote_paths(&homes.bare());
2964 assert!(paths.contains(".ljos/user-rules/old-host.json"), "{paths}");
2965 assert!(
2966 paths.contains(".ljos/user-rules/laptop.json.age"),
2967 "{paths}"
2968 );
2969 assert!(!paths.contains(".ljos/user-rules/laptop.json\n"), "{paths}");
2970 }
2971
2972 #[test]
2973 fn user_rule_sync_follows_tracker_git() {
2974 let homes = Homes::new();
2975 homes.home(&homes.cfg_a);
2976 write_preference(
2977 "the standing line",
2978 "a reason",
2979 &People::default(),
2980 &People::default(),
2981 )
2982 .unwrap();
2983 let start = head(&homes.a);
2984 {
2985 let _off = EnvVar::set("LJOS_TRACKER_GIT", "off");
2986 exchange(&homes.a, "laptop");
2987 assert_eq!(head(&homes.a), start, "off leaves the tracker alone");
2988 }
2989 {
2990 let _commit = EnvVar::set("LJOS_TRACKER_GIT", "commit");
2991 let said = exchange_said(&homes.a, "laptop", false);
2992 assert!(
2993 said.contains("not pushed (LJOS_TRACKER_GIT=commit)"),
2994 "{said}"
2995 );
2996 assert_ne!(head(&homes.a), start);
2997 assert!(!remote_paths(&homes.bare()).contains("user-rules"));
2998 let said = exchange_said(&homes.a, "laptop", true);
2999 assert!(said.contains("sealed and pushed"), "{said}");
3000 }
3001 assert!(remote_paths(&homes.bare()).contains("laptop.json.age"));
3002 let _sync_off = EnvVar::set("LJOS_USER_RULES_SYNC", "off");
3003 let _tracker = EnvVar::set("LJOS_USER_RULES_TRACKER", homes.a.to_str().unwrap());
3004 assert!(super::super::sync::user_rule_trackers().is_empty());
3005 assert!(sync_now().is_err());
3006 }
3007
3008 fn rewrite(id: &str, text: &str, author: &str, reason: &str, at: &str, deleted: bool) {
3009 let mut rows = read_all().unwrap();
3010 let rule = rows.iter_mut().find(|rule| rule.id == id).unwrap();
3011 rule.text = text.to_string();
3012 rule.author = author.to_string();
3013 rule.reason = reason.to_string();
3014 rule.updated_at = at.to_string();
3015 rule.deleted = deleted;
3016 store(&rows).unwrap();
3017 }
3018
3019 #[test]
3020 fn two_homes_with_different_rules_converge() {
3021 let homes = Homes::new();
3022 homes.home(&homes.cfg_a);
3023 let alan = write_preference(
3024 "never post or reply to Alan (ocaisa)",
3025 "he asked not to be pinged",
3026 &People::default(),
3027 &People::default(),
3028 )
3029 .unwrap();
3030 exchange(&homes.a, "laptop");
3031 homes.home(&homes.cfg_b);
3032 exchange(&homes.b, "shared");
3033 let got = load().unwrap();
3034 assert_eq!(got.len(), 1, "{got:?}");
3035 assert_eq!(got[0].id, alan.id);
3036 assert_eq!(got[0].author, "rohit");
3037 assert_eq!(got[0].reason, "he asked not to be pinged");
3038 let credit = write_preference(
3039 "EasyBuild pull requests name eb-stack and gpt-oss-120b",
3040 "the credit line is part of the work",
3041 &People::default(),
3042 &People::default(),
3043 )
3044 .unwrap();
3045 assert_eq!(credit.id, load().unwrap()[1].id);
3046 exchange(&homes.b, "shared");
3047 homes.home(&homes.cfg_a);
3048 exchange(&homes.a, "laptop");
3049 let got = load().unwrap();
3050 assert!(got.iter().any(|rule| rule.id == alan.id), "{got:?}");
3051 assert!(got.iter().any(|rule| rule.id == credit.id), "{got:?}");
3052 homes.home(&homes.cfg_b);
3053 let got = load().unwrap();
3054 assert_eq!(got.len(), 2, "{got:?}");
3055 assert!(got.iter().any(|rule| rule.id == alan.id));
3056 assert!(got.iter().any(|rule| rule.id == credit.id));
3057 }
3058
3059 #[test]
3060 fn a_newer_edit_wins_and_a_tombstone_beats_an_older_edit() {
3061 let homes = Homes::new();
3062 homes.home(&homes.cfg_a);
3063 let rule = write_preference(
3064 "the standing line",
3065 "first reason",
3066 &People::default(),
3067 &People::default(),
3068 )
3069 .unwrap();
3070 let id = rule.id.clone();
3071 exchange(&homes.a, "laptop");
3072 homes.home(&homes.cfg_b);
3073 exchange(&homes.b, "shared");
3074
3075 rewrite(
3076 &id,
3077 "older edit",
3078 "ada",
3079 "because older",
3080 "2099-01-01T00:00:01.000Z",
3081 false,
3082 );
3083 exchange(&homes.b, "shared");
3084 homes.home(&homes.cfg_a);
3085 rewrite(
3086 &id,
3087 "newest edit",
3088 "rohit",
3089 "because newest",
3090 "2099-01-01T00:00:09.000Z",
3091 false,
3092 );
3093 exchange(&homes.a, "laptop");
3094 homes.home(&homes.cfg_b);
3095 exchange(&homes.b, "shared");
3096 let got = load().unwrap();
3097 assert_eq!(got.len(), 1, "{got:?}");
3098 assert_eq!(got[0].id, id);
3099 assert_eq!(got[0].text, "newest edit");
3100 assert_eq!(got[0].author, "rohit");
3101 assert_eq!(got[0].reason, "because newest");
3102 homes.home(&homes.cfg_a);
3103 let got = load().unwrap();
3104 assert_eq!(got[0].text, "newest edit");
3105 assert_eq!(got[0].author, "rohit");
3106 assert_eq!(got[0].reason, "because newest");
3107
3108 let removed = forget(&id).unwrap();
3109 assert!(removed.deleted);
3110 assert_eq!(removed.author, "rohit");
3111 assert_eq!(removed.reason, "because newest");
3112 rewrite(
3113 &id,
3114 "newest edit",
3115 "rohit",
3116 "because newest",
3117 "2099-01-02T00:00:00.000Z",
3118 true,
3119 );
3120 exchange(&homes.a, "laptop");
3121 homes.home(&homes.cfg_b);
3122 exchange(&homes.b, "shared");
3123 assert!(load().unwrap().is_empty(), "{:?}", load().unwrap());
3124 let tomb = read_all().unwrap();
3125 assert_eq!(tomb.len(), 1);
3126 assert!(tomb[0].deleted);
3127 assert_eq!(tomb[0].author, "rohit");
3128 assert_eq!(tomb[0].reason, "because newest");
3129 assert!(!preface().contains("newest edit"));
3130 rewrite(
3131 &id,
3132 "older edit",
3133 "ada",
3134 "because older",
3135 "2099-01-01T00:00:01.000Z",
3136 false,
3137 );
3138 exchange(&homes.b, "shared");
3139 assert!(
3140 load().unwrap().is_empty(),
3141 "a tombstone keeps the older edit out"
3142 );
3143 let tomb = read_all().unwrap();
3144 assert!(tomb[0].deleted);
3145 assert_eq!(tomb[0].updated_at, "2099-01-02T00:00:00.000Z");
3146 }
3147
3148 #[test]
3149 fn the_hook_shows_local_rules_when_the_tracker_does_not_answer() {
3150 let (_lock, dir) = isolated();
3151 write_preference(
3152 "never post or reply to Alan (ocaisa)",
3153 "he asked not to be pinged",
3154 &People::default(),
3155 &People::default(),
3156 )
3157 .unwrap();
3158 let repo = dir.path().join("tracker");
3159 std::fs::create_dir_all(&repo).unwrap();
3160 git_ok(&repo, &["init", "-q", "-b", "main"]);
3161 prepare_clone(&repo);
3162 git_ok(&repo, &["commit", "--allow-empty", "-q", "-m", "init"]);
3163 git_ok(&repo, &["config", "http.lowSpeedLimit", "1"]);
3164 git_ok(&repo, &["config", "http.lowSpeedTime", "1"]);
3165 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3166 let port = listener.local_addr().unwrap().port();
3167 std::thread::spawn(move || {
3168 let Ok((mut stream, _)) = listener.accept() else {
3169 return;
3170 };
3171 let mut buf = [0u8; 64];
3172 let _ = std::io::Read::read(&mut stream, &mut buf);
3173 });
3174 let url = format!("http://127.0.0.1:{port}/tracker.git");
3175 git_ok(&repo, &["remote", "add", "origin", &url]);
3176 std::fs::create_dir_all(repo.join(".ljos")).unwrap();
3178 std::fs::write(
3179 repo.join(".ljos/sync.toml"),
3180 "name = \"home\"\nrecipients = []\n",
3181 )
3182 .unwrap();
3183 unsafe {
3184 std::env::set_var("LJOS_USER_RULES_SYNC", "on");
3186 std::env::set_var("LJOS_USER_RULES_TRACKER", &repo);
3187 std::env::set_var("LJOS_USER_RULES_SYNC_MS", "400");
3188 }
3189 let started = std::time::Instant::now();
3190 let line = super::super::session_start_for(Some("seat-two"));
3191 let elapsed = started.elapsed();
3192 assert!(
3193 elapsed < std::time::Duration::from_secs(3),
3194 "{elapsed:?}\n{line}"
3195 );
3196 assert!(line.starts_with("User rules"), "{line}");
3197 assert!(line.contains("never post or reply to Alan"), "{line}");
3198 }
3199}