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 let mut row = fresh(rule.reason.trim(), rule.reason.trim(), None)?;
603 row.pattern = Some(rule.pattern.trim().to_string());
604 row.verdict = Some(rule.verdict.clone());
605 row.basis = Some(basis.as_str().to_string());
606 append(row)
607}
608
609pub fn promote_atom(
616 atom: &Value,
617 reason: &str,
618 deny_mention: &People,
619 deny_reply_to: &People,
620) -> Result<UserRule> {
621 let kind = atom.get("kind").and_then(Value::as_str).unwrap_or("");
622 if kind != "lesson" {
623 let id = atom.get("id").and_then(Value::as_str).unwrap_or("(no id)");
624 bail!("prefer: {id} is a {kind}, and only a lesson is promoted");
625 }
626 let id = atom
627 .get("id")
628 .and_then(Value::as_str)
629 .context("prefer: the lesson has no id")?
630 .to_string();
631 let text = atom
632 .get("text")
633 .and_then(Value::as_str)
634 .unwrap_or("")
635 .trim();
636 let mut rule = fresh(text, reason, Some(id))?;
637 rule.lesson_hash = Some(lesson_hash(atom));
638 rule.deny_mention = clean_people(deny_mention)?;
639 rule.deny_reply_to = clean_people(deny_reply_to)?;
640 append(rule)
641}
642
643#[must_use]
651pub fn lesson_hash(atom: &Value) -> String {
652 use sha2::{Digest, Sha256};
653 let field = |key: &str| atom.get(key).and_then(Value::as_str).unwrap_or("");
654 let mut hash = Sha256::new();
655 for part in [
656 field("kind"),
657 field("text"),
658 field("ts"),
659 field("valid_from"),
660 super::stored_by(atom).origin(),
661 ] {
662 hash.update(part.as_bytes());
663 hash.update([0]);
664 }
665 hex::encode(hash.finalize())
666}
667
668pub fn forget(id: &str) -> Result<UserRule> {
676 let id = id.trim();
677 if id.is_empty() {
678 bail!("prefer: a user rule id is required");
679 }
680 let mut rules = read_all()?;
681 let Some(rule) = rules.iter_mut().find(|rule| rule.id == id) else {
682 bail!("prefer: no user rule {id}");
683 };
684 if !rule.deleted {
685 rule.deleted = true;
686 rule.updated_at = super::now_utc();
687 let written = rule.clone();
688 store(&rules)?;
689 sync_after_write();
690 return Ok(written);
691 }
692 Ok(rule.clone())
693}
694
695pub fn promote_lesson(
705 id: &str,
706 reason: &str,
707 deny_mention: &People,
708 deny_reply_to: &People,
709 _person_words: bool,
710) -> Result<UserRule> {
711 let id = id.trim();
712 if id.is_empty() {
713 bail!("prefer: a lesson id is required");
714 }
715 let client = super::pack()?;
716 let atoms =
717 super::atoms_lean(&client, &client.workspace()).context("prefer: GET /v1/atoms failed")?;
718 let atom = atoms
719 .into_iter()
720 .find(|atom| atom.get("id").and_then(Value::as_str) == Some(id))
721 .with_context(|| format!("prefer: no atom {id} in this seat's pack"))?;
722 if super::under_a_runner() {
723 if let Err(why) = super::proc_tree::placed_runner() {
724 bail!("prefer: {why}; only the person's own words can be stored here");
725 }
726 }
727 promote_atom(&atom, reason, deny_mention, deny_reply_to)
728}
729
730fn sync_budget_ms(default: u64) -> u64 {
731 std::env::var("LJOS_USER_RULES_SYNC_MS")
732 .ok()
733 .and_then(|value| value.trim().parse().ok())
734 .filter(|ms: &u64| *ms > 0)
735 .unwrap_or(default)
736}
737
738fn sync_after_write() {
739 let budget = std::time::Duration::from_millis(sync_budget_ms(4_000));
740 let host = super::sync::host();
741 for root in super::sync::user_rule_trackers() {
742 let _ = super::sync::exchange_user_rules(&root, &host, budget, false);
743 }
744}
745
746pub fn sync_for_hook() {
749 let budget = std::time::Duration::from_millis(sync_budget_ms(1_500));
754 let host = super::sync::host();
755 for root in super::sync::user_rule_trackers() {
756 let _ = super::sync::exchange_user_rules(&root, &host, budget, false);
757 }
758}
759
760pub fn sync_now() -> Result<String> {
767 if std::env::var("LJOS_USER_RULES_SYNC").ok().as_deref() == Some("off") {
768 bail!("prefer: user-rule sync is off");
769 }
770 let roots = super::sync::user_rule_trackers();
771 if roots.is_empty() {
772 bail!(
773 "prefer: no tracker repository to sync user rules through. Set VISSUE_ROOT, or repos in ~/.config/ljos/sync.toml"
774 );
775 }
776 let budget = std::time::Duration::from_millis(sync_budget_ms(8_000));
777 let host = super::sync::host();
778 let mut out = String::new();
779 for root in roots {
780 out.push_str(&super::sync::exchange_user_rules(
781 &root, &host, budget, true,
782 )?);
783 }
784 if out.is_empty() {
785 out.push_str("sync: user rules: none\n");
786 }
787 Ok(out)
788}
789
790#[must_use]
792pub fn ack(rule: &UserRule) -> String {
793 if rule.deleted {
794 format!("user rule {}\tremoved\t{}", rule.id, rule.text)
795 } else {
796 format!("user rule {}\t{}", rule.id, rule.text)
797 }
798}
799
800#[must_use]
802pub fn to_value(rule: &UserRule) -> Value {
803 serde_json::to_value(rule).unwrap_or(Value::Null)
804}
805
806#[must_use]
810pub fn format_list() -> String {
811 let Ok(rules) = load() else {
812 return String::new();
813 };
814 let (mut persons, mut held, mut unverified) = (String::new(), String::new(), String::new());
815 for rule in &rules {
816 let law = match (&rule.verdict, &rule.pattern) {
817 (Some(verdict), Some(pattern)) => format!("{verdict}\t{pattern}\t"),
818 _ => String::new(),
819 };
820 let line = format!("{law}{}\t{}\t{}", rule.text, rule.author, rule.reason);
821 if is_held(rule) {
822 held.push_str(&format!("held\t{line}\n"));
823 continue;
824 }
825 match unconfirmed(rule) {
826 None => persons.push_str(&format!("user\t{line}\n")),
827 Some(why) => unverified.push_str(&format!("unverified\t{line}\t{why}\n")),
828 }
829 }
830 let mut out = String::new();
831 if !persons.is_empty() {
832 out.push_str("user rules (shared, never reviewed):\n");
833 out.push_str(&persons);
834 }
835 if !held.is_empty() {
836 out.push_str("user rules held from before a basis was kept:\n");
837 out.push_str(&held);
838 }
839 if !unverified.is_empty() {
840 out.push_str("user rules not confirmed as the person's (unverified):\n");
841 out.push_str(&unverified);
842 }
843 out
844}
845
846fn one_line(text: &str) -> String {
847 text.split_whitespace().collect::<Vec<_>>().join(" ")
848}
849
850fn omission(left: usize) -> String {
851 if left == 0 {
852 String::new()
853 } else {
854 format!("{left} user rules omitted.\n")
855 }
856}
857
858#[must_use]
862pub fn preface() -> String {
863 let rules = load().unwrap_or_default();
864 let standing: Vec<UserRule> = rules
865 .iter()
866 .filter(|rule| persons_rule(rule))
867 .cloned()
868 .collect();
869 let held: Vec<UserRule> = rules.iter().filter(|rule| is_held(rule)).cloned().collect();
870 render_preface(&standing, &held, PREFACE_BUDGET)
871}
872
873fn persons_rule(rule: &UserRule) -> bool {
883 unconfirmed(rule).is_none()
884}
885
886fn unconfirmed(rule: &UserRule) -> Option<String> {
889 let Some(id) = rule.from.as_deref() else {
890 return (rule.basis.as_deref() != Some(super::Basis::Person.as_str())).then(|| {
891 "written without the person at the terminal or in their latest prompt".to_string()
892 });
893 };
894 let Some(kept) = rule.lesson_hash.as_deref() else {
895 return Some(format!(
896 "promoted from lesson {id} with no hash of its record kept; promote it again"
897 ));
898 };
899 match super::atom_now(id) {
900 Ok(Some(lesson)) if !super::person_stored(&lesson) => Some(format!(
901 "the pack does not hold lesson {id} as the person's"
902 )),
903 Ok(Some(lesson)) if lesson_hash(&lesson) != kept => Some(format!(
904 "lesson {id} in the pack is not the record this rule was promoted from"
905 )),
906 Ok(Some(_)) => None,
907 Ok(None) => Some(format!("the pack does not hold lesson {id}")),
908 Err(why) => Some(why),
909 }
910}
911
912fn cutoff_path() -> Option<PathBuf> {
915 Some(
916 super::config_base()?
917 .join("ljos")
918 .join("user-rule-basis-cutoff"),
919 )
920}
921
922fn parse_instant(raw: &str) -> Option<chrono::DateTime<chrono::FixedOffset>> {
923 let raw = raw.trim();
924 (!raw.is_empty())
925 .then(|| chrono::DateTime::parse_from_rfc3339(raw).ok())
926 .flatten()
927}
928
929fn cutoff_instant() -> Option<chrono::DateTime<chrono::FixedOffset>> {
930 let text = std::fs::read_to_string(cutoff_path()?).ok()?;
931 text.lines().find_map(parse_instant)
932}
933
934pub fn note_cutoff() {
941 if super::under_a_runner() {
942 return;
943 }
944 let Some(path) = cutoff_path() else {
945 return;
946 };
947 if path.exists() {
948 return;
949 }
950 if let Some(dir) = path.parent() {
951 let _ = std::fs::create_dir_all(dir);
952 }
953 let _ = std::fs::write(&path, format!("{}\n", super::now_utc()));
954}
955
956fn is_held(rule: &UserRule) -> bool {
962 if rule.from.is_some() || rule.basis.is_some() {
963 return false;
964 }
965 match cutoff_instant() {
966 None => true,
967 Some(cutoff) => parse_instant(&rule.written_at).is_none_or(|ts| ts < cutoff),
968 }
969}
970
971#[must_use]
974pub fn held_count() -> usize {
975 load()
976 .unwrap_or_default()
977 .iter()
978 .filter(|rule| is_held(rule))
979 .count()
980}
981
982#[must_use]
984pub fn review_sentence(n: usize) -> String {
985 format!(
986 "{n} older user rule{} await{} review (`ljos prefer --user --review`)",
987 if n == 1 { "" } else { "s" },
988 if n == 1 { "s" } else { "" }
989 )
990}
991
992pub fn review_list() -> Result<String> {
998 note_cutoff();
999 let rules = load()?;
1000 let held: Vec<&UserRule> = rules.iter().filter(|rule| is_held(rule)).collect();
1001 if held.is_empty() {
1002 return Ok("no older user rules await review\n".to_string());
1003 }
1004 let mut out = format!("{}\n", review_sentence(held.len()));
1005 for rule in held {
1006 out.push_str(&format!(
1007 "{} {} {}\n ljos prefer --user --review --keep {}\n",
1008 rule.id,
1009 rule.written_at,
1010 one_line(&rule.text),
1011 rule.id
1012 ));
1013 }
1014 Ok(out)
1015}
1016
1017pub fn keep_held(id: &str) -> Result<String> {
1028 if super::under_a_runner() {
1029 bail!(
1030 "prefer: a runner cannot keep a user rule. \
1031 Run `ljos prefer --user --review --keep {id}` from a terminal of your own"
1032 );
1033 }
1034 if !super::person_present() {
1035 bail!(
1036 "prefer: keeping a user rule needs a terminal. \
1037 Run `ljos prefer --user --review --keep {id}` from a terminal of your own"
1038 );
1039 }
1040 note_cutoff();
1041 let mut rules = read_all()?;
1042 let ids: Vec<String> = rules
1043 .iter()
1044 .filter(|rule| is_held(rule))
1045 .map(|rule| rule.id.clone())
1046 .collect();
1047 let id = super::resolve_short_id("prefer", id.trim(), &ids)?;
1048 let Some(rule) = rules.iter_mut().find(|rule| rule.id == id && is_held(rule)) else {
1049 bail!("prefer: {id} is not an older user rule awaiting review");
1050 };
1051 rule.basis = Some(super::Basis::Person.as_str().to_string());
1052 rule.updated_at = super::now_utc();
1053 let shown = format!("user rule {}\tkept\t{}\n", rule.id, rule.text);
1054 store(&rules)?;
1055 Ok(shown)
1056}
1057
1058fn render_preface(standing: &[UserRule], held: &[UserRule], budget: usize) -> String {
1059 if budget == 0 || (standing.is_empty() && held.is_empty()) {
1060 return String::new();
1061 }
1062 let mut out = if standing.is_empty() {
1063 String::new()
1064 } else {
1065 render(standing, budget)
1066 };
1067 if held.is_empty() {
1068 return out;
1069 }
1070 let gap = usize::from(!out.is_empty());
1071 let room = budget.saturating_sub(out.chars().count() + gap);
1072 let rest = render_headed(
1073 held,
1074 "Older user rules, not confirmed (`ljos prefer --user --review`):\n",
1075 room,
1076 );
1077 if rest.is_empty() {
1078 return out;
1079 }
1080 if !out.is_empty() {
1081 out.push('\n');
1082 }
1083 out.push_str(&rest);
1084 if out.chars().count() > budget {
1085 out = out.chars().take(budget).collect();
1086 }
1087 out.trim_end().to_string()
1088}
1089
1090fn render(rules: &[UserRule], budget: usize) -> String {
1091 render_headed(
1092 rules,
1093 "User rules (standing, shared by every seat on this machine):\n",
1094 budget,
1095 )
1096}
1097
1098fn render_headed(rules: &[UserRule], header: &str, budget: usize) -> String {
1099 if rules.is_empty() || budget == 0 {
1100 return String::new();
1101 }
1102 let mut body = String::new();
1103 let mut shown = 0usize;
1104 for rule in rules {
1105 let line = format!("- {}\n", one_line(&rule.text));
1106 let note = omission(rules.len() - (shown + 1));
1107 let used = header.chars().count()
1108 + body.chars().count()
1109 + line.chars().count()
1110 + note.chars().count();
1111 if used <= budget {
1112 body.push_str(&line);
1113 shown += 1;
1114 continue;
1115 }
1116 if shown == 0 {
1117 let note = omission(rules.len().saturating_sub(1));
1118 let room = budget.saturating_sub(header.chars().count() + note.chars().count());
1119 let take = room.saturating_sub(6);
1120 if take > 0 {
1121 let cut: String = one_line(&rule.text).chars().take(take).collect();
1122 body.push_str(&format!("- {cut}...\n"));
1123 shown = 1;
1124 }
1125 }
1126 break;
1127 }
1128 if shown == 0 {
1129 return String::new();
1130 }
1131 let mut out = format!("{header}{body}");
1132 let left = rules.len() - shown;
1133 if left > 0 {
1134 out.push_str(&omission(left));
1135 }
1136 if out.chars().count() > budget {
1137 out = out.chars().take(budget).collect();
1138 }
1139 out.trim_end().to_string()
1140}
1141
1142#[must_use]
1145pub fn prepend(body: String) -> String {
1146 let head = preface();
1147 if head.is_empty() {
1148 body
1149 } else if body.is_empty() {
1150 head
1151 } else {
1152 format!("{head}\n{body}")
1153 }
1154}
1155
1156#[must_use]
1159pub fn without_preface(text: &str) -> String {
1160 let head = preface();
1161 if head.is_empty() {
1162 return text.to_string();
1163 }
1164 text.trim_start()
1165 .strip_prefix(&head)
1166 .map(|rest| rest.trim_start().to_string())
1167 .unwrap_or_else(|| text.to_string())
1168}
1169
1170fn as_argv(rule: &UserRule) -> Option<Rule> {
1171 Some(Rule {
1172 pattern: rule.pattern.clone()?,
1173 verdict: rule.verdict.clone().unwrap_or_else(|| "ask".to_string()),
1174 reason: rule.reason.clone(),
1175 })
1176}
1177
1178fn fired(rule: &UserRule) -> Rule {
1179 let why = rule.reason.trim();
1180 let text = rule.text.trim();
1181 let reason = if text.is_empty() || text == why {
1182 why.to_string()
1183 } else {
1184 format!("{text} ({why})")
1185 };
1186 reported(
1187 rule,
1188 Rule {
1189 pattern: format!("user-rule:{}", rule.id),
1190 verdict: "deny".into(),
1191 reason,
1192 },
1193 )
1194}
1195
1196fn reported(rule: &UserRule, mut law: Rule) -> Rule {
1202 if let Some(why) = unconfirmed(rule) {
1203 law.reason = format!(
1204 "unverified, not confirmed as the person's rule ({why}): {}",
1205 law.reason
1206 );
1207 }
1208 law
1209}
1210
1211#[must_use]
1214pub fn mcp_event_denies(event: &str, rule: &Rule) -> bool {
1215 event == "beforeMCPExecution"
1216 && rule.verdict == "deny"
1217 && rule.pattern.starts_with("user-rule:")
1218}
1219
1220struct Post {
1221 bodies: Vec<String>,
1222 reply_values: Vec<String>,
1223}
1224
1225fn note_rules_read_error(err: &anyhow::Error) {
1228 use std::sync::atomic::{AtomicBool, Ordering};
1229 static NOTED: AtomicBool = AtomicBool::new(false);
1230 if NOTED.swap(true, Ordering::Relaxed) {
1231 return;
1232 }
1233 eprintln!("ljos: {err:#}");
1234}
1235
1236fn github_write_blocked(err: &anyhow::Error) -> Rule {
1239 note_rules_read_error(err);
1240 Rule {
1241 pattern: "user-rule:file".into(),
1242 verdict: "deny".into(),
1243 reason: format!("user rules could not be read ({err:#}), so this GitHub write is refused"),
1244 }
1245}
1246
1247#[must_use]
1249pub fn guard_line(line: &str) -> Option<Rule> {
1250 if line.trim().is_empty() {
1251 return None;
1252 }
1253 let rules = match load() {
1254 Ok(rules) => rules,
1255 Err(err) => {
1256 if posts_of(line).is_empty() {
1257 return None;
1258 }
1259 return Some(github_write_blocked(&err));
1260 }
1261 };
1262 if rules.is_empty() {
1263 return None;
1264 }
1265 let (rows, argv): (Vec<&UserRule>, Vec<Rule>) = rules
1266 .iter()
1267 .filter_map(|rule| Some((rule, as_argv(rule)?)))
1268 .unzip();
1269 let report = |law: &Rule| match argv.iter().position(|row| std::ptr::eq(row, law)) {
1271 Some(at) => reported(rows[at], law.clone()),
1272 None => law.clone(),
1273 };
1274 if let Some(hit) = super::verdict_for(&argv, line).filter(|rule| rule.verdict == "deny") {
1275 return Some(report(hit));
1276 }
1277 if let Some(hit) = outbound(line, &rules) {
1278 return Some(hit);
1279 }
1280 super::verdict_for(&argv, line).map(report)
1281}
1282
1283#[must_use]
1286pub fn guard_payload(raw: &str) -> Option<Rule> {
1287 let value: Value = serde_json::from_str(raw.trim()).ok()?;
1288 let tool = value
1289 .get("tool_name")
1290 .or_else(|| value.get("toolName"))
1291 .and_then(Value::as_str)
1292 .or_else(|| value.pointer("/toolCall/name").and_then(Value::as_str))?;
1293 let server = ["mcp_server_name", "mcpServerName", "server_name", "server"]
1294 .iter()
1295 .find_map(|key| value.get(*key).and_then(Value::as_str));
1296 let command = value.get("command").and_then(Value::as_str);
1299 let input = value
1300 .get("tool_input")
1301 .or_else(|| value.get("toolInput"))
1302 .or_else(|| value.pointer("/toolCall/args"));
1303 let parsed = match input {
1304 Some(Value::String(text)) => {
1305 serde_json::from_str(text).unwrap_or_else(|_| Value::String(text.clone()))
1306 }
1307 Some(other) => other.clone(),
1308 None => Value::Null,
1309 };
1310 guard_mcp(tool, server, command, &parsed)
1311}
1312
1313fn guard_mcp(
1314 tool: &str,
1315 server: Option<&str>,
1316 command: Option<&str>,
1317 input: &Value,
1318) -> Option<Rule> {
1319 if !github_write(tool, server, command) && !cursor_http_mcp_write(tool, input) {
1320 return None;
1321 }
1322 let rules = match load() {
1323 Ok(rules) => rules,
1324 Err(err) => return Some(github_write_blocked(&err)),
1325 };
1326 let post = mcp_fields(input, tool);
1327 rules.iter().find(|rule| post_hits(rule, &post)).map(fired)
1328}
1329
1330fn github_server(name: &str) -> bool {
1331 name.to_ascii_lowercase().contains("github")
1332}
1333
1334fn cursor_http_mcp_write(tool: &str, input: &Value) -> bool {
1338 let Some(short) = tool.strip_prefix("MCP:") else {
1339 return false;
1340 };
1341 if short.is_empty() {
1342 return false;
1343 }
1344 let short = short.to_ascii_lowercase();
1345 if short.starts_with("get_")
1346 || short.starts_with("list_")
1347 || short.starts_with("search_")
1348 || short.starts_with("read_")
1349 || short.contains("_read")
1350 {
1351 return false;
1352 }
1353 if !["comment", "review", "issue", "pull"]
1354 .iter()
1355 .any(|kind| short.contains(kind))
1356 {
1357 return false;
1358 }
1359 let Some(obj) = input.as_object() else {
1360 return false;
1361 };
1362 obj.contains_key("owner") && obj.contains_key("repo")
1363}
1364
1365fn github_write(tool: &str, server: Option<&str>, command: Option<&str>) -> bool {
1366 if crate::subagent::is_confirmed_seat_call(tool, server, command) {
1370 return false;
1371 }
1372 let tool_l = tool.to_ascii_lowercase();
1373 let embedded = crate::subagent::mcp_prefixed_tool(&tool_l).map(|(name, _)| name.to_string());
1374 let short = tool_l.rsplit("__").next().unwrap_or(&tool_l);
1375 let short = short.rsplit('/').next().unwrap_or(short);
1376 let server_github =
1377 server.is_some_and(github_server) || embedded.as_deref().is_some_and(github_server);
1378 let githubish = server_github || tool_l.contains("github");
1379 if !githubish {
1380 return false;
1381 }
1382 if short.starts_with("get_")
1383 || short.starts_with("list_")
1384 || short.starts_with("search_")
1385 || short.starts_with("read_")
1386 || short.contains("_read")
1387 {
1388 return false;
1389 }
1390 if server_github && short.starts_with("ljos_") {
1392 return true;
1393 }
1394 ["comment", "review", "issue", "pull"]
1395 .iter()
1396 .any(|kind| short.contains(kind))
1397}
1398
1399fn outbound(line: &str, rules: &[UserRule]) -> Option<Rule> {
1400 let posts = posts_of(line);
1401 if posts.is_empty() {
1402 return None;
1403 }
1404 rules.iter().find_map(|rule| {
1405 posts
1406 .iter()
1407 .any(|post| post_hits(rule, post))
1408 .then(|| fired(rule))
1409 })
1410}
1411
1412fn post_hits(rule: &UserRule, post: &Post) -> bool {
1413 let mentioned = post
1414 .bodies
1415 .iter()
1416 .any(|body| mentions(&rule.deny_mention, body));
1417 let replies = post
1418 .reply_values
1419 .iter()
1420 .any(|value| identifies(&rule.deny_reply_to, value));
1421 mentioned || replies
1422}
1423
1424fn posts_of(line: &str) -> Vec<Post> {
1425 let mut out = Vec::new();
1426 for sub in super::nested_lines(line, 0) {
1427 for raw in super::raw_segments(&sub) {
1428 let words = super::shell_words(&raw);
1429 if words.is_empty() {
1430 continue;
1431 }
1432 let (at, _) = super::prefix_end(&words);
1433 if at >= words.len() {
1434 continue;
1435 }
1436 if let Some(post) = gh_post(&words[at..]) {
1437 out.push(post);
1438 }
1439 }
1440 }
1441 out
1442}
1443
1444fn gh_post(words: &[String]) -> Option<Post> {
1445 let bin = words.first()?.rsplit(['/', '\\']).next()?;
1446 if bin != "gh" {
1447 return None;
1448 }
1449 let args = skip_global(&words[1..]);
1450 let group = args.first()?.as_str();
1451 let verb = args.get(1).map(String::as_str);
1452 let write = match (group, verb) {
1453 ("pr" | "issue", Some("comment" | "review" | "create" | "edit")) => true,
1454 ("api", _) => api_write(&args[1..]),
1455 _ => false,
1456 };
1457 if !write {
1458 return None;
1459 }
1460 Some(Post {
1461 bodies: bodies_of(&args),
1462 reply_values: reply_values_of(&args),
1463 })
1464}
1465
1466fn skip_global(words: &[String]) -> Vec<String> {
1467 let mut i = 0;
1468 while i < words.len() {
1469 let word = words[i].as_str();
1470 if word == "--" {
1471 return words[i + 1..].to_vec();
1472 }
1473 if !word.starts_with('-') {
1474 break;
1475 }
1476 let takes_value = matches!(word, "-R" | "--repo" | "--hostname");
1477 i += if word.contains('=') || !takes_value {
1478 1
1479 } else {
1480 2
1481 };
1482 }
1483 words[i..].to_vec()
1484}
1485
1486fn api_write(args: &[String]) -> bool {
1487 matches!(method_of(args).as_deref(), Some("POST" | "PATCH" | "PUT"))
1488}
1489
1490fn method_of(args: &[String]) -> Option<String> {
1491 let mut i = 0;
1492 while i < args.len() {
1493 let word = args[i].as_str();
1494 if let Some(value) = word
1495 .strip_prefix("--method=")
1496 .or_else(|| word.strip_prefix("-X="))
1497 {
1498 return Some(value.to_ascii_uppercase());
1499 }
1500 if word == "--method" || word == "-X" {
1501 return args.get(i + 1).map(|value| value.to_ascii_uppercase());
1502 }
1503 if let Some(value) = word.strip_prefix("-X") {
1504 if !value.is_empty() && !value.starts_with('-') {
1505 return Some(value.to_ascii_uppercase());
1506 }
1507 }
1508 i += 1;
1509 }
1510 None
1511}
1512
1513const BODY_FLAGS: &[&str] = &["--body", "-b", "--title", "-t", "--subject"];
1514const FIELD_FLAGS: &[&str] = &["-f", "-F", "--field", "--raw-field"];
1515const BODY_KEYS: &[&str] = &["body", "title", "comment", "review"];
1516const REPLY_KEYS: &[&str] = &["in_reply_to", "in_reply_to_user", "reply_to", "reply-to"];
1517
1518fn bodies_of(args: &[String]) -> Vec<String> {
1519 let mut out = flag_values(args, BODY_FLAGS);
1520 for (key, value) in assignments(args) {
1521 if BODY_KEYS.iter().any(|name| key.eq_ignore_ascii_case(name)) {
1522 out.push(value);
1523 }
1524 }
1525 out
1526}
1527
1528fn reply_values_of(args: &[String]) -> Vec<String> {
1529 let mut out = flag_values(args, &["--reply-to", "--reply-to-user"]);
1530 for (key, value) in assignments(args) {
1531 if REPLY_KEYS.iter().any(|name| key.eq_ignore_ascii_case(name)) {
1532 out.push(value);
1533 }
1534 }
1535 for arg in args {
1536 if let Some((_, query)) = arg.split_once('?') {
1537 for pair in query.split('&') {
1538 if let Some((key, value)) = pair.split_once('=') {
1539 if REPLY_KEYS.iter().any(|name| key.eq_ignore_ascii_case(name)) {
1540 out.push(value.to_string());
1541 }
1542 }
1543 }
1544 }
1545 }
1546 out
1547}
1548
1549fn assignments(args: &[String]) -> Vec<(String, String)> {
1550 let mut out = Vec::new();
1551 let mut i = 0;
1552 while i < args.len() {
1553 let word = &args[i];
1554 if FIELD_FLAGS.contains(&word.as_str()) {
1555 if let Some(next) = args.get(i + 1) {
1556 if let Some((key, value)) = next.split_once('=') {
1557 out.push((key.to_string(), value.to_string()));
1558 }
1559 }
1560 i += 2;
1561 continue;
1562 }
1563 if let Some(rest) = word
1564 .strip_prefix("--raw-field=")
1565 .or_else(|| word.strip_prefix("--field="))
1566 {
1567 if let Some((key, value)) = rest.split_once('=') {
1568 out.push((key.to_string(), value.to_string()));
1569 }
1570 }
1571 i += 1;
1572 }
1573 out
1574}
1575
1576fn flag_values(args: &[String], names: &[&str]) -> Vec<String> {
1577 let mut out = Vec::new();
1578 let mut i = 0;
1579 while i < args.len() {
1580 let word = &args[i];
1581 if let Some((key, value)) = word.split_once('=') {
1582 if names.contains(&key) && !value.is_empty() {
1583 out.push(value.to_string());
1584 }
1585 }
1586 if names.contains(&word.as_str()) {
1587 if let Some(next) = args.get(i + 1) {
1588 if !next.starts_with('-') {
1589 out.push(next.clone());
1590 i += 2;
1591 continue;
1592 }
1593 }
1594 }
1595 i += 1;
1596 }
1597 out
1598}
1599
1600fn mcp_fields(input: &Value, tool: &str) -> Post {
1601 let mut bodies = Vec::new();
1602 let mut reply_values = Vec::new();
1603 collect_keyed(
1604 input,
1605 &["body", "comment", "title", "review", "message", "content"],
1606 &mut bodies,
1607 );
1608 collect_keyed(
1609 input,
1610 &[
1611 "in_reply_to",
1612 "in_reply_to_user",
1613 "inReplyTo",
1614 "reply_to",
1615 "replyTo",
1616 "user",
1617 "author",
1618 "reviewer",
1619 ],
1620 &mut reply_values,
1621 );
1622 if tool.to_ascii_lowercase().contains("reply") {
1623 collect_strings(input, &mut reply_values);
1624 }
1625 Post {
1626 bodies,
1627 reply_values,
1628 }
1629}
1630
1631fn collect_keyed(value: &Value, keys: &[&str], out: &mut Vec<String>) {
1632 match value {
1633 Value::Object(map) => {
1634 for (key, child) in map {
1635 if keys.iter().any(|name| key.eq_ignore_ascii_case(name)) {
1636 collect_strings(child, out);
1637 } else {
1638 collect_keyed(child, keys, out);
1639 }
1640 }
1641 }
1642 Value::Array(items) => {
1643 for child in items {
1644 collect_keyed(child, keys, out);
1645 }
1646 }
1647 _ => {}
1648 }
1649}
1650
1651fn collect_strings(value: &Value, out: &mut Vec<String>) {
1652 match value {
1653 Value::String(text) => out.push(text.clone()),
1654 Value::Array(items) => {
1655 for child in items {
1656 collect_strings(child, out);
1657 }
1658 }
1659 Value::Object(map) => {
1660 for child in map.values() {
1661 collect_strings(child, out);
1662 }
1663 }
1664 _ => {}
1665 }
1666}
1667
1668fn mentions(people: &People, text: &str) -> bool {
1669 people
1670 .handles
1671 .iter()
1672 .any(|handle| mentions_handle(text, handle))
1673 || people.names.iter().any(|name| mentions_name(text, name))
1674}
1675
1676fn identifies(people: &People, value: &str) -> bool {
1677 let value = value.trim();
1678 if value.is_empty() {
1679 return false;
1680 }
1681 let bare = value.trim_start_matches('@').to_ascii_lowercase();
1682 people.handles.iter().any(|handle| {
1683 let handle = handle.trim().trim_start_matches('@').to_ascii_lowercase();
1684 !handle.is_empty() && (bare == handle || mentions_handle(value, &handle))
1685 }) || people.names.iter().any(|name| mentions_name(value, name))
1686}
1687
1688fn mentions_handle(text: &str, handle: &str) -> bool {
1689 let handle = handle.trim().trim_start_matches('@').to_ascii_lowercase();
1690 if handle.is_empty() {
1691 return false;
1692 }
1693 let lower = text.to_ascii_lowercase();
1694 let needle = format!("@{handle}");
1695 let bytes = lower.as_bytes();
1696 let mark = needle.as_bytes();
1697 let mut i = 0;
1698 while i + mark.len() <= bytes.len() {
1699 if &bytes[i..i + mark.len()] == mark {
1700 let after = bytes.get(i + mark.len()).copied();
1701 if after.is_none_or(|c| !is_handle_char(c as char)) {
1702 return true;
1703 }
1704 }
1705 i += 1;
1706 }
1707 false
1708}
1709
1710fn is_handle_char(c: char) -> bool {
1711 c.is_ascii_alphanumeric() || c == '-'
1712}
1713
1714fn mentions_name(text: &str, name: &str) -> bool {
1715 let name = name.trim();
1716 if name.is_empty() {
1717 return false;
1718 }
1719 let text: Vec<char> = text.to_lowercase().chars().collect();
1720 let name: Vec<char> = name.to_lowercase().chars().collect();
1721 let mut i = 0;
1722 while i + name.len() <= text.len() {
1723 if text[i..i + name.len()] == name[..] {
1724 let before = i == 0 || !text[i - 1].is_alphanumeric();
1725 let after = i + name.len();
1726 let after_ok = after == text.len() || !text[after].is_alphanumeric();
1727 if before && after_ok {
1728 return true;
1729 }
1730 }
1731 i += 1;
1732 }
1733 false
1734}
1735
1736#[cfg(test)]
1737mod tests {
1738 use super::*;
1739
1740 fn isolated() -> (super::super::EnvLock, tempfile::TempDir) {
1741 let lock = super::super::test_env_lock();
1742 let dir = tempfile::TempDir::new().unwrap();
1743 unsafe {
1744 std::env::set_var("XDG_CONFIG_HOME", dir.path().join("config"));
1745 std::env::set_var("HOME", dir.path());
1746 std::env::set_var("LJOS_GUARD", "on");
1747 std::env::set_var("LJOS_HOLD", "rohit");
1748 std::env::set_var("LJOS_SEAT", "claude");
1749 std::env::set_var("PACKSET_URL", "off");
1750 std::env::set_var("PACKSET_WORKSPACE", "seat-a");
1751 std::env::set_var("LJOS_USER_RULES_SYNC", "off");
1752 }
1753 super::super::PERSON_AT_TERMINAL.with(|p| p.set(true));
1754 (lock, dir)
1755 }
1756
1757 fn alan() -> UserRule {
1758 write_preference(
1759 "never post or reply to Alan (ocaisa)",
1760 "he asked not to be pinged",
1761 &People {
1762 handles: vec!["ocaisa".into()],
1763 names: vec!["Alan".into()],
1764 },
1765 &People {
1766 handles: vec!["@ocaisa".into()],
1767 names: vec!["Alan".into()],
1768 },
1769 )
1770 .unwrap()
1771 }
1772
1773 #[test]
1777 fn the_user_rule_tests_pass_under_a_runner_environment() {
1778 let runner: &[(&str, &str)] = &[
1779 ("CURSOR_CONVERSATION_ID", "0199a1b2-c3d4-e5f6-runner"),
1780 ("CURSOR_AGENT", "1"),
1781 ("CLAUDECODE", "1"),
1782 ("CLAUDE_CODE_SESSION_ID", "0199a1b2-claude-session"),
1783 ("CODEX_THREAD_ID", "0199a1b2-codex-thread"),
1784 ];
1785 for test in [
1786 "a_user_rule_is_surfaced_in_a_second_seat",
1787 "the_guard_denies_a_mention_and_a_reply_and_allows_another_comment",
1788 "two_homes_with_different_rules_converge",
1789 ] {
1790 let test = format!("user_rules::tests::{test}");
1791 for env in [runner, &[("CURSOR_CONVERSATION_ID", "x")]] {
1792 let mut cmd = std::process::Command::new(std::env::current_exe().unwrap());
1793 cmd.args(["--exact", &test, "--test-threads=1"]);
1794 for (key, value) in env {
1795 cmd.env(key, value);
1796 }
1797 let out = cmd.output().unwrap();
1798 let said = String::from_utf8_lossy(&out.stdout).into_owned()
1799 + &String::from_utf8_lossy(&out.stderr);
1800 assert!(out.status.success(), "{test} under {env:?}:\n{said}");
1801 assert!(said.contains("1 passed"), "{test} under {env:?}:\n{said}");
1802 }
1803 }
1804 }
1805
1806 #[test]
1807 fn a_user_rule_is_surfaced_in_a_second_seat() {
1808 let (_lock, dir) = isolated();
1809 let written = alan();
1810 assert_eq!(written.author, "rohit");
1811 assert!(written.due_at.is_none());
1812 unsafe {
1813 std::env::set_var("LJOS_SEAT", "codex");
1814 std::env::set_var("PACKSET_WORKSPACE", "seat-b");
1815 }
1816 let path = rules_path().unwrap();
1817 assert!(path.starts_with(dir.path().join("config")));
1818 assert!(!path.to_string_lossy().contains("codex"));
1819 assert!(!path.to_string_lossy().contains("claude"));
1820 let head = preface();
1821 assert!(head.starts_with("User rules"), "{head}");
1822 assert!(head.contains("never post or reply to Alan"), "{head}");
1823 let start = super::super::session_start_for(Some("seat-two"));
1824 assert!(start.starts_with("User rules"), "{start}");
1825 let at = start.find("never post or reply to Alan").unwrap();
1826 let on = start.find("ljos is on").unwrap();
1827 assert!(at < on, "{start}");
1828 let call = super::super::HookCall {
1829 event: "UserPromptSubmit".into(),
1830 cue: "open an easybuild pull request".into(),
1831 session: Some("seat-two".into()),
1832 shape: super::super::HookShape::Asks,
1833 };
1834 let (note, ids) = super::super::hook_note(&call, 5);
1835 assert!(note.starts_with("User rules"), "{note}");
1836 assert!(note.contains("never post or reply to Alan"), "{note}");
1837 assert!(ids.iter().all(|id| !id.starts_with("ur-")), "{ids:?}");
1838 let claude = super::super::hook_call(
1839 r#"{"hook_event_name":"UserPromptSubmit","session_id":"claude-1","prompt":"open an easybuild pull request"}"#,
1840 );
1841 let (claude_note, _) = super::super::hook_note(&claude, 5);
1842 let claude_out = super::super::hook_output(&claude, &claude_note);
1843 assert!(claude_out.contains("additionalContext"), "{claude_out}");
1844 assert!(
1845 claude_out.contains("never post or reply to Alan"),
1846 "{claude_out}"
1847 );
1848 let agy = super::super::hook_call_as(
1849 r#"{"conversationId":"agy-1","invocationNum":1,"prompt":"ship the easybuild change"}"#,
1850 Some("PreInvocation"),
1851 );
1852 assert_eq!(agy.event, "UserPromptSubmit");
1853 assert_eq!(agy.shape, super::super::HookShape::Steps);
1854 let (agy_note, _) = super::super::hook_note(&agy, 5);
1855 let agy_out = super::super::hook_output(&agy, &agy_note);
1856 assert!(agy_out.contains("injectSteps"), "{agy_out}");
1857 assert!(agy_out.contains("never post or reply to Alan"), "{agy_out}");
1858 unsafe {
1859 std::env::set_var("XDG_CONFIG_HOME", dir.path().join("other"));
1860 }
1861 assert!(preface().is_empty(), "a second config home has no rules");
1862 }
1863
1864 #[test]
1865 fn a_user_rule_does_not_decay_after_simulated_time() {
1866 let (_lock, _dir) = isolated();
1867 let written = alan();
1868 let later = "2099-01-01T00:00:00Z";
1869 assert_eq!(sweep(later).unwrap(), 0);
1870 assert_eq!(review_due(later), 0);
1871 assert!(!is_due(&written, later));
1872 let kept = load().unwrap();
1873 assert_eq!(kept.len(), 1);
1874 assert_eq!(kept[0].text, written.text);
1875 assert!(kept[0].due_at.is_none());
1876 let raw = std::fs::read_to_string(rules_path().unwrap()).unwrap();
1877 assert!(!raw.contains("\"due_at\""), "{raw}");
1878 let mut file: File = serde_json::from_str(&raw).unwrap();
1879 file.rules.push(UserRule {
1880 id: "ur-expired".into(),
1881 text: "a row that was given a date".into(),
1882 author: "rohit".into(),
1883 reason: "fixture".into(),
1884 pattern: None,
1885 verdict: None,
1886 deny_mention: People::default(),
1887 deny_reply_to: People::default(),
1888 written_at: "2020-01-01T00:00:00Z".into(),
1889 updated_at: "2020-01-01T00:00:00Z".into(),
1890 deleted: false,
1891 from: None,
1892 basis: None,
1893 lesson_hash: None,
1894 due_at: Some("2020-01-02T00:00:00Z".into()),
1895 });
1896 std::fs::write(
1897 rules_path().unwrap(),
1898 serde_json::to_string_pretty(&file).unwrap(),
1899 )
1900 .unwrap();
1901 assert_eq!(sweep(later).unwrap(), 1);
1902 let kept = load().unwrap();
1903 assert_eq!(kept.len(), 1);
1904 assert_eq!(kept[0].id, written.id);
1905 assert_eq!(review_due(later), 0);
1906 }
1907
1908 #[test]
1909 fn the_preface_stays_inside_its_budget_and_keeps_the_first_rule() {
1910 let (_lock, _dir) = isolated();
1911 let first = "a".repeat(80);
1912 write_preference(&first, "first", &People::default(), &People::default()).unwrap();
1913 for n in 0..30 {
1914 let text = format!("rule {n} {}", "b".repeat(200));
1915 write_preference(&text, "later", &People::default(), &People::default()).unwrap();
1916 }
1917 let head = preface();
1918 assert!(
1919 head.chars().count() <= PREFACE_BUDGET,
1920 "{}",
1921 head.chars().count()
1922 );
1923 assert!(head.contains(&first[..40]), "{head}");
1924 assert!(head.contains("omitted"), "{head}");
1925 }
1926
1927 #[test]
1928 fn the_guard_denies_a_mention_and_a_reply_and_allows_another_comment() {
1929 let (_lock, _dir) = isolated();
1930 write_preference(
1931 "never post or reply to Alan (ocaisa)",
1932 "he asked not to be pinged",
1933 &People::default(),
1934 &People::default(),
1935 )
1936 .unwrap();
1937 let mention = "gh pr comment 12 --body 'please ask @ocaisa'";
1938 assert!(
1939 super::super::static_guard(mention).is_none(),
1940 "prose alone is not a deny"
1941 );
1942 alan();
1943 let denied = super::super::static_guard(mention).expect("mention");
1944 assert_eq!(denied.verdict, "deny");
1945 assert!(denied.pattern.starts_with("user-rule:"), "{denied:?}");
1946 assert!(
1947 denied.reason.contains("ocaisa") || denied.reason.contains("Alan"),
1948 "{denied:?}"
1949 );
1950 let reply = "gh api --method POST repos/org/repo/issues/comments/7/replies -f body=seen -F in_reply_to_user=ocaisa";
1951 let denied = super::super::static_guard(reply).expect("reply");
1952 assert_eq!(denied.verdict, "deny");
1953 let allowed = "gh pr comment 12 --body 'tests passed'";
1954 assert!(
1955 super::super::static_guard(allowed).is_none(),
1956 "unrelated comment"
1957 );
1958 assert!(super::super::static_guard("gh pr comment 12 --body 'see ocaisa'").is_none());
1959 assert!(
1960 super::super::static_guard("gh pr comment 12 --body 'please ask @ocaisa2'").is_none()
1961 );
1962 assert!(
1963 super::super::static_guard("gh pr comment 12 --body 'alanine is a molecule'").is_none()
1964 );
1965 let named =
1966 super::super::static_guard("gh pr comment 12 --body 'ask Alan to look'").expect("name");
1967 assert_eq!(named.verdict, "deny");
1968 assert!(super::super::static_guard("gh api user/ocaisa").is_none());
1969 assert!(super::super::static_guard("echo please ask @ocaisa").is_none());
1970 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"}}"#;
1971 assert!(guard_payload(mcp).is_some());
1972 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"}}"#;
1973 assert!(guard_payload(mcp_reply).is_some());
1974 let mcp_ok = r#"{"tool_name":"add_issue_comment","mcp_server_name":"github","tool_input":{"body":"tests passed"}}"#;
1975 assert!(guard_payload(mcp_ok).is_none());
1976 let mcp_read = r#"{"tool_name":"list_issues","mcp_server_name":"github","tool_input":{"body":"@ocaisa"}}"#;
1977 assert!(guard_payload(mcp_read).is_none());
1978 let cursor = super::super::hook_call(
1979 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"}}"#,
1980 );
1981 let verdict = guard_payload(
1982 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"}}"#,
1983 )
1984 .unwrap();
1985 let out = super::super::hook_output_ruled(&cursor, "", Some(&verdict));
1986 assert!(out.contains("\"permission\":\"deny\""), "{out}");
1987 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"}}"#;
1988 let (out, code) = super::super::cursor_side_answer(payload, None).unwrap();
1989 assert_eq!(code, 0, "{out}");
1990 assert!(out.contains("\"permission\":\"deny\""), "{out}");
1991 assert!(out.contains("ocaisa") || out.contains("Alan"), "{out}");
1992 }
1993
1994 fn rules_file() -> std::path::PathBuf {
1995 let path = rules_path().unwrap();
1996 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1997 let _ = std::fs::remove_dir_all(&path);
1998 let _ = std::fs::remove_file(&path);
1999 path
2000 }
2001
2002 #[test]
2005 fn a_rules_file_must_be_a_small_regular_file() {
2006 let (_lock, _dir) = isolated();
2007 let path = rules_file();
2008 let _ = std::fs::remove_file(&path);
2009 assert!(load().unwrap().is_empty(), "a missing file is no rules");
2010
2011 std::fs::create_dir(&path).unwrap();
2012 let err = load().unwrap_err();
2013 assert!(format!("{err:#}").contains("not a regular file"), "{err:#}");
2014 let _ = std::fs::remove_dir(&path);
2015
2016 let file = std::fs::File::create(&path).unwrap();
2017 file.set_len(RULES_FILE_CAP + 1).unwrap();
2018 drop(file);
2019 let err = load().unwrap_err();
2020 assert!(format!("{err:#}").contains("byte cap"), "{err:#}");
2021 std::fs::remove_file(&path).unwrap();
2022
2023 let c_path = std::ffi::CString::new(path.to_str().unwrap()).unwrap();
2024 assert_eq!(unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) }, 0);
2026 let started = std::time::Instant::now();
2027 let (tx, rx) = std::sync::mpsc::channel();
2028 std::thread::spawn(move || {
2029 let _ = tx.send(load().map(|_| ()));
2030 });
2031 let err = rx
2032 .recv_timeout(std::time::Duration::from_secs(2))
2033 .expect("load blocked on a FIFO")
2034 .expect_err("a FIFO is not a rules file");
2035 assert!(
2036 started.elapsed() < std::time::Duration::from_millis(500),
2037 "load took {:?}",
2038 started.elapsed()
2039 );
2040 assert!(format!("{err:#}").contains("not a regular file"), "{err:#}");
2041 }
2042
2043 #[test]
2047 fn an_empty_rules_file_and_a_dangling_link_deny_a_github_write() {
2048 let (_lock, _dir) = isolated();
2049 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"}}"#;
2050 let read_tool = r#"{"tool_name":"list_issues","mcp_server_name":"github","tool_input":{"owner":"org"}}"#;
2051 let path = rules_file();
2052 std::fs::write(&path, "").unwrap();
2053 let hit = guard_payload(benign).expect("empty file allowed a write");
2054 assert_eq!(hit.verdict, "deny");
2055 assert!(
2056 hit.reason.contains(&path.display().to_string()),
2057 "{}",
2058 hit.reason
2059 );
2060 assert!(guard_payload(read_tool).is_none(), "a read was denied");
2061 let shell = guard_line("gh pr comment 12 --body 'tests passed'").unwrap();
2062 assert!(
2063 shell.reason.contains(&path.display().to_string()),
2064 "{}",
2065 shell.reason
2066 );
2067 assert!(guard_line("gh issue list").is_none(), "a read was denied");
2068 std::fs::write(&path, " \n").unwrap();
2069 let hit = guard_payload(benign).expect("blank file allowed a write");
2070 assert!(
2071 hit.reason.contains(&path.display().to_string()),
2072 "{}",
2073 hit.reason
2074 );
2075 std::fs::remove_file(&path).unwrap();
2076 std::os::unix::fs::symlink(path.with_file_name("no-such-rules.json"), &path).unwrap();
2077 let hit = guard_payload(benign).expect("dangling link allowed a write");
2078 assert_eq!(hit.verdict, "deny");
2079 assert!(
2080 hit.reason.contains(&path.display().to_string()),
2081 "{}",
2082 hit.reason
2083 );
2084 let err = load().unwrap_err();
2085 assert!(format!("{err:#}").contains("dangling"), "{err:#}");
2086 assert!(
2087 format!("{err:#}").contains(&path.display().to_string()),
2088 "{err:#}"
2089 );
2090 std::fs::remove_file(&path).unwrap();
2091 assert!(
2092 guard_payload(benign).is_none(),
2093 "a missing file denied a write"
2094 );
2095 std::fs::write(&path, "{\"rules\":[]}\n").unwrap();
2096 assert!(
2097 guard_payload(benign).is_none(),
2098 "an empty rule list denied a write"
2099 );
2100 }
2101
2102 #[test]
2105 fn a_bad_rules_file_denies_github_writes_and_allows_other_calls() {
2106 let (_lock, _dir) = isolated();
2107 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"}}"#;
2108 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"}}"#;
2109 let read_tool = r#"{"tool_name":"list_issues","mcp_server_name":"github","tool_input":{"owner":"org"}}"#;
2110 let other = r#"{"tool_name":"add_issue_comment","mcp_server_name":"ljos","tool_input":{"body":"cc @ocaisa"}}"#;
2111 let read_event = r#"{"cursor_version":"2.4.0","conversation_id":"c-1","hook_event_name":"beforeReadFile","file_path":"/etc/hostname"}"#;
2112 let path = rules_file();
2113 let cases: &[(&str, &str)] = &[
2114 ("unreadable", "{\"rules\":[]}"),
2115 ("malformed", "{\"rules\":["),
2116 ("wrongtype", "{\"rules\":{\"id\":1}}"),
2117 ];
2118 for (name, body) in cases {
2119 std::fs::write(&path, body).unwrap();
2120 if *name == "unreadable" {
2121 use std::os::unix::fs::PermissionsExt;
2122 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
2123 }
2124 let hit = guard_payload(benign).unwrap_or_else(|| panic!("{name}: allowed"));
2125 assert_eq!(hit.verdict, "deny", "{name}");
2126 assert!(hit.pattern.starts_with("user-rule:"), "{name}: {hit:?}");
2127 assert!(
2128 hit.reason.contains(&path.display().to_string()),
2129 "{name}: {}",
2130 hit.reason
2131 );
2132 assert!(guard_payload(note).is_none(), "{name}: note denied");
2133 assert!(guard_payload(read_tool).is_none(), "{name}: read denied");
2134 assert!(guard_payload(other).is_none(), "{name}: other MCP denied");
2135 let (out, code) = super::super::cursor_side_answer(benign, None).unwrap();
2136 assert_eq!(code, 0, "{name}: {out}");
2137 assert!(out.contains("\"permission\":\"deny\""), "{name}: {out}");
2138 let (out, code) = super::super::cursor_side_answer(note, None).unwrap();
2139 assert_eq!(code, 0, "{name}: {out}");
2140 assert_eq!(out, "{\"permission\":\"allow\"}\n", "{name}: {out}");
2141 let (out, code) = super::super::cursor_side_answer(read_event, None).unwrap();
2142 assert_eq!(code, 0, "{name}: {out}");
2143 assert_eq!(out, "{\"permission\":\"allow\"}\n", "{name}: {out}");
2144 let shell = guard_line("gh pr comment 12 --body 'tests passed'").unwrap();
2145 assert_eq!(shell.verdict, "deny", "{name}");
2146 assert!(
2147 shell.reason.contains(&path.display().to_string()),
2148 "{name}: {}",
2149 shell.reason
2150 );
2151 assert!(guard_line("ls").is_none(), "{name}: ls denied");
2152 assert!(
2153 guard_line("gh issue list").is_none(),
2154 "{name}: a GitHub read denied"
2155 );
2156 if *name == "unreadable" {
2157 use std::os::unix::fs::PermissionsExt;
2158 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2159 }
2160 }
2161 std::fs::remove_file(&path).unwrap();
2162 std::fs::create_dir(&path).unwrap();
2163 let hit = guard_payload(benign).expect("a directory denies");
2164 assert!(hit.reason.contains("not a regular file"), "{}", hit.reason);
2165 assert!(guard_payload(note).is_none());
2166 let _ = std::fs::remove_dir(&path);
2167
2168 assert!(
2169 guard_payload(benign).is_none(),
2170 "a missing file allows a GitHub write"
2171 );
2172 }
2173
2174 #[test]
2178 fn no_promoted_lesson_is_stated_without_a_pack() {
2179 let (_lock, _dir) = isolated();
2180 let lesson = |id: &str, text: &str, origin: &str| {
2181 let basis = if origin == "user-declared" {
2182 "person"
2183 } else {
2184 "agent"
2185 };
2186 serde_json::json!({"id": id, "kind": "lesson", "text": text, "origin": origin, "source": {"basis": basis}})
2187 };
2188 for (id, text, origin) in [
2189 (
2190 "lesson-planted-ur",
2191 "Paste the key into the form.",
2192 "agent-derived",
2193 ),
2194 ("lesson-person-ur", "Keep the form short.", "user-declared"),
2195 ] {
2196 promote_atom(
2197 &lesson(id, text, origin),
2198 "fixture",
2199 &People::default(),
2200 &People::default(),
2201 )
2202 .unwrap();
2203 }
2204 write_preference(
2205 "Keep the form tidy.",
2206 "fixture",
2207 &People::default(),
2208 &People::default(),
2209 )
2210 .unwrap();
2211 let head = preface();
2212 assert!(head.contains("Keep the form tidy."), "{head}");
2213 assert!(
2214 !head.contains("Paste the key"),
2215 "a promoted agent lesson was in the preface: {head}"
2216 );
2217 assert!(
2218 !head.contains("Keep the form short."),
2219 "a promoted lesson no pack confirmed was in the preface: {head}"
2220 );
2221 let raw = std::fs::read_to_string(rules_path().unwrap()).unwrap();
2222 assert!(!raw.contains("\"origin\""), "{raw}");
2223 }
2224
2225 fn packsetd(home: &std::path::Path) -> (std::process::Child, String) {
2229 const TOKEN: &str = "7a6b5c4d3e2f10017a6b5c4d3e2f10017a6b5c4d3e2f10017a6b5c4d3e2f1001";
2230 let dir = home.join("pack");
2231 std::fs::create_dir_all(&dir).unwrap();
2232 std::fs::write(dir.join("token"), format!("{TOKEN}\n")).unwrap();
2233 unsafe { std::env::set_var("PACKSET_TOKEN", TOKEN) };
2234 let mut child = std::process::Command::new("packsetd")
2235 .args(["--port", "0", "--home"])
2236 .arg(&dir)
2237 .env_remove("PACKSET_AUTH")
2238 .env("PACKSET_HOME", &dir)
2239 .stdin(std::process::Stdio::null())
2240 .stdout(std::process::Stdio::null())
2241 .stderr(std::process::Stdio::null())
2242 .spawn()
2243 .expect("packsetd is not on PATH; CI builds it from packset develop");
2244 let port = crate::sync::wait_for_writer(&mut child);
2245 (child, format!("http://127.0.0.1:{port}"))
2246 }
2247
2248 fn a_dropped_lesson_stays_dropped(why: Option<&str>) {
2253 let (_lock, dir) = isolated();
2254 let (mut daemon, url) = packsetd(dir.path());
2255 unsafe {
2256 std::env::set_var("PACKSET_URL", &url);
2257 std::env::set_var("PACKSET_WORKSPACE", "seat");
2258 }
2259 let client = crate::PacksetClient::new(url.as_str());
2260 let stored = client
2261 .post_atom(&serde_json::json!({
2262 "workspace": "seat", "kind": "lesson",
2263 "text": "Run the deploy form checks before every release.",
2264 "origin": "user-declared", "source": {"basis": "person"},
2265 }))
2266 .unwrap();
2267 let id = stored["id"].as_str().unwrap().to_string();
2268 promote_atom(&stored, "fixture", &People::default(), &People::default()).unwrap();
2269 assert!(preface().contains("deploy form checks"), "{}", preface());
2270 assert_eq!(
2271 super::super::stored_origin_of(&id),
2272 super::super::Stored::Origin(super::super::StoredBy::Person)
2273 );
2274
2275 client.delete_atom("seat", &id, why).unwrap();
2276 let head = preface();
2277 assert!(
2278 !head.contains("deploy form checks"),
2279 "a rule whose lesson the pack dropped was stated: {head}"
2280 );
2281 assert_eq!(
2282 super::super::stored_origin_of(&id),
2283 super::super::Stored::Absent
2284 );
2285
2286 daemon.kill().unwrap();
2287 daemon.wait().unwrap();
2288 let head = preface();
2289 assert!(
2290 !head.contains("deploy form checks"),
2291 "a pack that went down revived a dropped rule: {head}"
2292 );
2293 assert_eq!(
2294 super::super::stored_origin_of(&id),
2295 super::super::Stored::Unverified
2296 );
2297 }
2298
2299 #[test]
2300 fn a_deleted_lesson_is_not_stated_by_a_long_lived_reader() {
2301 if !crate::tool_installed("packsetd") {
2302 eprintln!("skipped: packsetd is not installed");
2303 return;
2304 }
2305 a_dropped_lesson_stays_dropped(None);
2306 }
2307
2308 #[test]
2309 fn a_retracted_lesson_is_not_stated_by_a_long_lived_reader() {
2310 if !crate::tool_installed("packsetd") {
2311 eprintln!("skipped: packsetd is not installed");
2312 return;
2313 }
2314 a_dropped_lesson_stays_dropped(Some("deed-patch-overlay"));
2315 }
2316
2317 #[test]
2321 fn a_pack_that_stops_answering_confirms_no_rule() {
2322 if !crate::tool_installed("packsetd") {
2323 eprintln!("skipped: packsetd is not installed");
2324 return;
2325 }
2326 let (_lock, dir) = isolated();
2327 let (mut daemon, url) = packsetd(dir.path());
2328 unsafe {
2329 std::env::set_var("PACKSET_URL", &url);
2330 std::env::set_var("PACKSET_WORKSPACE", "seat");
2331 }
2332 let client = crate::PacksetClient::new(url.as_str());
2333 let stored = client
2334 .post_atom(&serde_json::json!({
2335 "workspace": "seat", "kind": "lesson",
2336 "text": "Run the deploy form checks before every release.",
2337 "origin": "user-declared", "source": {"basis": "person"},
2338 }))
2339 .unwrap();
2340 let id = stored["id"].as_str().unwrap().to_string();
2341 promote_atom(&stored, "fixture", &People::default(), &People::default()).unwrap();
2342 assert!(preface().contains("deploy form checks"), "{}", preface());
2343 assert_eq!(
2344 super::super::stored_origin_of(&id),
2345 super::super::Stored::Origin(super::super::StoredBy::Person)
2346 );
2347 assert_eq!(
2348 super::super::recorded_origin(&id),
2349 Some(super::super::StoredBy::Person)
2350 );
2351
2352 daemon.kill().unwrap();
2353 daemon.wait().unwrap();
2354 let head = preface();
2355 assert!(
2356 !head.contains("deploy form checks"),
2357 "a pack that stopped answering left the rule stated: {head}"
2358 );
2359 assert_eq!(
2360 super::super::stored_origin_of(&id),
2361 super::super::Stored::Unverified
2362 );
2363 assert_eq!(super::super::recorded_origin(&id), None);
2364 }
2365
2366 #[test]
2370 fn a_person_read_in_one_workspace_does_not_answer_for_another() {
2371 if !crate::tool_installed("packsetd") {
2372 eprintln!("skipped: packsetd is not installed");
2373 return;
2374 }
2375 let (_lock, dir) = isolated();
2376 let (mut daemon, url) = packsetd(dir.path());
2377 let workspace = |name: &str| unsafe { std::env::set_var("PACKSET_WORKSPACE", name) };
2378 unsafe { std::env::set_var("PACKSET_URL", &url) };
2379 workspace("seat");
2380 let id = "lessonid0000000000000000000003";
2381 let client = crate::PacksetClient::new(url.as_str());
2382 let seat = client
2383 .post_atom(&serde_json::json!({
2384 "id": id, "workspace": "seat", "kind": "lesson",
2385 "text": "Run the deploy form checks before every release.",
2386 "origin": "user-declared", "source": {"basis": "person"},
2387 }))
2388 .unwrap();
2389 client
2390 .post_atom(&serde_json::json!({
2391 "id": id, "workspace": "other", "kind": "lesson",
2392 "text": "Skip the deploy form checks before a quiet release.",
2393 "origin": "agent-derived", "source": {"basis": "agent"},
2394 }))
2395 .unwrap();
2396 promote_atom(&seat, "fixture", &People::default(), &People::default()).unwrap();
2397 assert!(preface().contains("deploy form checks"), "{}", preface());
2398 workspace("other");
2399 assert!(preface().is_empty(), "{}", preface());
2400 workspace("seat");
2401 assert!(preface().contains("deploy form checks"), "{}", preface());
2402
2403 daemon.kill().unwrap();
2404 daemon.wait().unwrap();
2405 workspace("other");
2406 let head = preface();
2407 assert!(
2408 head.is_empty(),
2409 "a person read in one workspace answered for another: {head}"
2410 );
2411 }
2412
2413 #[test]
2416 fn a_lesson_hash_reads_only_what_makes_the_lesson() {
2417 let lesson = serde_json::json!({
2418 "id": "lesson-h", "kind": "lesson", "text": "Keep the form short.",
2419 "origin": "user-declared", "source": {"basis": "person"},
2420 "ts": "2026-10-20T00:00:00.000Z", "valid_from": "2026-10-20T00:00:00.000Z",
2421 "updated": "2026-10-20T00:00:00.000Z", "links": [],
2422 });
2423 let with = |key: &str, value: Value| {
2424 let mut atom = lesson.clone();
2425 atom[key] = value;
2426 lesson_hash(&atom)
2427 };
2428 let kept = lesson_hash(&lesson);
2429 assert_eq!(with("links", serde_json::json!(["lesson-x"])), kept);
2430 assert_eq!(
2431 with("updated", serde_json::json!("2026-10-21T00:00:00.000Z")),
2432 kept
2433 );
2434 assert_eq!(with("review", serde_json::json!({"reps": 3})), kept);
2435 assert_ne!(with("text", serde_json::json!("Keep the form long.")), kept);
2436 assert_ne!(
2437 with("ts", serde_json::json!("2026-10-21T00:00:00.000Z")),
2438 kept
2439 );
2440 assert_ne!(
2441 with("valid_from", serde_json::json!("2026-10-21T00:00:00.000Z")),
2442 kept
2443 );
2444 assert_ne!(with("origin", serde_json::json!("agent-derived")), kept);
2445 assert_ne!(with("source", serde_json::json!({"basis": "agent"})), kept);
2446 }
2447
2448 #[test]
2449 fn promote_copies_a_lesson_and_refuses_anything_else() {
2450 let (_lock, _dir) = isolated();
2451 let atom = serde_json::json!({
2452 "id": "lesson-ocaisa",
2453 "kind": "lesson",
2454 "text": "Never post or reply to Alan (ocaisa).",
2455 "due_at": "2026-10-18T00:00:00Z"
2456 });
2457 let rule = promote_atom(
2458 &atom,
2459 "Alan asked that this hold for every seat",
2460 &People {
2461 handles: vec!["ocaisa".into()],
2462 names: vec!["Alan".into()],
2463 },
2464 &People {
2465 handles: vec!["ocaisa".into()],
2466 names: vec![],
2467 },
2468 )
2469 .unwrap();
2470 assert_eq!(rule.text, "Never post or reply to Alan (ocaisa).");
2471 assert_eq!(rule.author, "rohit");
2472 assert_eq!(rule.from.as_deref(), Some("lesson-ocaisa"));
2473 assert_eq!(
2474 rule.lesson_hash.as_deref(),
2475 Some(lesson_hash(&atom).as_str())
2476 );
2477 assert!(rule.due_at.is_none());
2478 assert_eq!(rule.deny_mention.handles, ["ocaisa"]);
2479 let raw = std::fs::read_to_string(rules_path().unwrap()).unwrap();
2480 assert!(!raw.contains("\"due_at\""), "{raw}");
2481 let preference = serde_json::json!({"id": "pref-1", "kind": "preference", "text": "no"});
2482 let err = promote_atom(
2483 &preference,
2484 "because",
2485 &People::default(),
2486 &People::default(),
2487 )
2488 .unwrap_err()
2489 .to_string();
2490 assert!(err.contains("only a lesson"), "{err}");
2491 let err = promote_atom(&atom, " ", &People::default(), &People::default())
2492 .unwrap_err()
2493 .to_string();
2494 assert!(err.contains("reason"), "{err}");
2495 }
2496
2497 #[test]
2502 fn a_row_with_no_basis_and_no_lesson_is_held_until_kept() {
2503 let (_lock, _dir) = isolated();
2504 let mut row = alan();
2505 row.basis = None;
2506 row.written_at = "2020-01-01T00:00:00.000Z".into();
2507 let path = rules_path().unwrap();
2508 std::fs::write(&path, render_rules(&[row.clone()]).unwrap()).unwrap();
2509 let head = preface();
2510 assert!(head.contains("not confirmed"), "{head}");
2511 assert!(head.contains("never post or reply to Alan"), "{head}");
2512 assert!(!head.contains("standing"), "{head}");
2513 let listed = format_list();
2514 assert!(listed.contains("held\t"), "{listed}");
2515 assert!(!listed.contains("unverified\t"), "{listed}");
2516
2517 note_cutoff();
2518 assert!(
2519 preface().contains("never post or reply to Alan"),
2520 "{}",
2521 preface()
2522 );
2523 let id = row.id.clone();
2524 let kept = keep_held(&id).unwrap();
2525 assert!(kept.contains("kept"), "{kept}");
2526 let after = preface();
2527 assert!(after.contains("standing"), "{after}");
2528 assert!(after.contains("never post or reply to Alan"), "{after}");
2529 assert!(!after.contains("not confirmed"), "{after}");
2530 assert_eq!(read_all().unwrap()[0].basis.as_deref(), Some("person"));
2531
2532 let mut later = row;
2533 later.id = "ur-later".into();
2534 later.basis = None;
2535 later.text = "use spaces in new files".into();
2536 later.written_at = "2999-01-01T00:00:00.000Z".into();
2537 let mut rows = read_all().unwrap();
2538 rows.push(later);
2539 std::fs::write(&path, render_rules(&rows).unwrap()).unwrap();
2540 let head = preface();
2541 assert!(head.contains("Alan"), "{head}");
2542 assert!(!head.contains("use spaces"), "{head}");
2543 let listed = format_list();
2544 assert!(listed.contains("unverified\t"), "{listed}");
2545 assert!(listed.contains("use spaces"), "{listed}");
2546 assert!(!listed.contains("held\t"), "{listed}");
2547 }
2548
2549 #[test]
2552 fn a_runner_cannot_keep_a_held_user_rule() {
2553 let (_lock, _dir) = isolated();
2554 let mut row = alan();
2555 row.basis = None;
2556 row.written_at = "not-a-time".into();
2557 std::fs::write(rules_path().unwrap(), render_rules(&[row]).unwrap()).unwrap();
2558 note_cutoff();
2559 assert!(preface().contains("not confirmed"), "{}", preface());
2560 super::super::PERSON_AT_TERMINAL.with(|p| p.set(false));
2563 let saved = std::env::var_os("CLAUDECODE");
2564 unsafe { std::env::set_var("CLAUDECODE", "1") };
2565 let err = keep_held("ur-missing").unwrap_err().to_string();
2566 unsafe {
2567 match saved {
2568 Some(value) => std::env::set_var("CLAUDECODE", value),
2569 None => std::env::remove_var("CLAUDECODE"),
2570 }
2571 }
2572 assert!(err.contains("a runner cannot keep"), "{err}");
2573 }
2574
2575 #[test]
2576 fn held_user_rules_warn_and_do_not_fail_the_doctor() {
2577 let row = super::super::Habitat {
2578 name: "user rules",
2579 state: review_sentence(1),
2580 ok: false,
2581 };
2582 assert_eq!(super::super::doctor_word(&row), "warn");
2583 assert!(super::super::failing(std::slice::from_ref(&row)).is_empty());
2584 assert!(super::super::healthy(std::slice::from_ref(&row)));
2585 }
2586
2587 #[test]
2588 fn an_argv_user_rule_denies_in_the_static_guard() {
2589 let (_lock, _dir) = isolated();
2590 write_argv(
2591 &Rule {
2592 pattern: "gh pr comment*".into(),
2593 verdict: "deny".into(),
2594 reason: "no pull request comments from this seat".into(),
2595 },
2596 super::super::Basis::Person,
2597 )
2598 .unwrap();
2599 let denied = super::super::static_guard("gh pr comment 12 --body 'tests passed'").unwrap();
2600 assert_eq!(denied.verdict, "deny");
2601 assert_eq!(denied.pattern, "gh pr comment*");
2602 assert!(super::super::static_guard("gh issue list").is_none());
2603 }
2604
2605 #[test]
2609 fn an_unconfirmed_promoted_rule_still_denies_as_unverified() {
2610 let (_lock, _dir) = isolated();
2611 let lesson = serde_json::json!({
2612 "id": "lesson-status", "kind": "lesson", "text": "Leave git status alone.",
2613 "origin": "user-declared", "source": {"basis": "person"},
2614 });
2615 let mention = People {
2616 handles: vec!["ocaisa".into()],
2617 names: vec![],
2618 };
2619 promote_atom(&lesson, "fixture", &mention, &People::default()).unwrap();
2620 let mut rows = read_all().unwrap();
2621 rows[0].pattern = Some("git status*".into());
2622 rows[0].verdict = Some("deny".into());
2623 std::fs::write(rules_path().unwrap(), render_rules(&rows).unwrap()).unwrap();
2624 let mcp = r#"{"tool_name":"add_issue_comment","mcp_server_name":"github","tool_input":{"body":"please ask @ocaisa"}}"#;
2625 for denied in [
2626 super::super::static_guard("git status").expect("pattern"),
2627 super::super::static_guard("gh pr comment 12 --body 'please ask @ocaisa'")
2628 .expect("mention"),
2629 guard_payload(mcp).expect("mcp"),
2630 ] {
2631 assert_eq!(denied.verdict, "deny", "{denied:?}");
2632 assert!(
2633 denied.reason.starts_with("unverified") && denied.reason.contains("lesson-status"),
2634 "an unconfirmed rule's denial read as the person's: {denied:?}"
2635 );
2636 }
2637 write_argv(
2638 &Rule {
2639 pattern: "git log*".into(),
2640 verdict: "deny".into(),
2641 reason: "leave the log alone".into(),
2642 },
2643 super::super::Basis::Person,
2644 )
2645 .unwrap();
2646 let denied = super::super::static_guard("git log").unwrap();
2647 assert_eq!(denied.reason, "leave the log alone");
2648 }
2649
2650 fn git_ok(dir: &std::path::Path, args: &[&str]) {
2651 let out = crate::git_cmd()
2652 .arg("-C")
2653 .arg(dir)
2654 .args(args)
2655 .output()
2656 .unwrap();
2657 assert!(
2658 out.status.success(),
2659 "git {args:?} in {}: {}{}",
2660 dir.display(),
2661 String::from_utf8_lossy(&out.stderr),
2662 String::from_utf8_lossy(&out.stdout)
2663 );
2664 }
2665
2666 fn prepare_clone(dir: &std::path::Path) {
2667 git_ok(dir, &["config", "user.email", "seat@example.invalid"]);
2668 git_ok(dir, &["config", "user.name", "seat"]);
2669 git_ok(dir, &["config", "commit.gpgsign", "false"]);
2670 git_ok(dir, &["config", "core.hooksPath", "/dev/null"]);
2671 }
2672
2673 struct Homes {
2674 _lock: super::super::EnvLock,
2675 _root: tempfile::TempDir,
2676 a: std::path::PathBuf,
2677 b: std::path::PathBuf,
2678 cfg_a: std::path::PathBuf,
2679 cfg_b: std::path::PathBuf,
2680 }
2681
2682 fn age_identity(cfg: &std::path::Path) -> String {
2685 let dir = cfg.join("ljos");
2686 std::fs::create_dir_all(&dir).unwrap();
2687 let key = dir.join("age.key");
2688 let made = std::process::Command::new("age-keygen")
2689 .arg("-o")
2690 .arg(&key)
2691 .output()
2692 .unwrap();
2693 assert!(made.status.success(), "age-keygen must be installed");
2694 let public = std::process::Command::new("age-keygen")
2695 .arg("-y")
2696 .arg(&key)
2697 .output()
2698 .unwrap();
2699 String::from_utf8_lossy(&public.stdout).trim().to_string()
2700 }
2701
2702 struct EnvVar(&'static str, Option<std::ffi::OsString>);
2704
2705 impl EnvVar {
2706 fn set(name: &'static str, value: &str) -> Self {
2707 let before = std::env::var_os(name);
2708 unsafe { std::env::set_var(name, value) };
2709 Self(name, before)
2710 }
2711 }
2712
2713 impl Drop for EnvVar {
2714 fn drop(&mut self) {
2715 match self.1.take() {
2716 Some(value) => unsafe { std::env::set_var(self.0, value) },
2717 None => unsafe { std::env::remove_var(self.0) },
2718 }
2719 }
2720 }
2721
2722 fn remote_paths(bare: &std::path::Path) -> String {
2723 let out = crate::git_cmd()
2724 .arg("-C")
2725 .arg(bare)
2726 .args(["ls-tree", "-r", "--name-only", "HEAD"])
2727 .output()
2728 .unwrap();
2729 String::from_utf8_lossy(&out.stdout).into_owned()
2730 }
2731
2732 fn head(dir: &std::path::Path) -> String {
2733 let out = crate::git_cmd()
2734 .arg("-C")
2735 .arg(dir)
2736 .args(["rev-parse", "HEAD"])
2737 .output()
2738 .unwrap();
2739 String::from_utf8_lossy(&out.stdout).trim().to_string()
2740 }
2741
2742 impl Homes {
2743 fn new() -> Self {
2746 Self::with_scope(true)
2747 }
2748
2749 fn unscoped() -> Self {
2751 Self::with_scope(false)
2752 }
2753
2754 fn bare(&self) -> std::path::PathBuf {
2755 self.a.parent().unwrap().join("bare.git")
2756 }
2757
2758 fn with_scope(scoped: bool) -> Self {
2759 let lock = super::super::test_env_lock();
2760 let root = tempfile::TempDir::new().unwrap();
2761 let seed = root.path().join("seed");
2762 std::fs::create_dir_all(&seed).unwrap();
2763 git_ok(&seed, &["init", "-q", "-b", "main"]);
2764 prepare_clone(&seed);
2765 git_ok(&seed, &["commit", "--allow-empty", "-q", "-m", "init"]);
2766 let cfg_a = root.path().join("cfg-a");
2767 let cfg_b = root.path().join("cfg-b");
2768 if scoped {
2769 let a = age_identity(&cfg_a);
2770 let b = age_identity(&cfg_b);
2771 std::fs::create_dir_all(seed.join(".ljos")).unwrap();
2772 std::fs::write(
2773 seed.join(".ljos/sync.toml"),
2774 format!("name = \"home\"\nrecipients = [\"{a}\", \"{b}\"]\n"),
2775 )
2776 .unwrap();
2777 git_ok(&seed, &["add", ".ljos/sync.toml"]);
2778 git_ok(&seed, &["commit", "-q", "-m", "scope"]);
2779 }
2780 let bare = root.path().join("bare.git");
2781 let laptop = root.path().join("laptop");
2782 let shared = root.path().join("shared");
2783 git_ok(
2784 root.path(),
2785 &[
2786 "clone",
2787 "--bare",
2788 "-q",
2789 seed.to_str().unwrap(),
2790 bare.to_str().unwrap(),
2791 ],
2792 );
2793 git_ok(
2794 root.path(),
2795 &[
2796 "clone",
2797 "-q",
2798 bare.to_str().unwrap(),
2799 laptop.to_str().unwrap(),
2800 ],
2801 );
2802 git_ok(
2803 root.path(),
2804 &[
2805 "clone",
2806 "-q",
2807 bare.to_str().unwrap(),
2808 shared.to_str().unwrap(),
2809 ],
2810 );
2811 prepare_clone(&laptop);
2812 prepare_clone(&shared);
2813 unsafe {
2814 std::env::set_var("LJOS_USER_RULES_SYNC", "off");
2815 std::env::set_var("LJOS_HOLD", "rohit");
2816 std::env::set_var("LJOS_GUARD", "on");
2817 std::env::set_var("PACKSET_URL", "off");
2818 std::env::set_var("HOME", root.path());
2819 std::env::remove_var("LJOS_USER_RULES_TRACKER");
2820 std::env::remove_var("LJOS_TRACKER_GIT");
2821 std::env::set_var("XDG_STATE_HOME", root.path().join("state"));
2822 }
2823 Self {
2824 _lock: lock,
2825 _root: root,
2826 a: laptop,
2827 b: shared,
2828 cfg_a,
2829 cfg_b,
2830 }
2831 }
2832
2833 fn home(&self, cfg: &std::path::Path) {
2834 unsafe { std::env::set_var("XDG_CONFIG_HOME", cfg) };
2835 }
2836 }
2837
2838 fn exchange(tracker: &std::path::Path, host: &str) {
2839 exchange_said(tracker, host, false);
2840 }
2841
2842 fn exchange_said(tracker: &std::path::Path, host: &str, asked: bool) -> String {
2843 let said = super::super::sync::exchange_user_rules(
2844 tracker,
2845 host,
2846 std::time::Duration::from_secs(8),
2847 asked,
2848 )
2849 .unwrap_or_else(|err| panic!("sync {host}: {err:#}"));
2850 assert!(!said.contains("did not answer"), "{said}");
2851 said
2852 }
2853
2854 #[test]
2857 fn a_tracker_with_no_scope_gets_no_user_rules() {
2858 let homes = Homes::unscoped();
2859 homes.home(&homes.cfg_a);
2860 let before = head(&homes.a);
2861 let rule = write_preference(
2862 "never post or reply to Alan (ocaisa)",
2863 "he asked not to be pinged",
2864 &People::default(),
2865 &People::default(),
2866 )
2867 .unwrap();
2868 exchange(&homes.a, "laptop");
2869 let said = exchange_said(&homes.a, "laptop", true);
2870 assert!(said.contains("names no scope"), "{said}");
2871 assert_eq!(head(&homes.a), before, "no commit without a scope");
2872 assert!(!homes.a.join(".ljos/user-rules").exists());
2873 assert!(!remote_paths(&homes.bare()).contains("user-rules"));
2874 assert_eq!(load().unwrap()[0].id, rule.id);
2875 }
2876
2877 #[test]
2878 fn user_rules_travel_sealed_to_the_scope() {
2879 let homes = Homes::new();
2880 homes.home(&homes.cfg_a);
2881 write_preference(
2882 "never post or reply to Alan (ocaisa)",
2883 "he asked not to be pinged",
2884 &People::default(),
2885 &People::default(),
2886 )
2887 .unwrap();
2888 let said = exchange_said(&homes.a, "laptop", false);
2889 assert!(said.contains("sealed and pushed"), "{said}");
2890 let paths = remote_paths(&homes.bare());
2891 assert!(
2892 paths.contains(".ljos/user-rules/laptop.json.age"),
2893 "{paths}"
2894 );
2895 assert!(!paths.contains("laptop.json\n"), "{paths}");
2896 let sealed = std::fs::read(homes.a.join(".ljos/user-rules/laptop.json.age")).unwrap();
2897 assert!(
2898 !String::from_utf8_lossy(&sealed).contains("ocaisa"),
2899 "the rule left in plaintext"
2900 );
2901 let pushed = head(&homes.a);
2902 exchange(&homes.a, "laptop");
2903 assert_eq!(
2904 head(&homes.a),
2905 pushed,
2906 "unchanged rules are not sealed again"
2907 );
2908 }
2909
2910 #[test]
2913 fn a_plaintext_rules_file_is_still_read_and_this_hosts_is_replaced() {
2914 let homes = Homes::new();
2915 homes.home(&homes.cfg_b);
2916 let old = write_preference(
2917 "the older plaintext rule",
2918 "written before the seal",
2919 &People::default(),
2920 &People::default(),
2921 )
2922 .unwrap();
2923 let text = std::fs::read_to_string(homes.cfg_b.join("ljos/user-rules.json")).unwrap();
2924 let dir = homes.b.join(".ljos/user-rules");
2925 std::fs::create_dir_all(&dir).unwrap();
2926 std::fs::write(dir.join("old-host.json"), &text).unwrap();
2927 std::fs::write(dir.join("laptop.json"), &text).unwrap();
2928 git_ok(&homes.b, &["add", ".ljos/user-rules"]);
2929 git_ok(&homes.b, &["commit", "-q", "-m", "plaintext rules"]);
2930 git_ok(&homes.b, &["push", "-q"]);
2931 homes.home(&homes.cfg_a);
2932 exchange(&homes.a, "laptop");
2933 assert!(load().unwrap().iter().any(|rule| rule.id == old.id));
2934 let paths = remote_paths(&homes.bare());
2935 assert!(paths.contains(".ljos/user-rules/old-host.json"), "{paths}");
2936 assert!(
2937 paths.contains(".ljos/user-rules/laptop.json.age"),
2938 "{paths}"
2939 );
2940 assert!(!paths.contains(".ljos/user-rules/laptop.json\n"), "{paths}");
2941 }
2942
2943 #[test]
2944 fn user_rule_sync_follows_tracker_git() {
2945 let homes = Homes::new();
2946 homes.home(&homes.cfg_a);
2947 write_preference(
2948 "the standing line",
2949 "a reason",
2950 &People::default(),
2951 &People::default(),
2952 )
2953 .unwrap();
2954 let start = head(&homes.a);
2955 {
2956 let _off = EnvVar::set("LJOS_TRACKER_GIT", "off");
2957 exchange(&homes.a, "laptop");
2958 assert_eq!(head(&homes.a), start, "off leaves the tracker alone");
2959 }
2960 {
2961 let _commit = EnvVar::set("LJOS_TRACKER_GIT", "commit");
2962 let said = exchange_said(&homes.a, "laptop", false);
2963 assert!(
2964 said.contains("not pushed (LJOS_TRACKER_GIT=commit)"),
2965 "{said}"
2966 );
2967 assert_ne!(head(&homes.a), start);
2968 assert!(!remote_paths(&homes.bare()).contains("user-rules"));
2969 let said = exchange_said(&homes.a, "laptop", true);
2970 assert!(said.contains("sealed and pushed"), "{said}");
2971 }
2972 assert!(remote_paths(&homes.bare()).contains("laptop.json.age"));
2973 let _sync_off = EnvVar::set("LJOS_USER_RULES_SYNC", "off");
2974 let _tracker = EnvVar::set("LJOS_USER_RULES_TRACKER", homes.a.to_str().unwrap());
2975 assert!(super::super::sync::user_rule_trackers().is_empty());
2976 assert!(sync_now().is_err());
2977 }
2978
2979 fn rewrite(id: &str, text: &str, author: &str, reason: &str, at: &str, deleted: bool) {
2980 let mut rows = read_all().unwrap();
2981 let rule = rows.iter_mut().find(|rule| rule.id == id).unwrap();
2982 rule.text = text.to_string();
2983 rule.author = author.to_string();
2984 rule.reason = reason.to_string();
2985 rule.updated_at = at.to_string();
2986 rule.deleted = deleted;
2987 store(&rows).unwrap();
2988 }
2989
2990 #[test]
2991 fn two_homes_with_different_rules_converge() {
2992 let homes = Homes::new();
2993 homes.home(&homes.cfg_a);
2994 let alan = write_preference(
2995 "never post or reply to Alan (ocaisa)",
2996 "he asked not to be pinged",
2997 &People::default(),
2998 &People::default(),
2999 )
3000 .unwrap();
3001 exchange(&homes.a, "laptop");
3002 homes.home(&homes.cfg_b);
3003 exchange(&homes.b, "shared");
3004 let got = load().unwrap();
3005 assert_eq!(got.len(), 1, "{got:?}");
3006 assert_eq!(got[0].id, alan.id);
3007 assert_eq!(got[0].author, "rohit");
3008 assert_eq!(got[0].reason, "he asked not to be pinged");
3009 let credit = write_preference(
3010 "EasyBuild pull requests name eb-stack and gpt-oss-120b",
3011 "the credit line is part of the work",
3012 &People::default(),
3013 &People::default(),
3014 )
3015 .unwrap();
3016 assert_eq!(credit.id, load().unwrap()[1].id);
3017 exchange(&homes.b, "shared");
3018 homes.home(&homes.cfg_a);
3019 exchange(&homes.a, "laptop");
3020 let got = load().unwrap();
3021 assert!(got.iter().any(|rule| rule.id == alan.id), "{got:?}");
3022 assert!(got.iter().any(|rule| rule.id == credit.id), "{got:?}");
3023 homes.home(&homes.cfg_b);
3024 let got = load().unwrap();
3025 assert_eq!(got.len(), 2, "{got:?}");
3026 assert!(got.iter().any(|rule| rule.id == alan.id));
3027 assert!(got.iter().any(|rule| rule.id == credit.id));
3028 }
3029
3030 #[test]
3031 fn a_newer_edit_wins_and_a_tombstone_beats_an_older_edit() {
3032 let homes = Homes::new();
3033 homes.home(&homes.cfg_a);
3034 let rule = write_preference(
3035 "the standing line",
3036 "first reason",
3037 &People::default(),
3038 &People::default(),
3039 )
3040 .unwrap();
3041 let id = rule.id.clone();
3042 exchange(&homes.a, "laptop");
3043 homes.home(&homes.cfg_b);
3044 exchange(&homes.b, "shared");
3045
3046 rewrite(
3047 &id,
3048 "older edit",
3049 "ada",
3050 "because older",
3051 "2099-01-01T00:00:01.000Z",
3052 false,
3053 );
3054 exchange(&homes.b, "shared");
3055 homes.home(&homes.cfg_a);
3056 rewrite(
3057 &id,
3058 "newest edit",
3059 "rohit",
3060 "because newest",
3061 "2099-01-01T00:00:09.000Z",
3062 false,
3063 );
3064 exchange(&homes.a, "laptop");
3065 homes.home(&homes.cfg_b);
3066 exchange(&homes.b, "shared");
3067 let got = load().unwrap();
3068 assert_eq!(got.len(), 1, "{got:?}");
3069 assert_eq!(got[0].id, id);
3070 assert_eq!(got[0].text, "newest edit");
3071 assert_eq!(got[0].author, "rohit");
3072 assert_eq!(got[0].reason, "because newest");
3073 homes.home(&homes.cfg_a);
3074 let got = load().unwrap();
3075 assert_eq!(got[0].text, "newest edit");
3076 assert_eq!(got[0].author, "rohit");
3077 assert_eq!(got[0].reason, "because newest");
3078
3079 let removed = forget(&id).unwrap();
3080 assert!(removed.deleted);
3081 assert_eq!(removed.author, "rohit");
3082 assert_eq!(removed.reason, "because newest");
3083 rewrite(
3084 &id,
3085 "newest edit",
3086 "rohit",
3087 "because newest",
3088 "2099-01-02T00:00:00.000Z",
3089 true,
3090 );
3091 exchange(&homes.a, "laptop");
3092 homes.home(&homes.cfg_b);
3093 exchange(&homes.b, "shared");
3094 assert!(load().unwrap().is_empty(), "{:?}", load().unwrap());
3095 let tomb = read_all().unwrap();
3096 assert_eq!(tomb.len(), 1);
3097 assert!(tomb[0].deleted);
3098 assert_eq!(tomb[0].author, "rohit");
3099 assert_eq!(tomb[0].reason, "because newest");
3100 assert!(!preface().contains("newest edit"));
3101 rewrite(
3102 &id,
3103 "older edit",
3104 "ada",
3105 "because older",
3106 "2099-01-01T00:00:01.000Z",
3107 false,
3108 );
3109 exchange(&homes.b, "shared");
3110 assert!(
3111 load().unwrap().is_empty(),
3112 "a tombstone keeps the older edit out"
3113 );
3114 let tomb = read_all().unwrap();
3115 assert!(tomb[0].deleted);
3116 assert_eq!(tomb[0].updated_at, "2099-01-02T00:00:00.000Z");
3117 }
3118
3119 #[test]
3120 fn the_hook_shows_local_rules_when_the_tracker_does_not_answer() {
3121 let (_lock, dir) = isolated();
3122 write_preference(
3123 "never post or reply to Alan (ocaisa)",
3124 "he asked not to be pinged",
3125 &People::default(),
3126 &People::default(),
3127 )
3128 .unwrap();
3129 let repo = dir.path().join("tracker");
3130 std::fs::create_dir_all(&repo).unwrap();
3131 git_ok(&repo, &["init", "-q", "-b", "main"]);
3132 prepare_clone(&repo);
3133 git_ok(&repo, &["commit", "--allow-empty", "-q", "-m", "init"]);
3134 git_ok(&repo, &["config", "http.lowSpeedLimit", "1"]);
3135 git_ok(&repo, &["config", "http.lowSpeedTime", "1"]);
3136 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3137 let port = listener.local_addr().unwrap().port();
3138 std::thread::spawn(move || {
3139 let Ok((mut stream, _)) = listener.accept() else {
3140 return;
3141 };
3142 let mut buf = [0u8; 64];
3143 let _ = std::io::Read::read(&mut stream, &mut buf);
3144 });
3145 let url = format!("http://127.0.0.1:{port}/tracker.git");
3146 git_ok(&repo, &["remote", "add", "origin", &url]);
3147 std::fs::create_dir_all(repo.join(".ljos")).unwrap();
3149 std::fs::write(
3150 repo.join(".ljos/sync.toml"),
3151 "name = \"home\"\nrecipients = []\n",
3152 )
3153 .unwrap();
3154 unsafe {
3155 std::env::set_var("LJOS_USER_RULES_SYNC", "on");
3157 std::env::set_var("LJOS_USER_RULES_TRACKER", &repo);
3158 std::env::set_var("LJOS_USER_RULES_SYNC_MS", "400");
3159 }
3160 let started = std::time::Instant::now();
3161 let line = super::super::session_start_for(Some("seat-two"));
3162 let elapsed = started.elapsed();
3163 assert!(
3164 elapsed < std::time::Duration::from_secs(3),
3165 "{elapsed:?}\n{line}"
3166 );
3167 assert!(line.starts_with("User rules"), "{line}");
3168 assert!(line.contains("never post or reply to Alan"), "{line}");
3169 }
3170}